What Happened
The server runs RunCloud with NGINX in front of Apache, and MariaDB and Redis in Docker. Its sites were spread across several system users, and some users owned more than one site.
The attacker came in through the WordPress REST API batch endpoint, /wp-json/batch/v1, also reachable as /?rest_route=/batch/v1. It needed no vulnerable plugin and no stolen password. wp2shell chains two Core vulnerabilities:
- CVE-2026-63030: route confusion in the REST batch endpoint, present since WordPress 6.9.
- CVE-2026-60137: an SQL injection in
WP_Query, present since WordPress 6.8.
Together they give an anonymous visitor a new administrator account. With that account the attacker can upload a webshell and run code on the server.
The first rogue administrator, named w2s_…, appeared on one site on August 27, 2026. A second site followed on September 6, and between October 3 and 5 the attacker spread to the rest. Seven sites were fully compromised, and an eighth only had the attacker’s PHP configuration files dropped into it.
The sites were months behind on Core updates, with auto-updates turned off. On several of them the attacker had also installed the disable-wordpress-updates plugin to keep them that way.
How I Confirmed the Entry Point
It started with one site behaving strangely. Its webroot held files that clearly did not belong there, and a plugin I had never installed.
The proof was in the NGINX error log. During the minutes the first rogue administrator was created, it showed these warnings:
PHP Warning: Undefined array key 2 in .../wp-includes/rest-api/class-wp-rest-server.php on line 1836
PHP Warning: Trying to access array offset on value of type null ... line 1848
request: "POST /?rest_route=/batch/v1 HTTP/2.0"
Those warnings in class-wp-rest-server.php are the route confusion itself: the $matches and $validation arrays fall out of step. The access log showed the rest of the attack:
- About 300 rapid
POST /?rest_route=/batch/v1requests, answered with HTTP207. - A new administrator account appearing in the middle of the burst.
- A
GET /?rest_route=/wp/v2/users&slug=<hex>request, the attacker checking that the account exists. - No successful
wp-login.phplogin (302) before the account existed, which rules out a guessed or stolen password.
- Hundreds of anonymous POST requests to the REST batch endpoint
- Route confusion + SQL injection CVE-2026-63030 chained with CVE-2026-60137
- A new administrator account is created without any login
- The attacker uploads webshells and a self-reinstalling plugin
- Other sites under the same system user are infected through the shared files
One problem slowed this down. Every client IP in the logs was a Cloudflare address (172.x, 104.23.x, 141.101.x, 188.114.x), because NGINX logged the proxy instead of the visitor. Turning on Cloudflare real IP restoration in RunCloud fixes that for the next investigation.
Indicators of Compromise
These are the traces this attacker left. You can use them to check any WordPress site.
Rogue administrators
Usernames made of 16 hex characters (85a9deff69729e04) or 10 random letters (CNFhrnwbkI), with throwaway email domains or random hex Gmail addresses. Anything containing wp2shell, w2s_ or wp2_ settles it, for example w2s_3fa8c3d84080@wp2shell.local or wp2_0d589f@wp2shell.invalid.
A fake plugin
wp-content/plugins/background-image-cropper/ puts accesson.php back on every WordPress load. Deleting the webshell alone does nothing while this plugin exists.
Webshells and droppers
accesson.php, and hex-named files such as432fefc46aa2.phpor7b477a2c01.php, often in sets of<hex>.php,<hex>.txtand<hex>index.php.- Files named
edits.php,editindexs.php,menuindexs.php,pagess.php,presss.php,products.php,shops.php,503s.php,goods.php,php8.phpandw.php. - Shells disguised as media or fonts (
*.tif,*.f4v,*.3gp,*.ogm,*.wmv,*.ttf,*.gif) next to acache.phpand anindex.php, inside a directory nested in a copy of itself:images/images/,wp-admin/network/network/,HealthCheck/HealthCheck/. - A shell posing as an image:
plugins/akismet/_inc/img/logo-ssoospssps.png.
Persistence
php.inior.user.inifiles withdisable_functions = NONE,exec = ON,shell_exec = ON, or the brokensafe_mode=off\ndisable_functions=pattern.- A
.user.iniwithauto_prepend_filepointing atwp-config.php. That makes an injectedwp-config.phprun before every request, even when WordPress itself fails to load. eval()over strings decoded several times, near the bottom ofwp-config.php.- A malicious database drop-in at
wp-content/db.php, which also tends to break the database connection. - Thousands of
.htaccessfiles acrosswp-content, each blocking PHP except the attacker’s own files, which locks out cleanup tools.find wp-content -name .htaccess | wc -lreturning hundreds or thousands means the site is infected. - Files set to read only (
----r-----) and directories tod--x--x--x, so the site user can’t overwrite them.lsattrshowed no immutable flag, so root could still delete them.
Search Console abuse
robots.txt rewritten to list spam sitemaps (sitemap-1.xml to sitemap-16.xml), and google<hash>.html verification files in the webroot. With those files the attacker can verify as a Search Console owner, submit spam and remove your real URLs.
Triage
These steps come first, in the first few minutes.
- Snapshot before touching anything. Take a full file and database backup of each site and store it outside the webroots, so the evidence survives the cleanup.
- Find out whether root was reached. Check
lsattrfor immutable flags andstatfor the owner of locked files. Also check/root/.ssh/authorized_keys,/etc/passwdfor unexpected UID 0 or new users, every crontab,systemctl list-timers,ps aux --sort=-%cpuandss -tulpn. If root persistence turns up, rebuild the server instead of cleaning it in place. - List the sites that share a system user. PHP running as one user can write to every site that user owns, so all of them are in scope.
- Rotate the valuable credentials. Start with the Amazon SES SMTP and IAM keys, because a spam run through SES can get the whole AWS account suspended (the reason I built SES Guard). Then database passwords, SSH keys, WordPress salts and admin passwords.
- Block the entry point as described below, so sites can’t be reinfected halfway through the cleanup.
Cleaning One Site
I refined this recipe over seven sites. It runs as root; replace SITE, USER and the WordPress version.
cd /home/USER/webapps/SITE
# 0. Snapshot the infected state
tar czf /root/SITE-infected-$(date +%F).tgz .
wp db export /root/SITE-infected.sql --skip-plugins --skip-themes
wp core version # note the exact version to reinstall
# 1. Record ctimes BEFORE any chmod or chown (both reset ctime)
find . -type f -newerct "2026-08-20" -printf '%CY-%Cm-%Cd %CH:%CM %p\n' | sort > /root/ctime-SITE.txt
# 2. Remove the reinfection plugin and lock the attacker out
rm -rf wp-content/plugins/background-image-cropper
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
wp user delete <rogue-ids> --reassign=<your-id>
wp config shuffle-salts # logs everyone out, the attacker included
# 3. Remove the mass .htaccess drop, using the snapshot from step 1
grep -a '/\.htaccess$' /root/ctime-SITE.txt | awk '{print $3}' | xargs -r rm -f
# 4. Unlock files, then remove droppers and nested backdoors
chmod -R u+rwX .
rm -f accesson.php php.ini <hex>.php <hex>.txt <hex>index.php edits.php ... # per the indicators
rm -rf <nested name/name backdoor dirs>
# 5. Replace Core entirely (removes backdoors in wp-admin, wp-includes and the root)
rm -rf wp-admin wp-includes
find . -maxdepth 1 -name "*.php" ! -name wp-config.php -delete
chown -R USER:USER .
wp core download --skip-content --force --version=X.Y.Z
wp core verify-checksums # must say "verifies against checksums"
# 6. Recreate the standard WordPress .htaccess in the root, deleted in step 3
# 7. Clean wp-config.php if it was injected, and check robots.txt for spam sitemaps
# 8. Reinstall plugins (below)
Read wp-config.php without running it
Through auto_prepend_file, a poisoned wp-config.php runs whenever WordPress loads, and wp loads WordPress. Never run wp against a site until its config is clean. grep reads the file without executing it, and wp config create can rebuild it without loading WordPress:
grep -nE "eval|base64|gzinflate|\$_(GET|POST|REQUEST|COOKIE)" wp-config.php
Reinstall plugins at the same version first
Reinstalling each wordpress.org plugin at the version already installed gives clean files without changing behavior. Jumping straight to the latest version can break pairs of free and premium plugins: Elementor 4.x next to Elementor Pro 3.x caused a fatal error on one site.
wp plugin list --skip-plugins --skip-themes --fields=name,version --format=csv \
| tail -n +2 | grep -v advanced-cache |
while IFS=, read -r name ver; do
wp plugin install "$name" --version="$ver" --force --skip-plugins --skip-themes </dev/null >/dev/null 2>&1 \
&& echo "OK $name $ver" || echo "VENDOR $name $ver"
done
wp plugin verify-checksums --all 2>&1 | grep -vE "Could not|Couldn't"
OK means the plugin was reinstalled from wordpress.org. VENDOR marks a premium plugin that wordpress.org doesn’t host, such as ACF Pro, Elementor Pro, WP Rocket, WP Defender, WPMU DEV or LearnDash. Delete those folders and reinstall them from the vendor account. Several still held injected files: wp-rocket/.../HealthCheck/HealthCheck/, elementor-pro/.../upgrade/index.php and advanced-custom-fields-pro-master/assets/forum.php. That last folder name gives away a nulled copy from GitHub. Nulled plugins have to be replaced with licensed ones or removed.
Trust timestamps over grep
The malware is obfuscated to slip past signature searches, so file timestamps were more reliable than content patterns. ctime is harder to fake than mtime, but chmod and chown reset it, so it has to be captured before either one touches the tree.
What File Scans Miss
Some of the infection lives in the database and in Google’s systems, where a clean file tree says nothing.
# Injected scripts in options and posts
wp db query "SELECT option_name FROM PREFIX_options WHERE option_value LIKE '%<script%' OR option_value LIKE '%eval(%' OR option_value LIKE '%atob(%'"
wp db query "SELECT ID,post_title FROM PREFIX_posts WHERE post_modified > '2026-08-20' AND (post_content LIKE '%<script%' OR post_content LIKE '%<iframe%')"
# Code stored by snippet plugins survives a file cleanup: Custom CSS & JS, WPCode
wp post list --post_type=custom-css-js,wpcode --fields=ID,post_title,post_modified
# Open registration with an administrator default role is a live backdoor
wp option get users_can_register
wp option get default_role # must be subscriber or customer, never administrator
In Search Console, check every public domain: remove owners you don’t recognize, delete the spam sitemaps, and delete the attacker’s google<hash>.html files from the webroot. While their file stays, they can verify again.
SEO malware often cloaks, showing visitors a clean page and crawlers the spam, so also fetch each site as Googlebot:
curl -sL -A "Googlebot" https://SITE/ | grep -iE "casino|slot|gacor|togel|viagra|loan"
Blocking the Entry Point
The bug is in Core, so a site with clean files is still exploitable until Core is updated. While updates roll out, I block the endpoint in two places.
Layer 1: NGINX, for the whole server
RunCloud includes /etc/nginx-rc/extra.d/*.conf at the http level, so one file covers every site and stops the request before PHP. Block only the rest_route= query form here. Blocking /wp-json/batch/v1 for everyone breaks the block editor, which uses it.
# /etc/nginx-rc/extra.d/block-batch.conf
if ($args ~* "rest_route=/batch/v1") { return 403; }
Then systemctl reload nginx-rc.
Layer 2: a must-use plugin on every site
wp-content/mu-plugins/block-batch.php turns away anonymous batch requests and lets logged-in editors through:
<?php
add_filter('rest_pre_dispatch', function ($result, $server, $request) {
if (strpos($request->get_route(), '/batch/v1') !== false && !is_user_logged_in()) {
return new WP_Error('rest_forbidden', 'Authentication required for batch API.', ['status' => 401]);
}
return $result;
}, 0, 3);
Check that the block works
An anonymous POST should get 401 from the plugin or 403 from NGINX, never 207. A 400 for the empty body also means the request was rejected before processing.
curl -sL -o /dev/null -w "%{http_code}\n" -X POST "https://SITE/?rest_route=/batch/v1"
curl -sL -o /dev/null -w "%{http_code}\n" -X POST "https://SITE/wp-json/batch/v1"
Optional: Cloudflare, for every zone
A WAF custom rule with the Block action, (http.request.uri.path contains "/batch/v1" or http.request.uri.query contains "batch/v1"), adds a third layer. It is also worth adding a rate limit on POST /wp-login.php that challenges after about five attempts a minute per IP, and blocking /xmlrpc.php. Neither was the entry point here, but both are attack surface the sites don’t need.
Patching Core
The block holds the door shut; the update removes the hole. The safe versions are 6.9.5 and later, 7.0.2 and later, or any 7.1 release. Anything from 6.0 to 6.8 is still vulnerable. I updated all 20 sites to WordPress 7.1.3:
for d in /home/*/webapps/*/; do
[ -f "$d/wp-config.php" ] || continue
(cd "$d" && wp core update --skip-plugins --skip-themes >/dev/null 2>&1 \
&& wp core update-db --skip-plugins --skip-themes >/dev/null 2>&1)
printf '%-22s %s\n' "$(basename "$d")" "$(cd "$d" && wp core version --skip-plugins --skip-themes 2>/dev/null)"
done
Big jumps, such as 6.0 to 7.x, can break themes and plugins. If one does, pin the site to the first patched release with --version=7.0.2 --force. A site that refuses to update usually has a broken database connection. On containerized RunCloud, DB_HOST must be host, not localhost or 127.0.0.1, and the config needs RCWP_IS_CONTAINERIZED, RCWP_REDIS_HOST=host and WP_CACHE.
Checking the Whole Server
After the cleanup, one loop looks for this attacker’s toolkit on every site. The columns that matter are admins, drop, mal and ini, and each should read zero.
for d in /home/*/webapps/*/; do
[ -f "$d/wp-config.php" ] || continue
n=$(basename "$d")
admins=$(cd "$d" && wp user list --role=administrator --field=user_login --skip-plugins --skip-themes 2>/dev/null | grep -cE '^[a-f0-9]{16}$|^[A-Za-z]{10}$')
drop=$(find "$d" -maxdepth 1 -type f \( -name "accesson.php" -o -name "*index.php" -o -name "*.txt" \) 2>/dev/null | grep -cE '[a-f0-9]{8,}')
mal=$(find "$d/wp-content" -maxdepth 2 \( -name "background-image-cropper" -o -name "accesson.php" -o -name "edits.php" \) 2>/dev/null | grep -c .)
ini=$(find "$d" -name "php.ini" -o -name ".user.ini" 2>/dev/null | grep -vc "/cache/")
printf '%-22s admins:%s drop:%s mal:%s ini:%s\n' "$n" "$admins" "$drop" "$mal" "$ini"
done
I left out a column for nested name/name directories, because vendor libraries such as twig/twig and php-di/php-di make it noisy.
Monitoring File Integrity
The last piece is a weekly check that emails only when a checksum fails. With Cloudflare and RunCache in front of the sites, this check and the batch block replaced WP Defender, which I removed.
#!/bin/bash
# /root/wp-verify.sh
OUT=""
for d in /home/*/webapps/*/; do
[ -f "$d/wp-config.php" ] || continue
n=$(basename "$d")
core=$(cd "$d" && wp core verify-checksums --skip-plugins --skip-themes 2>&1 | grep -iE "should not|doesn't verify")
plug=$(cd "$d" && wp plugin verify-checksums --all --skip-plugins --skip-themes 2>&1 | grep -iE "doesn't verify|was added")
[ -n "$core$plug" ] && OUT="$OUT\n== $n\n$core\n$plug"
done
[ -n "$OUT" ] && echo -e "WordPress integrity issues on $(hostname):\n$OUT" \
| mail -s "WP checksum alert: $(hostname)" you@example.com
It runs every Monday at 03:00 with 0 3 * * 1 /root/wp-verify.sh. Send a test first to confirm mail works, and mark it as not spam so the real alerts reach the inbox.
Lessons
Update Core automatically
The root cause was WordPress Core left months out of date with auto-updates off. wp2shell was disclosed in July 2026 and exploited within hours, and from then on any public site that wasn’t patched could be taken over without a login. No weak password, vulnerable plugin or missing firewall was involved, and a security plugin would not have stopped it. Automatic Core updates on every site prevent this whole class of incident.
Give every site its own system user
Shared system users turned one breach into seven. With one user per site, a webshell can only write to the site it landed on.
Keep DISALLOW_FILE_MODS on
It returned 403 when the attacker tried to upload plugins through the dashboard. They switched to webshells, but it still closed one door.
Scope credentials per site
Every site already had its own SES IAM credentials, which limited the damage and meant I could rotate one site’s keys without touching the others.
Make the logs usable before you need them
Without real IP restoration, every request in the logs came from Cloudflare, and I couldn’t see a single attacker address. Turn it on before the next incident.
Run fewer premium plugins, and no nulled ones
Checksums can’t verify a premium plugin, and each one is another thing to patch. A nulled plugin is a file from an unknown source running with full access to the site.
The batch block and the weekly checksum check stay on as backstops. The thing that actually keeps the sites safe is a patched Core.