After wp2shell: How We Checked Our Site Was Actually Clean

After wp2shell: how we checked our WordPress site was actually clean, server log with shield and magnifier

In July we published what the wp2shell attack looked like in our server logs: the exploit chain, the tool announcing itself in the user agent string, and the five checks we recommended. That post ended on a point we want to follow up on. Updating WordPress closes the vulnerability. It does not remove anything an attacker planted before the update. The only way to know a site is clean is to check it.

Eight weeks after the patch, we ran the full set of checks on our own site, muzedevelopment.com, and kept the output. This post walks through each check, shows what the result looked like, and explains the three warnings that looked like trouble and were not. If you run WordPress and the only thing you have confirmed is your version number, this is the rest of the work.

One note on scope. Everything below was run against our own site. We run the same checks on the sites we maintain for clients, and those results stay private.

Why “we updated” is not the same as “we are clean”

wp2shell chained two flaws in WordPress core, CVE-2026-60137 and CVE-2026-63030, into unauthenticated remote code execution. The fix shipped on July 17 in 7.0.2, 6.9.5, and 6.8.6. Public exploit tooling followed within a day, and CISA added both CVEs to its Known Exploited Vulnerabilities catalog on July 21.

A site that updated on July 17 was never exploitable by this chain. A site that updated on July 25 spent a week exposed, and if anything landed during that week, the update did not remove it. A webshell dropped into a plugin folder, a new administrator account, a scheduled task that re-downloads the payload: all of these survive a core update. That is why the checks below matter even on a site that is fully patched today.

Seven post-patch WordPress checks after wp2shell with the command for each and the result on our site

The seven checks, with the real output

All of these use WP-CLI and standard shell tools, run from the WordPress root directory. Where we quote output, it is copied from our server with nothing changed except the removal of anything that identifies the hosting environment.

1. Core version

wp core version

Ours returned 7.1, several releases past the fix. The passing values are 7.0.2 or later, 6.9.5 or later on the 6.9 branch, or 6.8.6 or later on the 6.8 branch. If you see 7.0.1, you are not patched.

2. Core file integrity

wp core verify-checksums

This compares every core file against the checksums WordPress.org publishes for that version. Ours returned two lines:

Warning: File should not exist: [salts-file].php
Success: WordPress installation verifies against checksums.

The warning is the first false positive. Our hosting platform stores the authentication salts in a separate file that wp-config.php loads with a require statement, instead of defining them inline. WP-CLI does not know about that file, so it flags it as something that should not be in the WordPress root. We confirmed it by opening the file: eight define statements for the salt constants and nothing else, with a modification date from the day the site was set up. If you see an unexpected file in this output and you do not know why it is there, open it. A salts file is eight constants. A webshell is not.

3. Plugin file integrity

wp plugin verify-checksums --all

Same idea, applied to every plugin. This is where the output gets noisy, and it helps to know what the noise means before you see it. Ours reported 15 plugins: 9 verified, 5 skipped, and 1 failed.

The five skipped plugins are the second false positive. Each produced a pair of warnings like this:

Warning: Couldn't fetch response from https://downloads.wordpress.org/plugin-checksums/[plugin]/[version].json (HTTP code 404).
Warning: Could not retrieve the checksums for version [version] of plugin [plugin], skipping.

All five are commercial plugins that are not distributed through WordPress.org. There are no public checksums for them, so the command has nothing to compare against. That is not a problem with the plugins. It does mean this check cannot vouch for them, so for paid plugins the verification is different: confirm the installed version matches what the vendor shows in your account, and include their folders in the file date check in step 6.

The one failed plugin is the third false positive, and the one that took a few minutes to resolve. WP-CLI listed three of its files as Checksum does not match. That is exactly what a tampered plugin looks like, so we treated it as real until proven otherwise. We downloaded the same version from WordPress.org and compared the files:

diff --strip-trailing-cr original/index.php wp-content/plugins/[plugin]/index.php

No output. The files were identical except for line endings. The copy on WordPress.org uses Windows-style CRLF line endings and the copy on our server uses Unix-style LF, which changes the checksum of every file while changing none of the code. The file modification dates were from January 2023, the month the plugin was installed, and nothing had touched them since. Line ending conversion happens when files pass through certain deployment tools or editors, and it is a common cause of checksum mismatches. It is also exactly the kind of result an attacker would hope you ignore, which is why the diff matters. A mismatch you cannot explain with a diff is a mismatch you should treat as a compromise.

Three WP-CLI checksum warnings that are not a hack: separate salts file, commercial plugins skipped, and line ending changes

4. PHP files in the uploads directory

find wp-content/uploads -name "*.php"

Returned nothing. The uploads directory holds images and documents. It should never contain PHP, and it is the most common place a webshell gets dropped, because the directory has to be writable by the web server. Any result here is a finding.

5. Administrator accounts

wp user list --role=administrator --fields=ID,user_login,user_registered

Ours returned three accounts, registered in January 2023 and January 2024. We know who each one is and when it was created. We then listed every user regardless of role. That found one more account, an author-level user created on July 24, 2026, which is the account we set up that day for blog bylines. The reason to list all roles and not only administrators is that an attacker who gets in through a flaw like this one can create an account at any level and escalate later. Every account needs a name you recognize and a creation date you can explain.

6. Recently changed PHP files

find wp-content -name "*.php" -mtime -30 -not -path "*/cache/*"

This lists every PHP file under wp-content modified in the last 30 days. Ours returned about 20 files, all inside one plugin folder and all timestamped 13:32 on August 28, plus the caching plugin’s drop-in file timestamped three hours later the same day. We checked the plugin’s changelog: a new version was released and installed on our site on August 28. The cache drop-in regenerates when the caching plugin updates or its settings change. Both explained.

The way to read this output is to group the files by timestamp. Files that changed together at one moment belong to one event, and that event should match an update you can point to. A single PHP file with a timestamp that matches nothing is the one to open.

7. Scheduled tasks

wp cron event list --fields=hook,next_run_relative

WordPress runs scheduled tasks through its own cron system, and attackers use it for persistence, because a cron hook can re-download a payload after you delete it. Ours listed 36 hooks. Every one traced to something installed: core tasks like wp_version_check and wp_update_plugins, and hooks prefixed by our SEO, caching, object cache, forms, and custom fields plugins.

To attribute a hook you do not recognize, search for its name in the plugins and themes directories:

grep -rl "hook_name_here" wp-content/plugins wp-content/themes

If that returns nothing, the hook was registered by code that is not in any plugin or theme, and you need to find out what registered it.

What the logs still show, eight weeks on

We also re-ran the log search from the first post against the most recent 30 days of access logs:

zgrep -h "batch/v1" access.log*

Twelve requests to the exploit endpoint between August 12 and September 11. Ten were GET requests that returned 404. Two were POST requests to /wp-json/batch/v1 on August 29 that returned 400, meaning WordPress received a malformed batch request and refused it. One pair of requests on August 25, 28 seconds apart, still carried wp2shell as the user agent string. Another on September 10 came from a plain Python HTTP library.

The same 30 days also show what attackers do after a mass exploitation campaign: they come back looking for what other attackers left behind. On September 4, one client requested /shell.php, then /wp-content/plugins/shell/, then /wp-login-css.php, within three minutes. Those are common webshell filenames. Over the month we also saw requests for /backup.tgz, /backup/, and /Backup, from clients hoping to find a site archive left in a public folder. All returned 404.

These are the requests that reached the server. Our edge firewall rejects most exploit traffic before it gets this far, so the count in the raw logs understates the scanning. What matters is that nothing that did arrive was processed.

Eight weeks after wp2shell: 12 exploit endpoint probes in 30 days, wp2shell user agent still seen, zero requests processed

If any check had failed

We would not have started by deleting things. The order is: take a full backup of the site and database as it is, so evidence is preserved; put the site into maintenance mode; change every password and rotate the salts; then remove the artifacts, starting with any webshell, then accounts, then cron hooks, then modified files replaced from clean copies. After that, the checks above get run again, and the logs get watched for two weeks to confirm nothing calls the removed files. InstaWP maintains an open source wp2shell scanner that automates several of these checks, read-only by default, and it is a reasonable starting point if you manage more than a handful of sites.

If you are not comfortable doing this work, the right move is to stop and get someone who is. A cleanup done halfway leaves the attacker in place and gives you a false sense that the problem is solved.

What this has to do with maintenance

Every check in this post took a few minutes. The reason we could run them and read the results is that we know what normal looks like on this site: which plugins are installed, when they updated, which accounts exist, which cron hooks belong to which plugin. That knowledge comes from maintaining the site continuously, not from looking at it once when something goes wrong.

A site nobody maintains has no baseline. When the owner finally runs these checks, every warning looks like a compromise and every real compromise looks like a warning. If you are not sure anyone is doing this for your site, ask us and we will tell you plainly where it stands.

Common questions

Does updating WordPress remove wp2shell?

No. Updating to 7.0.2, 6.9.5, or 6.8.6 closes the vulnerability so it cannot be exploited again. It does not remove a webshell, a rogue account, or a scheduled task that an attacker planted before the update. A site that was exposed before it was patched needs to be checked, not just updated.

What does “File should not exist” mean in wp core verify-checksums?

It means a file is present in a WordPress core directory that is not part of the official release. It can be harmless, for example a salts file your hosting platform places next to wp-config.php, or it can be a file an attacker added. Open the file and check its contents and modification date. If you cannot explain it, treat it as a compromise.

Why does wp plugin verify-checksums skip some plugins?

The command compares plugin files against checksums published by WordPress.org. Commercial plugins that are not distributed through WordPress.org have no public checksums, so the command reports that it could not retrieve them and skips those plugins. Verify paid plugins by confirming the installed version against the vendor and checking file modification dates.

Can a plugin checksum mismatch be a false positive?

Yes. The most common cause is a change in line endings between the WordPress.org copy and the copy on your server, which alters every checksum without changing any code. Confirm by downloading the same version from WordPress.org and running diff –strip-trailing-cr against your files. If the diff is empty and the file dates match the install date, the mismatch is benign. A mismatch the diff cannot explain should be treated as a compromise.


André Vieira is the Lead Web Developer and Systems Engineer at Muze Development, a WordPress development, design, and maintenance agency in Houston. A former security analyst, he runs the hosting infrastructure behind the sites Muze maintains, and the checks in this post are part of his normal week.