— Article — № 038

038 —Security

Fake plugin updates in wp-content: seven signatures to audit

A practical audit for spotting fake plugin updates and supply-chain malware inside wp-content. Seven signatures, with the grep and SQL to actually run.

Overhead photo of opened manila folder, grep cheat-sheet, plugin listing, SQL run-sheet, brass key, red wax seal on linen.
Hero · staged still№ 038

A freelance developer opened a client's wp-content/plugins/woocommerce/ at 23:14 on a Sunday because the staging Hotjar replay showed the cart page spawning JavaScript that nobody on the team had written. The plugin's version string read 8.6.1, matching the WordPress.org repo exactly. But two files inside includes/ had been touched four days after every other file in the directory. Neither was mentioned in the changelog. The shape of a fake plugin update is usually exactly this: a real release, mostly intact, with one or two extra files no one shipped.

That is the new face of the WordPress supply-chain attack: not a brute-force, not a known CVE, not a stolen FTP password, but a fake plugin update that looks legitimate enough to pass a five-second glance. It arrives via a compromised maintainer account, a typosquatted plugin clone, or a paid plugin that quietly changed hands to an SEO spammer. The seven signatures below are the things we actually check, in the order we check them, on a legacy site we have just inherited.

Why wp-content became the default landing spot

Three things converged. WordPress core got harder to attack: automatic updates closed the long tail of installs running 4.x, and modern hardening guides have got most agencies blocking direct PHP execution under wp-includes. The plugin economy, meanwhile, is built on thousands of small, unfunded maintainers, where a two-factor reset on an author's personal email is often the entire attack chain. And the plugin update mechanism itself trusts whatever the .org repo serves, which in turn trusts whatever a maintainer pushes. The result: wp-content is now both the easiest place to land code and the place admins look at last.

The seven signatures below are ordered by how cheap they are to check. Run them top to bottom on a site you suspect, or wire them into a weekly cron on a site you have inherited. None of them require a paid scanner.

Signature 1: mtime mismatch inside a single plugin

The cleanest tell is timing. A real plugin update touches every file in the bundle within the same second, because under the hood it is an unzip operation. A fake plugin update injected after that unzip leaves exactly the fingerprint you want: a single file modified hours or days after the rest.

cd wp-content/plugins/woocommerce
find . -type f -newer readme.txt -mtime -30 -ls | sort -k 8

Read the output by date. Three hundred files dated 2026-05-12 and one file dated 2026-05-16 is the shape you are looking for. The outlier is where to start. If the host strips timestamps on deploy (some managed-WordPress providers do), fall back to comparing checksums against a fresh download of the same plugin version from the .org repo.

Signature 2: obfuscated PHP in a file that should be readable

Plugin source is supposed to be readable. Real PHP rarely needs eval() wrapped around base64_decode() to do its job, and the legitimate exceptions (a vendored Composer dependency, an inlined CSS compiler) almost never appear in a plugin's includes/ directory unannounced.

grep -rEn --include="*.php" \
  "(eval\(base64_decode|eval\(gzinflate|eval\(gzuncompress|str_rot13\(base64)" \
  wp-content/

Signature 3: executable PHP under wp-content/uploads

The uploads directory is supposed to be inert. Images, PDFs, the occasional CSV export. PHP should not execute from there, ever. The most common post-compromise persistence trick is to drop a .php file (or a .png containing PHP, served via a permissive handler) into a year-month folder where no one looks.

find wp-content/uploads -type f \( -name "*.php" -o -name "*.phtml" -o -name "*.php5" -o -name "*.phar" \) -ls
find wp-content/uploads -type f -name "*.png" -size +50k \
  -exec grep -l "<?php" {} \;

The hardening goes in wp-content/uploads/.htaccess:

<FilesMatch "\.(php|phtml|php5|phar)$">
  Require all denied
</FilesMatch>

If the site is on Nginx, the equivalent lives in the server block, not a per-directory file. The Apache documentation on .htaccess scope and AllowOverride is worth re-reading the first time you write one of these, because a denied FilesMatch that lives in a context where AllowOverride is off does nothing.

Signature 4: mu-plugins entries nobody added

WordPress auto-loads every PHP file in wp-content/mu-plugins/. They do not appear in the Plugins admin screen. They cannot be deactivated from the dashboard. They run on every request. The official documentation describes them as a deployment convenience, which they are, and also as a perfect malware hiding place, which they also are.

ls -la wp-content/mu-plugins/ 2>/dev/null

On a clean install this directory either does not exist or contains a single file you put there yourself. On a compromised site it often contains something innocent-looking: wp-cache.php, index.php, db-health.php. Read it line by line. If you did not write it and your hosting provider did not write it, that is the backdoor.

Signature 5: an admin user that wasn't created through the dashboard

This is a database signature rather than a filesystem one. Once an attacker has filesystem write access, the next move is usually persistence through a second admin account that survives a plugin reinstall. The dashboard creates these users with a normal user_registered timestamp and an email you would recognise. A direct SQL insert often leaves a placeholder email, a near-zero registered date, or a login that mimics a real one (admln, wpadmin1, support_wp).

SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
JOIN wp_usermeta m ON m.user_id = u.ID
WHERE m.meta_key LIKE '%_capabilities'
  AND m.meta_value LIKE '%administrator%'
ORDER BY u.user_registered DESC;

Mind the table prefix. If the install uses wp_xy_users, the meta key is wp_xy_capabilities. Run the query in your MySQL editor of choice and read the registered dates. Anything created out of business hours, by no one in particular, is the row to investigate. Cross-reference against wp_users.user_email: an attacker often uses a free-mail address with a numeric suffix, or a domain you have never heard of.

Signature 6: autoloaded options carrying a serialised payload

The wp_options table is read on every request when autoload is yes. It is also a popular place to hide a payload, because most developers never query it. A 200KB autoloaded row is not automatically malicious. Some caching plugins do legitimately store fragments there. But it is always worth opening.

SELECT option_name, LENGTH(option_value) AS bytes, autoload
FROM wp_options
WHERE autoload = 'yes'
ORDER BY bytes DESC
LIMIT 25;

Open the top five by hand. Anything starting with a: or O: is a PHP-serialised array or object. If you cannot read it, paste the first 200 characters into a serialised-data unpacker and see what is inside. A real cache fragment is dull and self-explanatory. A malicious payload usually contains a URL, a base64 blob, or a list of admin emails being staged for exfiltration. The fake plugin update sometimes uses this row as its command-and-control channel rather than touching the filesystem at all.

Signature 7: plugin header version vs the .org repository

The last signature is the one most often skipped because it feels too obvious. The header at the top of a plugin's main file looks like this:

<?php
/**
 * Plugin Name: WooCommerce
 * Plugin URI:  https://woocommerce.com/
 * Version:     8.6.1
 * Author:      Automattic
 */

Open the same plugin on WordPress.org. If the .org repo says 8.6.1 was released on a date the file's own mtime predates by weeks, the version string was hand-edited to disguise an older, vulnerable copy. The fake plugin update's header was edited to keep the displayed version number in step with the legitimate release, so the admin dashboard's update notice never fires. For paid plugins, compare against the developer's published release notes. A version number you cannot trace back to a release post is a version number an attacker wrote.

Combining the signals

No single signature is a death sentence. A mu-plugin can be a legitimate hosting addition. A 200KB autoloaded option can be a real cache fragment. An obfuscated string can be a poorly written but harmless third-party library. The point of the seven is that they cluster. A real backdoor almost always leaves at least three of them at once: an mtime outlier, an eval call, and either an mu-plugin entry or an autoloaded option. If you find two unrelated signatures in the same audit, treat it as a probable compromise and start a backup-and-diff workflow against a clean copy of every plugin. If you find one, file it and check again next week.

The smallest thing to do today

Pick one site you have not touched in over six months. Run the seven commands above in sequence. The whole audit fits inside fifteen minutes if nothing is wrong, an hour if something is. The two findings that come up most often, in our experience, are an old mu-plugin from a hosting migration and an autoloaded option from a long-uninstalled caching plugin. Both are easy to clean. The one that comes up rarely but matters enormously is signature 1: the single file with the wrong mtime. That is the one worth a recurring calendar reminder.

When we built Pier we ran into this exact shape of problem more often than expected. Clients would ask us to clean up a compromised site, and the cleanup itself needed a safety net, because reverting a fake plugin update on a live store at 02:00 is not the moment for guesswork. The way we ended up handling it is that every edit Pier makes to a remote file is wrapped in a version history snapshot, so reverting any change (yours or an attacker's) is a click rather than a database restore.

— Questions —

How is a fake plugin update different from a CVE exploit?

A CVE exploits a known bug in a current version. A fake plugin update replaces or appends to the plugin's own code with attacker-controlled PHP, usually via a compromised maintainer account or a typosquatted clone on the .org repo.

Will reinstalling WordPress core remove wp-content malware?

No. The core reinstaller only touches wp-admin and wp-includes. Anything under wp-content, mu-plugins, or autoloaded rows in wp_options survives the reinstall completely untouched.

Is a .htaccess deny rule enough to stop PHP execution in uploads?

On Apache it is, provided AllowOverride is on for that directory. On Nginx the equivalent goes in the server block. Either way, also block dangerous extensions at upload time.

Which of the seven signatures is the fastest to check?

Signature 1, the mtime mismatch. One find command, no database access, runs in under a second on most plugins, and it catches the largest share of injected backdoors.