Home / Blog / Cloud, DevOps and Security

How I clean up a hacked WordPress site: preserve evidence, rebuild the timeline, hunt persistence, re-verify

In October 2026 attackers used leaked UpdraftCentral keys to push backdoor plugins to WordPress sites. Patchstack saw the first install on 18 Oct and published its analysis on 19 Oct. The Hacker News followed on 21 Oct. In June 2026 Wordfence reported that backdoored ShapedPlugin Pro plugins had been distributed through the vendor’s official update channel (The Hacker News covered it on 22 Jun).

Neither was a bug in a site’s own code. In both, the attacker arrived through something the site trusted. That changes what “clean” means, and it is why I do not treat a hacked site as a restore job.

This is the method I use. It has four stages, and I do not skip any of them.

Why “restore from backup and move on” is not enough

A restore puts the files back. It does not tell you how the attacker got in, and it does not find what they left behind.

Three things go wrong.

The way in is still open. If the entry point was a leaked key, a weak credential or a trojanised plugin, the restored site has the same door. For a site showing signs of compromise, Patchstack’s advice in the UpdraftCentral case is to delete the keys, rotate passwords and restore from a pre-attack backup. Restoring is only one of the steps.

The backup may already be infected. If you do not know when the compromise started, you do not know which backup is safe.

Persistence lives outside the files you restore. Patchstack reports that the UpdraftCentral backdoor created an account that appears as an Editor but can manage options and install plugins, and that the account stays in WordPress after the plugin is removed. Users, scheduled tasks and options live in the database. Config files and server settings sit outside the plugin folders. A file restore can leave all of them as they were.

On the ShapedPlugin incident, The Hacker News reports that a full clean-up was advised, because updating installs a clean package but does not remove a second-stage payload that has already run.

The rest of this post is how I find those leftovers.

Step 1: preserve and hash the evidence

If the site is actively harming visitors, put it into maintenance mode or block it at the edge first. Do not reboot the server or restart PHP yet: that throws away what is running now.

Before I change anything, I copy what I will need later: the web server and PHP logs, a database export, and a copy of the site files. Then I hash them.

Hashing means I can show later that the evidence did not change while I worked, as long as the hashes are kept off the server. Copying first also stops me from losing the one file that would have told me how the attacker got in.

# Adapt to your server; run on a copy first.
# Read-only on the site; writes to a dated evidence folder outside the web root.
# Add your PHP error log path to the copies if it is not under /var/log/nginx.
umask 077
EV=~/evidence-$(date +%F-%H%M); mkdir -p "$EV"
cp -a /var/log/nginx "$EV/nginx-logs"
wp db export "$EV/db.sql" --path=/var/www/site
tar -czf "$EV/files.tar.gz" -C /var/www site
( cd "$EV" && find . -type f ! -name SHA256SUMS -exec sha256sum {} + > SHA256SUMS )

The evidence folder holds a full database export and the site’s secrets. Keep it private and delete it when you no longer need it.

My scripts run over SSH and follow the same shape every time: a dated backup, a check, a fix, and a rollback path. The backup comes first. Nothing destructive runs before it exists.

If the site holds personal data, check whether you have to report the breach. In the UK and EU the deadline is 72 hours from when you become aware of it.

Step 2: rebuild the timeline from logs

Next I work out when the site changed and what the attacker did. I start from the logs, not from the file system. An attacker can change file dates. Request logs are harder to fake than file dates, but on many servers the web user can write to them. A copy held off the server, such as your host’s or Cloudflare’s logs, is worth checking too. If the site sits behind Cloudflare, make sure the logs record the visitor’s IP and not Cloudflare’s.

I look for:

  • the first request that does not look like normal traffic, such as a POST to an odd path, a request carrying a long unfamiliar parameter, or a login that skipped the login page
  • requests to files that should not be requested directly
  • a change in who is calling wp-admin or admin-ajax.php

Then I cross-check against what is on disk.

# Adapt to your server; run on a copy first.
# Files whose metadata changed in the last 14 days, newest first (read-only).
# ctime is harder to fake than the modified date. Widen -14 to match your timeline.
find /var/www/site -type f -ctime -14 -printf '%CY-%Cm-%Cd %CH:%CM  %p\n' | sort -r | head -50

The aim is a line I can write down: first suspicious request at this time, first changed file at that time, first new user at that time. That line tells me which backups are trustworthy and which credentials were exposed.

Step 3: hunt persistence

This is the stage a restore skips, and the one that matters most. I check five places every time.

WP-CLI loads WordPress, so on an infected site it runs the attacker’s code as well, and a backdoor can hide its own user from wp user list. Read wp-config.php and the mu-plugins folder first. Add --skip-plugins --skip-themes to the wp commands, and never use --allow-root. Then cross-check users straight from the database, which also shows capabilities a role name hides:

# Adapt to your server; run on a copy first. Changes nothing; loads only wp-config.php.
wp db query "SELECT u.ID, u.user_login, u.user_email, u.user_registered, m.meta_value AS caps FROM wp_users u JOIN wp_usermeta m ON m.user_id = u.ID AND m.meta_key = 'wp_capabilities' ORDER BY u.user_registered DESC;" --path=/var/www/site

Scheduled tasks. WP-Cron is a favourite hiding place because it re-runs by itself.

# Adapt to your server; run on a copy first. Changes nothing, but loads WordPress (see above).
wp cron event list --skip-plugins --skip-themes --path=/var/www/site --fields=hook,next_run_relative,recurrence
# Needs root. Use the user PHP runs as if it is not www-data.
crontab -l -u www-data
ls -la /etc/cron.d /var/spool/cron
systemctl list-timers --all

I look for hook names I cannot place and for anything calling a remote URL.

Payloads stored in the database. Attackers hide code in options and post content, where a file scan does not look.

# Adapt to your server; run on a copy first. Changes nothing, but loads WordPress (see above).
wp db query "SELECT option_name, autoload, LENGTH(option_value) FROM wp_options WHERE option_value LIKE '%base64_decode%' OR option_value LIKE '%eval(%' OR option_value LIKE '%gzinflate%' LIMIT 50;" --path=/var/www/site
wp db query "SELECT ID, post_type, post_status FROM wp_posts WHERE post_content LIKE '%<script%' OR post_content LIKE '%<iframe%' LIMIT 50;" --path=/var/www/site
# All users, newest first. Do not filter to administrators (see below).
wp user list --skip-plugins --skip-themes --fields=ID,user_login,user_email,roles,user_registered --orderby=user_registered --order=DESC --path=/var/www/site

Change the table prefix if yours is not wp_. The options query does not filter on autoload: since WordPress 6.6, new options are saved with values other than ‘yes’. A legitimate plugin can match these patterns, so a hit is a lead, not a verdict. I list every user, not only administrators, because the account in the UpdraftCentral case appeared as an Editor while holding permissions to manage options and install plugins.

wp-config.php injection. One extra include or eval line in this file runs on every request. I read the whole file, not just the top.

Trojanised plugins and core. I compare against known-good copies.

# Adapt to your server; run on a copy first. Changes nothing, but loads WordPress (see above).
wp core verify-checksums --skip-plugins --skip-themes --path=/var/www/site
wp plugin verify-checksums --all --skip-plugins --skip-themes --path=/var/www/site

Both commands compare files against checksums published by WordPress.org, so they only cover core and plugins that WordPress.org hosts. Premium plugins and any plugin from outside the directory need a fresh download from the vendor to compare against. Given the ShapedPlugin case, I treat a “legitimate” premium update as something to verify, not something to assume.

Server-level hooks. .user.ini files can prepend a PHP file to every script, and PHP files in uploads should not exist at all.

# Adapt to your server; run on a copy first. Read-only.
find /var/www/site -type f \( -name '.user.ini' -o -name '.htaccess' \) -print
find /var/www/site/wp-content/uploads -type f -name '*.php'

Anything the second command prints needs an explanation. On a normal site it should print nothing, or only an empty index.php a plugin added. The first command lists files I then read, so I open each one. A security plugin firewall can use the same .user.ini setting legitimately, so check what any prepended file is.

Must-use plugins and drop-ins. One more place the checksum commands do not cover. Files in wp-content/mu-plugins load on every request and cannot be turned off from the dashboard. Drop-ins such as object-cache.php, advanced-cache.php and db.php in wp-content load before most plugins. Read each one, and compare themes against a fresh copy too.

# Adapt to your server; run on a copy first. Read-only.
ls -la /var/www/site/wp-content/mu-plugins /var/www/site/wp-content/*.php

Step 4: eradicate, rotate, then re-verify

Only now do I remove things, and only after the backup from step 1 exists and I have a rollback path.

I remove the persistence I found and delete the rogue users. It is safer to replace core, plugins and themes from clean sources, not only the files that looked wrong, and to delete any the site does not use. For a premium plugin, check the vendor’s advisory for which version is clean before downloading it again.

Then I rotate everything the attacker could have seen: the salts, WordPress user passwords, database credentials, API keys and any SSH or hosting credentials that touched the box. Rotating the salts logs every user out. Changing a password does not revoke application passwords, so list them for every user (wp user application-password list <user>) and delete any you did not create. Check the admin email and each user’s email too, because a changed address is a way back in through password reset. Remove any SSH key in authorized_keys you cannot account for. Change the database password in MySQL and in wp-config.php in the same step, or the site goes down.

# Adapt to your server; run on a copy first.
# Back up first, outside the web root (a .bak file inside it can be downloaded).
# This changes wp-config.php and logs every user out.
# $EV is the folder from step 1; in a new shell set it again first (echo "$EV" should not be empty).
cp -a /var/www/site/wp-config.php "$EV/wp-config.php.bak"
wp config shuffle-salts --path=/var/www/site

I generate new passwords with a random generator, not by hand. My own password generator is one option. Admin accounts also get two-factor authentication. I wrote about the same idea for stores in WooCommerce 2FA.

Then I re-verify, more than once, after I finish. Each time I run the whole of step 3 again, and I check the logs for the requests I identified in step 2. I only call a site clean when the checks pass repeatedly, not because the dashboard looks normal. Even then, clean means I found nothing in every place I checked, and I say which places those were.

What I harden afterwards

Clean-up closes one incident. The hardening makes the next one harder.

  • nginx deny rules for dotfiles and for files that should never be served: .log, .bak, .wpress, .sql and .gz
  • PHP blocked in uploads, so an uploaded file cannot be run by requesting it
  • Checking from outside. I confirm the rules work at the edge as well as on the origin, by requesting a test path and checking the response in several Cloudflare locations, not only the one I happen to hit
# Adapt to your server; run on a copy first. Test with: nginx -t
# Put these inside the site's server block, ABOVE the location that passes
# .php requests to PHP-FPM. nginx uses the first matching regex location.

# Dotfiles (allow /.well-known for certificate renewal)
location ~ /\.(?!well-known) { deny all; }

# Backup, log and archive files (this also blocks any .gz you do want served)
location ~* \.(log|bak|wpress|sql|gz)$ { deny all; }

# No PHP execution in uploads
location ~* ^/wp-content/uploads/.*\.php(/|$) { deny all; }

Back up the current nginx config before you edit it, run nginx -t, then reload. nginx -t only checks syntax, so then load the home page and a post to make sure the rules have not blocked the site itself. For the browser side, a tight content security policy limits what injected script can do. I covered that in Content Security Policy (CSP): protect your site from XSS and data injection attacks.

A scanner can help you spot problems. If you want a quick look at a site, ShieldScope is a free WordPress security scanner I wrote. A scanner is not a clean-up, though. It does not rebuild a timeline or hunt persistence for you.

I used AI to help draft this post from my own idea and notes. I verified the information and edited the text myself. Test every command on a copy of your site before you run it on a live one.

Dhiren Patel

Principal Full-Stack Engineer · Founder, Exivon Tech

I’ve built and run enterprise web platforms and e-commerce for 13 years, and I built ShieldScope, a free WordPress security scanner.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.