The Werkzeug debugger console
Flask’s debug mode does not just print tracebacks. It mounts an interactive Python
console — by default at /console, and on every traceback frame — that evaluates
whatever you type inside the running process.
That is not a bug and it is not a CVE. It is the feature working exactly as designed, addressed to whoever can reach the port.
The consequence is code execution as the application user: read the configuration including the secret key, read the database credentials, open a socket outward. It is the most severe thing in this cluster by a wide margin, which is why it is the cluster’s threat page.
WERKZEUG_DEBUG_PINenvSetting it to off removes the console PIN entirely, turning the debugger into unauthenticated remote code execution. Werkzeug does say so — it logs " * Debugger PIN disabled. DEBUGGER UNSECURED!" — but that line scrolls past in startup output nobody reads. Unset it and you get a PIN, which is a speed bump rather than a control: it is derived from the username, module name, app class, file path, MAC address and machine-id, and Werkzeug's own source comment says that information "only exists to make the cookie unique on the computer, not as a security feature".
- accepts
off|a numeric PIN- set in
process environment
* Debugger is active!Werkzeug announcing that the interactive console is being served. On a production host this single startup line is the whole finding — nothing else needs to be wrong.
Read in: werkzeug werkzeug/debug/__init__.py * Debugger PIN disabled. DEBUGGER UNSECURED!WERKZEUG_DEBUG_PIN was set to off, so the console has no PIN at all. Usually added to stop the PIN prompt interrupting local work, and it survives into the image because it lives in an env file rather than in code.
Read in: werkzeug werkzeug/debug/__init__.pyWARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.Printed by app.run() on every start. It is about the server rather than the debugger, but the two travel together — a host printing this is a host running the development invocation.
Read in: werkzeug werkzeug/serving.pyThe PIN is not the control people think it is
Section titled “The PIN is not the control people think it is”Since Werkzeug 0.11 the console is guarded by a PIN printed to the server’s stdout. That raises the bar and it is explicitly not a security boundary.
The PIN is a SHA-1 over two groups of inputs. The first group, verbatim from the source:
# This information only exists to make the cookie unique on the# computer, not as a security feature.probably_public_bits = [ username, modname, getattr(app, "__name__", type(app).__name__), getattr(mod, "__file__", None),]
private_bits = [str(uuid.getnode()), get_machine_id()]So the ingredients are: the OS username, the module name, the application class name,
the file path of the module, the machine’s MAC address (uuid.getnode()), and the
machine ID.
Read that list next to what a traceback page discloses and the problem is visible. The
unauthenticated debug page already shows file paths, module names and the application
class. What remains is the username, the MAC address and the machine ID — all of which
are readable through any file-read or SSRF primitive, from /proc/self/environ,
/sys/class/net/*/address and /etc/machine-id.
A file-read bug plus a reachable debug console is remote code execution. The PIN converts one into the other rather than preventing it, and Werkzeug’s own comment is the honest description: it exists to make the value unique on the machine, not to defend it.
WERKZEUG_DEBUG_PIN=off removes even that. It is a common local convenience and it
sets pin_security false, at which point the console needs nothing.
# never this, anywhere that is not your laptop# app.run(debug=True)
if __name__ == "__main__": app.run(debug=False)The real fix is structural rather than a flag: production should not be running
app.run() at all. A WSGI server imports your application object and never calls
run(), so the debugger cannot be switched on by an environment variable:
gunicorn --bind 0.0.0.0:8000 --workers 4 'app:create_app()'Check the environment too, because FLASK_DEBUG reaches the same place:
env | grep -iE 'FLASK_DEBUG|FLASK_ENV|WERKZEUG_DEBUG_PIN'# the console mounts at /console when the debugger is activecurl -s -o /dev/null -w '%{http_code}\n' https://app.example.com/console404 is what you want. A 200 — even one asking for a PIN — means the console is
being served and the only thing between a visitor and your process is that hash.
Force a traceback and read the shape of it, since the debugger renders differently from a plain error page:
curl -s https://app.example.com/__no_such_path__/ | grep -ciE 'werkzeug|traceback|console'Zero is correct. And check what the process was started with:
ps -o args= -C python 2>/dev/null || ps aux | grep '[p]ython'flask run, app.run( or --reload in that output all mean the development
invocation is live.
Why it reaches production at all
Section titled “Why it reaches production at all”Nobody deploys with debug=True deliberately. Three routes account for most of it.
The environment variable. FLASK_DEBUG=1 set in a base image, a compose file or a
chart’s default values, inherited by an environment nobody re-read.
The truthiness mistake. debug=os.getenv("FLASK_DEBUG") is True for the string
"0" and for "false", because any non-empty string is truthy in Python. The variable
looks switched off and is switched on.
The container CMD. A Dockerfile whose command is python app.py, where the file
ends with app.run(debug=True) guarded by if __name__ == "__main__": — which is
exactly the branch that runs.
Turning the debugger off removes the tool your team debugs with, and the replacement has to exist first — otherwise the next production error is a blank 500 page with nothing behind it.
Put the traceback somewhere before you take the page away:
import logginglogging.basicConfig(level=logging.INFO)
@app.errorhandler(Exception)def unhandled(e): app.logger.exception("unhandled error on %s", request.path) return "Internal Server Error", 500app.logger.exception records the traceback server-side, which is the same information
the debug page had, delivered to the right audience.
Confirm those records reach your log destination before deploying — a handler writing to a stream nobody collects is the same blackout with extra steps.
If your team genuinely needs an interactive session against production data, the answer is a shell on the host with an audit trail, not a console on a public port.
Related
Section titled “Related”- Debug mode — the setting itself, and the other things it changes
- SECRET_KEY — the first thing a console session reads