Post

3,015 Hacked WordPress Sites Later, I Think I Found the Front Door

A theory: 52.4% of 3,015 compromised WordPress sites run the exact version that patched wp2shell, a pre-auth RCE chain - and why that's not a coincidence.

3,015 Hacked WordPress Sites Later, I Think I Found the Front Door

The views and opinions expressed in this post are my own and do not represent those of my employer. This is a personal blog where I share research and things I’m learning.

TL;DR

A theory, not a confirmed finding: pooling 3,015 confirmed-compromised WordPress sites from five ClickFix investigations turned up one huge anomaly - 52.4% run WordPress 7.0.2, the exact security release that patched “wp2shell” (CVE-2026-60137 + CVE-2026-63030), a pre-auth RCE chain in WordPress core disclosed 17 July 2026. The pattern fits: compromised while vulnerable, then silently force-patched by WordPress’s own emergency auto-update, leaving the backdoor in place behind a site that now reads as fully up to date.

If this is your fleet, do these first:

  • Check whether any WordPress site you run was on 6.8.0-6.8.5, 6.9.0-6.9.4, or 7.0.0-7.0.1 between 17-21 July 2026 - if so, treat it as compromised regardless of its current version
  • Rotate AUTH_KEY/AUTH_SALT in wp-config.php and audit wp_users for admin accounts you don’t recognise
  • Check wp-content/plugins/ for unfamiliar wp2shell_<hex>-style folders and wp-content/cache/ for PHP files that shouldn’t be there

Full corpus stats, the theory’s weak points, hunt queries, and a Sigma rule are below.


The question I kept not answering

Over the last few months I’ve published a run of posts about ClickFix campaigns that use compromised WordPress sites as their delivery layer - the EtherHiding work, the smart-contract pivots, the operator mapping. Each one ended with a number: 153 sites. Then 1,829. Then 1,738.

Every single time, I answered what was injected, where the C2 lived, and who the injected code talked to - and every single time I quietly skipped the most obvious question a sysadmin would ask: how did these sites get hacked in the first place? It’s an awkward question to duck, because it’s the only one that matters to the person who owns the site. Knowing the attacker rotates C2 domains through a Polygon smart contract is fascinating; it does not help the dentist in Ohio whose homepage is serving a fake CAPTCHA to her patients. She wants to know how they got in.

So I finally sat down and pooled every compromised domain from five separate investigations, looking for what they had in common. I have an answer, and I want to be upfront that it is a theory - one with a genuinely strong statistical backbone, I think, but built entirely on passive external observation. I never had access to a single one of these sites’ logs, filesystems, or databases. What I have is a large corpus, one very loud statistical anomaly, and a timeline that fits uncomfortably well.

Here’s the theory, the numbers behind it, how I got them, and the things that could still prove me wrong. If you run WordPress anywhere, or you’re the one who gets asked “are we affected” when a headline like this drops, stick around - there’s a version-check you can run on your own site in the time it takes to read this. Let’s dig in.


The theory at a glance

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
1. The corpus
   → 3,015 unique compromised WordPress sites
   → pooled from 5 separate ClickFix investigations
   → all serving fake-Cloudflare-Turnstile lures to their own visitors

2. The obvious hypotheses, tested and discarded
   → a shared vulnerable PLUGIN?     no version cluster; plugin mix tracks market share
   → a shared rogue ADMIN account?   no dominant username; long thin tail
   → template/clone sites?           no; verified as genuinely distinct businesses

3. The anomaly that showed up at full scale
   → 1,172 of 2,238 confirmed-WordPress sites (52.4%) run EXACTLY WordPress 7.0.2
   → 7.0.2 is a security release, published 17 July 2026

4. The theory
   → "wp2shell": CVE-2026-60137 (SQLi in WP_Query author__not_in, since 6.8)
     chained with CVE-2026-63030 (REST /wp-json/batch/v1 route confusion, since 6.9)
   → unauthenticated pre-auth RCE. WordPress CORE. No plugin required.
   → patched in 7.0.2 / 6.9.5 / 6.8.6 on 17 July 2026
   → WordPress.org force-pushed the patch given the severity

5. Why the sites look "fully patched" and are still hacked
   → compromised while vulnerable → force-updated hours later → still backdoored
   → a core update does not remove an injected script tag or a rogue DB option

How I actually found it

Step 1 - Pool the corpus, because scale was the whole trick

Five investigations, each with its own confirmed-compromised list - the mamkor campaign, a sibling EtherHiding panel, an authorization-cdn ClickFix cluster, a WebDAV loader campaign, and the case that finally triggered all this. Deduplicated:

1
2
cat */compromised_sites.txt | tr 'A-Z' 'a-z' | sort -u > all_unique.txt
wc -l all_unique.txt        # 3015

The single most useful lesson from this whole exercise: a stratified sample of 561 domains showed nothing - the version spread looked like noise, the plugins looked ordinary. I nearly wrote the idea off. The signal only became undeniable at the full 3,015. If you’re doing corpus work like this, resist the urge to sample - the thing you’re looking for may only separate from the background at full scale.

Step 2 - Fingerprint passively, and stay on the right side of the line

Every request was an unauthenticated GET to a public endpoint - homepage, readme.html, wp-json/wp/v2/users, wp-content/plugins/ directory index. Exactly what any crawler does; no authentication, no injected parameters, no exploit code. These are victims, not targets. The core version comes out of the WordPress generator meta tag on the homepage:

1
2
3
4
5
6
# WordPress publishes its own version in the homepage <head> unless told not to
m = re.search(r'<meta name="generator" content="(WordPress [\d.]+)"', html)

# plugin and theme inventory from asset paths
plugins = set(re.findall(r'/wp-content/plugins/([^/]+)/', html))
themes  = set(re.findall(r'/wp-content/themes/([^/]+)/',  html))

Response rates across the 3,015:

StageCount%
Domains in corpus3,015-
Responded to a request2,61686.8%
Confirmed WordPress2,23885.6% of responders
Disclosed a core version1,61772.3% of WP sites
Exposed wp-json/wp/v2/users1,25356.0% of WP sites

About 28% of WordPress sites don’t disclose a version - every percentage below carries that caveat, and I’ve stated which denominator I’m using each time.

Step 3 - Kill the obvious hypotheses first

A vulnerable plugin? Where everyone’s mind goes first, mine included. The plugin distribution across 2,238 WordPress sites:

PluginSites%
elementor1,03646.3%
contact-form-766029.5%
elementor-pro59226.5%
woocommerce40117.9%
revslider27012.1%
elementskit-lite24110.8%

46% on Elementor looks damning until you remember that’s roughly its general small-business market share. No version clustering - the pile-on-one-release pattern a plugin-CVE theory requires. (readme.txt stable-tag probing was too noisy to use - abandoned.) Hypothesis dead.

A worm creating a rogue admin? 1,253 sites exposed their user list - one automated tool popping all of them would leave a signature username:

1
2
3
4
5
6
7
admin          319     # generic pre-existing bad practice, not an IOC
jbgroup          8
backupadmin      8
developer        8
user             7
dev              6
wp_developer     6

That’s background radiation, not a calling card. Hypothesis dead too - though hold that thought, because why it’s dead turns out to support the theory rather than undermine it.

Step 4 - The anomaly

Then I looked at core versions, and the distribution fell over:

WordPress versionSites% of disclosers
7.0.21,17272.5%
6.9.51338.2%
6.8.6452.8%
7.0362.2%
6.9.4281.7%
6.7.5241.5%
everything else~179~11%

1,172 sites - 52.4% of every confirmed-WordPress site in the corpus, or 72.5% of those that disclosed a version - running the exact same point release. Across an otherwise unrelated population: I spot-checked expecting template farms or a hosting provider’s clone army and found neither. Dental practices, logistics firms, charities, real-estate agents, a martial arts school - genuinely different businesses, hosts, countries, themes. All on 7.0.2.

Three versions in that table - 7.0.2, 6.9.5, 6.8.6 - are the same security release across three branches. Together, 1,350 sites, 60.3% of the corpus’s WordPress population, sitting on a patch published 17 July 2026.

Step 5 - What 7.0.2 actually was

WordPress 7.0.2 / 6.9.5 / 6.8.6 patched two bugs that chain into unauthenticated remote code execution, nicknamed wp2shell:

  • CVE-2026-60137 - SQL injection via WP_Query’s author__not_in parameter, present since WordPress 6.8.
  • CVE-2026-63030 - a REST API route-confusion auth bypass on /wp-json/batch/v1, introduced in WordPress 6.9.

Chained on WordPress 6.9.0-7.0.1, an unauthenticated attacker reaches the batch endpoint, uses the SQLi to read security hashes or create an admin, and gets full site takeover. No plugin needed. This is core. The timeline is the uncomfortable part:

Date (2026)Event
Jul 177.0.2 / 6.9.5 / 6.8.6 released. WordPress.org enables forced auto-updates given the severity.
Jul 17-18Public proof-of-concept exploits appear within hours.
Jul 18-20Multiple vendors independently confirm mass in-the-wild exploitation, including webshells dropped as rogue wp-content/plugins/wp2shell_<hex>/ folders.
Jul 21Both CVEs added to CISA’s Known Exploited Vulnerabilities catalogue.
Jul 31The live case that started this: a compromised podiatry-practice site serving a ClickFix lure through an injected fake-Google-Tag-Manager script, running WordPress 7.0.2.

Step 6 - The paradox this resolves

These compromised sites look well maintained: current core, current plugins. That’s backwards - and it fits a pattern other researchers already flagged as unexplained. Sekoia’s public research into this same style of WordPress-targeting ClickFix framework noted the identical oddity, though on WordPress 6.9 and 6.8.3, months before wp2shell was even disclosed. So wp2shell doesn’t explain their observation; it explains why the same pattern recurs now, on versions that line up with the wp2shell patch dates.

Forced auto-update explains it: site running 7.0.0, vulnerable; attacker exploits wp2shell 17-20 July and drops a backdoor into theme output or a database option; hours later WordPress.org’s emergency mechanism force-updates the site to 7.0.2; the update patches the hole but does not remove the backdoor; the site now reads as fully patched and is still serving malware. The patch closed the door after the attacker was already inside, then made the house look tidy from the street.

That also explains the empty rogue-admin hunt: wp2shell’s alternate path reads security hashes via the SQL injection and forges cookies instead of creating an account, never touching wp_users. Absence of rogue admins isn’t evidence against the theory - for that path, it’s exactly what you’d expect.


Where the theory is weak

I’d rather say this myself than have it said to me.

1. No control group - the big one. I can tell you 52.4% of compromised sites run 7.0.2. I cannot tell you what percentage of all WordPress sites run 7.0.2. If WordPress.org force-pushed that patch ecosystem-wide, a high concentration might just be the internet’s baseline, not an anomaly. I went looking for that baseline before publishing and came up short: WordPress.org’s public stats report adoption by major version, not point release - the closest I found was roughly 51-57% of all WordPress sites on “Version 7” generally, which conflates 7.0.0, 7.0.1, and 7.0.2 and doesn’t answer the question. A true point-release baseline, or a matched control set of never-compromised sites, is the single most valuable next step - I’d genuinely like someone to run it.

2. Correlation, not causation. “In the vulnerable window” is not “exploited through this bug”. Proving it needs server access logs showing a request to /wp-json/batch/v1 with an injected author__not_in parameter around 17-21 July - something I don’t have.

3. Unconfirmed webshell. The cleanest post-exploitation IOC, a wp-content/plugins/wp2shell_<hex>/ folder, was directory-listing-disabled on every still-vulnerable site I checked - standard hosting hardening. Inconclusive, not negative, and confirming it further would need access I don’t have and wouldn’t take without consent.

4. Only explains the most recent wave. This style of campaign traces back to December 2024, about eighteen months before these CVEs existed, so wp2shell can’t be the whole history’s vector. Earlier waves almost certainly used older plugin CVEs, infostealer-sourced credentials, and phishing. wp2shell looks like this ecosystem’s newest and currently most productive front door, not its only one.

5. Version fingerprints can lie. About 28% of sites disclosed no version at all, some spoof the generator tag, and security plugins strip it. The 1,617-site denominator is what it is.

6. I corrected my own count while writing this. My first pass flagged 81 sites as still directly vulnerable; the correct number is 92 - the original script didn’t treat a bare WordPress 6.9 generator string as inside the 6.9.0-6.9.4 window and quietly dropped 11 real, still-exploitable sites. I’d rather publish the correction than the tidier number.

92 sites in this corpus are still running a directly chain-vulnerable core, right now - 4.1% of the WordPress population in the corpus, already compromised and exploitable by anyone else who comes along. I’m not publishing that list: these are mostly small businesses with no idea this is happening, and a public list of “sites you can currently pop” is a target list, not threat intelligence. The aggregate number makes the point.


Techniques observed (MITRE ATT&CK)

The following techniques have been mapped to MITRE ATT&CK from the observed behaviour and public reporting on the CVEs.

TacticTechniqueATT&CK IDWhat it did here
Initial AccessExploit Public-Facing ApplicationT1190Pre-auth RCE chain against WordPress core
PersistenceServer Software Component: Web ShellT1505.003wp-content/plugins/wp2shell_<hex>/ folders reported in the wild
Credential AccessUnsecured CredentialsT1552SQLi reads AUTH_KEY/salts to forge auth cookies
Defense Evasion / PersistenceValid AccountsT1078Forged cookies or a rogue admin - no login event to alert on
Resource DevelopmentStage Capabilities: Drive-by TargetT1608.004Injecting the ClickFix loader into a legitimate site’s output
Initial Access (victim’s visitors)Drive-by CompromiseT1189The compromised site serves the fake CAPTCHA to its own audience

Why this matters

Two sets of victims here, worth separating. The site owners are running a small business, not a SOC - popped through a bug in the platform itself, no dodgy plugin, no weak password. Their platform then quietly patched itself, so every tool they’d check says they’re fine, while they’re actually still serving malware to their own customers. Their visitors get a fake Cloudflare CAPTCHA on a site they trust, and some of them paste the command - the same initial-execution path behind the credential stealers I’ve written about. The delivery model rests on borrowed trust, at scale, because of one unpatched window in a platform that runs a huge share of the web.

The takeaway: patch-then-forget doesn’t work when the patch arrived after the break-in. Being on the current version tells you the hole is closed, not whether someone was already inside when it closed. For anything internet-facing and unpatched during a known mass-exploitation window, “we’re patched now” is the beginning of the investigation, not the end of it.


What defenders can do

Technique (ATT&CK)What to doEssential EightWhat to hunt for
Exploit public-facing app (T1190)Patch internet-facing services on a 48-hour clock when exploits exist; scan dailyPatch Applications (ML1)Requests to /wp-json/batch/v1 with author__not_in containing UNION, SLEEP(, /*
Web shell (T1505.003)File-integrity monitoring on wp-content/; deny PHP execution in upload/cache dirsNo clean E8 homeNew/unfamiliar dirs under wp-content/plugins/; PHP files in wp-content/cache/
Unsecured credentials (T1552)Rotate AUTH_KEY/AUTH_SALT and friends in wp-config.phpRestrict Administrative PrivilegesSessions valid without a corresponding successful login event
Valid accounts (T1078)Audit admin accounts; MFA on every WP admin loginMFA (ML2+); Restrict Administrative PrivilegesNew admin accounts; admin logins from new ASNs
Drive-by target (T1608.004)Compare rendered homepage output against source controlNo clean E8 homeUnexpected <script> in theme output; unfamiliar wp_options rows
RecoveryBackups that predate the compromise window, testedRegular Backups-

Patching is the whole ballgame, and the Essential Eight is unusually specific about the timeframe. A public WordPress site is an “online service” in the model’s language, and Maturity Level One requires that “Patches, updates or other vendor mitigations for vulnerabilities in online services are applied within 48 hours of release when vulnerabilities are assessed as critical by vendors or when working exploits exist” - plus that “A vulnerability scanner is used at least daily to identify missing patches or updates for vulnerabilities in online services” (Essential Eight Maturity Model, November 2023). Public PoCs for wp2shell landed within hours of disclosure, so the 48-hour clock applied. See Patching Applications and Operating Systems (November 2023). WordPress auto-updates get you most of the way for free - the failure mode is usually “wasn’t patched during the window and nobody checked”, not “didn’t patch”.

If a site was on an affected version between 17 and 21 July 2026, treat it as compromised regardless of what version it shows today - the single most actionable line in this post. Audit wp_users for admins you don’t recognise; rotate every key and salt in wp-config.php (invalidates any forged auth cookie - the only thing that evicts an attacker who used the SQLi path); diff wp-content/ against a known-good copy; check wp-content/plugins/ for unfamiliar wp2shell_<hex>-style folders and wp-content/cache/ for PHP that has no business being there. I’m leaning on Restrict Administrative Privileges for the key-rotation step even though the standard mapping is written for Windows credential theft, not a web app’s stored secrets - the rationale holds either way: don’t let a stolen secret keep working after you know it’s stolen.

Put MFA on WordPress admin logins. If keys were stolen and cookies forged, MFA doesn’t retroactively help, but it bounds what the attacker does next and stops the credential-reuse path entirely. The Maturity Model’s Level Two requires that “Multi-factor authentication used for authenticating users of online services is phishing-resistant” (Essential Eight Maturity Model, November 2023). See Implementing Multi-Factor Authentication (November 2023).

Deny PHP execution where PHP has no business running. Both reported post-exploitation artefacts - a fake plugin folder, stray cache-directory files - are PHP written somewhere that should never execute it. A few lines of web-server config turns a working webshell into an inert file. No tidy Essential Eight home here; it’s a hardening control, closest reference is the ISM Guidelines for System Hardening (June 2025).

Check the logs you still have. The cleanest evidence is server-side: requests to /wp-json/batch/v1 around 17-21 July carrying SQL metacharacters in author__not_in or author_exclude. If you run WordPress and keep access logs that far back, you can confirm or refute this theory on your own site in five minutes.

Back up in a way that survives this. A backup taken after the injection just preserves the backdoor. Retention needs to reach back before a compromise you didn’t notice for weeks - the whole argument for Regular Backups’ retention and testing requirements.


Hunting summary

Take these straight to your own environment:

  1. Access logs, 17-21 July 2026: GET/POST to /wp-json/batch/v1, especially with author__not_in or author_exclude containing UNION, SLEEP(, SELECT, or /*.
    1
    2
    3
    
    grep -E 'wp-json/batch/v1' access.log \
      | grep -iE 'author__not_in|author_exclude' \
      | grep -iE 'union|sleep\(|select|/\*'
    
  2. Unfamiliar plugin directories, particularly wp2shell_<hex>-shaped names:
    1
    2
    
    ls -la wp-content/plugins/ | grep -vFf known-good-plugins.txt
    find wp-content/cache/ wp-content/uploads/ -name '*.php' -newermt '2026-07-15'
    
  3. Admin accounts created in the window:
    1
    2
    3
    4
    
    SELECT u.ID, u.user_login, u.user_registered FROM wp_users u
      JOIN wp_usermeta m ON m.user_id = u.ID
     WHERE m.meta_key = 'wp_capabilities' AND m.meta_value LIKE '%administrator%'
       AND u.user_registered > '2026-07-15';
    
  4. Injected loader in theme output or the options table:
    1
    2
    
    SELECT option_name FROM wp_options
     WHERE option_value LIKE '%<script%' AND option_name NOT LIKE '_transient%';
    
  5. Your own version history, not your current version. What were you running on 17 July?
  6. Outbound from the web host - a compromised site often fetches its next stage; a web server making unexpected egress is worth a look.

Indicators

There are no file hashes here, and that’s the point - this is an initial-access story, not a malware sample. The indicators are versions, paths, and log patterns.

TypeIndicatorNotes
Vulnerable versionsWordPress 6.9.0 - 6.9.4, 7.0.0 - 7.0.1Full chain. 92 sites in this corpus are still here.
Partially affectedWordPress 6.8.0 - 6.8.5Has the SQLi (CVE-2026-60137) but not the 6.9-era REST route
Patched versions7.0.2, 6.9.5, 6.8.6Released 17 Jul 2026 - presence proves patching, not cleanliness
CVECVE-2026-60137SQLi, WP_Query author__not_in
CVECVE-2026-63030REST route confusion, /wp-json/batch/v1
Webshell pathwp-content/plugins/wp2shell_<hex>/Per public vendor reporting; I could not confirm it passively
Webshell pathUnexpected .php under wp-content/cache/Same
Log pattern/wp-json/batch/v1 + author__not_in + SQL metacharactersThe cleanest confirmable evidence
Exploitation window17-21 July 2026Public PoC to CISA KEV listing
Corpus stat1,172 / 2,238 sites (52.4%) on exactly 7.0.2The anomaly this post is built on

Detection rules

No YARA this time - there’s no sample to match. The detection here is a log rule, so here’s a Sigma equivalent:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
title: Possible wp2shell Exploitation Attempt (CVE-2026-60137 / CVE-2026-63030)
id: 8f3c1d92-6a4e-4b17-9c05-2ad7e1f4b8c3
status: experimental
description: >
  Detects requests to the WordPress REST batch endpoint carrying SQL
  metacharacters in an author-exclusion parameter, consistent with the
  wp2shell pre-auth RCE chain.
author: Luke Wilkinson
date: 2026/08/01
logsource:
  category: webserver
detection:
  endpoint:
    cs-uri-stem|contains: '/wp-json/batch/v1'
  sqli_param:
    cs-uri-query|contains:
      - 'author__not_in'
      - 'author_exclude'
  sqli_syntax:
    cs-uri-query|contains:
      - 'UNION'
      - 'SLEEP('
      - 'SELECT '
      - '/*'
      - 'information_schema'
  condition: endpoint and sqli_param and sqli_syntax
falsepositives:
  - Vulnerability scanners and authorised penetration tests
  - Security plugins replaying payloads for self-assessment
level: high

Untested against a live payload - I never fired the exploit and never saw a real request - so treat it as a starting point and tune it against your own traffic before trusting it.


Closing

It took pooling five investigations to finally go back and ask the boring question about the front door, and the answer was sitting in a meta tag on the homepage the whole time - invisible in 561 sites, unmissable at the full 3,015. If you’ve got a corpus and a hunch, run the whole thing.

And I want to end where I started: this is a theory, well-supported I think, but built on external observation and a statistical concentration, and I’ve laid out exactly what would knock it down. If you run WordPress and have access logs back to July, you can test it on your own site faster than I could. If you own one of the 92 sites still sitting on a vulnerable version: please update, then assume you need to look for a guest.

Stay curious.


On methodology: the investigation is mine. The reverse engineering and analysis assembly were carried out with AI workflows (Claude, primarily). I reviewed every finding. Errors are mine - ping me on X or Instagram if you spot something off.


References

This post is licensed under CC BY 4.0 by the author.