Skip to content

Tuner Integrity And Malware

github-actions[bot] edited this page Oct 25, 2026 · 1 revision

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 each

Important

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: --integrity

--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.

What it compares

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.

Integrity findings

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.

Caching, offline use and partial scans

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.

Malware signatures

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, .hg and node_modules are skipped.
  • vendor/ at the project root is skipped by most rules. local-malware-eval-base64 and local-malware-webshell read it too, because hiding a shell among dependencies is a known trick. The other content rules don't, so code planted in vendor/ can go unnoticed.
  • PHP in uploads. A "Silence is golden" index.php is 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.

Every install on a server: --discover

--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

Running it regularly

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 →

Clone this wiki locally