Overview
Chain HTML injection in user profile bios with web cache deception via Nginx/Express path delimiter confusion to exfiltrate an admin API token from a cached dashboard page, then replay the token through a second cache deception to retrieve the flag from an admin-only secrets endpoint without an admin session.
Recon
The application is a Node.js (Express) portal behind an Nginx reverse proxy. Users can register, update profile bios, and submit URLs for an admin bot to review. The admin bot logs in via Puppeteer, visits the dashboard (where all user bios render), then navigates to the submitted URL.
Key observations from the source:
- Regex-based bio sanitizer (
server.js:62-76) strips<script>,on*attributes,javascript:, and specific tags, but allows<style>,<link rel="stylesheet">, and arbitrary<div>attributes. - CSP via meta tag (
dashboard.ejs:6) usesstyle-src 'self' 'unsafe-inline' *andimg-src 'self' data: *, allowing external stylesheet loads and image fetches from any origin. - Nginx caching (
nginx.conf:11-22) caches any response whose request URI matches\.(css|js|woff2|...)$for 60 seconds. Cache ignoresSet-Cookieheaders and forwards session cookies to the backend. - Express path normalization (
server.js:33-38) strips path parameters after semicolons:/dashboard;foo.cssbecomes/dashboard. - Admin dashboard (
dashboard.ejs:26) renders a secrets link with the admin API token in the href:<a id="secrets-link" href="/admin/secrets?token=TOKEN">. - Secrets endpoint (
server.js:200-206) requires admin session AND matching API token to return the flag.
Vulnerabilities
Path delimiter confusion. Express treats semicolons as path parameter delimiters (via custom middleware), stripping everything after ; in each path segment. Nginx does not recognize ; as special and evaluates /dashboard;foo.css against the \.(css|js|...)$ location regex, matching it as a cacheable static asset.
When the admin bot navigates to /dashboard;foo.css:
- Nginx matches
*.css, enables caching, proxies to Express - Express strips
;foo.css, routes to/dashboard, renders the full dashboard HTML including the secrets link with the admin’s API token - Nginx caches the 200 response for 60 seconds
Any subsequent request to the same URI (including unauthenticated requests) receives the cached dashboard page.
HTML injection via bio. The sanitizer allows <link rel="stylesheet"> tags. An attacker sets their bio to:
<link rel="stylesheet" href="/admin/secrets;flag.css?token=EXTRACTED_TOKEN">
When the admin visits the attacker’s profile, the browser loads this “stylesheet” from the same origin. Nginx matches *.css, caches the response (the flag JSON), and the attacker replays the cached URL.
Exploit
Phase 1: Token extraction via dashboard cache deception
- Register a user account.
- Submit a URL for admin review:
http://TARGET/dashboard;RAND.css. - Admin bot logs in and navigates to this URL. Nginx caches the admin’s dashboard page.
- Fetch the cached URL without authentication. Extract the admin API token from
<a id="secrets-link" href="/admin/secrets?token=TOKEN">.
Phase 2: Flag retrieval via secrets cache deception
- Update the user bio to
<link rel="stylesheet" href="/admin/secrets;RAND.css?token=TOKEN">. - Submit the profile URL for admin review.
- Admin visits the profile. The browser loads the
<link>URL, sending the admin session cookie. Express serves the flag JSON. Nginx caches it. - Fetch the cached URL to retrieve the flag.
Red herrings
/debug/sessionsexposes session IDs but not session data. Without the session secret, forging a session cookie from a session ID is infeasible./redirectparameter looks like an open redirect but sanitizes the target URL, only allowinglocalhostand127.0.0.1origins for absolute URLs./metricsendpoint is ACL-restricted to127.0.0.1and172.16.0.0/12in Nginx, so external SSRF through it is blocked.- CSP nonce in meta tag is visible in cached pages but not directly useful because the sanitizer prevents script tag injection. The nonce is a decoy for a more complex CSS exfiltration path.
- Speculation rules on the admin panel page are functional but not required for the exploit. They prerender the secrets link, which is interesting but the cache deception approach is simpler.