From Path Traversal to RCE: Walking the Kill Chain
A single ../ can end in remote code execution. We trace how a 'low severity' file-read bug escalates into full compromise — and where to break the chain.
Path traversal often gets triaged as “informational — attacker can read some files.” That triage is frequently wrong. In the right application, an arbitrary-file-read primitive is a stepping stone to remote code execution. Let’s walk the chain on a deliberately vulnerable example, then talk mitigations.
Lab only. Everything below assumes a system you own or are authorized to test. The point is to understand the escalation so you can defend against it.
The primitive: unsanitized file access
A classic offender — a download endpoint that trusts user input:
@app.route("/download")
def download():
name = request.args.get("file")
return send_file(f"/var/app/uploads/{name}") # no validation
The intended request is /download?file=report.pdf. The attacker’s request is:
GET /download?file=../../../../etc/passwd
The ../ sequences climb out of uploads/ and the server happily returns
/etc/passwd. We now have arbitrary file read as the app’s user.
Escalation step 1 — reconnaissance
Arbitrary read is reconnaissance gold. An attacker pulls:
/proc/self/environ— environment variables, often including secrets and tokens./proc/self/cmdline— exact process invocation and config paths.- App source (
app.py,settings.py) — to find database creds andSECRET_KEY. ~/.ssh/id_rsa,~/.aws/credentials,.envfiles — direct keys to other systems.
A “file read” just became a credential harvest.
Escalation step 2 — turning read into write or exec
The bug becomes RCE when read combines with any of these:
- Log poisoning + LFI. If the same flaw can include files the app executes
(e.g. PHP), inject code into a log via the
User-Agent, then include the log. - Leaked secrets → authenticated RCE. A harvested admin token or
SECRET_KEYlets the attacker forge sessions or sign malicious serialized objects. - Read config → reach an exposed admin/debug interface (a leaked Flask debug PIN, a discovered internal endpoint) that offers code execution by design.
Each link is mundane. Chained, “informational” becomes “game over.”
Where to break the chain
Defense in depth means you don’t need to win at every layer — just one:
-
Canonicalize and confine. Resolve the real path and verify it stays inside the intended directory:
import os base = "/var/app/uploads" target = os.path.realpath(os.path.join(base, name)) if not target.startswith(base + os.sep): abort(403) -
Allowlist, don’t blocklist. Map an opaque ID to a known file instead of accepting filenames at all. Blocklisting
../loses to encoding tricks (%2e%2e%2f, overlong UTF-8,....//). -
Least privilege. The web user shouldn’t be able to read
id_rsa,.env, or its own source. Separate secrets into a vault the process reads once at boot. -
Don’t ship secrets where the app can read them. Inject at runtime; keep them out of the web root and the process’s readable tree.
-
Detect the recon. Requests containing
etc/passwd,proc/self, or stacked../are almost never legitimate — alert on them.
Takeaway
Severity isn’t a property of a single bug; it’s a property of the chain the bug enables. When you triage a file-read flaw, ask “what can this read, and what does reading that unlock?” Often the honest answer is: everything.
Starter content shipped with the site — replace with your own research.
Newsletter
Liked this? Get the weekly digest.
One email a week. The breaches that matter, the fixes that work, and the deep dives worth your time. No trackers, no spam, unsubscribe anytime.
⚙ Newsletter not yet wired. Set PUBLIC_LISTMONK_URL and
PUBLIC_LISTMONK_LIST_UUID in your environment, then this
form goes live. See SETUP.md.