Critical CVE Response

Eight Copies, One Backdoor: Why Your WordPress Cleanup Keeps Reinfecting the Site

Dark cyberpunk illustration of a monolith of glowing amber glass blocks in a night-time void, its broken shards suspended in mid-air and drifting back toward the structure to rebuild it, above a faint cyan grid.

You can delete every malicious file on a compromised WordPress server, watch the scanner come back clean, and have the site backdoored again on the next page load. That is the designed behavior of a malware family Sucuri documented on September 30, tracked as SC after the SC_ guard constants in its older components. The infection keeps eight copies of itself, each able to rebuild the others, and three of those copies never touch the filesystem.

Gabriel Barbosa's writeup states the mechanism plainly: "Delete the plugin and a drop-in rewrites it. Delete the drop-in and the theme rewrites it. Clean every file on disk, and the next page load restores the whole set from the database or from a shared-memory segment." A responder working from a file-based scan result will record a successful cleanup and be wrong about it.

The relevance verdict: this one is yours if you run WordPress on a host where PHP can write into wp-content, which covers almost every shared, cPanel, and single-VPS install and a good share of managed ones. It is also yours if you run PHP under FastCGI or PHP-FPM, because one of the eight components is a .user.ini file and PHP processes those only under the CGI/FastCGI SAPI. If your WordPress filesystem is read-only at runtime, built in CI and deployed as an immutable artifact, the healing mesh has nowhere to write and the whole design collapses into a single disposable container. On a reinfection call I ask one question before I look at scanner output: can PHP write into wp-content on this host? The answer decides whether you are cleaning or redeploying.

Four of the eight components run before your security plugin

The reason a plugin-based scanner loses this fight is load order, and WordPress core publishes it. Read wp-settings.php from top to bottom and the sequence is fixed:

  • wp-content/advanced-cache.php is included at line 100 of current core, gated on the WP_CACHE constant. Any site with a caching plugin has that constant set to true.
  • wp-content/db.php arrives next, pulled in by require_wp_db() at line 136; the function in wp-includes/load.php requires the drop-in whenever the file exists.
  • Must-use plugins run at line 509. WordPress documents that they are "loaded by PHP in alphabetical order before normal plugins" and that they "cannot be disabled from wp-admin."
  • Normal plugins, including every security plugin you own, load after all of the above.

The .user.ini file sits even earlier, outside WordPress entirely. It sets auto_prepend_file, a directive PHP classifies as INI_PERDIR and therefore accepts from a per-directory file, which makes the loader execute ahead of index.php on every request in that directory tree.

So by the time your security plugin's first hook fires, attacker code has already run four times and holds every filter it needs. Sucuri's analysis lists the consequences: the backdoor filters the plugin list to hide its own entries from the admin screens, can deactivate or delete a security plugin outright, and can mint a hidden administrator with forged authentication cookies. Asking a normal plugin to remove it is asking the fourth arrival to evict the first.

The three copies that are not files

Deleting files is the part most cleanups get right and the part that matters least. The mesh stores redundant payloads off disk in three places:

Where to look

  • An options row. The payload sits in wp_options under a randomly named key as a gzip plus base64 blob. The advanced-cache.php drop-in opens its own database connection using the site's credential constants and reads the payload straight out of that row, so it works even with WordPress half-loaded.
  • A System V shared memory segment. Reachable by a numeric key, holding readable PHP, and completely untouched by anything that walks the filesystem. It survives deletion of every file on disk.
  • A ZIP restore bundle with a random hex name, plus cron hooks under randomized names that schedule redeployment.

Enumerate all of it before you delete anything. On a host with WP-CLI and shell access:

# 1. Large option rows (the payload blob) - review, do not delete yet
wp option list --format=csv --fields=option_name,option_value \
  | awk -F, 'length($2) > 2000 { print $1, length($2) }'

# 2. Scheduled hooks with no owning plugin
wp cron event list --fields=hook,next_run_relative,recurrence

# 3. Drop-ins, mu-plugins, and per-directory INI files
ls -la wp-content/db.php wp-content/advanced-cache.php \
       wp-content/object-cache.php 2>/dev/null
ls -la wp-content/mu-plugins/
find . -name '.user.ini' -o -name '.htaccess' | xargs grep -l \
  -e auto_prepend_file -e auto_append_file 2>/dev/null

# 4. Hidden dot-prefixed PHP and recent ZIPs under wp-content
find wp-content -name '.*.php' -o -name '*.zip' -mtime -45

# 5. Shared memory segments owned by the PHP user
ipcs -m | awk '$3=="www-data" || $3=="nobody" || $3 ~ /^php/ { print }'

# 6. Administrators you did not create
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered

Step 5 is the check that separates a real eradication from a hopeful one. If ipcs -m shows a segment owned by the web user that no cache or session extension explains, the site has an off-disk copy of the loader and your file cleanup was theater.

The order that actually clears it

Sequence matters here more than completeness, for a reason that comes out of the PHP documentation rather than the malware: user_ini.cache_ttl defaults to 300 seconds, so PHP holds a parsed .user.ini for up to five minutes. Delete the file the directive points at and leave the directive, and every request for the next five minutes tries to include a path that no longer exists. Neutralize the directive first.

# 0. Snapshot before you touch anything (keep for IR, do not restore from it)
tar czf /root/wp-evidence-$(date +%F).tgz wp-content/db.php \
  wp-content/advanced-cache.php wp-content/mu-plugins wp-content/.*.php \
  .user.ini 2>/dev/null
mysqldump --single-transaction DBNAME wp_options > /root/wp_options-$(date +%F).sql

# 1. Prepend directive first, target second
: > .user.ini          # empty it, then wait out user_ini.cache_ttl (300s)

# 2. Off-disk copies
wp option delete <random_option_name>
wp transient delete --all
ipcrm -m <shmid>       # each segment identified above

# 3. Scheduled redeployment
wp cron event delete <hook_name>

# 4. The attacker's account
wp user delete <ID> --reassign=1

# 5. Files, in one pass, not one at a time
rm -f wp-content/db.php wp-content/advanced-cache.php \
      wp-content/mu-plugins/<name>.php wp-content/.<hex>.php \
      wp-content/<hex>.php wp-content/<hex>.zip
rm -rf wp-content/plugins/<name>/

# 6. Prove it stayed gone
wp eval 'echo (int) function_exists("wp_cache_postload");'
curl -sS -o /dev/null -w '%{http_code}\n' https://SITE/ && sleep 120
ls -la wp-content/db.php wp-content/advanced-cache.php 2>/dev/null

Step 6 is the acceptance test. Load the home page, wait two minutes for a cron cycle, and list the drop-in paths again. A file that reappears means a copy you did not find is still live, and the right move is to stop cleaning and rebuild.

Why the scanner missed it, and the control that still works

Signature matching fails on the newer components by construction. Sucuri's analysis is blunt about it: "There is no eval, no marker on the newest pieces, and no readable function names. Each file carries a table of scrambled strings and a small decoder that resolves a numeric index into a real function name through a positional substitution cipher." A scanner tuned for eval(base64_decode( has nothing to match. Neither do you, if you are grepping for function names.

What survives obfuscation is structure, so hunt on shape instead of content:

  • A PHP block inside wp-content/db.php or wp-content/advanced-cache.php at all. On a legitimate install these drop-ins either do not exist or belong to a named cache or database plugin you can account for by name and version.
  • An options row whose value is thousands of characters of base64 and whose name matches no plugin's documented key.
  • A dot-prefixed .php file anywhere under wp-content. There is no legitimate reason for one.
  • An administrator whose capabilities live under the default capabilities meta key while the rest of your admins were provisioned by a membership or role plugin.
  • Outbound HTTPS from the web server to public Ethereum RPC gateways.

The command channel you cannot block one domain at a time

That last indicator deserves its own paragraph, because the egress control most teams would reach for does not work here. The backdoor carries a list of roughly twenty public Ethereum RPC gateways and a set of smart-contract method selectors, and it reads its instructions out of a smart contract. The transport is legitimate third-party infrastructure, which means there is no single attacker domain to sinkhole and no low-reputation IP for a threat feed to flag. Blocking one gateway moves it to the next one in the list.

The control that does work is an allowlist in the other direction. A WordPress host needs to reach its own package sources, its update endpoints, and whatever payment or mail API the site actually uses. It does not need arbitrary outbound HTTPS. Default-deny egress from the web server, with an allowlist of the handful of hosts the site calls on purpose, turns the whole decentralized-resolver design into failed connections in a firewall log. That log line is also your highest-confidence detection for this family, because a genuine WordPress install has no reason to speak JSON-RPC to a blockchain gateway.

Close the door, then rotate what the malware could read

Sucuri reports the initial access vector as unknown, and I am not going to improve on that. What I will point out is that CISA added CVE-2026-87902 to the Known Exploited Vulnerabilities catalog on September 25, five days before this writeup, with a federal remediation deadline of September 28. The core advisory, GHSA-7hp8-65ch-5whp, describes a remote file inclusion in page-template resolution (CWE-98) that lets an unauthenticated attacker include a chosen readable local .php file from outside the active theme directories. That is the exact primitive a loader mesh needs. Treat it as the first thing to rule out rather than as attribution, and patch core before you spend an hour on file hashes.

Then rotate credentials, because of a detail buried in how the drop-in works. advanced-cache.php read the payload using the site's own database credential constants, which means the attacker's code had DB_USER, DB_PASSWORD, and the salts in wp-config.php. Change the database password, regenerate every key and salt in wp-config.php to force re-authentication, reset all administrator passwords, revoke application passwords, and reissue any API key stored in wp_options by a plugin. A cleanup that leaves those intact leaves a second way back in that no file scan will show.

Decide whether this site can be rebuilt from source

For a one-person IT team, the cleanup above is two to four hours of careful work with a real chance of missing a copy. Rebuilding is often faster and always more verifiable: stand up a clean WordPress at the current version, install the plugin set from the official repository rather than from the compromised tree, restore the theme from version control, and import the database only after deleting the payload option row, the rogue cron hooks, and the hidden administrator. Your posts and media come back; the mesh does not. Whichever path you take, record who did what and when, because that record is the only thing that will let you answer an insurer or a customer later. If you need a second set of eyes on a reinfection that keeps coming back, or a response plan written before the next one, that is the work we do.

Need an incident response plan before the next attack?

We help organizations build and test incident response playbooks, including the reinfection cases where the first cleanup did not hold. Book a session with our team.