Security Research — CVE-2026-78416 — CVSS 8.8 High
CleanseMeMaybe
An authenticated RCE in Craft CMS, found by hiding a payload in the one shape the sanitizer never inspected.
The short version
- Craft CMS before
5.10.6and4.18.2— authenticated RCE, any CP user withaccessCp - The patch for CVE-2026-33157 stripped dangerous Yii config keys — but only from array keys
condition.configis a JSON string: decoded and merged back after the cleanse- Decoded
as/onkeys reachCraft::createObject()→ Yii behavior/event injection →shell_exec() - Fixed in 5.10.6 / 4.18.2 (2026-06-16) — GHSA-4ghj-75g5-h9pf
01How you end up here
I got into PHP gadget chains the way most people get into bad habits: slowly, then all at once. It started with Magento bug bounty work — file upload bypasses, extension validation — but somewhere along the way I stopped caring about the specific vulnerabilities and started caring about the language itself. PHP is everywhere, complex, and full of object-oriented magic most people never look at closely.
A gadget chain is what happens when you connect that magic. A class that does something boring — typecasting attributes — happens to call a method on another class, which happens to call another method, and three or four hops later you're at shell_exec() with attacker-controlled input. None of the intermediate classes were designed to be dangerous. They just connect.
Every big framework has its own flavor. Laravel has its famous __destruct() chains, Symfony has PropertyAccessor, Drupal has render arrays. Yii2 — which powers Craft CMS — has a particularly elegant one: config arrays with special keys. When an object is created through Yii::createObject(), the framework interprets two key prefixes:
as name → attach a behavior to the object
on event → register an event handler
This is a feature. But if an attacker controls that config array, they can attach arbitrary behaviors and register arbitrary handlers. The gadget writes itself.
02The patch you don't trust
Someone had already reported direct as/on injection into Craft's condition arrays. Craft 5.10.1 shipped the fix for CVE-2026-33157: Component::cleanseConfig(), a sanitizer that walks the config array and strips any key starting with as or on .
I downloaded 5.10.1 to test the patch. Because that's what you do. You don't trust patches. You verify them.
I traced the code and confirmed it with a canary — a non-executing payload pointing to a class that doesn't exist. Direct array keys: stripped, gone. Good patch.
But then I saw this, in Conditions::createCondition():
// The base config will be JSON-encoded within a `config` key if this
// came from a condition builder
if (isset($config['config']) && Json::isJsonObject($config['config'])) {
$config = array_merge(
Json::decode(ArrayHelper::remove($config, 'config')), // ← decoded AFTER cleanse
$config
);
}
// ...
return Craft::createObject([ // ← attacker-controlled config reaches Yii
'class' => $class,
'attributes' => $config,
'conditionRules' => $rules,
]);
The condition array has a field called config. Not a sub-array — a JSON string. And the sanitize that ran upstream only checks array keys:
public static function cleanseConfig(array $config): array
{
foreach ($config as $key => $value) {
if (is_string($key) && (str_starts_with($key, 'on ') || str_starts_with($key, 'as '))) {
unset($config[$key]);
continue;
}
if (is_array($value)) {
$config[$key] = static::cleanseConfig($value);
}
}
return $config;
}
It finds as something keys and removes them. But config is just config. Its value is a string. The cleanse doesn't parse JSON. It sees a normal string and moves on.
03The blind spot
You don't bypass this sanitizer with encoding tricks or unicode magic. You just put the payload in a format the sanitizer doesn't inspect. It's like a security guard who checks your backpack but not your jacket, because the policy says check backpacks.
The JSON can contain anything — including the exact as rce and on * keys the sanitize was designed to remove. It gets decoded and merged back in after the boundary. Without re-cleansing.
It was 3AM.
I rebuilt the canary for the JSON variant. Direct array version: stripped, working as intended. JSON-smuggled version: Yii threw class not found — meaning my nonexistent behavior class had reached the framework's object hydration. The bypass was confirmed before a single line of exploit code existed.
04Four boring classes
The chain itself is almost boring, which is the point:
Four classes. None of them meant to be exploitable. All of them just doing their job, connected by PHP's magic method dispatch.
The one wrinkle: the executor pipes through escapeshellcmd(), so >, |, ; and friends are escaped — no shell redirection. But /usr/bin/script -q -c id /path/to/webroot/output.txt works fine. script is a real binary that records a command's output to a file. No metacharacters. Just a command and an output file.
05HTTP 200
The trigger is a single authenticated POST to the element-search endpoint, with the payload JSON inside condition.config. The response is perfectly normal:
Command execution as the web user. From a non-admin Control Panel session. Through a JSON string the framework's own sanitizer was supposed to clean. Semi-blind by design — the trigger returns a normal response, and the proof is a server-side side effect read back in a second request. No callbacks, no collaborator, no reverse shell.
06The part nobody writes about
I reported it to the Craft CMS VDP on May 19, 2026 — four days after 5.10.1 shipped. Full source references, both Burp requests, differential proof of the bypass.
On May 26 it was closed as a duplicate of an earlier report. No technical explanation. No comparison. Just "submitted previously by another researcher."
I asked whether that report specifically included this variant — the condition.config JSON, decoded after the cleanse, in 5.10.1. No response. I asked again. Filed mediation. Silence.
So I checked the git history myself: four releases since 5.10.1, zero changes to any file in the vulnerable path. The bug was still there. Still exploitable. Latest release.
Then I sent a formal review request. Not a threat — a request for one sentence of technical justification: either the earlier report described this bypass, or it didn't.
June 1. Reopened. Accepted.
June 2, 8:09 PM. brandonkelly, Craft's lead developer: "Fixed for the next release via 353b5d67."
| Date | Event |
|---|---|
| 2026-05-15 | Craft 5.10.1 ships (CVE-2026-33157 patch) |
| 2026-05-19 | Report #1366 submitted — 4 days after release |
| 2026-05-21 | Acknowledged |
| 2026-05-26 | Closed as duplicate — no technical explanation |
| 2026-05-30 | Formal review request sent |
| 2026-06-01 | Reopened → Accepted |
| 2026-06-02 | Fixed on 5.x (353b5d67) |
| 2026-06-10 | Fixed on 4.x (789789dc) |
| 2026-06-16 | Craft 5.10.6 & 4.18.2 released with the fix |
| 2026-06-19 | Report resolved |
| 2026-08-24 | CVE-2026-78416 published — CVSS 8.8 High |
07The fix
The fix is exactly what you'd hope for. The decoded JSON goes back through the cleanse before it's merged:
$config = array_merge(
ComponentHelper::cleanseConfig(Json::decode(ArrayHelper::remove($config, 'config'))),
$config
);
The jacket gets checked too, now.
08What I keep
I started this journey fascinated by how PHP objects connect — how magic methods create invisible wires between classes, how a typecaster leads to a process executor leads to a shell. I found a real RCE by understanding those wires better than the people who wrote them.
Then I learned that sometimes the hardest part isn't finding the bug. It's getting someone to read the report.
And then the last lesson: the disclosure process has invisible wires too, and sometimes you have to pull them yourself. Politely. In writing. With timestamps.
The code doesn't lie. The git history doesn't lie. And uid=501(user) in my webroot doesn't lie.
CleanseMeMaybe. Because sometimes the sanitize function is just a suggestion. And sometimes you have to run it twice.