Blog

StyleSmuggler (CVE-2026-75650): a technical analysis

StyleSmuggler (CVE-2026-75650) is an unauthenticated RCE affecting every current Magento and Adobe Commerce version. We rebuild the full chain from source and verify it on live stores, and show why the September releases ship without the fix.

StyleSmuggler (CVE-2026-75650): a technical analysis
Dragos Albastroiu

Dragos Albastroiu

September 8, 2026

On 5 September 2026, Sansec disclosed StyleSmuggler, an unauthenticated remote code execution vulnerability in Magento Open Source and Adobe Commerce, tracked as CVE-2026-75650 with a CVSS base score of 10.0. Sansec confirmed the full chain on clean installs of 2.4.7, 2.4.8, and 2.4.9, and withheld its internals. This post reconstructs the chain from source code and verifies each step against live instances. The first observed exploitation occurred on 4 September at 22:20 UTC; the first victim, running 2.4.6-p15 with the July and August 2026 security patches, was breached within 50 minutes. Adobe published APSB26-146 with a hotfix on 7 September.

This post reconstructs and verifies the entire chain except the delivery of parameters from an anonymous request into the template model, which is still under investigation.

All file and line references were verified against the public magento/magento2 repository (tag 2.4.8-p4) and a composer distribution build of 2.4.9.


Adobe's hotfix (VULN-39341)

The fix for CVE-2026-75650 is not part of any release. It is a zip of composer patches (VULN-39341-composer-patches.zip, downloadable from repo.magento.com without an account), with one patch per version line from 2.4.4-p18 through 2.4.9. All six patches modify the same nine files with identical changes.

What the hotfix changes

The gadget factory now validates before construction. The pre-patch UrlGeneratorFactory, shown in full later in this post, created the object first and checked instanceof after. The payload executes inside the constructor, so the check always ran too late. The fix flips the order:

1// Validate the type BEFORE instantiation. 2if (!is_a($generatorClassName, \Magento\Backend\Model\Widget\Grid\Row\GeneratorInterface::class, true)) { 3 throw new \InvalidArgumentException('Passed wrong parameters'); 4} 5return $this->_objectManager->create($generatorClassName, $arguments);

Aws\S3\S3Client does not implement GeneratorInterface, so it is never constructed. Because the check sits at the factory, it holds for every path an attacker could take.

Block creation validates the resolved type. BlockFactory::createBlock() resolves the requested name through the object manager's preference chain and checks is_a($resolvedType, BlockInterface::class, true) before calling create(). The layout generator catches the resulting LogicException. A {{block}} directive can still instantiate any block class, and nothing else.

The preview blocks require authorization. The Email template preview block and the two Newsletter preview blocks now begin _toHtml() with an authorization check. These three blocks are the only code in core that reads text, type, and styles from the request into the template model:

1if (!$this->_authorization->isAllowed(self::ADMIN_RESOURCE)) { 2 return ''; 3}

Template text and styles must be strings. AbstractTemplate gains setTemplateText() and setTemplateStyles() overloads that replace non-string values with an empty string. styles[...] arrives as an array, and the coercion discards it.

Report files begin with an execution guard. The web API fatal writer (Framework\Webapi\ErrorProcessor) and the frontend report processor (pub/errors/processor.php) prefix new reports with <?php exit; ?> and replace <? with < ? inside report data. A subsequent include of a guarded file terminates at the first line. The report viewer strips the guard before display, so legitimate reads are unaffected.

Residual risk

The template filter, the directive processors, the signature scheme that seals variable values, the object manager's instance-key argument resolution, and the three include sinks are unmodified. Adobe's patch closes the gadget and the known delivery channels. The placement channels outside the two guarded report writers remain: system.log through the store-code error, session files, and custom-option uploads. The email render path cannot be disabled as a workaround because it is required for legitimate store operations.

The September releases ship without the fix

The version tags for the September security releases (2.4.8-p5, 2.4.7-p10, 2.4.6-p15, 2.4.5-p17, 2.4.4-p18) appeared on 7 September. We diffed each against its predecessor. Every security-relevant change in them (the custom-option path validators, the REST input deduplication, the GraphQL parser limits) already existed in vulnerable 2.4.9. The releases are routine line syncs and contain no VULN-39341 hunk.

On a clean 2.4.8-p5 install with default configuration, the full chain from this post executes as shipped: a single request produced our marker file. If you installed the September releases and your tooling validates patch level by version, you are still exposed. The only remedy is the hotfix zip, and patch state is not derivable from the version string. Adobe's knowledge base notes this and tells Cloud merchants to confirm with vendor/bin/magento-patches status. Do not verify the fix by version. Check the file contents instead:

  • vendor/magento/module-backend/Model/Widget/Grid/Row/UrlGeneratorFactory.php validates with is_a before calling create()
  • newly written files under var/report/ begin with <?php exit; ?>

Overview of the chain

Diagram of the StyleSmuggler (CVE-2026-75650) attack chain in Magento: log poisoning, unauthenticated payment-failed email render, template directive, object injection, code execution through include

The attack has two steps.

In the first step, the attacker places PHP code inside a file that Magento itself writes: a log line in var/log/system.log, a failure report under var/report/, a session file, or a file uploaded through a product custom option. Rejected input is stored verbatim in several places, so this step requires no vulnerability on its own.

In the second step, the attacker triggers an email render. During that render, a template directive instantiates a class chosen by the attacker, and the object manager interprets an attacker-supplied parameter array as a live object graph. The resulting call chain reaches a dependency-injection compiler class that includes a file path taken from the same parameter array. The included file is the one poisoned in the first step.

Two properties make this chain resistant to code review. First, each component is an intentional feature: a template engine that instantiates blocks, an object manager that resolves instance keys, a compiler that includes generated configuration files. Second, no static call path exists from any HTTP entry point to the include sinks. The connection is assembled at runtime from strings that arrive in a request.


The template filter and directive dispatch

Magento renders transactional email through Magento\Framework\Filter\Template::filter() (lib/internal/Magento/Framework/Filter/Template.php:195). The method scans the template text for directives (the {{...}} constructions) and passes each match to a registered processor.

The dispatch for most directives goes through Magento\Framework\Filter\DirectiveProcessor\LegacyDirective::process() (lib/internal/Magento/Framework/Filter/DirectiveProcessor/LegacyDirective.php:38):

1$reflectionClass = new \ReflectionClass($filter); 2$method = $reflectionClass->getMethod($construction[1] . 'Directive'); 3$method->setAccessible(true); 4return (string)$method->invokeArgs($filter, [$construction]);

$construction[1] is the directive name, taken from the template text. A directive named block resolves to blockDirective(), a directive named layout to layoutDirective(). The template text controls which method runs.

Directive parameters can also reference template variables. In Template::getParameters() (Template.php:511), any parameter value beginning with $ is resolved against the render context. One of the values in that context is template_styles, exposed by Magento\Email\Model\AbstractTemplate (AbstractTemplate.php:503). In this chain template_styles holds the attacker's parameter array.


The block directive

Magento\Email\Model\Template\Filter::blockDirective() (app/code/Magento/Email/Model/Template/Filter.php:407) implements {{block ...}}. It grants three capabilities to whoever controls the template text.

It instantiates a class by name (Filter.php:414):

1if (isset($blockParameters['class'])) { 2 $block = $this->_layout->createBlock($blockParameters['class'], null, ['data' => $blockParameters]); 3}

The class parameter selects the class, and all remaining parameters become the new block's initial data.

It applies the remaining parameters as setters (Filter.php:428):

1foreach ($blockParameters as $k => $v) { 2 if (in_array($k, $skipParams)) { continue; } 3 $block->setDataUsingMethod($k, $v); 4}

setDataUsingMethod('generatorClass', $v) calls setGeneratorClass($v) when such a method exists and writes the data key otherwise. There is no allowlist on parameter names.

It invokes a method on the result, chosen by name (Filter.php:438):

1if (isset($blockParameters['output'])) { 2 $method = $blockParameters['output']; 3} 4if (!isset($method) || !is_string($method) || !method_exists($block, $method) || !is_callable([$block, $method])) { 5 $method = 'toHtml'; 6} 7return $block->{$method}();

The guard verifies that the method exists and is callable. It places no restriction on which method.


From directive parameter to object manager

The link between the template layer and the object manager sits in a backend grid block. The constructor of Magento\Backend\Block\Widget\Grid\ColumnSet (app/code/Magento/Backend/Block/Widget/Grid/ColumnSet.php:112) reads a rowUrl structure from its data:

1$generatorClassName = \Magento\Backend\Model\Widget\Grid\Row\UrlGenerator::class; 2if (isset($data['rowUrl'])) { 3 $rowUrlParams = $data['rowUrl']; 4 if (isset($rowUrlParams['generatorClass'])) { 5 $generatorClassName = $rowUrlParams['generatorClass']; 6 } 7 $this->_rowUrlGenerator = $generatorFactory->createUrlGenerator( 8 $generatorClassName, 9 ['args' => $rowUrlParams] 10 ); 11}

The class name reaches UrlGeneratorFactory::createUrlGenerator() (app/code/Magento/Backend/Model/Widget/Grid/Row/UrlGeneratorFactory.php:37):

1$rowUrlGenerator = $this->_objectManager->create($generatorClassName, $arguments); 2if (false === $rowUrlGenerator instanceof \Magento\Backend\Model\Widget\Grid\Row\GeneratorInterface) { 3 throw new \InvalidArgumentException('Passed wrong parameters'); 4}

A string that originated as a directive parameter now reaches objectManager->create() with attacker-controlled constructor arguments, and the type check runs after construction.


Argument injection through instance keys

The connection between this factory call and the code execution sink consists of two mechanisms.

The first is in the object manager. When create() resolves constructor arguments, Magento\Framework\ObjectManager\Factory\AbstractFactory::parseArray() (lib/internal/Magento/Framework/ObjectManager/Factory/AbstractFactory.php:192) walks the argument arrays:

1foreach ($array as $key => $item) { 2 if ($item === (array)$item) { 3 if (isset($item['instance'])) { 4 $array[$key] = $this->objectManager->get($item['instance']); 5 } 6 } 7}

Any nested array that contains an instance key is replaced with a live object during argument resolution. An HTTP parameter tree can therefore place real instances inside constructor arguments.

The second is in the AWS SDK for PHP, which ships with every current Magento as a composer dependency. The constructor of Aws\AwsClient ends with:

1if (isset($args['with_resolved'])) { 2 $args['with_resolved']($config); 3}

with_resolved is a documented SDK option: a callable invoked with the resolved client configuration.

The styles request parameter is the rowUrl payload itself. The render context exposes it as template_styles, and the directive references it as rowUrl="$template_styles", which is why the observed traffic carries styles[generatorClass] at the top level instead of nested under rowUrl. The generatorClass value is Aws\S3\S3Client. The factory calls create('Aws\S3\S3Client', ['args' => $rowUrlParams]), and the object manager maps args onto the SDK constructor's $args parameter. The attacker's array carries four groups of keys:

  • generatorClass: Aws\S3\S3Client
  • region, version, credentials: the minimum required for the client to construct. Construction performs no network requests.
  • with_resolved: [["instance" => "Magento\\Setup\\Module\\Di\\Code\\Scanner\\ArrayScanner"], "collectEntities"]. The instance mechanism turns element zero into a live ArrayScanner during argument resolution.
  • the path to the poisoned file, carried in both region and endpoint.

The path appears twice because of a validation detail in the SDK. Without an explicit endpoint, the SDK derives one from region and validates region as an RFC host label. A file path fails that check with an InvalidRegionException. Supplying the endpoint skips it. collectEntities then receives the resolved configuration array and iterates its values as file paths. The region value is reached early in the iteration, file_exists passes, and the file is included. In the modified log file, everything outside the PHP tags is emitted as plain output, and the code between the tags is executed.

An implementation detail in ArrayScanner provides a reliable forensic indicator. A file without a return statement makes include return 1, and the following line throws array_merge(): Argument #2 must be of type array, int given. A TypeError line in system.log indicates successful execution: the payload runs before the error is thrown. Disrex observed the dropped binary appear three seconds after this line. A payload that ends with return []; throws nothing.


The include sinks

The chain terminates in classes from Magento's dependency-injection compiler. Three of them accept a path and execute it.

setup/src/Magento/Setup/Module/Di/Code/Scanner/ArrayScanner.php:

1public function collectEntities(array $files) 2{ 3 $output = []; 4 foreach ($files as $file) { 5 if (file_exists($file)) { 6 $data = include $file; 7 $output = array_merge($output, $data); 8 } 9 } 10 return $output; 11}

setup/src/Magento/Setup/Module/Di/Code/Reader/ClassesScanner.php:132:

1private function includeClass(string $className, string $fileItemPath): bool 2{ 3 if (!class_exists($className)) { 4 require_once $fileItemPath;

setup/src/Magento/Setup/Module/Di/Code/Scanner/XmlInterceptorScanner.php:100:

1$filePath = stream_resolve_include_path(str_replace('_', '/', $className) . '.php'); 2if (file_exists($filePath)) { 3 require_once $filePath;

During bin/magento setup:di:compile these are legitimate: the compiler scans generated configuration arrays, which are PHP files that return arrays, and reflects over classes it needs. Their only callers in the codebase are the four compile task operations (setup/src/Magento/Setup/Module/Di/App/Task/Operation/). Nothing that serves HTTP traffic calls them.

ArrayScanner entered the codebase on 16 April 2015 (upstream change MAGETWO-36072) and has not changed in substance since 2017.


Payload placement

The first step of the attack requires a file under the installation containing PHP. Magento writes rejected input to disk in several places.

One sink is the store code error. app/code/Magento/Store/Controller/Store/Redirect.php:145 interpolates the requester-supplied store code into a rejection message:

1$this->messageManager->addErrorMessage(__("Requested store is not found ({$fromStoreCode})"));

A PHP fragment submitted as a store code is recorded verbatim in a main.CRITICAL line in var/log/system.log on multi-store installations. A second sink is failure reporting: payment failures and web API fatal errors write reports under var/report/ with request data embedded. Sansec's indicators include POST /paypal/transparent/response/?<?=eval(base64_decode(..., which is this sink. Both stores Disrex remediated were poisoned through system.log.

Session storage is a third placement channel. With file-based sessions, a failed customer login stores the submitted username in var/session/sess_<id>. The attacker knows the path because it derives from their own session cookie. Moving sessions to Redis or the database removes this file, and Sansec observed an attacker switch to a file uploaded through a product custom option eight seconds later. Because placement works through multiple independent channels, mitigating a single channel is insufficient.


The execution trigger

The model that processes the attacker's text is Magento\Email\Model\AbstractTemplate. It is the basis of every transactional email. getProcessedTemplate() (AbstractTemplate.php:337) exposes template_styles in the render context (line 503) and runs the template text through the filter (line 363):

1$result = $processor->filter($this->getTemplateText());

The trigger is the standard Payment Transaction Failed Reminder (checkout_payment_failed_template, declared in app/code/Magento/Checkout/etc/email_templates.xml:9), which we reproduced unauthenticated with a guest quote and a gateway rejection. The exploit executes during template rendering, regardless of whether the email is delivered. A high volume of malformed payment-failed email is an indicator of compromise.


Verification

All experiments ran on clean installs from public tags, with marker payloads only: a touch() inside an ordinary log line. No commands ran, and nothing contacted the network.

End-to-end execution. On a clean 2.4.8-p4 install, a single request carrying the directive in text and the gadget array in styles produced the marker file. The surrounding log lines appeared in the response as output, and construction ended with array_merge(): Argument #2 must be of type array, int given, matching the signature observed in compromised stores. The driver for this reproduction was the authenticated admin preview, which reads the same three parameters.

The unauthenticated trigger. The Payment Transaction Failed Reminder render was reproduced with a guest quote over the REST API and a gateway rejection. The render ran with stock template text and empty styles, which confirms the trigger requires no credentials.

Session file as payload. A failed login with PHP in the username places the payload in var/session/sess_<id>. Pointing the gadget's path parameters at that file executed it through the same chain. This confirms why relocating sessions does not neutralize the attack.

The hotfix in both directions. On a composer distribution build of 2.4.9 with PageBuilder and the market-only modules, the chain executes before the hotfix and is inert after it. We applied each line's patch variant where the tag exists: all six carry identical guards and apply cleanly. We also tested 2.4.6-p15, which the first confirmed victim ran. On that version the stock build executes the chain and the patched build does not.

The observed attack shape against the patched build. We replayed the request from Sansec's indicators (POST /graphql?styles[generatorClass]=Aws\S3\S3Client&..., the only public capture of the gadget's parameters; Sansec attributes that shape to a second attacker on the same victims) and confirmed the payment-failed render executes inside the same request. On the patched build nothing executes, no instance key is converted, and no attacker-controlled data reaches template position.

The gadget and delivery surfaces. We searched for alternative paths that would defeat the hotfix. A sweep of all 1,648 class-creation sites in the distribution (create($var), get($var), new $var) found none that take a request-derived class name without validation. We sent roughly 900 requests with unique markers across every frontend route, REST endpoint, GraphQL flow, and guest-reachable email trigger. Attacker input reached template variables but never template text. We characterized the deferred-signature scheme, which has prevented variable values from spawning directives since 2022: one signature per request, generated with random_int, rotated on every request even within a single PHP process. We found no forgery path.

The hotfix has one gap. The is_a check validates the class name but not the argument tree, which still passes through instance-key resolution. Exploiting this requires an array with attacker-chosen keys to reach block data, and no stock channel does that today. It is a property to monitor in future changes; it is not a working bypass.


The unauthenticated entry point

Magento guest checkout requires no account, so a guest quote gives an attacker an anonymous position.

Three of the chain's four parts work from that position, and we verified each one. Placement: the store-code error, the payment response query string, and the failed login username all land in files the attacker can name. Trigger: a guest quote and a gateway rejection render the Payment Transaction Failed Reminder. Execution: the parameter language and the gadget run during the render, and this post reconstructs both.

We could not reproduce the fourth part, injecting text and styles into the template model from an anonymous request. Roughly 900 marked requests across every frontend route, REST endpoint, GraphQL flow, and guest-reachable email trigger never put attacker input into template text position, and a static sweep of the entire clean-install package set found no writer of template text or styles outside admin-authenticated code. The delivery therefore depends on a non-default component or on a mechanism outside normal request handling.


Indicators

From Sansec, Disrex, and our verification runs; match on shape where literal values drift:

  • Requests: payload-bearing requests such as POST /paypal/transparent/response/?<?=.... Payload delivery also works through request headers: Disrex captured a variant with the payload in the User-Agent header, so the request line itself shows nothing unusual. The exploit requests carried python-requests 2.15.0 (first wave) and python-requests/2.32.4 (second wave); any scripting client on that route with styles[] parameters is the signal. Log searches: styles(\[|%5B), generatorClass, with_resolved, cdnflare, and the trigger headers X[_-](TRACE[_-])?[0-9A-Fa-f]{10,12} (two families: X-TRACE-<10 hex>, then X-<12 hex>).
  • Execution proof: MG<20 hex>::<base64>::/MG<20 hex> in response bodies or logs proves the payload executed. Requests that fail to execute leave no marker. Search MG[0-9a-f]{16,}::.
  • Files: PHP content inside var/log/system.log and var/report/, check both; array_merge(): Argument #2 must be of type array, int given in system.log, with collectEntities in the accompanying trace.
  • Staged payloads: the operator re-stages files on compromised hosts. The dropper URL Sansec published was serving a different second stage as of 8 September (sha256 9a8b8344d47b4a1f68563db7e622490fc172cde19d47dc0506554e69e68361f5): a PHP payload that evals app/etc/env.php for DB credentials and dumps core_config_data base URLs plus a month of order statistics by payment method. Hunt for staged text files: find . -name '*.txt' -path '*js*', and any PHP under pub/media.
  • Files: PHP content inside var/log/system.log and var/report/, check both; array_merge(): Argument #2 must be of type array, int given in system.log, with collectEntities in the accompanying trace.
  • Sessions: <?php inside var/session/sess_*. Failed-login usernames carry the payload, so any session file containing PHP marks placement. Verified end to end in this post.
  • Email: a burst of Payment Transaction Failed Reminder messages to throwaway guest addresses marks trigger probing, whether or not delivery succeeded.
  • Second actor: Sansec attributes POST /graphql?styles[...]=, POST /graphql with PHP in the Store: header under a storeConfig cover query, ss5_<hex> and ss6_<hex> markers echoed into pages, and GET /customer/section/load/?sections=customer&force_new_section_timestamp=true to a second attacker using unrelated tooling on the same victims. That actor's web shell is pub/media/catalog/product/cache/ss_<hex>/sync_<hex>.php, which returns 404 without the header X-Cache-Token: fced27f6d57702565353ecc11722533b, and calls back to <id>.daf892t5qau4og8pi4cghbc6fhm1dim3.oast.site.
  • Process: a [kworker/u:8:0], fc-cache, or chronyd name running as the web user with nonzero memory usage; match on args instead of comm; hash /proc/<pid>/exe, which may point to a deleted inode.
  • Persistence: cron spool entries under /var/spool/cron/ that reappear within a second of removal; implant under ~/.local/share/.gvfsd/, ~/.cache/fontconfig/, /tmp/.fc-<hex>/, or /tmp/.chrony-<hex>/; schedules at */5, 13,43, and 57,27.
  • Network: payload host 247.cdnflare.xyz; the implant may make no outbound connections and read Magento's Redis instead.
  • Patch state: no version string carries VULN-39341. Confirm the fix by the is_a guard in UrlGeneratorFactory or the <?php exit; ?> prefix on newly written report files.

Timeline

DateEvent
2015-04-16DI compiler and ArrayScanner enter the codebase (MAGETWO-36072)
2015-08Email template styles machinery merges
2026-09-04 22:20 UTCFirst observed exploitation (Sansec)
2026-09-04/05Two stores breached, both poisoned through system.log (Disrex)
2026-09-05Sansec discloses StyleSmuggler; no vendor patch exists
2026-09-07Community mitigations ship (Disrex, Graycore, ProxiBlue)
2026-09-07 20:20 UTCAdobe publishes APSB26-146 and the VULN-39341 hotfix
2026-09-08September security releases ship without the fix

Credits

Sansec discovered and named the vulnerability and handled the disclosure. Disrex performed the incident forensics and built the sink-guard mitigation. Graycore and ProxiBlue shipped stop-gap hardening. aisafe.io produced the source-level reconstruction and verification in this post.