Sokudo: GraphQL Broken Function-Level Authorization
Executive Summary
Sokudo is a React single-page typing-speed application backed by an Express API that exposes both REST routes under /api/* and a GraphQL endpoint at /api/graphql. The headline finding is a broken access control defect: the GraphQL finalizeChampionship mutation enforces authentication but performs no authorization check. Any authenticated account, including a self-registered one with no role, can invoke this privileged operation and read back a secret prizeCode field in the response. The mutation is visible in the schema because introspection is enabled, yet the front end never calls it. The prize code is the flag.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Broken function-level authorization on GraphQL finalizeChampionship mutation |
High | 7.5 | CWE-285, CWE-862 | POST /api/graphql |
The flag-bearing finding is F1. A throwaway account registered through the open POST /api/register endpoint produced a low-privilege JWT with no role claim. A single finalizeChampionship mutation sent with that token returned the secret prizeCode in the response body. Because registration is open and free, any internet user can obtain a valid token and reach the same operation, which is why the privilege requirement is scored as none.
Objective
Identify and exploit the web application vulnerability in the Sokudo lab (operator hint: GraphQL) and capture the bug{...} flag.
Scope / Initial Access
# Target Application
URL: https://lab-1782433712286-labf84.labs-app.bugforge.io
# Auth details
Registration: open, POST /api/register (no approval, no email verification)
Token: JWT HS256, returned on register and login, sent as "Authorization: Bearer <jwt>"
Starting privileges: self-registered user, JWT payload {"id":7,"username":"haxor","iat":...}
no role or admin claim present in the token
Registration returns a usable token immediately, so the starting position is any-authenticated with no privilege encoded in the token itself.
Reconnaissance: Mapping the GraphQL Schema
The application surface was mapped from the React production bundle and a small set of authenticated probes against the API. The bundle named the REST routes the client calls and the GraphQL endpoint, and because introspection is enabled, the GraphQL schema could be read directly rather than reconstructed.
- The front end talks to a GraphQL endpoint at
POST /api/graphqland the client only ever issues thecurrentSeasonquery. Any other operation in the schema is reachable but unused by the client. - GraphQL introspection is enabled, so the full schema (types, queries, and mutations) is returned on request.
- The schema exposes a mutation the client never calls:
finalizeChampionship(seasonId: ID): ChampionshipResult, whereChampionshipResultcarriesseasonId,champion,finalized, and aprizeCodefield. The name and the dedicatedprizeCodefield mark it as a privileged business operation and the likely flag carrier. - The JWT returned at registration carries
{id, username, iat}with no role or admin claim, so the token encodes no privilege level. Whether a caller is allowed to finalize a season is therefore decided (or not) entirely on the server.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (X-Powered-By: Express); REST under /api/* plus GraphQL at /api/graphql |
| Frontend | React single-page application (Create React App production build) |
| Auth | JWT HS256, Bearer token; issued by POST /api/register and POST /api/login |
| Database | Not observable from client responses |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/register |
POST | None | Open registration, returns JWT |
/api/login |
POST | None | Returns JWT |
/api/graphql |
POST | Required (Bearer) | Introspection enabled; serves currentSeason and finalizeChampionship |
/api/admin/users |
GET | Unknown | Referenced by front end, never exercised (see Failed Approaches) |
/api/admin/sessions |
GET | Unknown | Referenced by front end, never exercised (see Failed Approaches) |
Known Users
| Username | ID | Role |
|---|---|---|
| haxor | 7 | self-registered test account (no role claim) |
| speedtyper | n/a | current season champion (from currentSeason standings) |
Attack Chain Visualization
┌──────────────────┐ ┌───────────────────┐ ┌──────────────────────┐ ┌───────────────────────┐
│ Register via │ │ Introspect │ │ Find hidden mutation │ │ Call mutation with │
│ open /api/ │──▶│ /api/graphql │──▶│ finalizeChampionship │──▶│ low-priv JWT → │
│ register → │ │ (enabled) → full │ │ returning a │ │ 200 + prizeCode │
│ low-priv JWT │ │ schema │ │ prizeCode field │ │ (the flag) │
└──────────────────┘ └───────────────────┘ └──────────────────────┘ └───────────────────────┘
Findings
F1: Broken function-level authorization on GraphQL finalizeChampionship mutation
Severity: High
CVSS v3.1: 7.5 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N)
CWE: CWE-285 (Improper Authorization), CWE-862 (Missing Authorization)
Endpoint: POST /api/graphql
Authentication required: Yes (any authenticated user; see characterization below)
The privilege requirement is scored as none (PR:N) because registration is open and free: any internet user can self-register a token, so no pre-existing privilege is needed to reach the operation. PR:N is the lever that holds this at 7.5 (High); on the alternative reading that a valid token must be presented during the attack (PR:L), the score is 6.5 (Medium). Confidentiality is scored C:H because prizeCode is the specific protected secret the operation exists to return, not on the basis of data volume. The score reflects that disclosure only; the mutation also reports finalizing the season, but that integrity effect was not independently confirmed and is not scored (see Description).
Description
The finalizeChampionship resolver enforces authentication (the endpoint returns 401 without a token) but applies no role or ownership check. A token belonging to a freshly self-registered account with no role claim can invoke the mutation and read every field it returns, including the secret prizeCode. The operation is exposed in the schema because introspection is enabled, and the front end never calls it, so in practice it is gated only by obscurity rather than by an access control decision.
Two outcomes are observable in the response:
- Secret disclosure (confirmed). The response body returns
prizeCodein full to the low-privilege token. This is the airtight impact and the basis for the score. - Season finalization (reported, not independently confirmed). The same response reports
finalized: true, andcurrentSeasonhad earlier reportedstatus: "open". This is consistent with the mutation changing server state, but the resolver reports its own result and no separate re-read ofcurrentSeasonwas performed to prove the state persisted. It is treated here as a plausible secondary effect, not a proven one.
Impact
Unauthorized disclosure of the championship prize code to any authenticated user, with a plausible further effect of finalizing an open season.
Reproduction
Step 1: Register an account to obtain a token
POST /api/register HTTP/1.1
Host: lab-1782433712286-labf84.labs-app.bugforge.io
Content-Type: application/json
{"username":"haxor","email":"[email protected]","password":"password","full_name":""}
Response: 200 OK with {"token":"eyJ...","user":{"id":7,"username":"haxor",...}}. The token payload decodes to {"id":7,"username":"haxor","iat":1782433731} with no role or admin claim.
Step 2: Read the schema and locate the privileged mutation
With introspection enabled, a standard introspection query against POST /api/graphql returns the full schema. The relevant mutation is:
type Mutation {
finalizeChampionship(seasonId: ID): ChampionshipResult
}
type ChampionshipResult {
seasonId: ID
champion: String
finalized: Boolean
prizeCode: String
}
Observation: the mutation returns a dedicated prizeCode field and is never called by the front end.
Step 3: Invoke the mutation with the low-privilege token
POST /api/graphql HTTP/1.1
Host: lab-1782433712286-labf84.labs-app.bugforge.io
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NywidXNlcm5hbWUiOiJoYXhvciIsImlhdCI6MTc4MjQzMzczMX0.-GyLF2T6yuyXVAtJg4IAjlIwDxVuBLl7JFXorIEYW4A
{"query":"mutation { finalizeChampionship(seasonId: \"1\") { seasonId champion finalized prizeCode } }"}
Response:
{"data":{"finalizeChampionship":{"seasonId":"1","champion":"speedtyper","finalized":true,"prizeCode":"bug{yh4F52wMCJfcBFtNuc0WJ7RtUB45hAyZ}"}}}
Observation: a 200 response returns the secret prizeCode to a token that carries no role. The seasonId argument also accepts an unquoted integer literal (seasonId: 1), since GraphQL’s ID type accepts integers.
Step 4: Characterize the access boundary (no token)
Replaying the same mutation with the Authorization header removed:
{"error":"Access token required"}
Response: 401 Unauthorized. This characterizes the gap as any-authenticated rather than fully anonymous: authentication is enforced, authorization is not.
Remediation
Fix 1: Enforce an authorization check in the resolver
// BEFORE (Vulnerable): resolver checks for a token but not for a role
finalizeChampionship: async (_parent, { seasonId }, ctx) => {
// ctx.user is set by auth middleware, but no role/ownership check follows
return finalizeSeason(seasonId);
}
// AFTER (Secure): require an administrator before performing the action
finalizeChampionship: async (_parent, { seasonId }, ctx) => {
if (!ctx.user) throw new AuthenticationError('Access token required');
if (ctx.user.role !== 'admin') throw new ForbiddenError('Admin privileges required');
return finalizeSeason(seasonId);
}
REST and GraphQL enforce authorization separately. The REST admin endpoints (/api/admin/*) were not tested in this engagement, so their authorization state is unknown; regardless, a check on the REST layer would not cover the GraphQL resolver. Every privileged resolver needs its own authorization check.
Additional recommendations:
- Do not rely on the front end omitting a mutation as an access control. Every mutation that performs a privileged or state-changing action must authorize the caller server side.
- Disable GraphQL introspection in production as defense in depth. This hides the operation but is not the root fix; the missing authorization check is.
- Avoid returning secrets such as
prizeCodeon operations reachable by non-privileged callers; scope sensitive fields to the roles entitled to them.
OWASP Top 10 Coverage
- A01:2021 Broken Access Control: the GraphQL
finalizeChampionshipresolver performs no function-level authorization check, so a non-privileged authenticated user can execute a privileged operation and read its secret result. - A05:2021 Security Misconfiguration: GraphQL introspection is enabled in the deployed environment, exposing the privileged mutation and its
prizeCodefield in the schema. - OWASP API Security Top 10, API5:2023 Broken Function Level Authorization: the finding is a textbook case of a privileged API operation lacking a role check.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Intercepting proxy and request replay (capture, modify headers, re-send) |
| GraphQL introspection query | Enumerate the schema (types, queries, mutations) |
| Manual JWT decode (base64) | Inspect token claims and confirm no role is present |
References
- CWE-285 (Improper Authorization): https://cwe.mitre.org/data/definitions/285.html
- CWE-862 (Missing Authorization): https://cwe.mitre.org/data/definitions/862.html
- OWASP Top 10 2021, A01 Broken Access Control: https://owasp.org/Top10/A01_2021-Broken_Access_Control/
- OWASP API Security Top 10 2023, API5 Broken Function Level Authorization: https://owasp.org/API-Security/editions/2023/en/0xa5-broken-function-level-authorization/
- GraphQL introspection: https://graphql.org/learn/introspection/
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
finalizeChampionship with no Authorization header |
401 {"error":"Access token required"} |
Authentication is enforced at the endpoint; the gap is authorization, not authentication. The operation is any-authenticated, not anonymous. |
/api/admin/users and /api/admin/sessions broken access control |
Not exercised | Referenced by the front end but out of scope for the flag; the lab was torn down before a dedicated pass. Server-side authorization state unknown. |
JWT HS256 forge / alg=none / weak-secret crack |
Not pursued | The token carries no role claim, so forging or cracking it grants no extra privilege. Low value against this finding. |
Hidden destructive REST verbs (PUT/PATCH/DELETE) on /api/* |
None found in front-end bundle | The seven .delete() call sites in the bundle are React Map/Set internals, not HTTP calls. |
Tags: #graphql #broken-access-control #authorization-bypass #bugforge #sokudo #webapp
Document Version: 1.0
Last Updated: 2026-10-03