SC WordPress Malware: Why Deleted Backdoors Return

SC WordPress malware is a self-healing mesh. Learn why deleted backdoors return and the correct WordPress backdoor removal order.

A system administrator in a small server room crouching beside an open server rack, holding a laptop, with tangled Ethernet cables and blinking status lights in the background.

The same file, replaced in under a minute

It is the timestamp that tells you something is wrong. You delete the file out of wp-content, refresh the listing, and the replacement is only seconds old. You delete it again, watch it vanish, and by the time you close the SFTP client it is back with a newer mtime than the one you just removed. That behaviour is what defines the family Sucuri documented in late September as SC WordPress malware. The name comes from the SC_ markers left inside the injected code, and the thing you are fighting is not a file. It is a mesh.

The payload sits in at least eight places at once. A one-line PHP directive in .user.ini. A visible shim with a plain name. A hidden dot-prefixed loader next to it. Two WordPress drop-ins, db.php and advanced-cache.php. A bounded block appended to the active theme’s functions.php. And the actual backdoor, installed twice over, as a must-use plugin and as an ordinary plugin with a fake settings page. Some of those copies live on disk, one lives in a database option row, and one can sit in a System V shared memory segment that a file-level cleanup never touches. As the Sucuri analysis (opens in new tab) puts it, delete the plugin and a drop-in rewrites it, delete the drop-in and the theme rewrites it, wipe every file and the next page load restores the set from the database or from shared memory. There is no single point whose removal stops the chain.

The analogy I keep coming back to is a landlord changing the locks while the tenant still has copies of the key and a locksmith on retainer. You have changed one lock. Four other doors still open.

What makes the SC variant worth reading about, rather than just another injection, is that the components are not passive copies of each other. They check conditions, find payloads, and deploy what is missing. Two details in particular matter: the layers load at different points in the request lifecycle, so catching one tells you nothing about the others, and the visible files are designed to stay quiet when their hidden partner is gone. That second trick is the reason a partial cleanup can look successful for hours before everything reappears.

Background

The reason a cleanup has to understand WordPress load order is that SC does not put all its weight in one place. Each component sits at a different stage of the request, and the stages run in a fixed sequence.

Start at the PHP level. An .user.ini file in the account sets auto_prepend_file, which points PHP-FPM at a loader that executes before WordPress is aware anything is happening. Requests that 404, requests that hit a static handler, requests to admin-ajax: all of them pass through it. Next comes wp-content/advanced-cache.php, which WordPress only loads when WP_CACHE is set, and which runs earlier than ordinary plugins. Then wp-content/db.php, loaded during bootstrap when the database object is constructed. Then anything in mu-plugins, which WordPress loads automatically with no activation step and no entry in the plugins list. Finally the active theme’s functions.php, on essentially every front-end request.

Five positions, five different triggers. Finding one tells you nothing about whether the others are present.

Every SC file we have seen shares a single obfuscation scheme, and the details are worth knowing because they defeat the obvious scanning approach. There is no eval. There are no readable function names. Each file carries a table of scrambled strings and a short decoder that takes a numeric index and resolves it into a real function name through a positional substitution cipher. So a grep -r "eval" over the account returns nothing useful, and reading the file gives you a list of integers rather than a picture of what it does. You end up having to trace it dynamically or reason about it from the surrounding structure.

The visible shim and the hidden dot-prefixed file exist as a pair for a reason. The .user.ini value points at a plainly named file, something like wp-content/c1b12371.php, and that file does exactly one thing: if a hidden sibling named .c1b12371.php exists next to it, include it. The plainly named file keeps the auto_prepend_file target stable and boring, the sort of value an administrator skims past. And if someone deletes only the hidden file, the shim does nothing at all, the site keeps serving pages, and nobody raises an alarm. That silence is the feature. It gives the remaining components time to write the hidden file back.

This is why the standard instinct, find the bad file and delete it, produces a site that looks clean and then is not. There is no single bad file. Each component treats the others as an installation source, so the act of removing one is closer to a trigger than a fix.

What’s happening now: the SC WordPress malware mesh

Start at the top of the load order, because that is where the trap sits. The .user.ini carries one line, auto_prepend_file, pointing PHP at the shim before every request in that directory tree, including requests that never reach WordPress at all. PHP caches that value. If you delete the shim while the cache entry is still warm, PHP still believes it has something to prepend and every PHP request on the account returns a fatal error until the cache expires. I have watched people panic at that moment and start reinstalling PHP, which is not the problem. The problem is that you removed the target before clearing the cache entry that points at it.

Below that, the db.php drop-in sits inside wp-content and loads very early in bootstrap. It is unusual in that it does not fetch its payload from anywhere. It carries the whole backdoor inside itself as a gzip plus base64 blob. On any request where the fake plugin is missing, or present but smaller than expected, it decodes the blob, writes the plugin back, sets mode 0644, and calls opcache_invalidate so the freshly written file executes on the same request. There is no delay to notice and no window to intervene in.

advanced-cache.php is the one to worry about most. When WP_CACHE is set, WordPress loads it before ordinary plugins, which makes it the earliest writable position in the stack. It is not a payload itself. It is a finder, and it tries five sources in order: an existing mu-plugin, an existing plugin copy, a System V shared-memory segment holding PHP, a random hex-named ZIP bundle it searches for across several folders, and finally the database. For that last one it opens a direct mysqli connection using the site’s own credential constants from wp-config.php and reads the payload out of a specific option row, decoding the same gzip format. Once it has a copy it hooks plugins_loaded and includes it. The theme matters too, since the active theme’s functions.php runs on nearly every request and carried a bounded block fenced by begin and end markers, a twin of the db.php drop-in that rewrites the plugin whenever it disappears.

The analogy I keep coming back to is a hydra with a filing cabinet. Cut a head and another one reads the paperwork and grows it back. Delete the paperwork on disk and the head in memory still knows what to write.