Panellicense

WordPress reinfected after cleanup: hunt the persistence layer

A WordPress site that keeps reinfecting after a clean scan usually has a persistence layer the scanner doesn't see. Here's how to find it on a cPanel box, browser included.

ImAll Imunify360 articlesTroubleshooting6 min readUpdated 2026-10-02
wordpressmalwarereinfectionservice-workerincident-responseimunify360
schema: TechArticleschema: FAQPageschema: BreadcrumbList

The scanner says the account is clean, passwords are rotated, and 48 hours later the same redirect is back. That pattern means the files you cleaned were the payload, not the persistence. Current WordPress campaigns layer five or six regenerators, and one of them now lives in the site admin's browser, where no server-side scanner can reach.

This is the root-level hunt to run on a cPanel or CloudLinux server after Imunify360 cleanup has done its pass and the site reinfects anyway. The examples use the account baduser.

Work down this list in order

Check each layer before moving to the next one. Each layer is enough on its own to rebuild the infection.

  1. Immutable files. Did the cleanup report files it couldn't modify?
  2. WordPress drop-ins and mu-plugins. Does wp-content/ contain anything that loads before the plugins?
  3. PHP prepend. Does any .user.ini or .htaccess set auto_prepend_file?
  4. Shared memory. Does the account own SysV shared memory segments?
  5. Scheduled jobs. Are there unknown WP-Cron hooks, user crontabs, or admin users?
  6. The database. Are payloads stored in wp_options or wp_posts?
  7. The browser. Did the reinfection start right after an admin logged in?

Immutable files

Attackers with a foothold sometimes run chattr +i on their backdoors. The account user can't delete them, and neither can a cleanup running as that user. Look for them as root:

lsattr -R /home/baduser 2>/dev/null | grep -E '^[-a-zA-Z]*i[-a-zA-Z]* '

Clear the flag before cleaning or deleting:

chattr -i /home/baduser/public_html/wp-content/mu-plugins/craft-system-orb.php

Drop-ins, mu-plugins, and PHP prepend

WordPress loads wp-content/db.php, advanced-cache.php, and object-cache.php before any plugin, and it loads everything in mu-plugins/ on every request. Nothing can disable these from the dashboard. List them along with their sizes:

ls -la /home/baduser/public_html/wp-content/{db.php,advanced-cache.php,object-cache.php} \
  /home/baduser/public_html/wp-content/mu-plugins/ 2>/dev/null

A legitimate advanced-cache.php comes from a cache plugin and names that plugin in its header. A db.php you can't trace to a plugin (Query Monitor ships one) is a backdoor. Recent samples hide gzip-compressed, base64-encoded payloads between marker comments and rebuild the other components when they're deleted.

Then check for PHP-level prepends, which run before WordPress loads:

grep -rlE 'auto_(prepend|append)_file' /home/baduser --include='.user.ini' \
  --include='php.ini' --include='.htaccess' 2>/dev/null

Shared memory and scheduled jobs

Some regenerators cache a copy of themselves in SysV shared memory. That copy survives file deletion and rewrites the backdoor on the next request. Look for segments owned by the account:

ipcs -m -c | awk 'NR<=3 || $3=="baduser" || $5=="baduser"'
ipcrm -m 131074

Use the shmid from the first command in the ipcrm call. Restarting PHP doesn't remove these segments. Only ipcrm or a reboot does.

Then check every scheduler, running WP-CLI as the account user:

crontab -l -u baduser
cd /home/baduser/public_html
sudo -u baduser wp cron event list --fields=hook,next_run_relative,recurrence
sudo -u baduser wp user list --role=administrator --fields=ID,user_login,user_registered
sudo -u baduser wp db search 'base64_decode' --all-tables --stats

Random-looking cron hook names and admin accounts registered during the infection window are your persistence. Delete them with wp cron event delete and wp user delete --reassign.

The browser layer: malicious service workers

This layer catches experienced operators out. Some 2026 campaigns register a JavaScript service worker scoped to /wp-admin/ and /wp-login.php in the browser of every admin who logs in during the infection. The worker intercepts admin page loads and can inject script that uses the admin's live session to reinstall the backdoor through the plugin installer or the theme editor.

You can clean every file on the server and the worker survives. Once a worker is registered, it keeps running from the browser's copy. If its script now returns 404, the update check fails quietly and the old worker stays registered. The symptom is reinfection that starts within minutes of a specific person logging in.

Two ways to remove it:

  • Per browser. Each admin opens DevTools → Application → Service Workers on the site and clicks Unregister, or uses chrome://serviceworker-internals. They need to do this on every device they logged in from during the infection, which is why it rarely happens.
  • From the server. Send Clear-Site-Data: "storage" on admin responses. Browsers unregister the site's service workers when they receive it. Add this to the account's .htaccess file for two weeks, then remove it:
<IfModule mod_headers.c>
SetEnvIf Request_URI "^/(wp-login\.php|wp-admin/)" CLEAR_SW
Header always set Clear-Site-Data "\"storage\"" env=CLEAR_SW
</IfModule>

The header also clears localStorage, so admins lose some dashboard preferences. It doesn't clear cookies, so they stay logged in. Browsers only honour it over HTTPS.

When to stop hunting and restore

If you find immutable files or a hidden regenerator, or you've done two cleanup rounds without success, stop hunting. Restore files and the database from a backup taken before the first detection, using a JetBackup point-in-time restore. Reinstall core and plugins from wordpress.org, then rotate the cPanel password, the database password, and the WordPress salts. Then switch on Proactive Defense, so a regenerator you missed can't execute.

Why does my WordPress site keep getting hacked after malware removal?+
The cleanup removed the payload but not the persistence. The usual culprits are a rogue db.php or mu-plugin, an auto_prepend_file in .user.ini, unknown WP-Cron events, hidden admin users, and, increasingly, a malicious service worker running in an admin's browser.
How do I remove a malicious service worker from a WordPress site?+
Each affected admin can unregister it in DevTools under Application → Service Workers. Server-wide, send a Clear-Site-Data: "storage" header on wp-admin and wp-login.php responses, which makes browsers drop the site's service workers on their next admin visit.
Why can't I delete a malware file even as the account owner?+
It probably has the immutable attribute set with chattr +i. Find it with lsattr and remove the flag as root with chattr -i. Since only root can set that flag, assume the server itself was compromised.
Is wp-content/db.php malware?+
Not necessarily. Plugins such as Query Monitor and some caching layers ship a legitimate db.php drop-in. If you can't trace it to an installed plugin, or it contains encoded payloads, treat it as a backdoor.
Does Imunify360 remove malicious service workers?+
Yes. Imunify360 added browser-side cleanup for malicious service workers in September 2026. Update the agent to a release that includes it. Server-side file cleanup alone doesn't remove a worker that's already registered in a browser.

Next steps

Switch in an afternoon

Switch from your current reseller — free.

We migrate active cPanel, Plesk, LiteSpeed and CloudLinux licenses from any reseller. We prorate the first month so you never pay twice, and your customers see zero downtime during the swap.