Apply Adobe’s patch — how. It supersedes the containment patch below; revert that first, never run both. Composer patches for 2.4.4-p18, 2.4.5-p17, 2.4.6-p15, 2.4.7-p10, 2.4.8-p5 and 2.4.9. A patch does not clean an infected node — the hunting and recovery steps below still apply.
Apply Adobe’s VULN-39341 patch, reverting any interim containment patch first. Run Sansec Shield, block GraphQL if you don’t use it, hunt both implant builds (gvfsd-user and fc-cache), rebuild anything that comes back dirty.
Last updated: 08/09/2026 — Adobe has shipped VULN-39341; it supersedes the containment patch here.
StyleSmuggler (CVE-2026-75650) — tracked by Adobe as VULN-39341 and shipped in bulletin APSB26-146 — is an unauthenticated Magento RCE: attacker-controlled data is written into a report or log file, then evaluated by a later request. Adobe patched it on 08/09, four days after exploitation started. Being current did not protect you before that.
Sansec found it, reproduced it and are still investigating. Their post is the source for everything here, and the only thing updated in real time. This page is the on-call runbook I am running across my Magento fleets: what to check, what to change, what to do when a node comes back dirty.
Emergency checklist
For every production environment:
- Get Sansec Shield in front of the store if it isn’t already.
- Block public access to GraphQL if your storefront and integrations don’t use it. Do this regardless of Shield.
- Check every application node for both implant builds: the fake
kworkerprocess andgvfsd-user, and the fakefc-cacheprocess. - Inspect existing
var/report,var/report/apiandvar/logfiles before deleting or replacing anything. - Apply Adobe’s VULN-39341 patch, reverting any interim containment patch first.
- Recycle PHP-FPM and OpCache, then verify from outside the origin.
- If any implant or persistence is found, isolate and rebuild that node. Removing the visible process is not recovery.
Update 07/09/2026: multiple implant identifiers
Sansec have now documented three names for the same implant in four days.
| Original | fc-cache (06/09) | chronyd (07/09) | |
|---|---|---|---|
| Process | [kworker/u:8:0] | fc-cache | chronyd |
| Drop path | ~/.local/share/.gvfsd/gvfsd-user | ~/.cache/fontconfig/fc-cache | /tmp/.chrony-<8hex>/chronyd |
| Lock | ~/.local/share/.gvfsd/.gvfsd_<8hex>.lock | /tmp/.fc_<8hex>.lock | none documented |
| Cron | */5 * * * * | 13,43 * * * * | none — self-relaunching |
| Version | — | 2.1.4 | 2.1.5 |
Two things about the chronyd build matter more than the rename.
An empty crontab is no longer evidence of a clean host. It has no cron entry and its parent is PID 1 — it relaunches itself. Every cron-based check published so far, mine included, will now pass on an infected host.
The name is chosen to defeat the obvious hunt. Anyone filtering UDP/123 for NTP-shaped C2 has already whitelisted chronyd as the legitimate time daemon. That exclusion hides this build completely.
Sansec give a beacon fingerprint that separates it from a real NTP client, and it is a good one: nine 48-byte datagrams roughly 10 ms apart every 60 seconds, where a real client sends one — and every datagram marked NTPv4 server mode, which a client has no reason to send.
Removal is unchanged: persistence first where it exists, then the process, then rebuild the node. See recovery — a renamed implant is the same compromise.
The checks and the IOC list below cover all three builds.
Have I Been Compromised?
Run these checks as the Magento filesystem user, then repeat the process and socket checks as root.
crontab -l | grep -iE 'gvfsd|fontconfig|fc-cache|chrony'
grep -rlE 'gvfsd|fc-cache|chronyd' /var/spool/cron/ 2>/dev/null
ls -la ~/.local/share/.gvfsd/ ~/.cache/fontconfig/fc-cache \
/tmp/.kw_* /tmp/.cache_* /tmp/.gvfsd[-_]* /tmp/.fc[-_]* /tmp/fc-cache \
/tmp/.chrony-*/chronyd 2>/dev/null
ps -eo user,pid,ppid,comm,args | awk '$1 != "root" && ($4 ~ /kworker|fc-cache|chronyd/)'
grep -r 'crontab command not allowed' /var/log/
find pub/media -name '*.php'
grep -Ral -e 'X_TRACE_' -e 'X-TRACE-' var/report/ var/log/
grep -Ral --fixed-strings '<?' var/report/ var/log/
grep -Fal 'array_merge()' var/log/system.log
Disrex found successful infections using var/log/system.log, so search both var/report and var/log. The commands match both marker spellings, both lock-file separators and both implant builds. The implant can also write directly to /var/spool/cron/.
The array_merge() error is the strongest application-level signal: it means Magento’s DI scanner included a poisoned file. No matches do not prove a host is clean. Resolve unreadable paths and scan errors before continuing.
~/.local/share/.gvfsd/ and ~/.cache/fontconfig/ are the PHP-FPM user’s home, not root’s. Check every account on shared hosting. Normal kworker processes are root-owned; one running as the Magento user is an IOC. fc-cache is a legitimate binary, so check what the process actually resolves to before acting on the name.
For each suspicious PID, capture:
readlink -v /proc/<pid>/exe
readlink -v /proc/<pid>/cwd
sha256sum /proc/<pid>/exe
lsof -nP -a -p <pid>
ss -plantu
On one affected node I found a non-root [kworker/u:8:0] resolving to ~/.local/share/.gvfsd/gvfsd-user, running from Magento’s pub directory and connected to local Redis. The on-disk and in-memory hashes differed, so hash /proc/<pid>/exe as well as the file.
For the fc-cache build, readlink /proc/<pid>/exe pointing at ~/.cache/fontconfig/fc-cache is the confirmation. Collect what you need before attaching a debugger: Sansec notes the implant checks whether it is traced and goes quiet.
Drain the node and restrict outbound traffic. Preserve /proc/<pid>/exe, its hash, process metadata, sockets, cron and relevant logs. Capture memory if your incident process supports it, and protect the evidence as sensitive data.
Remove persistence before killing the process or it will recreate itself. Do not reboot or run composer install: both can destroy useful evidence. Rebuild the node from a trusted image.
How to mitigate StyleSmuggler
This attack needs several layers of containment.
1. Sansec Shield
Sansec Shield has a live virtual patch and is being updated as the campaign changes. One Disrex victim was breached before the rules landed, so Shield reduces the exposure window; it does not replace threat hunting.
2. Block GraphQL if you don’t use it
If you do not use GraphQL, block the complete route at every public ingress. Cover plain and index.php URLs, store prefixes and direct origin access. Do not rely on payload signatures.
Stores using GraphQL must rely on Shield and the patch below. Blocking the route does not clean existing poisoned files or infected hosts.
3. Apply Adobe’s patch (VULN-39341)
Adobe shipped VULN-39341 — CVE-2026-75650 — on 08/09/2026 as APSB26-146, with composer patches for 2.4.4-p18, 2.4.5-p17, 2.4.6-p15, 2.4.7-p10, 2.4.8-p5 and 2.4.9 — see Adobe’s announcement and download links. This is the fix to run. Everything below it is superseded and kept only for stores mid-rollout.
# revert any interim containment patch FIRST — it touches the same files
patch -p1 -R < stylesmuggler-containment.patch
patch -p1 --forward < VULN-39341_<your-version>.patch
bin/magento setup:di:compile # BlockFactory gains a constructor argument
systemctl reload php8.5-fpm
One path stays open, unchanged by the patch: {{block class=X}} autoloads X before the
type check rejects it, so a .php file written into generated/code or app/code still
executes through the directive. It needs a separate file-write bug to reach.
Graycore’s patch closes it
with a {{block}} allowlist.
4. Interim containment patch (superseded)
Kept for stores mid-rollout. Revert it once Adobe’s patch is on.
Revert revision 1 before applying revision 2. Never run both.
Revision 1 closed the one disclosed include sink. Revision 2 targets the cause both stages
share — a caller-supplied class reaching a constructor before its type is checked — and
keeps the sink hardening too, so poisoned reports and logs stay inert either way. Neither
has an application-level GraphQL block or a broad Magento\Setup autoloader restriction,
because both cause more collateral damage than they prevent.
Apply this with your normal patching techniques, I’ve written about how to patch at scale before in this blog post. Which covers both Ansible for initial incident response and Meta packages for persistence.
diff --git a/vendor/magento/framework/View/Element/BlockFactory.php b/vendor/magento/framework/View/Element/BlockFactory.php
--- a/vendor/magento/framework/View/Element/BlockFactory.php
+++ b/vendor/magento/framework/View/Element/BlockFactory.php
@@ -41,6 +41,18 @@
public function createBlock($blockName, array $arguments = [])
{
$blockName = ltrim($blockName, '\\');
+ // Validate the resolved type before instantiating: the stock order runs an
+ // arbitrary caller-supplied constructor before the type check rejects it.
+ if (!is_string($blockName) || $blockName === '') {
+ throw new \LogicException('Block name must be a non-empty string');
+ }
+ $omConfig = \Magento\Framework\App\ObjectManager::getInstance()
+ ->get(\Magento\Framework\ObjectManager\ConfigInterface::class);
+ $instanceType = $omConfig->getInstanceType($omConfig->getPreference($blockName));
+ if (!is_subclass_of($instanceType, BlockInterface::class)) {
+ throw new \LogicException($blockName . ' does not implement BlockInterface');
+ }
+
$block = $this->objectManager->create($blockName, $arguments);
if (!$block instanceof BlockInterface) {
throw new \LogicException($blockName . ' does not implement BlockInterface');
diff --git a/vendor/magento/module-backend/Model/Widget/Grid/Row/UrlGeneratorFactory.php b/vendor/magento/module-backend/Model/Widget/Grid/Row/UrlGeneratorFactory.php
--- a/vendor/magento/module-backend/Model/Widget/Grid/Row/UrlGeneratorFactory.php
+++ b/vendor/magento/module-backend/Model/Widget/Grid/Row/UrlGeneratorFactory.php
@@ -36,6 +36,18 @@
*/
public function createUrlGenerator($generatorClassName, array $arguments = [])
{
+ if (!is_string($generatorClassName) || $generatorClassName === '') {
+ throw new \InvalidArgumentException('Passed wrong parameters');
+ }
+ // Validate the resolved type before instantiating; the post-construction
+ // check below is kept so both invariants hold.
+ $omConfig = \Magento\Framework\App\ObjectManager::getInstance()
+ ->get(\Magento\Framework\ObjectManager\ConfigInterface::class);
+ $instanceType = $omConfig->getInstanceType($omConfig->getPreference($generatorClassName));
+ if (!is_subclass_of($instanceType, \Magento\Backend\Model\Widget\Grid\Row\GeneratorInterface::class)) {
+ throw new \InvalidArgumentException('Passed wrong parameters');
+ }
+
$rowUrlGenerator = $this->_objectManager->create($generatorClassName, $arguments);
if (false === $rowUrlGenerator instanceof \Magento\Backend\Model\Widget\Grid\Row\GeneratorInterface) {
throw new \InvalidArgumentException('Passed wrong parameters');
diff --git a/vendor/magento/module-email/Model/Template/Filter.php b/vendor/magento/module-email/Model/Template/Filter.php
--- a/vendor/magento/module-email/Model/Template/Filter.php
+++ b/vendor/magento/module-email/Model/Template/Filter.php
@@ -408,6 +408,14 @@
{
$skipParams = ['class', 'id', 'output'];
$blockParameters = $this->getParameters($construction[2]);
+
+ // Authored {{block}} params are always scalar; a non-scalar only arrives
+ // through $var substitution, which is how attacker constructor data gets in.
+ foreach ($blockParameters as $blockParameterValue) {
+ if ($blockParameterValue !== null && !is_scalar($blockParameterValue)) {
+ return '';
+ }
+ }
$block = null;
diff --git a/vendor/magento/framework/Logger/Handler/Base.php b/vendor/magento/framework/Logger/Handler/Base.php
--- a/vendor/magento/framework/Logger/Handler/Base.php
+++ b/vendor/magento/framework/Logger/Handler/Base.php
@@ -95,6 +95,16 @@
$this->filesystem->createDirectory($logDir);
}
+ // StyleSmuggler containment: neutralise PHP open tags so a poisoned log
+ // file is inert whichever include gadget reaches it.
+ if (isset($record['formatted']) && is_string($record['formatted'])) {
+ $record['formatted'] = str_replace(
+ ['<?', '<%'],
+ ['<?', '<%'],
+ $record['formatted']
+ );
+ }
+
parent::write($record);
}
diff --git a/vendor/magento/framework/Webapi/ErrorProcessor.php b/vendor/magento/framework/Webapi/ErrorProcessor.php
--- a/vendor/magento/framework/Webapi/ErrorProcessor.php
+++ b/vendor/magento/framework/Webapi/ErrorProcessor.php
@@ -17,6 +17,7 @@
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Phrase;
use Magento\Framework\Serialize\Serializer\Json;
+use Magento\Framework\Serialize\Serializer\JsonHexTag;
use Magento\Framework\Webapi\Exception as WebapiException;
/**
@@ -96,7 +97,7 @@
$this->_logger = $logger;
$this->_filesystem = $filesystem;
$this->directoryWrite = $this->_filesystem->getDirectoryWrite(DirectoryList::VAR_DIR);
- $this->serializer = $serializer ?: ObjectManager::getInstance()->get(Json::class);
+ $this->serializer = $serializer ?: ObjectManager::getInstance()->get(JsonHexTag::class);
$this->registerShutdownFunction();
}
diff --git a/pub/errors/processor.php b/pub/errors/processor.php
--- a/pub/errors/processor.php
+++ b/pub/errors/processor.php
@@ -8,7 +8,7 @@
namespace Magento\Framework\Error;
use Magento\Config\Model\Config\Reader\Source\Deployed\DocumentRoot;
-use Magento\Framework\Serialize\Serializer\Json;
+use Magento\Framework\Serialize\Serializer\JsonHexTag;
use Magento\Framework\Escaper;
use Magento\Framework\App\ObjectManager;
use Magento\Framework\App\Response\Http;
@@ -156,7 +156,7 @@
/**
* JSON serializer
*
- * @var Json
+ * @var JsonHexTag
*/
private $serializer;
@@ -172,7 +172,7 @@
/**
* @param Http $response
- * @param Json $serializer
+ * @param JsonHexTag $serializer
* @param Escaper $escaper
* @param DocumentRoot|null $documentRoot
*/
@@ -185,7 +185,7 @@
$this->_response = $response;
$this->_errorDir = __DIR__ . '/';
$this->_reportDir = dirname(dirname($this->_errorDir)) . '/var/report/';
- $this->serializer = $serializer ?: ObjectManager::getInstance()->get(Json::class);
+ $this->serializer = $serializer ?: ObjectManager::getInstance()->get(JsonHexTag::class);
$this->escaper = $escaper ?: ObjectManager::getInstance()->get(Escaper::class);
$this->documentRoot = $documentRoot ?? ObjectManager::getInstance()->get(DocumentRoot::class);
if (!empty($_SERVER['SCRIPT_NAME'])) {Six changes.
BlockFactory::createBlock()— resolve preferences and virtual types, checkBlockInterfacebefore instantiating. Stock order instantiates first, so the constructor has already run by the time the check fails.UrlGeneratorFactory::createUrlGenerator()— same bug, same fix.Filter::blockDirective()— reject non-scalar{{block}}parameters. Removes the primitive rather than one gadget.- Report serializers —
JsonHexTagon both writers, so newvar/reportfiles carry no raw PHP tags. Logger\Handler\Base::write()— escape PHP open tags, so a poisonedvar/logfile is inert.
The last two are defence in depth, not the fix. The write side is not reliably containable.
Dropped from revision 1: Io::includeFile(), the three DI scanner guards and
PaymentFailuresService::handle().
diff --git a/pub/errors/processor.php b/pub/errors/processor.php
--- a/pub/errors/processor.php
+++ b/pub/errors/processor.php
@@ -8,7 +8,7 @@
namespace Magento\Framework\Error;
use Magento\Config\Model\Config\Reader\Source\Deployed\DocumentRoot;
-use Magento\Framework\Serialize\Serializer\Json;
+use Magento\Framework\Serialize\Serializer\JsonHexTag;
use Magento\Framework\Escaper;
use Magento\Framework\App\ObjectManager;
use Magento\Framework\App\Response\Http;
@@ -156,7 +156,7 @@
/**
* JSON serializer
*
- * @var Json
+ * @var JsonHexTag
*/
private $serializer;
@@ -172,7 +172,7 @@
/**
* @param Http $response
- * @param Json $serializer
+ * @param JsonHexTag $serializer
* @param Escaper $escaper
* @param DocumentRoot|null $documentRoot
*/
@@ -185,7 +185,7 @@
$this->_response = $response;
$this->_errorDir = __DIR__ . '/';
$this->_reportDir = dirname(dirname($this->_errorDir)) . '/var/report/';
- $this->serializer = $serializer ?: ObjectManager::getInstance()->get(Json::class);
+ $this->serializer = $serializer ?: ObjectManager::getInstance()->get(JsonHexTag::class);
$this->escaper = $escaper ?: ObjectManager::getInstance()->get(Escaper::class);
$this->documentRoot = $documentRoot ?? ObjectManager::getInstance()->get(DocumentRoot::class);
if (!empty($_SERVER['SCRIPT_NAME'])) {
diff --git a/setup/src/Magento/Setup/Module/Di/Code/Reader/ClassesScanner.php b/setup/src/Magento/Setup/Module/Di/Code/Reader/ClassesScanner.php
--- a/setup/src/Magento/Setup/Module/Di/Code/Reader/ClassesScanner.php
+++ b/setup/src/Magento/Setup/Module/Di/Code/Reader/ClassesScanner.php
@@ -132,6 +132,11 @@
private function includeClass(string $className, string $fileItemPath): bool
{
if (!class_exists($className)) {
+ // phpcs:ignore Magento2.Security.Superglobal
+ if (PHP_SAPI !== 'cli' || isset($_SERVER['REQUEST_METHOD'])) {
+ throw new \RuntimeException('Class files can only be scanned from a command-line process.');
+ }
+
// phpcs:ignore
require_once $fileItemPath;
return true;
diff --git a/setup/src/Magento/Setup/Module/Di/Code/Scanner/ArrayScanner.php b/setup/src/Magento/Setup/Module/Di/Code/Scanner/ArrayScanner.php
--- a/setup/src/Magento/Setup/Module/Di/Code/Scanner/ArrayScanner.php
+++ b/setup/src/Magento/Setup/Module/Di/Code/Scanner/ArrayScanner.php
@@ -15,6 +15,11 @@
*/
public function collectEntities(array $files)
{
+ // phpcs:ignore Magento2.Security.Superglobal
+ if (PHP_SAPI !== 'cli' || isset($_SERVER['REQUEST_METHOD'])) {
+ throw new \RuntimeException('DI array files can only be scanned from a command-line process.');
+ }
+
$output = [];
foreach ($files as $file) {
if (file_exists($file)) {
diff --git a/setup/src/Magento/Setup/Module/Di/Code/Scanner/XmlInterceptorScanner.php b/setup/src/Magento/Setup/Module/Di/Code/Scanner/XmlInterceptorScanner.php
--- a/setup/src/Magento/Setup/Module/Di/Code/Scanner/XmlInterceptorScanner.php
+++ b/setup/src/Magento/Setup/Module/Di/Code/Scanner/XmlInterceptorScanner.php
@@ -99,6 +99,13 @@
$className = preg_replace('/^([0-9A-Za-z]*)_([0-9A-Za-z]*)/', '\\1_\\2_controllers', $className);
$filePath = stream_resolve_include_path(str_replace('_', '/', $className) . '.php');
if (file_exists($filePath)) {
+ // phpcs:ignore Magento2.Security.Superglobal
+ if (PHP_SAPI !== 'cli' || isset($_SERVER['REQUEST_METHOD'])) {
+ throw new \RuntimeException(
+ 'Controller class files can only be scanned from a command-line process.'
+ );
+ }
+
require_once $filePath;
}
}
diff --git a/vendor/magento/framework/Code/Generator/Io.php b/vendor/magento/framework/Code/Generator/Io.php
--- a/vendor/magento/framework/Code/Generator/Io.php
+++ b/vendor/magento/framework/Code/Generator/Io.php
@@ -157,7 +157,23 @@
*/
public function includeFile($fileName)
{
- return include $fileName;
+ $generationDirectory = $this->filesystemDriver->getRealPath($this->_generationDirectory);
+ $realFileName = $this->filesystemDriver->getRealPath($fileName);
+ $generationDirectory = $generationDirectory
+ ? rtrim(str_replace('\\', '/', $generationDirectory), '/') . '/'
+ : false;
+ $realFileName = $realFileName ? str_replace('\\', '/', $realFileName) : false;
+
+ if (!$generationDirectory || !$realFileName || strpos($realFileName, $generationDirectory) !== 0) {
+ throw new FileSystemException(
+ new \Magento\Framework\Phrase(
+ 'The generated code file "%1" is outside the generation directory.',
+ [$fileName]
+ )
+ );
+ }
+
+ return include $realFileName;
}
/**
diff --git a/vendor/magento/framework/Logger/Handler/Base.php b/vendor/magento/framework/Logger/Handler/Base.php
--- a/vendor/magento/framework/Logger/Handler/Base.php
+++ b/vendor/magento/framework/Logger/Handler/Base.php
@@ -95,6 +95,17 @@
$this->filesystem->createDirectory($logDir);
}
+ // Temporary StyleSmuggler containment: neutralise PHP open tags on the
+ // fully formatted line, so a poisoned log file is inert whichever include
+ // gadget reaches it. Revert once Adobe ships a supported fix.
+ if (isset($record['formatted']) && is_string($record['formatted'])) {
+ $record['formatted'] = str_replace(
+ ['<?', '<%'],
+ ['<?', '<%'],
+ $record['formatted']
+ );
+ }
+
parent::write($record);
}
diff --git a/vendor/magento/framework/Webapi/ErrorProcessor.php b/vendor/magento/framework/Webapi/ErrorProcessor.php
--- a/vendor/magento/framework/Webapi/ErrorProcessor.php
+++ b/vendor/magento/framework/Webapi/ErrorProcessor.php
@@ -17,6 +17,7 @@
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Phrase;
use Magento\Framework\Serialize\Serializer\Json;
+use Magento\Framework\Serialize\Serializer\JsonHexTag;
use Magento\Framework\Webapi\Exception as WebapiException;
/**
@@ -96,7 +97,7 @@
$this->_logger = $logger;
$this->_filesystem = $filesystem;
$this->directoryWrite = $this->_filesystem->getDirectoryWrite(DirectoryList::VAR_DIR);
- $this->serializer = $serializer ?: ObjectManager::getInstance()->get(Json::class);
+ $this->serializer = $serializer ?: ObjectManager::getInstance()->get(JsonHexTag::class);
$this->registerShutdownFunction();
}
diff --git a/vendor/magento/module-sales/Model/Service/PaymentFailuresService.php b/vendor/magento/module-sales/Model/Service/PaymentFailuresService.php
--- a/vendor/magento/module-sales/Model/Service/PaymentFailuresService.php
+++ b/vendor/magento/module-sales/Model/Service/PaymentFailuresService.php
@@ -98,6 +98,12 @@
string $message,
string $checkoutType = 'onepage'
): PaymentFailuresInterface {
+ // Temporary StyleSmuggler containment.
+ $this->logger->warning('Payment failure email suppressed by temporary StyleSmuggler containment.');
+
+ return $this;
+
+ // phpcs:disable
$this->inlineTranslation->suspend();
$quote = $this->cartRepository->get($cartId);Eight changes.
- Report serializers use
JsonHexTag, preventing newvar/reportandvar/report/apifiles from containing raw PHP tags. The Web API change adapts Graycore’s mitigation. Io::includeFile()rejects files outside the generated-code directory.- DI scanners refuse to include PHP during web requests. CLI deployment commands remain available.
PaymentFailuresService::handle()returns before rendering. Failed-payment emails remain disabled while the patch is installed.Logger\Handler\Base::write()escapes PHP open tags on the formatted line, so a poisonedvar/logfile is inert whichever include gadget reaches it.
If your release process allows it, exclude the complete setup/ tree from web-runtime
images.
DI metadata in generated/ describes constructor arguments, so leaving them alone means
the patch goes out with a patch command and an opcache clear — no recompile, no static
content deploy, no maintenance window. The factory fixes call
ObjectManager::getInstance() inline instead: a service locator, which is the right trade
for a hot patch and the wrong one for an upstream PR.
vim stylesmuggler-containment.patch # Write patch
patch -p1 --dry-run < stylesmuggler-containment.patch
patch -p1 < stylesmuggler-containment.patch
systemctl reload php8.5-fpm # or: cachetool opcache:reset
Rollback, same:
patch -p1 -R < stylesmuggler-containment.patch
systemctl reload php8.5-fpm
Verified in production mode with generated/ byte-identical across apply, revert and
re-apply.
5. Deal with reports and logs created before the patch
The serializer changes only protect new files. Search existing reports and logs before returning a node to service:
grep -Ral --fixed-strings '<?' var/report/ var/log/
grep -Ral -e 'X_TRACE_' -e 'X-TRACE-' var/report/ var/log/
Preserve suspicious files with timestamps and hashes, then move them outside the web runtime. Rewriting them destroys evidence and does not make the host trustworthy.
Deploy and verify
Revision 2 needs no DI rebuild, so this is an opcache clear and a node-by-node return rather than a redeploy.
Verify that:
- if you blocked it, GraphQL is unreachable through every public hostname, and the origin is blocked or authenticated too
- both the normal and Web API error processors resolve
JsonHexTag - newly generated files in
var/reportandvar/report/apicontain escaped tag characters rather than a raw PHP opening tag - admin grids that use
rowUrlgenerators still list rows — Attribute Sets, Email Templates, Newsletter Queue, Integrations, Catalog Price Rules - a
{{for}}loop in a custom email template still renders - a PHP-tagged
Storeheader lands invar/log/system.logescaped as<?php, not raw - transactional email rendering still works, including failed-payment mail, and
{{block}}directives in CMS blocks, CMS pages and email templates still render - no suspicious process, persistence file or report marker exists on any fleet node
Remove the patch only after an Adobe fix covering StyleSmuggler is deployed and verified on every node:
patch -p1 -R < stylesmuggler-containment.patch
systemctl reload php8.5-fpm
If a node is compromised
Do not spend the recovery window trying to disinfect it. Once code has run as the Magento filesystem user, assume it could read app/etc/env.php, database and Redis credentials, the Magento encryption key, payment/API credentials and any deployment secrets available to PHP-FPM.
My minimum recovery plan is:
- Preserve memory, disk and central logs.
- Build replacement nodes from a known-good source revision and base image.
- Apply the mitigations above before accepting traffic.
- Rotate every secret readable by the compromised Unix account.
- Invalidate admin and customer sessions.
- Audit admin users, integrations, OAuth tokens, CMS content, email templates, database triggers, cron, queues and SSH keys.
- Hunt across every node, environment and shared home directory.
Run eComscan against the entire account rather than only the active release, because the implant lives above the Magento document root:
~/bin/ecomscan \
--skip-dashboard \
--format=json \
--min-confidence=0 \
--deep \
/path/to/account/root
Capture volatile evidence first. Sansec notes that eComscan 1.9.7 may terminate the malicious process for Shield customers.
The scope argument matters more than it looks. Disrex had a store return a clean eComscan result while its cron persistence was actively re-adding itself, because the scan was pointed at the document root and the implant lives in the user’s home directory. A clean scan at the default scope is not evidence of a clean host. Point it at the account root or the result means very little.
Unexpected bursts of failed-payment notification emails warrant investigation, although legitimate declines also generate them. Sansec reports that execution happens during rendering: nobody needs to open the email, and delivery failure does not prevent exploitation.
Current IOCs
This list is a snapshot. The campaign is live and the operators are rotating infrastructure, so treat Sansec’s advisory as the authoritative, current source and re-check it before every hunt.
Almost all of the following is from Sansec’s StyleSmuggler research — they did the work here, I’m just repeating it so it’s in one place with the response steps:
247.cdnflare.xyz # malware download
209.141.43.95 # malware download, http://209.141.43.95/files/ (FranTech AS53667)
99.84.67.186:443 # C2, WebSocket over TLS
windwsecurity.run:443 # TCP, remote shell
ntp.timesysnc.net:123 # UDP, NTP-shaped C2 traffic
time.microsft.run:123 # UDP, NTP-shaped C2 traffic
pool.microsft.studio:123 # UDP, NTP-shaped C2 traffic
ntp.timesync.to:123 # UDP, NTP-shaped C2 traffic, fc-cache build
ntp.synctime.to:123 # UDP, fallback
ntp.syncstime.to:123 # UDP, fallback
api4.ipify.org # plain-HTTP public IP lookup before beaconing
ipv4.icanhazip.com # plain-HTTP public IP lookup before beaconing
ipv4.ident.me # plain-HTTP public IP lookup before beaconing
ipinfo.io # plain-HTTP public IP lookup before beaconing
88.216.72.181 # attacker source, seen at multiple victims
182.182.152.48 # attacker source
209.73.130.148 # attacker source, successful exploit
76.31.99.207 # attacker source, failed exploit
77.239.124.107 # attacker source, follow-up requests
5.181.86.133 # historic — no longer listed by Sansec, unconfirmed
185.157.160.251:123 # C2, A record for ntp.timesync.to and ntp.timesysnc.net (07/09)
sha256 e315687a1dfe61ef4a5a5642214db6d3b2b05d81391285eebc2af664641a26a7
sha256 4352cabaa451e5a894535fbcc4d46628701303322a13745cb5479d7d0534ae8e # kworker-linux-x64, new build
sha256 d2fbf9eb75c495bfea48790d3b228fab0c15a282419c3d3f5e49294c4e1a3e82 # kworker-linux-arm64, new build
/tmp/.kw_<random><random>
/tmp/.cache_<random><random> # drop-path variant
/tmp/.fc-<8hex>/fc-cache
/tmp/fc-cache # drop-path variant
/tmp/.fc_<8hex>.lock # holds the implant PID
~/.local/share/.gvfsd/gvfsd-user
~/.local/share/.gvfsd/.gvfsd_<8hex>.lock
~/.cache/fontconfig/fc-cache
process [kworker/u:8:0]
process fc-cache
process chronyd # 07/09 build, no cron, parent PID 1
/tmp/.chrony-<8hex>/chronyd
implant version 2.1.5 # beacon field; 2.1.4 in the 06/09 build
crontab */5 * * * * exec <home>/.local/share/.gvfsd/gvfsd-user
crontab 13,43 * * * * <home>/.cache/fontconfig/fc-cache >/dev/null 2>&1
User-Agent: python-requests 2.15.0 # on ALL attacker exploit requests
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
# implant only, for its plain-HTTP IP lookups
Disrex recovered two more, one from disk and one from the running process on the same host:
sha256 8334b434fa3fe9f59cebe9609b11e0b1fd19d10212c45c705adec1902a1d06ef # on disk
sha256 251fabd50d7b18a8b5e1b3ef5d64e7198c17244778f6461fb1ab07f6169bf220 # in memory
The sample recovered during my own investigation had a different SHA-256 again, which Sansec have since adopted into their published list:
sha256 b79dfdc1eed860e0b76c629d6adfce251db379b0b45a6d728d4ef483f7551420
Four hashes for one implant, two of them from the same machine. Hash matching is a bonus here, not a detection strategy.
Hunt UDP/123, not just HTTPS. Six of the C2 endpoints speak NTP-shaped traffic on the standard NTP port. Egress rules that only inspect 443 will not see them, and neither will a hunt that greps web logs. If your nodes do not need to reach arbitrary time servers, pin them to your own NTP source and alert on everything else.
A second, unrelated attacker has turned up at the same victims — nothing shared with the implant operator except the way in. Same poisoning vector, recovered from a Store: header, dropping a small PHP web shell that executes whatever arrives in a task POST parameter. Sansec read it as off-the-shelf tooling, someone verifying access rather than operating a fleet, which still means one host can carry two unrelated intrusions through one flaw:
sha256 d61217ca0bca83204302fa7b41935ce36f73764559c156d5c980f2fedddffb6e # 485-byte dropper
pub/media/catalog/product/cache/ss_<10hex>/sync_<10hex>.php # web shell
X-Cache-Token: fced27f6d57702565353ecc11722533b # required; 404 without it
457cfa2fb7p5.daf892t5qau4og8pi4cghbc6fhm1dim3u.oast.site # exec callback
ss5_457cfa2fb7 # marker echoed outside the PHP tags
find pub/media -name '*.php' is the whole check, and it’s already in the block above. There should be none there.
Alert on plain-HTTP IP lookups. The fc-cache build calls api4.ipify.org, ipv4.icanhazip.com, ipv4.ident.me or ipinfo.io over port 80, with the truncated User-Agent above. A storefront node making that request is worth a page.
The request paths alone are not IOCs. Sansec observed the campaign using /graphql, /paypal/transparent/response/ and /customer/section/load/. All three are legitimate routes carrying ordinary traffic all day. They are only meaningful correlated with payload content, a poisoned report or log, and process activity in the same window.
Do not rely on hashes or one source IP alone. Look for the combination of process identity, executable path, persistence, outbound traffic, poisoned reports and the original web requests.
Bonus Tip: How to check the fleet at scale?
Doing the checks above by hand is fine for a handful of stores, less so for a fleet. I’ve added the following to our standard healthcheck playbook so every node gets checked on the next run, and so the check sticks around long after the incident.
Run it as the Magento filesystem user, not root — ~ has to expand to the web user’s home for the implant check to look in the right place.
Ansible Playbook
# tasks/healthchecks/security/stylesmuggler.yaml
---
- name: Look for the gvfsd and fc-cache implants and persistence files
tags: stylesmuggler
register: stylesmuggler_implant
changed_when: false
failed_when: false
ansible.builtin.shell:
executable: /bin/bash
cmd: |
set -eu
for path in ~/.local/share/.gvfsd ~/.cache/fontconfig/fc-cache \
/tmp/.kw_* /tmp/.cache_* /tmp/.gvfsd[-_]* /tmp/.fc[-_]* /tmp/fc-cache; do
if [[ -e "$path" || -L "$path" ]]; then
printf '%s\n' "$path"
fi
done
if cron_output=$(LC_ALL=C crontab -l 2>&1); then
printf '%s\n' "$cron_output" | awk 'tolower($0) ~ /gvfsd|fontconfig|fc-cache/'
elif [[ "$cron_output" == "no crontab for "* ]]; then
:
else
printf '%s\n' "$cron_output" >&2
exit 2
fi
grep -rlE 'gvfsd|fc-cache' /var/spool/cron/ 2>/dev/null || true
- name: Look for a non-root kworker or fc-cache process
tags: stylesmuggler
register: stylesmuggler_kworker
changed_when: false
failed_when: false
ansible.builtin.shell:
executable: /bin/bash
cmd: |
set -o pipefail
ps -eo user,pid,ppid,comm,args | awk '$1 != "root" && ($4 ~ /kworker/ || $4 ~ /fc-cache/)'
- name: Look for poisoned reports and logs
tags: stylesmuggler
register: stylesmuggler_reports
changed_when: false
failed_when: false
ansible.builtin.shell:
executable: /bin/bash
chdir: "{{ project_root }}"
cmd: |
grep -Ral -e '<?' -e 'X_TRACE_' -e 'X-TRACE-' var/report/ var/log/
grep -Fal 'array_merge()' var/log/system.log
- name: Assert StyleSmuggler IOC status
tags: stylesmuggler
ansible.builtin.assert:
that:
- stylesmuggler_implant.rc == 0
- stylesmuggler_kworker.rc == 0
- stylesmuggler_reports.rc in [0, 1]
- stylesmuggler_implant.stdout | trim == ''
- stylesmuggler_kworker.stdout | trim == ''
- stylesmuggler_reports.stdout | trim == ''
fail_msg: >-
A check failed or possible StyleSmuggler IOCs were found. Review each task's
stderr and stdout. For suspected compromise, drain the node and collect evidence
before killing anything. See https://www.samdjames.uk/docs/platforms/magento/security/stylesmuggler/
success_msg: "No known indicators found by these checks; this does not establish that the host is clean."
- name: Look for report-serializer patch markers
tags: stylesmuggler
register: stylesmuggler_patch
changed_when: false
failed_when: false
ansible.builtin.shell:
executable: /bin/bash
chdir: "{{ project_root }}"
cmd: |
set -e
grep -q JsonHexTag pub/errors/processor.php
if [[ -f vendor/magento/framework/Webapi/ErrorProcessor.php ]]; then
grep -q JsonHexTag vendor/magento/framework/Webapi/ErrorProcessor.php
else
grep -q JsonHexTag lib/internal/Magento/Framework/Webapi/ErrorProcessor.php
fi
- name: Assert report-serializer marker status
tags: stylesmuggler
ansible.builtin.assert:
that:
- stylesmuggler_patch.rc == 0
fail_msg: "Report-serializer markers are missing or unreadable; review the deployed artifact."
success_msg: "Serializer markers found; verify all four patch changes and runtime behaviour separately."
A few things worth knowing before you run it:
- The
/tmp/.gvfsd[-_]*and/tmp/.fc[-_]*globs and the two marker spellings are deliberate. The lock file uses an underscore where earlier write-ups showed a hyphen, and a later payload variant switched to a hyphenated marker. Matching one spelling misses live infections. - The
fc-cacheprocess match will fire on a genuine fontconfig run. Treat it as a triage prompt: checkreadlink /proc/<pid>/exeand the cron entry before escalating. - The
array_merge()grep catches theTypeErrorMagento’s DI array scanner raises when it includes a poisoned file. It is the one check here indicating the exploit reached its sink rather than merely being attempted. - The cron check reads
/var/spool/cron/as well ascrontab -l, because the implant writes its entry straight into the spool directory. failed_when: falselets the asserts distinguish no matches from scan failures using both exit status and output. Review stderr on any failed assertion; a failed scan is not a clean result.- Run the IOC tasks with
--limitin waves if your fleet is large. A hit means you want to be looking at that node before the playbook has moved on to another fifty. - The serializer marker check is a quick drift check, not proof that the full patch is installed or effective. It does not verify the scanner guards or payment-failure suppression. Validate all seven changes and their runtime behaviour on the final artifact.
What these mitigations do not do
The disclosed attack uses two stages: attacker-controlled data is written into a Magento-controlled file, then that data is evaluated while Magento renders a failed-payment email. The controls above break the paths observed so far, but they are not a complete correction to Magento’s template parser and cannot prove there is no alternative gadget or writable data source.
Never having seen a failed-payment email does not mean you are safe. The code runs while the email is rendered. Delivery is irrelevant and nobody has to open it. Bursts of “Payment Transaction Failed Reminder” messages are worth investigating, but legitimate declines produce the same email, and the attack succeeds even when delivery fails entirely.
They also do not clean an infected node. WAF rules, an application patch, threat hunting and trusted rebuilds solve different parts of the incident. Use them together until Adobe’s supported fix is available.
Going forward, how do we improve security posture?
The same takeaways from Session Reaper and PolyShell apply again, and StyleSmuggler adds one more.
1. Sansec Shield
Shield had a virtual patch out ahead of any vendor fix. Third time running now that it has been the control that mattered on day zero. It is still not a guarantee: stores were compromised before the rules landed, so pair it with the hunting and hardening below rather than treating it as cover.
2. Turn off what you don’t use
If GraphQL had been off on the stores that don’t use it, the disclosed entry point wouldn’t have existed for them. The same goes for unused controllers, endpoints and modules — every one you disable is one you don’t have to patch under pressure.
3. Bulk patching infrastructure
A temporary patch is only useful if you can push it across the fleet in minutes and roll it back just as fast when Adobe ships the real one. I’ve covered a few approaches with Ansible and a Magento module in magento2-patching.
4. Read only file system
Read-only application code reduces opportunities for persistence. Also restrict the PHP-FPM account’s access to home directories, user cron and other writable locations, including temporary directories. Required writable paths still need review: protecting the application tree does not automatically protect the web user’s home.
Nginx restrictions govern incoming HTTP requests. They do not prevent an already-running PHP process from including readable files, starting processes or establishing persistence after code execution.
Two smaller controls in the same spirit, both worth doing today because neither depends on knowing anything about this vulnerability. Add proc_open to PHP’s disable_functions, which removes the mechanism the dropper used to spawn its process. Mount /tmp, /var/tmp and /dev/shm with noexec, which stops a payload staged there from executing at all. Test both in staging first: some deployment tooling and a handful of extensions do legitimately shell out.
5. Hunt above the document root
The implant lives in ~/.local/share, not in pub/. Scans scoped to the active release miss it entirely. Scan the whole account.
Conclusion
- Run Sansec Shield, and block GraphQL outright if nothing you own needs it
- Hunt for both builds in the web user’s home on every node, not just the ones showing symptoms: non-root
kworkerwithgvfsd-user, andfc-cachein~/.cache/fontconfig/ - Collect volatile evidence before killing anything
- Rebuild nodes with confirmed compromise and rotate every secret PHP-FPM could read; investigate report markers before concluding that execution occurred
- Remove temporary containment only after a vendor fix explicitly covering StyleSmuggler has been deployed and verified on every node
Sansec also lists requests involving /paypal/transparent/response/, /customer/section/load/ and /graphql with suspicious payloads. Correlate these with report/log content and process activity; the routes alone are legitimate traffic. Include UDP/123 in outbound hunting, not only HTTPS. Recheck the live advisory as indicators change.
References
- Adobe: APSB26-146 security bulletin
- NVD: CVE-2026-75650
- Adobe: APSB26-146 announcement and patch downloads
- Sansec: StyleSmuggler, Magento and Adobe Commerce zero-day RCE
- Graycore: neutralise PHP open tags in Web API fatal reports
- Disrex: StyleSmuggler mitigation and live-incident findings
- Sansec: eComscan incident scanning
- Linux kernel
/procdocumentation - NIST SP 800-86: Guide to Integrating Forensic Techniques into Incident Response
- Adobe Commerce production filesystem permissions