# scanWordPressBackdoors — heuristic scanner for compromised WordPress sites Scans a backup tree of hosted WordPress sites for PHP files that look like backdoors/webshells, and reports them grouped by client/site so an admin can triage which sites need a closer look. It is meant to run nightly on the backup server, right after nightly replication from the live server finishes, so a compromise gets flagged from the backup copy without loading the live server. This is a heuristic, not a signature/AV scanner. It scores files on things like use of eval/shell_exec/curl_exec, base64/hex/octal/decimal string obfuscation, goto-based control flow, files sitting somewhere a `.php` file has no business being (`wp-content/uploads`, `wp-content/cache`), and suspicious filenames — including a file dropped right in the WordPress docroot whose name isn't a real core filename, especially a typosquat of one (e.g. `wp-conffq.php` next to the real `wp-config.php`). Files scoring at or above a threshold are reported. Expect some false positives (a legitimate plugin doing something unusual) and don't assume a clean report means a site is definitely clean — review the output, don't treat it as a verdict. ## Files - **`scanWordPressBackdoors`** — the scanner itself (Perl). Run standalone against a directory, or fed a NUL-delimited file list on stdin (see below). Accepts an optional `--whitelist=FILE` to permanently suppress files that have been manually reviewed and confirmed OK (see [Suppressing known-good files](#suppressing-known-good-files) below), plus `--max-bytes`/`--threshold`/`--max-per-site` to override the tuning knobs without editing the script (see [Tuning](#tuning) below). Run with `--help` for the full option list. Full documentation, including the scoring model, is in its own POD block — read it with: ```sh perldoc scanWordPressBackdoors ``` - **`scanWordPressBackdoors.sh`** — wrapper script (POSIX sh) that uses `find(1)` to locate candidate PHP files — pruning `.git`/`.svn`/ `node_modules`/`vendor` before descending into them — then uses `grep(1)` to prefilter down to files containing at least one suspicious token, before handing the (much smaller) candidate list to `scanWordPressBackdoors`. This is the recommended way to run a scan; it avoids the Perl script opening and scoring every single PHP file in a large backup tree. Any scanner flag (`--whitelist=FILE`, `--max-bytes`, `--threshold`, `--max-per-site`, `--help`, and anything added later) is forwarded through as-is, in any order relative to the root argument; `--help` works standalone, without a root argument. - **`report2tsv`** — filter (Perl) that converts a `scanWordPressBackdoors` report into tab-separated values (client#/web#, score, file name, matches) for import into a spreadsheet or further script processing: ```sh scanWordPressBackdoors.sh /path/to/clients | report2tsv > report.tsv ``` - **`../testing/`** — a few real-world backdoor samples (disguised as core WordPress files) used to sanity-check detection while developing this. Useful for regression-testing after changing the detection patterns — a clean scan of a copy of that directory should always report all three files. ## Directory layout Both scripts assume: ``` /client#/web#/web/... ``` where `client#` and `web#` are the literal words "client"/"web" followed by a 1-3 digit number, and everything under `web/` is the site's DocumentRoot. `` is normally the top-level directory containing every client, e.g. `/storage/backup/nfs//www/clients` — both scripts recurse through all clients/sites under it in a single run and group results by `client#/web#`. A file whose path doesn't contain a `client#/web#` segment is silently skipped from the report (and counted in a "skipped" footer line) — this usually means `` wasn't pointed at the right level of the tree. ## Usage ```sh scanWordPressBackdoors.sh /path/to/website/root ``` or, to run the Perl scanner standalone without the find/grep prefilter (slower on large trees, but has no shell dependency beyond `File::Find`, which ships with Perl): ```sh perl scanWordPressBackdoors /path/to/website/root ``` ### Sample cron entry Run nightly, after replication completes: ``` 0 4 * * * /path/to/scanWordPressBackdoors.sh /path/to/website/root | mail -s "WordPress backdoor scan" you@example.com ``` ### A more complete example For more complex setups, you may want a script that's called manually or by cron. Here, we run the check, run the output through `report2tsv` to also produce a TSV, then mail both to the sysadmin: ```sh #!/usr/bin/env sh date /path/to/scanWordPressBackdoors.sh --whitelist=/path/to/whitelist.sha256 /path/to/root/of/websites > /tmp/possibleHacks.txt date /path/to/report2tsv < /tmp/possibleHacks.txt > /tmp/possibleHacks.tsv /path/to/mailMultiFiles \ --to "me@example.org" \ --subject "Report" \ --body "See attachments" \ --from root@`hostname` /tmp/possibleHacks.txt /tmp/possibleHacks.tsv \ | sendmail -t ``` ## Suppressing known-good files Some legitimate, bundled libraries (phpseclib's `RSA.php`, `PHPMailer`, `pclzip.class.php`, `wp-includes/functions.php`, etc.) legitimately trip several heuristics and get flagged on nearly every WordPress install, often identically across many different client sites. Once you've manually reviewed one of these and confirmed it's the genuine, unmodified file, you can suppress it from future reports with `--whitelist=FILE`: ```sh scanWordPressBackdoors.sh --whitelist=/path/to/whitelist.sha256 /path/to/website/root ``` The whitelist is matched by the file's full SHA-256 content hash, not its name or path — deliberately so. A path/filename-based exclusion would let a compromised site evade detection just by replacing a previously-approved file with a backdoor under the same name; hashing the content means any change at all makes it reappear in the report. It also means one whitelist entry suppresses the same legitimate file everywhere it's bundled identically, which is exactly where most of the repeat noise comes from. The whitelist file ignores everything after a `#`, and ignores any line that doesn't contain a bare 64-character hex token, so all of the following are valid entries in the same file: ``` # This is an example of a checksum file. Note that arbitrary comment lines and blank lines are ignored # These are bare SHA256 checksums generated by FreeBSD's 'sha256 -q file' 4ba5da78bd0a837eeb4e818f44df52d56915658508ee44cd86b371ea5d12df7b a0f944cef4856535db9b53a1eb21f3bdf4f19ebca21d7d3e77d649db017245ec # here we have the checksum and full path, separated by a few spaces, generated by GNU's 'sha256sum file' 3394b3ce03a8f5ca9ba28af276bb1a22b87d77a2fb031b467991d3ec4e241387 wp-includes/user.php f06764a8f214a4e3d71cbac4ea5af4d9edeaf07701cfe09436c251a4042db0d7 wp-includes/utf8.php # WP 7.0.2 # Finally, the format used by FreeBSD's 'sha256 file' SHA256 (wp-includes/version.php) = 3eb0e81153a91014e9eb7f5cf2b5261e1b3b8ead89d834869d42eeb2024e860f SHA256 (wp-includes/view-transitions.php) = 2295af1d4d7c1c666c53d3eda143c7403db05960067c03c464a22f72b3b20740 ``` There is no default whitelist file — it's only used when `--whitelist=FILE` is passed explicitly, and the script exits with an error rather than silently ignoring the flag if that file can't be read. Any of these common recipes will produce a working entry (the parser just looks for a bare 64-hex-digit hash somewhere on the line, so the exact tool/format doesn't matter): ```sh sha256sum knownGoodFile.php >> whitelist.sha256 # GNU coreutils (Linux) sha256 -q knownGoodFile.php >> whitelist.sha256 # FreeBSD base sha256 knownGoodFile.php >> whitelist.sha256 # FreeBSD base, verbose form ``` Suppressed files aren't silently dropped — the report includes a `(N file(s) suppressed by whitelist match)` footer so it stays visible that the whitelist is active and how much it's filtering out. ### Bulk-populating from upstream WordPress checksums A list of SHA-256 checksums for WordPress core files, broken down by version, is available at [Blobfolio/wp-core-checksums](https://github.com/Blobfolio/wp-core-checksums). Since the whitelist parser ignores everything but the bare hash on each line, you can append one of these files straight into your whitelist without any reformatting: ```sh { echo '# WordPress 7.0.2' wget -q -O - 'https://raw.githubusercontent.com/Blobfolio/wp-core-checksums/main/chk/7/7.0.2.sha256' } >> /path/to/whitelist.sha256 ``` would download the checksums for v7.0.2 and append them to `whitelist.sha256`. A slightly more complex example, whitelisting every core file across several versions at once: ```sh { for v in 6.4.3 6.4.4 6.5.0; do echo "# WordPress $v" wget -q -O - "https://raw.githubusercontent.com/Blobfolio/wp-core-checksums/main/chk/6/$v.sha256" done } >> /path/to/whitelist.sha256 ``` If you don't have wget (some FreeBSD's don't install), the same thing with curl ```sh { echo "# WordPress 7.0.2" curl -sSfL "https://raw.githubusercontent.com/Blobfolio/wp-core-checksums/main/chk/7/7.0.2.sha256" } >> /path/to/whitelist.sha256 ``` ```sh { for v in 6.4.3 6.4.4 6.5.0; do echo "# WordPress $v" curl -sSfL "https://raw.githubusercontent.com/Blobfolio/wp-core-checksums/main/chk/6/$v.sha256" done } >> /path/to/whitelist.sha256 ``` ### Duplicate Checksums Populating from more than one version will create duplicate entries (if file is not changed from one version to another), but the impact is minimal on execution. The checksums are stored as case insensitive hash keys, so the only impact is in the actual file read. ## Tuning The alert threshold, byte-sample size, and per-site display limit are all overridable on the command line without editing the script, through either entry point: ```sh scanWordPressBackdoors.sh --threshold=10 --max-bytes=1000000 --max-per-site=25 /path/to/website/root ``` The weighted pattern lists themselves (`@DANGEROUS`/`@STAGING`/`@BAD_NAME`/ etc.) are still code-only knobs — see `scanWordPressBackdoors`'s POD (ARGUMENTS / TUNING KNOBS sections) for details. `scanWordPressBackdoors.sh`'s own grep prefilter pattern should be kept in rough sync with whatever detections exist in the Perl script, or it may filter out a file the Perl script would otherwise have flagged. ## Requirements Perl (core modules only — `File::Find`, `Digest::SHA`, `Getopt::Long`), and a standard FreeBSD (or Linux) `find`/`grep`/`xargs`/`sort`/`tr` for the wrapper script. No third-party Perl modules or GNU-only command flags are used. ## Processing Considerations During testing, I ran on a FreeBSD system which had no additional processes running. The web root was on a ZFS file system, with the following specs: - 56 Gigabytes used ``du -sh`` - 111k directories ``find -type d | wc`` - 973k files ``find -type d | wc`` - 154k PHP files ``find -type f \( -iname '*.php' -o -iname '*.phtml' -o -iname '*.phps' \) | wc`` It took 33 minutes to process these files, mainly pre-processing using the grep command. I did not even attempt to test on a live server. As you can see, the process is very resource intensive (memory and disk reads). In our case, we have the live web site backing up to another machine which is mainly quiesant, so we run on that. That gives us security (more difficult for third parties to find and modify the script) and does not impact the active server. It means, however, alerts are delayed until after a backup. ## Limitations and Future Changes This set of scripts were written specifically for processing WordPress sites on an ISPConfig server. It may be that it would be better as a more generic tool to process PHP files in any subdirectory, then adding filters to check for WordPress or other systesm.