Repository navigation
Tuner Integrity And Malware
A local scan can do more than review settings: with --integrity it compares WordPress core and wordpress.org plugin files with the checksums WordPress.org publishes. In every local scan its malware signatures look for code that has no business on a site. --discover finds every WordPress and Laravel install under a folder and scans each one. This page covers all three, what they read and what they can't tell you.
rhythm tuner scan /var/www/shop --integrity # core and plugin files against wordpress.org checksums
rhythm tuner scan /var/www --discover -o all.md # every install under /var/www, one report eachImportant
All of this only reads files. Tuner never runs the site's PHP, never changes or deletes a file and never quarantines anything. A finding tells you where to look; cleaning up a compromised site is still your job.
--integrity needs a folder target. On a URL it stops before scanning:
$ rhythm tuner scan 127.0.0.1:20400 --integrity
rhythm tuner scan: --integrity needs a folder target, such as rhythm tuner scan --integrity /var/www/site
Here is a scan of a test copy of a WordPress 6.9.3 site in which one core file was changed, a PHP file was added to wp-includes/ and a small script was dropped into the uploads folder. The selection keeps the integrity and malware findings:
$ rhythm tuner scan /var/www/shop --integrity --id "local-integrity-*,local-malware-*" --plain
rhythm tuner 0.4.0 - scanning /var/www/shop
Fingerprint: WordPress 6.9.3 · PHP 8.3.36 (from php on PATH)
Components: contact-form-7 5.3.1 · theme astra 4.6.0
[CRITICAL] eval() of base64-decoded code - /wp-content/uploads/2026/10/cache.php
evidence: /wp-content/uploads/2026/10/cache.php (43 B)
fix: Treat the site as compromised. Restore the file from a clean copy (or delete it if it isn't yours) and change all passwords and keys. Then find how it got there.
[HIGH, likely] PHP file in the uploads folder - /wp-content/uploads/2026/10/cache.php
likely: a few plugins keep PHP files in uploads on purpose
evidence: /wp-content/uploads/2026/10/cache.php (43 B)
fix: Check what the file does and when it appeared. If nobody added it on purpose, delete it, then block PHP in uploads (nginx: location ~* /wp-content/uploads/.*\.php$ { deny all; }) and look for how it got there.
[CRITICAL] Modified WordPress core file - /wp-includes/load.php
evidence: /wp-includes/load.php (92 B)
fix: Compare it with a fresh copy of WordPress 6.9.3, then reinstall core (wp core download --force --skip-content --version=6.9.3). Look for how the attacker got in before you clean up: other changed files, new admin users and old plugins.
[HIGH] Unexpected PHP file in WordPress core - /wp-includes/class-wp-cache.php
evidence: /wp-includes/class-wp-cache.php (31 B)
fix: Check what the file does and when it appeared, then delete it and reinstall core (wp core download --force --skip-content --version=6.9.3).
Findings by severity:
[CRITICAL] Modified WordPress core file - /wp-includes/load.php
[CRITICAL] eval() of base64-decoded code - /wp-content/uploads/2026/10/cache.php
[HIGH] Unexpected PHP file in WordPress core - /wp-includes/class-wp-cache.php
[HIGH, likely] PHP file in the uploads folder - /wp-content/uploads/2026/10/cache.php
Score 17/100 - 2 critical, 2 high - 35 checks, 0s
The plain view shows the first evidence line. JSON, SARIF and Markdown reports hold every line, such as the hash comparison and the matched code:
"matched": [
"/wp-includes/load.php (92 B)",
"MD5 42c5ce4b7da8d45d811bcea1f799aa47, expected 79dac13af956b8875da18ed60e9d9e28 for WordPress 6.9.3 (en_US)"
]Evidence never holds a file's contents beyond the matched snippet, which is cut to 80 bytes with secrets masked.
| Part | Compared with | Notes |
|---|---|---|
| WordPress core | the MD5 of every file in the release, from api.wordpress.org/core/checksums/1.0/
|
the version and locale come from wp-includes/version.php ($wp_local_package, else en_US). Files under wp-content/ (bundled themes and plugins) are never compared. |
| wordpress.org plugins | the SHA-256 of every file in the plugin release, from downloads.wordpress.org/plugin-checksums/<slug>/<version>.json
|
the version comes from the plugin's own Version: header |
| other plugins | nothing | premium and custom plugins have no public checksums; they're listed in one info finding |
| themes | nothing | WordPress.org publishes no theme checksums |
Large files are hashed in full; hashing stops at once when the scan is cancelled or hits --max-time. A core file replaced by a link to a regular file inside the install is hashed through the link. A link that points outside the install, doesn't resolve or isn't a regular file is reported as a modified file at high, with the evidence line a link to … outside the install, so it wasn't compared.
| ID | Severity | When |
|---|---|---|
local-integrity-core-modified |
critical | a core file's MD5 differs from the release (high when it's a link Tuner can't follow) |
local-integrity-core-unknown |
high | a .php file in wp-admin/ or wp-includes/ that the release doesn't have |
local-integrity-core-missing |
low | core files are missing; one finding lists up to 20 |
local-integrity-plugin-modified |
high | a file in a wordpress.org plugin differs from its release |
local-integrity-plugin-unknown |
medium | a PHP file the plugin release doesn't have |
local-integrity-plugin-unlisted |
medium, likely | the plugin is on wordpress.org, though not at the installed version, so its files weren't compared |
local-integrity-plugin-unchecked |
info | plugins that aren't on wordpress.org weren't compared |
local-integrity-plugin-unlisted is likely because a development build or a release published minutes ago has no checksums yet either. It's worth a look all the same: editing a plugin's Version: header to a release that doesn't exist is a way to dodge this check.
Checksums are cached under the vulnerability data cache (…/rhythm/tuner/vulns/checksums). A release's checksums never change, so they're kept for good; "not on wordpress.org" answers are kept for a day. rhythm tuner update --clear deletes them with the rest. --offline uses the cache only.
When --integrity was asked for and checksums that should exist couldn't be loaded, the scan is partial: the JSON report says "complete": false, "stopped_by": "integrity", the report shows a note and --fail-on can't pass it. Here the cache was empty and the scan ran offline:
$ rhythm tuner scan /var/www/shop --integrity --offline --plain --id "local-integrity-*"
rhythm tuner 0.4.0 - scanning /var/www/shop
Fingerprint: WordPress 6.9.3 · PHP 8.3.36 (from php on PATH)
Components: contact-form-7 5.3.1 · theme astra 4.6.0
Incomplete: some checksums for --integrity could not be loaded
Score 100/100 - no findings - 28 checks, 0s
note: integrity: no cached checksums for WordPress 6.9.3 (en_US) while offline; run once online to cache them
note: integrity: skipped 1 plugin with no cached checksums while offline: contact-form-7
With --fail-on, the same scan exits 3 instead of passing:
rhythm tuner scan: /var/www/shop: the scan is incomplete (some checksums for --integrity could not be loaded), so --fail-on critical can't pass it
add --allow-partial to accept a partial scan
A plugin wordpress.org doesn't host, or a version it doesn't list, is no gap: those get the info and likely findings above. TUNER_WPORG_API and TUNER_WPDL_API point the checksum lookups at mirrors; see Known vulnerabilities.
To fill the cache ahead of an offline or firewalled run, scan once online with --integrity. rhythm tuner update --prefetch caches vulnerability data, not checksums.
Every local scan runs nine signature rules, with or without --integrity. They're declarative YAML rules with positive and negative fixtures, like the remote checks; they match file names and contents and never run anything.
| ID | Severity | Looks for |
|---|---|---|
local-malware-php-in-uploads |
high, likely |
.php, .phtml, .phar and similar files under wp-content/uploads
|
local-malware-eval-base64 |
critical |
eval() of base64_decode()
|
local-malware-gzinflate-base64 |
high |
gzinflate, gzuncompress or gzdecode of base64 data |
local-malware-assert-request |
critical |
assert() on request input |
local-malware-preg-replace-e |
high |
preg_replace() with the /e modifier |
local-malware-create-function-input |
critical |
create_function() built from request input |
local-malware-webshell |
critical | markers of well-known web shells |
local-malware-prepend-uploads |
critical |
auto_prepend_file pointing into uploads, in .user.ini, .htaccess or php.ini
|
local-malware-mu-hidden-admin |
critical | a must-use plugin that creates a hidden admin user |
What they read and skip:
-
The whole project, media library included.
.git,.svn,.hgandnode_modulesare skipped. -
vendor/at the project root is skipped by most rules.local-malware-eval-base64andlocal-malware-webshellread it too, because hiding a shell among dependencies is a known trick. The other content rules don't, so code planted invendor/can go unnoticed. -
PHP in uploads. A "Silence is golden"
index.phpis exempt only when Tuner read the whole file: a PHP file in uploads over 1 MB is always reported (only the first 1 MB was read, so the file can't be shown to be harmless). A link named like PHP in uploads is reported by its name (a link to ../../shell.inc, not followed); links are never followed. -
File names are cleaned before they're printed: control characters, bidi overrides and zero-width characters become
., so a planted file can't recolor or reorder the report.
A check pack can carry newer local rules, which replace the built-in ones. Tuner 0.4.0 doesn't use packs yet, so it runs the rules built into the binary.
Note
Signatures catch well-known patterns, not every backdoor. A clean result means none of these rules matched, not that the site is clean. Pair them with --integrity, which catches changed core and plugin files whatever their contents.
--discover replaces each folder target with the WordPress and Laravel installs it finds under it, the folder itself included. Each install becomes its own target with its own report:
$ rhythm tuner scan /var/www --discover -o all.md
rhythm tuner scan: found 3 installs under /var/www
rhythm tuner 0.4.0 - scanning /var/www/shop
…
Report written to all.md
all.md then holds one section per install (/var/www/app, /var/www/blog and /var/www/shop here); with --json you get an array with one report each. --integrity and every other local flag apply to each install. The "found" line is a note on stderr, hidden under -q.
| WordPress |
wp-includes/version.php plus wp-config.php or wp-config-sample.php in the folder, or wp-config.php one folder up (which WordPress allows) |
| Laravel |
artisan plus a composer.json that requires laravel/framework
|
| Depth | 6 folders below each target; at most 250,000 entries, with a warning if it stops early |
| Skipped |
vendor, node_modules, bower_components, .git, .svn, .hg anywhere; wp-admin, wp-includes and wp-content inside a WordPress install; storage, bootstrap, app, config, database, resources, routes and tests inside a Laravel app (public is still searched) |
| Links | never followed, so a link loop can't trap it |
Installs nested in another (a WordPress in /blog of another, a WordPress inside a Laravel public/) are found too. The outer install's file walk then skips the inner one's folder, so a stray file or a signature hit in the inner site is reported once, in the inner site's report.
Both of these stop the scan with exit code 2:
$ rhythm tuner scan /tmp/srv --discover
rhythm tuner scan: --discover found no WordPress or Laravel installs under /tmp/srv (looked 6 folders deep)
$ rhythm tuner scan 127.0.0.1:20400 --discover
rhythm tuner scan: --discover needs a folder target, such as rhythm tuner scan --discover /var/www
A nightly job on the server, with a dated Markdown report and exit code 1 when something critical appears:
$ rhythm tuner scan /var/www --discover --integrity --fail-on critical -o "$HOME/tuner-$(date +%F).md" > /dev/null
rhythm tuner scan: found 3 installs under /var/www
Report written to /home/you/tuner-2026-10-25.md
In a crontab, put the command in a small script so cron's special handling of % doesn't cut it short, as in the weekly agency scan.
Run it as a user that can read every site, such as the sites' own user or the web server's. Files Tuner can't read are skipped and counted in a warning. On a server with a firewall, the checksum, vulnerability data and OSV.dev lookups need HTTPS out to api.wordpress.org, downloads.wordpress.org, www.wpvulnerability.net, www.php.net and api.osv.dev, or mirrors.
The tuner repository also has a WP-CLI command in wp-cli/ (wp tuner scan --integrity) that installs a verified Tuner release and scans the install WP-CLI points at. Its README covers setup.
Coming from Wordfence CLI? Coming from Wordfence CLI or WPScan maps its commands to Tuner's.
← Local scans · Home · Changes and notifications →
rhythm · Useful ideas. Working systems. · Check only sites you own or have permission to test. Keep staging sealed (responsible use) · This wiki is published from wiki/ in the rhythm repository; suggest changes there with a pull request.
Get started
rhythm in depth
Tuner
- Overview
- Your first scan
- Reading results
- Choosing checks
- Requests and politeness
- Output and reports
- Batch scans
- Local scans
- Integrity and malware
- Changes and notifications
Tuner in depth
- Known vulnerabilities
- PHP advisories
- CI and code scanning
- GitHub Action
- Config file
- Watch and serve
- Writing checks
- Check packs
- Finding reference
- CLI reference
- Coming from Wordfence CLI or WPScan
- FAQ and troubleshooting
Soundcheck
- Overview
- Your first check
- Reading results
- Review and accept
- Discovery
- Page health
- Visual changes
- Interactions
- Forms and flows
- Cache
- Speed
- Accessibility and SEO
Soundcheck in depth
- CI
- CI baselines and PR comments
- Configuration
- Error rules and exec
- WordPress updates
- Fleet and client reports
- History
- Import from BackstopJS and Lost Pixel
- Firefox and WebKit
- CLI reference
- FAQ and troubleshooting
- Benchmark
Echo
- Overview
- Your first run
- Connectors
- Sealing staging
- Echo Inbox
- Messages and checks
- Checks and rules
- Attachments
- Actions and compare
- With Soundcheck
- Replay
- Deliverability
Echo in depth
- Docker and DDEV
- Agency hub and reports
- Schedule
- MCP server
- Conformance kit
- CI
- Configuration
- API
- CLI reference
- FAQ and troubleshooting
- Benchmark
Project