$ cat cve-2026-75650-apsb26-146-magento-built-the-object-before-it-checked-the-class-type.md
CVE-2026-75650 / APSB26-146: Magento built the object before it checked the class type
A close reading of Adobe's APSB26-146 patch for Magento, which fixes eight files across all supported lines. It shows how the block factory built classes before checking their type, and how unvalidated request parameters reached template code.
dor.ky08 Sept 2026 · 8 min read · summary
Read on dor.ky ↗This post is about CVE-2026-75650 / APSB26-146.
This post discusses the StyleSmuggler exploit, discovered by my good friends at Sansec.
Adobe has shipped a set of composer patches under the internal reference VULN-39341, one build for each supported Magento line: 2.4.4-p18, 2.4.5-p17, 2.4.6-p15, 2.4.7-p10, 2.4.8-p5 and 2.4.9. I diffed all six against each other first, because that usually tells you something. They are byte-for-byte the same fix. The only differences are hunk offsets, blob hashes and one line where the older branch still uses const instead of public const. Every supported version gets the identical eight-file change, which means the bug has been in the platform since at least 2.4.4 and probably a lot longer.
What follows is an analysis of what the patch closes, based on the patch itself and on reading the pre-patch code in a real 2.4.7 vendor tree. There is no working exploit here and I am not going to publish one. The interesting part is the shape of the bugs, because two of them are patterns you have almost certainly written yourself.
¶The eight files
vendor/magento/framework/View/Element/BlockFactory.php
vendor/magento/framework/View/Layout/Generator/Block.php
vendor/magento/framework/Webapi/ErrorProcessor.php
vendor/magento/module-backend/Model/Widget/Grid/Row/UrlGeneratorFactory.php
vendor/magento/module-email/Block/Adminhtml/Template/Preview.php
vendor/magento/module-email/Model/AbstractTemplate.php
vendor/magento/module-newsletter/Block/Adminhtml/Queue/Preview.php
vendor/magento/module-newsletter/Block/Adminhtml/Template/Preview.php
pub/errors/processor.php
Three distinct problems, and they are worth separating because they carry very different risk.
¶Problem one: instantiate first, type-check second
This is the important one. BlockFactory::createBlock() looked like this:
public function createBlock($blockName, array $arguments = [])
{
$blockName = ltrim($blockName, '\\');
$block = $this->objectManager->create($blockName, $arguments);
if (!$block instanceof BlockInterface) {
throw new \LogicException($blockName . ' does not implement BlockInterface');
}
...
Read the order of operations. The class is constructed, and only then is it asked whether it was ever allowed to exist. By the time the instanceof check runs and throws, the constructor has already executed. So has every constructor in the dependency graph the object manager resolved to build it, and so has any __destruct that fires when the rejected object is garbage collected.
That is arbitrary object instantiation, which in PHP is a well-worn route to worse things. You are not looking for a class that does something useful when you call a method on it, because you never get to call a method. You are looking for a class whose construction has a side effect: one that writes a file, opens a connection, deserializes something or hands a value to a sink further down. Magento’s object manager can construct a very large share of the classes in the codebase, plus everything in vendor/, and the arguments array is under the caller’s control too.
The fix is to resolve the DI preference for the requested name and type-check the string, before anything gets built:
$resolvedType = $this->objectManagerConfig->getInstanceType(
$this->objectManagerConfig->getPreference($blockName)
);
if (!is_a($resolvedType, BlockInterface::class, true)) {
throw new \LogicException($blockName . ' does not implement BlockInterface');
}
$block = $this->objectManager->create($blockName, $arguments);
is_a() with the third argument set to true accepts a class name string rather than an object, so the check happens with nothing instantiated. Resolving the preference first matters, because the name passed in is frequently an interface or a virtual type, and checking the raw name would reject legitimate blocks.
The same pattern, and the same fix, in UrlGeneratorFactory::createUrlGenerator(). Before:
$rowUrlGenerator = $this->_objectManager->create($generatorClassName, $arguments);
if (false === $rowUrlGenerator instanceof GeneratorInterface) {
throw new \InvalidArgumentException('Passed wrong parameters');
}
After, with Adobe’s own comment left in the patch:
// Validate the type BEFORE instantiation.
if (!is_a($generatorClassName, GeneratorInterface::class, true)) {
throw new \InvalidArgumentException('Passed wrong parameters');
}
return $this->_objectManager->create($generatorClassName, $arguments);
That one is reached from Magento\Backend\Block\Widget\Grid\ColumnSet::__construct(), which reads the generator class name straight out of block data:
$generatorClassName = \Magento\Backend\Model\Widget\Grid\Row\UrlGenerator::class;
if (isset($data['rowUrl'])) {
$rowUrlParams = $data['rowUrl'];
if (isset($rowUrlParams['generatorClass'])) {
$generatorClassName = $rowUrlParams['generatorClass'];
}
Anywhere block data is influenced by something less trustworthy than a developer editing layout XML, that is a class-name sink.
¶Why the block factory is the one that matters
A factory buried in the framework only matters if something can feed it a name. Something can.
Magento\Email\Model\Template\Filter::blockDirective() is what implements the {{block class="..."}} directive:
if (isset($blockParameters['class'])) {
$block = $this->_layout->createBlock($blockParameters['class'], null, ['data' => $blockParameters]);
}
Layout::createBlock() goes to Layout\Generator\Block::getBlockInstance(), which goes to BlockFactory::createBlock(). And Magento\Cms\Model\Template\Filter is a one-line subclass:
class Filter extends \Magento\Email\Model\Template\Filter
So the directive is live in CMS pages, CMS blocks and widget content, and the result renders on the storefront to anonymous visitors. Pre-patch, the class named in that directive was constructed before anyone asked whether it was a block. An admin whose role grants nothing but CMS editing could reach a construction primitive across most of the codebase, and see the fallout on a public page. That is a privilege escalation inside the admin, not just a hardening nit.
The email and newsletter template editors reach the same directive by the same route, which is why the preview blocks are in this patch at all.
There is a small companion change in Layout\Generator\Block that tells you the new check throws in places the old code never expected an exception:
- } catch (\ReflectionException $e) {
+ } catch (\ReflectionException | \LogicException $e) {
Without it, a layout naming a non-block class would surface the new LogicException as a fatal instead of a logged critical. Worth knowing if you have a theme or a module that names something odd in layout XML: post-patch it will log and render empty rather than blow up, but it also will not silently construct the object any more.
¶Problem two: request parameters typed as “whatever arrived”
Magento\Email\Block\Adminhtml\Template\Preview::_toHtml() builds an unsaved template out of raw request input:
$template->setTemplateType($request->getParam('type'));
$template->setTemplateText($this->_maliciousCode->filter($request->getParam('text')));
$template->setTemplateStyles($request->getParam('styles'));
getParam() returns whatever the query string or POST body says, and PHP will happily give you an array from text[]=a&text[]=b. The filter in the middle does not stop that. Magento\Framework\Filter\Input\MaliciousCode::filter() is documented as taking string|array, and it runs preg_replace() over the value, which returns an array when handed one:
/**
* @param string|array $value
* @return string|array
*/
public function filter($value)
{
$replaced = 0;
do {
$value = preg_replace($this->_expressions, '', $value ?? '', -1, $replaced);
} while ($replaced !== 0);
So an array, or a nested array, survived into setTemplateText(), which is plain magic-method setData() with no type at all, and from there into the template filter which expects a string throughout. The patch replaces the magic setters with real ones on Magento\Email\Model\AbstractTemplate:
public function setTemplateText($value)
{
return $this->setData('template_text', is_string($value) ? $value : '');
}
public function setTemplateStyles($value)
{
return $this->setData('template_styles', is_string($value) ? $value : '');
}
Coerce to empty string rather than throw, so nothing that legitimately passes null keeps working. Note also that styles was never passed through the malicious-code filter at all, and still is not: the string coercion is the whole of its protection.
The lesson generalises past this patch. getParam() is not a string. Every setSomething($request->getParam(...)) in your own code is an untyped hand-off, and Magento’s magic setters will store an array as cheerfully as a string.
¶Problem three: attacker-controlled strings written to disk as PHP-adjacent files
Two near-identical fixes, one in the Web API error path and one in the public error page.
Magento\Framework\Webapi\ErrorProcessor::_saveFatalErrorReport() before:
$this->directoryWrite->create('report/api');
$reportId = abs((int)(microtime(true) * random_int(100, 1000)));
$this->directoryWrite->writeFile('report/api/' . $reportId, $this->serializer->serialize($reportData));
After:
if (is_string($reportData)) {
$reportData = str_replace('<?', '< ?', $reportData);
}
$this->directoryWrite->writeFile(
'report/api/' . $reportId,
self::REPORT_EXECUTION_GUARD . PHP_EOL . $this->serializer->serialize($reportData)
);
where the guard is the string <?php exit; ?>. pub/errors/processor.php gets the same treatment for the storefront reports under var/report/, with a recursive walk because that report data is an array:
private function sanitizeReportData(array $data): array
{
array_walk_recursive($data, static function (&$value) {
if (is_string($value)) {
$value = str_replace('<?', '< ?', $value);
}
});
return $data;
}
and a matching reader that strips the guard back off, so existing report viewing still works:
private function readRe
source
This is a summary of an article first published by dor.ky. The full piece is on their site.