Security Documented

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:

  1. wp core verify-checksums compares core files with the official release hashes.
  2. wp plugin verify-checksums --all does the same for plugins from WordPress.org.
  3. Files that match are skipped.
  4. 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.