Anatomy of a Hacked WordPress: What Attackers Leave Behind

Anatomy of a Hacked WordPress: What Attackers Leave Behind

Written by

in

When a WordPress site gets hacked, the visible symptom is usually the last thing to arrive. The real footprint is a handful of files, a couple of database rows, and one user account you did not create. If you know the six places to look, you can tell a hacked site from a broken one in about ten minutes — and you can do it without installing a security plugin.

Clean up a few of these and the pattern repeats: the same four directories, the same three tables, the same two persistence tricks. Attackers reuse what already works.

The short version

  • Most infections live in wp-content/plugins, wp-content/mu-plugins, wp-content/uploads, and the active theme’s functions.php.
  • mu-plugins is the favourite hiding place: files there load automatically and never appear in the Plugins screen.
  • Core files usually get one or two lines changed, so run wp core verify-checksums before you trust anything else on the site.
  • Check users and cron next. A rogue administrator or a scheduled event calling an outside URL is persistence, not just a symptom.
  • Cleaning files without finding the entry point means you will be back here in three weeks.

Start with the file system, not the scanner

A scanner matches signatures; a hand-edited theme file that includes a remote script usually passes. These three structural checks are faster and do not depend on a signature database being current.

Run them from the WordPress root over SSH, or through your host’s file manager terminal.

# PHP files inside uploads — there should be none
find wp-content/uploads -type f -name '*.php'

# Everything modified in the last 30 days, newest last
find wp-content -type f -name '*.php' -mtime -30 -printf '%TY-%Tm-%Td %TH:%TM  %p\n' | sort

# Any PHP file that is world-writable, a common attacker trick
find . -type f -name '*.php' -perm -o+w

The first command should print nothing. Uploads is for images and documents; a .php file sitting next to your photos was put there by someone, and it is a backdoor.

The second command is a timeline: compare it with the dates you actually ran an update.

mu-plugins: the folder that loads and hides

This is the trick I see most often, and the one most site owners have never heard of. Anything in wp-content/mu-plugins loads on every request with no activation step and no entry in the Plugins list. Good hosts use it for a cache loader or an object-cache drop-in. Attackers use it for a single line of PHP.

ls -la wp-content/mu-plugins/

If you do not recognise a filename, read the first twenty lines. A real mu-plugin has a comment header and actual code. A malicious one is often a lone include of a file elsewhere, or one very long base64 string passed to eval, gzinflate or create_function. There is no legitimate reason for a plugin to decode a string and execute it.

Do not just delete the file. Note its name and look for the same string in the theme and in wp-config.php — a mu-plugin loader is usually called from two or three places so that removing one copy does nothing.

Fake plugins with real-looking headers

A dropped plugin differs from a real one in ways that survive a quick glance. It has a proper header block — Plugin Name, Version, Author — because that header is what makes the file a plugin at all. What it lacks is history: no changelog, no readme in the format WordPress expects, and an author URL on a recently registered domain.

wp plugin list --fields=name,status,version,update
ls -la wp-content/plugins/

Compare the two: the command lists what WordPress knows about, the directory shows what is on disk. A folder with no matching row was never installed through the admin — someone dropped it there. A plugin whose files all share one timestamp while the header claims an old version is not one anyone maintained.

Core files: one line is enough

Injections into core are small on purpose. A single line at the top of wp-settings.php or index.php, often wrapped in a check for a specific user agent or a cookie, is enough to serve a payload to Googlebot and nothing to you.

WordPress ships with checksums for every file in every release, and WP-CLI can compare your installation against them:

wp core verify-checksums

Clean output means no core file has been edited; warnings name the files that differ. Do not fix it by unpacking a fresh copy over a live site — read the diff first, then replace that one file from the official zip.

The database footprint: users, options, cron

Files get you access. The database is what keeps it. Three tables carry almost all of the persistence: wp_users, wp_options, and wp_usermeta with the serialised cron array.

wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
wp cron event list --fields=hook,next_run,recurrence
wp option get siteurl
wp option get home

On the user list, look at the registration date, not the name. Attackers pick names that blend in — wp_support, admin_backup, editor2 — but they appear on a date when you were not working. Every administrator should be a person you can name.

On the cron list, a hook with no plugin behind it is the tell. WordPress will happily schedule an event pointing at a function that a malicious mu-plugin defined; the event itself looks ordinary. If you cannot trace a hook to a plugin you installed, treat it as hostile.

For options, siteurl and home should be identical and should be the URL you expect. A mismatch is the classic way to keep an admin logged in on one hostname while sending visitors elsewhere.

Redirects and injected sitemaps

Some infections never add a PHP file. They edit .htaccess and add a rule that fires only for mobile user agents, or only for traffic arriving from a search engine. The page looks completely normal to you and redirects every visitor from Google to a casino.

Read .htaccess from the top. Rules inside <IfModule mod_rewrite.c> that reference HTTP_USER_AGENT, HTTP_REFERER, or a specific country code are not part of WordPress — the stock rules only rewrite to index.php.

The sitemap is the other quiet channel. Check /sitemap.xml and /wp-sitemap.xml against your post list. URLs you never published mean the injection is generating pages on the fly, usually only for a crawler carrying a Google referrer. That is exactly what happened when my own site was hit with Japanese keyword spam — the recovery steps are here.

Where to look, at a glance

Where it hides What you see How to confirm
uploads a .php file beside images find wp-content/uploads -name '*.php'
mu-plugins unrecognised file, no Plugins entry ls -la wp-content/mu-plugins/
plugins folder with no matching wp plugin list row diff the list against the directory
core one edited line in a core file wp core verify-checksums
users admin registered on a quiet day wp user list --role=administrator
cron hook that belongs to no plugin wp cron event list
.htaccess / sitemap redirects or URLs you never published read the rules, compare sitemap to posts

Removing files is half the job

The other half is the entry point: an unupdated plugin, a reused password, or a theme that bundles “nulled” premium code. Locking down wp-login closes the front door; it does not remove a key already copied.

So after you clean the files: change every administrator password, change the database password, regenerate the WordPress salts in wp-config.php (which invalidates every existing login cookie), and revoke any FTP or SSH key you do not recognise. Then check your hosting account for extra FTP users — a leftover FTP account survives a full WordPress reinstall.

Be careful with the restore: if the last backup already contained the backdoor, restoring it puts you back where you started. Compare files against a known-good copy, not a snapshot from an infected week.

Then read the access log for the day the files changed — the request that wrote them is in there, with the IP and the path it used. That is how you find the entry point instead of guessing at it.

What to do today

Run the three file-system commands and wp core verify-checksums, and save the output to a file on your own machine. That file is your baseline: next time something feels wrong, run the same commands and compare. Ten minutes now beats an afternoon of wondering.

Comments

Leave a Reply

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