CONTENTS
Attack Chain
untrusted filename → arbitrary file write → overwrite nginx cache entry for trusted JS → complaint bot logs in as admin and opens dashboard → poisoned JS runs as admin → exfil flag into attacker account
Prerequisites, What you need to understand before reading
If you don’t understand these, the exploit looks random.
- Concept 1: Path traversal in file uploads means the filename is not metadata anymore, it becomes a filesystem primitive.
- Concept 2: Nginx file cache stores cached responses as real files on disk, keyed by
md5(cache_key). - Concept 3: If you can overwrite a cached trusted JavaScript file, you get script execution without finding a DOM XSS sink.
- Tooling:
curl, basic Flask reading, and comfort reading infra files likenginx.conf.
Intro: What this challenge teaches
- Goal: turn a boring upload bug into admin-context JavaScript execution.
- Main idea: I did not inject into HTML. I replaced a trusted cached asset the admin bot loads.
- Key lesson: arbitrary file write becomes much stronger when the target stack has proxy caches, bots, or auto-review systems.
Code Analysis
High-level architecture
- Backend: Flask + Flask-Login
- Reverse proxy: nginx with cache enabled for
/static/and/uploads/ - Bot: Selenium logs in as
adminand opens/dashboard
The vulnerable upload
Fatal assumption: secure_filename() is only used for display, not for the actual write path.
The trusted asset sink
The bot trigger
Why normal XSS was the wrong path?
dashboard.js escapes invoice names before inserting them into the DOM, so a filename like <script> does nothing useful. The real sink is not HTML. The real sink is cached /static/js/dashboard.js.
Why this works
Step 1: Upload traversal gives arbitrary write
Because the backend saves file.filename directly, I can write outside uploads/.
Step 2: Live deployment blocks direct overwrite of app files
Trying to overwrite /app/static/js/dashboard.js returned 500 on the live box, so direct sibling overwrite was dead.
Step 3: /var/cache/nginx was still writable
That changed everything. nginx was already serving /static/js/dashboard.js from cache, and the cache directory was writable from the upload primitive.
Step 4: Poison trusted JS, not HTML So instead of searching for XSS, I overwrote the cached response for:
Its md5 is:
With levels=1:2, the cache file lands at:
Critical implementation detail that cost me time
The nginx cache marker is a 6-byte array:
So the forged file must use:
Not a NUL-terminated 7-byte string.
Exploit Strategie I used
Why I chose this chain?
- No DOM XSS needed
- No template injection
- No quote breaking
- Uses the challenge’s intended moving parts:
- upload
- nginx cache
- admin bot
Extra nuance: the bot loads dashboard twice
My first payload “worked” but the output kept getting overwritten. Reason: after I stole the admin session action, my own login/upload flow caused /dashboard to load again as my user. Fix: only run the payload when:
That role check stopped the second clobber.
My working payload
Core JavaScript I cached into dashboard.js
What the forged cache file had to contain
- Valid
ngx_http_file_cache_header_tlayout - Correct
crc32(key) - Correct
header_start - Correct
body_start - Exact key marker:
\nKEY: - Raw cached HTTP response whose body is my JavaScript
Traversal target I uploaded to
Then I just:
- uploaded a normal seed invoice
- called
/api/complain/<seed_id> - waited for the bot
- downloaded
flag.txtfrom my own account
Solve Analysis, What happened on the server?
- Flask accepted my custom multipart filename
- Path traversal wrote my forged file into nginx cache
- Complaint endpoint triggered the Selenium bot
- Bot logged in as admin and opened
/dashboard - Browser loaded poisoned cached
dashboard.js - JS fetched
/flag - JS logged into my account
- JS uploaded the flag as a normal invoice
- I downloaded it back through
/uploads/flag.txt
Real-World Fix. How to prevent this Defensive rules?
- Never use raw
file.filenamefor disk paths - Generate server-side filenames only
- Store uploads outside the app tree and outside proxy/cache paths
- Mount app code and cache dirs with least privilege
- Don’t let authenticated bots browse the same origin with powerful endpoints like
/flag - Add asset integrity / immutable asset pipelines for trusted JS
Minimal correct fix
Closing
This challenge was not “find XSS in a table row.”
It was:
- filename becomes filesystem write
- filesystem write reaches nginx cache
- cached trusted JavaScript becomes attacker JavaScript
- admin bot turns that into privileged execution