Reading WordPress Malware Scan Results: Severity Levels, the False Positives You Can Ignore, and the Checks After a Compromise
A scanner that prints 86 matches is only useful if you can tell which three matter. The questions that come up every time: how urgent is a HIGH, is xmlrpc.php supposed to match, and what do you check when something real turns up?
It continues the post on the two scanners and checksum verification.
The summary first
A scan ends with a block like:
SCAN SUMMARY:
Directories scanned: 1,828
Files scanned: 6,638
Files with matches: 79
Total matches: 86
Errors: 0
Skipped files: 2
Scan time: 1.69 seconds
Read it for scale. 79 files with matches out of 6,638 is typical before checksum verification and meaningless on its own. A non-zero Errors count means the scan did not see everything and is worth fixing first.
Severity levels
| Level | Examples | What to do |
|---|---|---|
| CRITICAL | eval() with base64_decode, a Facebook redirect injected into a script, an arbitrary file download, system commands from user input |
Investigate now |
| HIGH | Suspicious redirects, unsafe file operations, potential SQL injection | Review within a day |
| MEDIUM | Unauthenticated AJAX handlers, obfuscation patterns, dynamic function calls | Review when you can |
Matches you can ignore
Legitimate code uses the same functions as malware. These are the ones that show up in clean sites. Checksum verification removes the core and WordPress.org plugin ones automatically, and the rest remain because there is nothing to verify them against.
WordPress core (skipped when the checksum passes):
xmlrpc.php,wp-includes/class-json.php:file_get_contents('php://input')is how WordPress reads request bodies.wp-includes/rest-api/*: the REST server handling requests.
Plugins:
- LiteSpeed Cache:
php://inputfor its API. - ACF Pro and Gravity Forms:
wp_ajax_nopriv_handlers, which are front-end AJAX by design. - UpdraftPlus:
chmod()and file operations, because it restores backups.
Libraries:
- phpseclib:
chr(), base64 andgzinflatefor encryption. - SimplePie:
gzinflateandgzuncompressfor compressed feeds.
The shortcut I use: if a file matches and passes the checksum, it is not the problem. If it matches and fails the checksum, a person or a program changed it, and that is the one to look at. Premium plugins and themes cannot be checked this way, so for those read the match and the surrounding code.
What a real hit looks like
c99.php,r57.phporshell.phpanywhere, especially inuploads.eval(base64_decode('...'))in a theme or plugin file.system($_GET['cmd']), orreadfile($_GET['file'])with no sanitising.- A redirect to an unrelated site injected into a JavaScript or PHP file.
- New PHP files in
wp-content/uploads/, where only media belongs. - Files with odd names modified in the last day or two.
Signs of compromise, and the commands
When something real turns up, check these before you clean anything:
# PHP files in uploads: there should be none
find wp-content/uploads -name "*.php"
# Did core change?
wp core verify-checksums
# Did a plugin change?
wp plugin verify-checksums --all
# Administrators you do not recognise
wp user list --role=administrator
# PHP files modified in the last week
find . -type f -name "*.php" -mtime -7
On a Trellis server the same checks run over SSH as the web user, which has no sudo and can read the site and its logs:
ssh web@yoursite.com "find /srv/www/yoursite.com/current/web/app -mtime -7 -type f"
ssh web@yoursite.com "tail -1000 /srv/www/yoursite.com/logs/access.log | grep -v 'GET /' | grep -v '.css' | grep -v '.js'"
The second one removes ordinary page and asset requests so that POSTs to odd URLs stand out.
Also look in the database: users with strange display names, and posts or widgets containing hidden iframes or script tags. A clean file system with a poisoned database is common.
If the scan finds an infection, take a backup before you start the cleanup, so you can compare later and so you do not lose evidence. Then replace the infected core and plugin files from clean copies rather than editing them, change every password and the salts, and find out how it got in. A cleaned site with the same vulnerable plugin gets reinfected.
After the scan
- Delete any scanner files that you copied onto the server (the remote streaming approach never leaves any).
- Check modification dates on whatever matched.
- Compare with a clean backup if you have one.
- Write down what you found. The second time you see the same false positive you will want to know it was one.
Hardening that helps
- Disable file editing in the admin:
define('DISALLOW_FILE_EDIT', true);inconfig/application.phpon Bedrock. - Limit login attempts, which on Trellis means fail2ban, and a plugin only if you cannot.
- Two-factor authentication for administrators.
- File integrity monitoring: the checksum commands above, run on a schedule, are a cheap version.
- Keep WordPress and plugins updated. Most compromises are a known bug in a plugin that nobody updated.
A schedule that holds
| When | What |
|---|---|
| Before each deployment, on development | Targeted scan |
| After each deployment, on staging | Targeted scan |
| Weekly, on production | Targeted scan, from cron |
| Monthly | General scan |
| After any update or incident | Targeted after updates, both after incidents |
Each scan only takes a couple of seconds, so there is no good reason to skip them.
Seen a false positive that should be in the list, or a pattern the scanners miss? Find me on Mastodon at @jfrumau@mastodon.social.