All blogs

How to clean a hacked WordPress site (and find the way in)

The first malicious file you find is almost never the way in. How to separate payload from entry point, where injected code hides, and how to stop it returning.

The first malicious file you find is almost never the entry point. It's the payload. Delete it and the symptom disappears for a few days, then comes back — which is how most people end up on their second or third "cleanup" of the same site.

Payload and vector are different problems

A payload is what does the visible damage: the mobile-only redirect, the spam pages, the injected script in the footer, the fake plugin in the admin list.

A vector is how it got in: an outdated plugin with a known vulnerability, a reused admin password, a writable uploads directory serving PHP, a stolen hosting credential, or a backdoor left behind by an earlier compromise nobody fully cleaned.

Fixing only the payload guarantees a repeat visit. Almost all of the real work is finding the vector.

Before you clean anything

Take a full backup of the compromised state — files and database. You are about to destroy evidence, and if you later need to work out what was taken, or when it started, that snapshot is the only record you'll have.

Then take the site offline or put it behind maintenance mode. Cleaning a live site while it's still serving the payload means fighting reinfection while you work.

Establishing when it started

Timestamps narrow everything else down. Find recently modified files:

find . -type f -name "*.php" -mtime -30 -printf "%TY-%Tm-%Td %p\n" | sort

What you're looking for is a file whose modification time doesn't match its neighbours. Core files all written on the same date are a WordPress update. One file in a theme folder touched three months after everything else is worth opening.

Treat this as a lead, not proof — modification times can be set by whoever wrote the file. But attackers are frequently lazy, and it works more often than it should.

Cross-reference against your access logs for that date. A POST to an unexpected endpoint, or a burst of requests to one plugin's file, is usually the moment you're looking for.

Where injected code actually hides

The database, not just the filesystem. Check wp_options for siteurl and home first — if those point somewhere unfamiliar, that's your redirect. Then look at large autoloaded options, which load on every single request and are a comfortable hiding place:

wp option list --autoload=on --format=table --fields=option_name,size_bytes \
  | sort -k2 -n -r | head -20

Anything unexpectedly large near the top deserves a look.

Must-use plugins. wp-content/mu-plugins/ loads unconditionally, can't be deactivated from the admin, and doesn't appear in the normal plugin list. It's a favourite. Many sites legitimately have nothing there at all, which makes anything you find easy to assess.

The active theme's functions.php, and any file it includes. Scroll to the bottom — appended code after a long run of blank lines is a common pattern.

PHP in wp-content/uploads/. Nothing in the uploads directory should ever execute:

find wp-content/uploads -name "*.php" -o -name "*.phtml"

Any result here is worth treating as hostile until proven otherwise.

Scheduled tasks. wp cron event list will show jobs that reinstall a payload on a timer. If you clean files and the infection returns on a schedule, this is usually why.

Admin users. wp user list --role=administrator. Look for accounts you don't recognise, and for legitimate accounts whose email address has been changed.

Let the checksums do the boring part

WP-CLI compares core files against the official release:

wp core verify-checksums
wp plugin verify-checksums --all

This finds modified core and repository plugin files in seconds and is far more reliable than reading through directories yourself. It won't cover premium or custom plugins — those you compare against a clean copy from the vendor.

Closing the vector

Once the payload is gone, the actual job starts.

Update everything — core, plugins, themes, PHP version. Remove plugins and themes that aren't in use rather than leaving them deactivated; deactivated code is still on disk and still reachable.

Then rotate credentials, all of them, not just the one you think was used: admin passwords, database password, hosting and SFTP, and any API keys stored in the site. Replace the salts in wp-config.php too, which invalidates every existing login session — including the attacker's.

If the compromise had filesystem access for any length of time, assume anything writable may have been touched.

Before you call it done

  • Fetch the site with a mobile user agent and with a search-engine referrer. Plenty of payloads stay dormant for a normal desktop visit and only fire for those.
  • Check Google Search Console for security notices, and request a review if the site was flagged.
  • Search the rendered HTML of a few pages for <script tags you can't account for.
  • Watch the logs for a week. Reinfection usually shows up quickly.

When to stop cleaning and rebuild

Sometimes cleaning is the wrong call. If the install has been compromised repeatedly, if you're finding backdoors in unrelated directories, or if nobody can tell you what half the plugins are for, the faster and safer path is a rebuild: fresh WordPress, fresh copies of each plugin from source, and only the content and uploads migrated across — with the uploads scanned before they go anywhere.

That's a harder conversation to have with a client than "I cleaned it". It's also the one that ends with a site you can stop thinking about.

Once it is clean and updated, the work that keeps it that way is dull and scheduled — see a WordPress maintenance checklist that prevents problems.