Disabling GraphQL looked like the fix for the StyleSmuggler zero-day. But it wasn't, and Adobe's patch proves it.
For the four days between disclosure and the hotfix, the advice everywhere was the same: turn GraphQL off. Sansec said it, and they're the ones who found the thing while it was already being exploited in the wild. Everyone else repeated it, and I passed this advice out myself as well. With no patch available, closing the road people are driving down is exactly what you do.
Then Adobe shipped VULN-39341 on the evening of September 7th, which was out of cadence and a day ahead of their own schedule. I pulled the archive down and read the whole diff:
curl -fSLO https://repo.magento.com/patch/VULN-39341-composer-patches.zipIt included nine changed files, but not one of them has anything to do with GraphQL.
Update (2026-10-16): Adobe's October security release, APSB26-153 (October 13, Priority 2), does not replace this hotfix. The bulletin says to "apply the Hotfix for CVE-2026-75650 in addition to the security updates released in this bulletin." So moving to
2.4.9-2026-oct(or the matching build on your line) still means applyingVULN-39341. Adobe has also added patch variants for older patch levels since this post went up; see Applying the patch.
That changes what "patched" means here. The patch also leaves a standing defect in the core, plus a bug pattern that has nothing to do with Magento at all (and it’s probably sitting in your own code right now).
The road versus the hole
StyleSmuggler is CVE-2026-75650, CVSS 10.0, Priority 1. Unauthenticated RCE. This is about as bad as it gets.
The attack runs in two stages.
Stage one is the write. An attacker-controlled block of PHP gets injected into a file that Magento generates itself, which is typically a report under var/report. Nothing executes yet, because it's just text sitting in a file.
Stage two is the execution. Magento then processes that file through its template system, where the injected code finally runs.
GraphQL was one of the ways to reach stage one, but it was never the only one. The same poisoned-report step is reachable through pub/errors/processor.php, the error handler that runs on every Magento instance, and it has nothing to do with the API layer at all.
Adobe's own classification tells us the same story. They filed it under the CWE-1336 classification, which is Improper Neutralization of Special Elements Used in a Template Engine. And if you read it through, nothing in the description of it is about an endpoint.
So if you turned GraphQL off last week and moved on to other things, you closed a lane, but kept the onramp open.
What the patch actually changes
The diff sorts cleanly into four groups, and they map onto the two different stages.
1. Neutralize the payload on the way in
Both pub/errors/processor.php and Magento\Framework\Webapi\ErrorProcessor now write a literal "<?php exit; ?>" into the first line of every generated report. They also walk the report data recursively, and rewrite every "<?" into "< ?", so the payload won’t be able to open a new PHP tag itself.
A readReportFile() helper then strips that guard back off when a report is legitimately loaded for viewing, so all of the existing report tooling keeps working.
2. Reject non-string template payloads
The Magento\Email\Model\AbstractTemplate class picks up two setters that didn't exist before:
public function setTemplateStyles($value)
{
return $this->setData("template_styles", is_string($value) ? $value : "");
}The template_styles data property previously accepted arrays and objects, which then survived all the way into the directive filter layer. That field is the "Style" this exploit is named after.
3. Gate the admin preview blocks
The email template preview, the newsletter template preview, and the newsletter queue preview would all render without any ACL check at all. Now, each one picks up an ADMIN_RESOURCE constant (either Magento_Email::template, Magento_Newsletter::template, or Magento_Newsletter::queue), and an isAllowed() gate is now at the top of _toHtml().
4. Validate a class before instantiating it
This is the only change in the patch that isn’t specific to Magento, so it got its own section.
Build the object, then check it
The Magento\Framework\View\Element\BlockFactory::createBlock() method looked like this before the patch:
$block = $this->objectManager->create($blockName, $arguments);
if (!$block instanceof BlockInterface) {
throw new \LogicException("Invalid block type");
}Let’s read this one through line by line. The first line builds the object, which means the constructor has already run and already done whatever it does. Line two then confirms the type of object which was just created.
But that check doesn’t gate the execution; it’s reporting on something that already happened. And since $blockName is a variable, whatever class name gets sent in is what gets constructed, before the object type check occurs.
Adobe swapped in this block within the patch:
// Validate the type BEFORE instantiation.
$resolvedType = $this->objectManagerConfig->getInstanceType(
$this->objectManagerConfig->getPreference($blockName)
);
if (!is_a($resolvedType, BlockInterface::class, true)) {
throw new \LogicException("Invalid block type");
}
$block = $this->objectManager->create($blockName, $arguments);This resolves the DI preference first so it can validate the actual class binding that the object manager will build. In the old code, an interface or a rewritten preference could possibly validate the wrong class. The UrlGeneratorFactory class in the Backend module had the identical bug, and got the identical treatment. And the View\Layout\Generator\Block class was expanded to catch the LogicException alongside ReflectionException, since createBlock() could now throw it earlier than it used to.
When to apply this to your own code
"Validate before you instantiate" seems like a solid rule. But before you rewrite every factory in your custom code...
The old shape is not a bug by itself. If you check the type after you build an object with regular code, it’s fine. The class name is the literal value you typed yourself. You can leave these alone.
It only matters when the class name arrives from an external source. Let’s say a request, a queue payload, a configuration value, an error handler... anywhere the name has the possibility to get assembled from some area that isn’t directly typed out by a developer. That is the only place the order of those two lines matters, and it's exactly the reason why both GraphQL and the error handler worked as entry points here.
A broad type check isn’t enough. Narrowing a type to something like BlockInterface does add a level of validation, but an attacker could still choose from any class which implements it.
You could also build an array to allow specific, multiple implementations:
private const ALLOWED = [
"product" => ProductBlock::class,
"cart" => CartBlock::class,
];
if (!isset(self::ALLOWED[$blockName])) {
throw new \LogicException("Unknown block");
}
$block = $this->objectManager->create(self::ALLOWED[$blockName]);And maybe you’re even asking why Adobe just didn’t do this in the core. It’s because they can't; layout XML lets any module name any block, and having a specific allow-list would break every single third-party module.
You almost certainly don't have that constraint inside your own modules, so if you are allowing names from external sources, you will want to do this.
The defect the hotfix doesn't reach
The 9 files in Adobe's patch harden the two stages, but Sansec has since recommended applying an additional patch from Bigbridge, as a vulnerability still exists after you apply APSB26-146.
We’ll start with two filters, because everything here follows from the fact that they know about different things.
The Magento\Framework\Filter\Template class handles the template directives {{var}}, {{if}}, {{for}}, {{depend}}, {{template}} -- but it doesn't know the {{block}} directive exists.
The Magento\Email\Model\Template\Filter class then extends it, which inherits all of those but adds two additional methods: blockDirective() and inlinecssDirective(). The {{block}} directive is the dangerous one, because it allows the creation of a PHP class directly out of a name written in a template.
Let’s say that a string arrived through something like a text field submitted from a customer, and it looked like this:
{{block class="Some\Evil\Class"}}Remember, this is just free-form data that was submitted (it wasn’t typed by a developer). Before it’s rendered, it would then be fed to a filter in the framework.
The framework filter wouldn’t know what the {{block}} directive is, so it wouldn’t filter anything out at all; the string would just be returned exactly as it came in.
Magento now has to make a decision about that unchanged string, and here is why. Some directives get left alone on purpose. A child template can hand a directive up to its parent because it can only be resolved once the whole document is assembled. The only real case for this happening in the core is {{inlinecss}}, since CSS inlining needs the fully-finished HTML in order to be processed. Those get signed on the way back up, and that signature is what tells the parent it's cleared to execute.
So which ones actually get signed? Here's the code as it's shipped:
if ($this->filteringDepthMeter->showMark() > 1) {
// Signing own deferred directives (if any).
$signature = $this->signatureProvider->get();
foreach ($templateDirectivesResults as $result) {
if ($result['directive'] === $result['output']) {
$value = str_replace(
$result['output'],
$signature . $result['output'] . $signature,
$value
);
}
}
}The showMark() method is the nesting depth, so > 1 just means "I'm running as a child template". Then the test: did processing leave this directive unchanged?
There are two reasons output can equal the input, but that test only thinks of one of them.
- "Not my job, the parent handles this one." → the real deferral
- "I have no idea what this is." → attacker input
Both come back untouched and wrapped in the signature. The signed string then rides into a parent template running the email filter, which does implement blockDirective(), sees a valid signature, and builds the class.
The payload got signed precisely because the framework filter had no idea what it was.
But the fix makes the honest case declare itself. The Framework\Filter\Template class now gets a new list, along with a method to append to it:
private $deferredDirectives = [];
public function deferToParent(string $directive): void
{
$this->deferredDirectives[] = $directive;
}The one genuine deferral in Magento calls it. This is the entire change to the email filter:
if ($this->isChildTemplate()) {
+ $this->deferToParent($construction[0]);
return $construction[0];
}And signing that output now requires it to be in that list:
-if ($this->filteringDepthMeter->showMark() > 1) {
+if ($this->filteringDepthMeter->showMark() > 1 && $this->deferredDirectives) {
$signature = $this->signatureProvider->get();
foreach ($templateDirectivesResults as $result) {
- if ($result['directive'] === $result['output']) {
+ if ($result['directive'] === $result['output']
+ && in_array($result['directive'], $this->deferredDirectives, true)
+ ) {Reason A announces itself naturally, but Reason B never does -- so it never gets signed.
Mage-OS shipped the same fix in 3.5.0, and the original work is a gist from Jelle Besseling that Bigbridge ported over to be easily installed with Composer.
There’s a lesson here worth learning. Magento inferred intent from a side effect, and this should never happen. "The output didn't change" was standing in for "the author meant to defer this", and those two look the same, right until we get a CVSS 10.0 exploit.
Applying the patch
The original archive shipped six patch files, one per branch, covering the latest patch level of each line from 2.4.4-p18 through 2.4.9. They're all the same ~305 line change; you just want the one matching the version that you're actually on. Adobe has since added separate variants for older patch levels (for example VULN-39341_248-p1 for 2.4.8 and 2.4.8-p1, and VULN-39341_246-p11 for 2.4.6-p11 and older), so check the table in the KB for your exact version. Adobe's knowledge base article for APSB26-146 also includes the instructions to install it with Composer.
Once it’s installed, be sure to confirm it was actually applied with (on-prem, magento-patches comes from the magento/quality-patches package, so install that first):
vendor/bin/magento-patches -n status | grep 39341Eight of the nine files live under vendor/magento/, so Composer handles them without any issues. But the pub/errors/processor.php file does not; it sits in the project root, and it’s a potentially customized file in your Magento install. If you've ever skinned your error pages or dropped in a custom local.xml over there, expect to go through and apply the hunks one-by-one, then verify the patched code.
The Bigbridge patch is a separate file you drop into patches/ and let your Composer patch plugin apply it. Two variants ship in this one; one for 2.4.7 through 2.4.9, and one for 2.4.5-p1 through 2.4.6. This happens because the signing block is written differently across the related lines. Keep composer-exit-on-patch-failure turned on while you apply the patch, or a patch that fails to apply will just print a warning and composer install still exits 0 (aka success), which is how a patch application nearly silently fails.
Adobe also wants the encryption key rotated, along with every credential that key protected. Rotate those at their source (regenerate payment gateway credentials with the gateway, third-party keys with each related service, etc.) rather than only swapping the value in Magento. You’ll want to do this because rotating just the key does nothing regarding credentials which were already exploited by an attack.
Patching and cleaning are two different jobs
The patch stops the next attacker, but it doesn’t do anything about exploits which already occurred. This exploit started on September 4th, which was three days before Adobe shipped anything to fix it.
One of our University students pointed me at Swiss Up Labs' writeup on checking, mitigating and cleaning an affected store, and it's more thorough than anything I would have written. It carries the current Sansec indicators, the command-and-control addresses, the known malware paths, and the grep commands for finding injected tags:
grep -Ril 'x_trace_\|<?php' var/report/ var/log/ 2>/dev/nullThere are two very important details from it that if left incomplete, could still let an attacker in (even after the patches are applied).
Remove the cron persistence before you kill the process. Kill the process first, and the cron will simply start another copy of it a minute later. You'll think you cleaned up the machine, but it’ll be auto-spawned and running again, still on the old code.
Don't delete report or log files that match. Those are normal Magento files, and finding X_TRACE_ in one of these files doesn't mean the log contains malware; it just means the exploitation path was reached. Good idea to preserve them as evidence, then clean them out.
Also be sure to look for any random .php files living in the pub/media directory. There is never a legitimate reason for this directory to contain a PHP file, and a second attacker using the same entry point has been observed dropping a web shell into pub/media/catalog/product/cache.
A clean patch status is not a security posture
The first store confirmed compromised was running 2.4.6-p15, with the July and August 2026 patches applied, and security:patch-status came back clean.
The command wasn't lying. It answered the question it was built to answer: are all patches which exist applied? This only checks for patches that exist and doesn’t confirm your store is safe.
If you've been through this process on a store already, I'd really like to hear what turned up in the var/report directory that the published security reports didn't mention. The list of things to grep for gets better every day that goes by, and almost everything that helps protect our sites comes from developers who compare notes, rather than relying on a single source for protection.



