Two WordPress Malware Scanners and Checksum Verification: What Each One Catches, and How to Run Them on Any Host
A WordPress malware scan has to do two opposite things: be sensitive enough to catch an eval(base64_decode(...)) in a theme file, and quiet enough that you still read the output. The early version of my scanners failed the second test. Every scan flagged wp-includes/kses.php and half of SimplePie for using functions that malware also uses.
The fix was checksum verification: ask WordPress.org what the official files should look like and stop pattern-matching the ones that match. This is how the two scanners work, what the checksum step does, and how to run them whatever the hosting.
I wrote about running them against a remote server in the malware-scan post. This one covers the scanners themselves. Reading the results is in the next post.
Two scanners with different jobs
Targeted scanner. Fast, aimed at the things that actually hit WordPress sites: Facebook redirect injections, file-disclosure holes (readfile($_GET[...])), unauthenticated AJAX handlers, SQL injection patterns, eval with base64_decode, and obfuscation. About 1.7 seconds over 6,600 files. Run it weekly and after every deployment.
General scanner. Broader and slower, about 2.5 seconds over 7,400 files. It looks for known malware filenames (c99.php, r57.php, shell.php), pharmaceutical and SEO spam, hidden iframes, webshell signatures such as FilesMan and WSO, stacked encoding layers, backdoor functions and long suspicious base64 or hex strings. Run it monthly and after anything suspicious.
| When | Scanner |
|---|---|
| Weekly | Targeted |
| Monthly | General |
| After a deployment | Targeted |
| After an incident | Both (the wrapper runs both) |
Checksum verification
At the start of a scan the scanners check whether WP-CLI is available. If it is:
wp core verify-checksumscompares core files with the official release hashes.wp plugin verify-checksums --alldoes the same for plugins from WordPress.org.- Files that match are skipped.
- Files that fail are flagged as high priority.
| Type | Checked against | Result |
|---|---|---|
| WordPress core | official release hashes | verified files skipped |
| WordPress.org plugins | official plugin hashes | verified files skipped |
| Premium and custom plugins | nothing to compare with | pattern scan only |
| Themes, mu-plugins | custom code | pattern scan only |
| Uploads | user content | pattern scan only |
The result is that the legitimate-but-scary core files drop out, which leaves the files nobody can vouch for: your theme, your premium plugins and your uploads. That is also where malware tends to be. A file that fails a checksum is even better evidence than a pattern match, because it means something changed an official file.
Without WP-CLI the scanners fall back to pattern-only mode. The scan still works, but you get the old false positives back.
Running them
On Trellis or any host with WP-CLI
From the Bedrock site directory:
# Both scanners
wp eval-file wp-cli/security/scanner-wrapper.php
# Individually
wp eval-file wp-cli/security/scanner-targeted.php
wp eval-file wp-cli/security/scanner-general.php
Without WP-CLI
Plain PHP works. Pass the WordPress path as an argument:
php scanner-wrapper.php
php scanner-targeted.php /path/to/wordpress
Both scanners require_once a sibling checksum-verify.php, so copy that file along with the scanner. Copying one scanner by itself fatals on the missing file.
Shared hosting
| Hosting | Best option |
|---|---|
| VPS or dedicated | wp eval-file |
| Trellis or Bedrock | wp eval-file; WP-CLI is installed |
| Shared with SSH | Install WP-CLI in your home directory, or use php |
| cPanel or Plesk | The built-in terminal and php |
| Shared without SSH | Download the site and scan it locally |
On cPanel, Advanced, Terminal, change to the WordPress directory and run php scanner-targeted.php. On Plesk, open the File Manager’s terminal and use the version-specific PHP binary if the default is old, for example /opt/plesk/php/8.2/bin/php scanner-targeted.php.
If you have nothing but FTP, download the WordPress files and scan them on your own computer:
php scanner-targeted.php /path/to/downloaded-wordpress
That is safer than the alternative, which is running the scanner in a browser.
Browser access: the last resort
The scanner can be uploaded and opened in a browser, with an IP allowlist near the top of the file:
$allowed_ips = [
'127.0.0.1',
'YOUR.PUBLIC.IP',
];
Delete every scanner file from the server immediately afterwards. This is the worst way to run a security tool: it exposes the file listing and detection patterns to the internet, a WAF may block it, and the scanner itself becomes something to exploit if you forget to remove it.
Remote, without copying anything
If you can SSH to the site, the better route is to stream the scanner to the server’s PHP over SSH and never leave a file behind. I built wp-ops malware-scan for that:
wp-ops malware-scan example.com production --mode both
Point it at the WordPress core directory (web/wp on Bedrock), because wp core verify-checksums runs from that path. Aim it at the Bedrock site root and the core checksum step is silently lost.
Several sites
#!/bin/bash
sites=("/var/www/site1.com" "/var/www/site2.com" "/var/www/site3.com")
for site in "${sites[@]}"; do
echo "Scanning $site..."
wp eval-file wp-cli/security/scanner-targeted.php --path="$site"
done
For a weekly run, a cron entry is enough:
0 3 * * 1 /usr/bin/wp eval-file /path/to/scanner-targeted.php --path=/var/www/wordpress > /var/log/wp-scan.log 2>&1
Customising
Both scanners keep their rules in a PHP array. A new pattern is a name, a description, one or more regexes and a severity:
'custom_threat' => [
'name' => 'My Custom Threat',
'description' => 'What this detects',
'patterns' => ['/your-regex-here/i'],
'severity' => 'CRITICAL',
],
and excluded directories (node_modules, .git, vendor) live in a config array at the top.
When a scan will not run
| Message | What to try |
|---|---|
wp: command not found |
Install WP-CLI, call wp-cli.phar by its full path, or use php directly |
php: command not found |
Try php8.2, php-cli, or find it with which -a php; on cPanel or Plesk use the version-specific binary |
| Timeout | php -d max_execution_time=300 scanner-targeted.php |
| Out of memory | php -d memory_limit=512M scanner-targeted.php |
| Reports no files | Give the full WordPress path as the argument; check pwd |
| Permission errors | Run as the web user; avoid loosening permissions on production |
| “Headers already sent” | A UTF-8 BOM at the start of the file; save it without BOM |
| Blank page in the browser | Check the PHP error log; the file may be blocked by a WAF |
Cannot upload .php |
Upload as .txt and rename, or use FTP or SCP |
Scan your sites a different way, or have a pattern the scanners should know about? Find me on Mastodon at @jfrumau@mastodon.social.