wp2shell in the Wild: From Patch to Mass Exploitation in Under 48 hours
The ELLIO Deception Network recorded more than 11,500 sessions across 700 sensors as traffic moved from probing to attempted database extraction, administrator creation, and a web-shell write. The first probe arrived the morning after WordPress published its fix.
On July 17, 2026, WordPress published a security update. By the next morning, attackers had studied the fix, worked out the bug it closed, and aimed scanners at the internet.
The first probe hit the ELLIO Deception Network, our distributed set of instrumented, internet-facing decoys, at 08:12 UTC on July 18. Weaponized SQL injection followed that evening. By 00:00 UTC on July 19 it was a mass exploitation campaign: thousands of sessions in three hours.
Between July 18 and 21 we recorded more than 11,500 sessions from 241 source IP addresses against 700 sensors, carrying 1,360 distinct request bodies. For unpatched sites, the release of the fix opened the most dangerous window: attackers had reverse-engineered it within hours.
Most sources ran broad, high-volume scans to find targets. A smaller group probed for database content. Only a handful went for full takeover - and one of them looked, at first, like a person rather than a script.
That diversity of traffic is what makes the data useful: defenders can treat the mass scanning as a background signal and focus on the few sources showing real intent.
How wp2shell works... in the nutshell...
wp2shell chains two WordPress Core vulnerabilities:
- CVE-2026-63030 is a route-confusion issue in the REST API batch endpoint.
- CVE-2026-60137 is improper sanitization of the
author__not_inparameter inWP_Query. The posts REST controller maps the publicauthor_excludefield to that parameter and normally requires an array of integers.
A batch endpoint packs several API calls into one HTTP request. WordPress validates the items in one pass and executes them in another, keeping the matched routes and validation results in two parallel lists. A deliberately malformed item can push those lists out of alignment. The result: WordPress validates one batch item with rules meant for another, then sends it to the wrong handler.
The observed payloads nest one batch inside another. The nesting bypasses both request-method and parameter validation, so a scalar author_exclude value reaches the unsafe author__not_in query path instead of being rejected or sanitized as an array of integers.
Chained together, the two flaws give an unauthenticated attacker SQL injection on a default affected WordPress installation. No vulnerable plugin is needed. From there, the observed payloads escalated step by step: confirm the flaw, read database content, try to create an administrator, try to write a web shell.
The full chain affects WordPress 6.9.0 - 6.9.4 and 7.0.0 - 7.0.1. The underlying SQL flaw also exists in WordPress 6.8.0 - 6.8.5, but that branch lacks the batch-route issue, so the unauthenticated default-install chain described here does not apply to it. Fixes are available in 6.9.5, and 7.0.2. WordPress has recommended immediate updates and enabled forced automatic updates where supported.
From zero to mass exploitation
The timeline shows how fast a public fix turned into live attack traffic.
The first probe appeared the morning after the release. Weaponized injection followed the same evening, and mass exploitation began about 16 hours after that first probe. The speed points to patch-diffing: read the fix, find the hole it closes, weaponize it. Defenders had less than two days before widespread exploitation traffic arrived. A weekly patch cycle, let alone a monthly one, was never going to be fast enough.
Time (UTC) What the ELLIO Deception Network observed
---------------------- ---------------------------------------------------------------
July 8–17 No requests to the batch endpoint during the ten-day baseline
July 17 WordPress publishes the security release
July 18, 08:12 First probe, from a DigitalOcean-hosted address
July 18, 21:00 - 24:00 First injection burst: 192 sessions, 183 carrying SQL
July 19, 00:00 - 03:00 Mass activity begins: 2,055 sessions in three hours
July 19 Peak day: 7,214 sessions from 98 source IPs against 498 sensors
July 20–21 (partial) Activity continues at roughly 1,000 - 2,700 sessions per day

One campaign, several levels of intent
Calling every observed session "exploitation" would bury the most useful signal. We classified each session by the deepest capability its payload attempted.
Deepest behavior observed Approximate sessions What it suggests
--------------------------------------- -------------------- ----------------------------------------
Endpoint discovery and route validation 5,000 Broad target discovery
Blind SQL injection confirmation 3,900 Automated vulnerability checking
Schema and data extraction 2,500 Purpose-built database extraction
Credential and role extraction 54 Targeted access-seeking behavior
Administrator creation 20 Direct takeover attempt
Web-shell write 2 Attempted code execution and persistence
Of 241 source IPs, 190 sent an injection payload. Only 33 went on to schema or data extraction. Four went after credentials or roles. Two tried to create administrator accounts, and one tried to write a web shell.
Discovery traffic sprayed more than 500 sensors, while the sources attempting account creation or a shell touched only a few targets. This is a familiar pattern: broad scanners build inventories, and deeper activity concentrates on a shortlist.
For a SOC, these stages should not produce identical alerts. A probe is useful threat intelligence. An administrator-creation request is an incident-response priority.
Query-string should not be missed
The REST batch endpoint can be reached in two forms:
Form Sessions Path most logs record
--------------------------- -------- ---------------------
POST /?rest_route=/batch/v1 10,337 /
POST /wp-json/batch/v1 1,193 /wp-json/batch/v1
The 10,337 figure is purely incidental. leet
About 90% of the traffic used the query-string route, where the request path is just / and the real signal lives in the query string. A rule that keys on /wp-json/ therefore sees roughly one request in ten. Detection and logging have to retain the query string, and controls have to inspect every exposed web port: injection attempts landed on port 443 (4,158 sessions), 80 (1,249), and 8080 (1,098), not just HTTPS.
The exploit has a recognizable shape
Payload details varied, but the structure barely changed. Exploit-shaped batches opened with a malformed, URL-like path such as https://: or ///. A later batch element then carried another requests array inside its body: a batch nested inside a batch.
A trimmed request looks like this on the wire, in the query-string form that carried about 90% of the traffic:
POST /?rest_route=/batch/v1 HTTP/1.1
Host: blog.example.com
User-Agent: Python-urllib/3.12
Content-Type: application/json
{"requests":[
{"method":"POST","path":"https://:"},
{"method":"POST","path":"/wp/v2/posts","body":{"requests":[
{"method":"GET","path":"/wp/v2/categories?author_exclude=SELECT IF((1=1),SLEEP(0.8),0)"}
]}}
]}Two things make it an exploit rather than a valid batch: the malformed first path, which desynchronizes the router, and the second requests array nested inside another element's body, which smuggles author_exclude past validation to the SQL sink.
The malformed opener was not random noise. A short list of forms recurred across the corpus, and no legitimate batch request contains any of them:
First path in the batch Distinct payloads
----------------------- -----------------
https://: 868
/// 190
http://: 95
http:///x 47
http:// 11Once the router is desynchronized, the nested batch reaches the endpoints the auth layer was supposed to gate. By session count, the most-targeted were /wp/v2/posts (15,355), /wp/v2/posts/999999 (6,264), /wp/v2/categories (5,407), /wp/v2/block-renderer/core/archives (1,756), /wp/v2/users (862), and /wp/v2/widgets (149). A single nested batch usually hits several of these, so the counts run higher than the session total.
SQL injection zoo
The most common confirmation method was time-based blind SQL injection: the attacker asks the database a yes-or-no question and tells it to pause only if the answer is yes. The database returns nothing useful in its response, but by timing how long the reply takes, the attacker reads the answer anyway.
The delays were short. Sleep durations across all variants ranged from 0.3 to 15 seconds, but 96% of distinct variants paused for under one second, most often 0.4 or 0.8. Only a handful used the noisy seven-second sleep you see in off-the-shelf proof-of-concept scanners.

The dominant construction fired as a true/false pair, 1=0 against 1=1, so ordinary network jitter cannot be mistaken for a hit:
Sessions IPs Payload
-------- --- ----------------------------------------------
2,011 10 SELECT IF((1=0),SLEEP(0.4),0)
1,067 6 SELECT IF((1=0),SLEEP(7),0)
175 6 0) OR (SELECT 1 FROM (SELECT SLEEP(0))x)-- -
56 3 1) OR (SELECT * FROM (SELECT(SLEEP(15)))a)-- -
34 3 SELECT IF(1=0,SLEEP(0),0)
15 2 0) OR (SELECT 1 FROM (SELECT SLEEP(3.0))x)-- -A sub-second pause slips under the request-duration and slow-query thresholds most teams monitor, while enough repeated measurements still spell out the answer one bit at a time.
A database extractor, not just a scanner
One cluster accounted for 849 distinct payload variants from seven Google Cloud addresses, all using Python-urllib/3.12. It was not asking whether a site was vulnerable. Its payloads were built to walk the database one character at a time.
The tool searched for table names ending in _posts rather than assuming WordPress used the default wp_ prefix (using RIGHT(TABLE_NAME,6)='_posts'). It then compared individual characters with ASCII(SUBSTRING(...)), narrowing the answer with each request until it had reconstructed the full name or value.
Changing the default table prefix would not stop this extractor. It was built to discover the site's real schema first and adapt to it.
One variant went further than anything else in the corpus. Rather than guessing the users table by name at all, it searched for any table whose columns matched all ten WordPress user fields, user_login, user_pass, user_email, and the rest, and returned the match directly:
SELECT c.TABLE_NAME FROM INFORMATION_SCHEMA.COLUMNS c
JOIN INFORMATION_SCHEMA.TABLES t ON ...
WHERE c.TABLE_SCHEMA = DATABASE()
GROUP BY c.TABLE_NAME
HAVING COUNT(DISTINCT CASE WHEN c.COLUMN_NAME IN
('user_login','user_pass','user_email', ...) THEN c.COLUMN_NAME END) = 10A prefix rename changes the table's name; it does not change the fact that the table still has exactly those ten columns. This payload defeats prefix randomization entirely. It is the clearest single piece of evidence that the hardening tip still widely recommended for WordPress buys nothing against this campaign.
The payload family also left two memorable fingerprints:
- UNION-based variants searched responses for
||4F4B||, hexadecimal for "OK". - Four sessions queried
wp_psstsinstead ofwp_posts. A typo like that can identify a tool family long after its user agent has changed.
The takeover attempt used WordPress against itself
The most advanced automated payloads did not simply insert a row into the users table. They took a longer path through ordinary WordPress features. The original technical analysis of the chain explains the internals; at a high level, three things happen:
- A UNION query fabricates content objects inside WordPress.
- WordPress's own caching and persistence behavior saves a forged
customize_changeset(the object that holds pending Customizer changes) whose JSON namesuser_id1, which the payload assumes is an administrator. - While WordPress temporarily applies that user's authority, a crafted post makes WordPress invoke its existing
parse_requesthook and process the batch a second time. The replayed/wp/v2/usersrequest can then create a persistent administrator.
Stripped down, the injected row carries JSON like this:
{
"nav_menu_item[...]": {
"user_id": 1,
"status": "publish"
}
}The "user_id": 1 is the whole point: it asserts the change was authored by the default administrator. The same injection fabricates a companion post carrying an [embed] shortcode that points back at the target host; when WordPress renders it, the operator can confirm the forged row actually took effect. It is a read-oriented SQL injection turned into a write and privilege-escalation path by making WordPress update its own state.
The payoff is the replayed user creation, which arrives as an ordinary-looking API call:
{"method": "POST", "path": "/wp/v2/users", "body": {
"username": "wp2_65aff658915c",
"password": "Wp2!Shell2024!",
"email": "[email protected]",
"roles": ["administrator"]
}}The 20 takeover attempts came from just two hosts, one attempt per sensor across 20 different sensors: 148.222.186.127 (Scalaxy, Finland) and 35.201.145.40 (Google Cloud, Taiwan). The generated details varied by target, but the signals stayed stable:
- Usernames beginning with
wp2_orw2s_, followed by 12 hexadecimal characters - The frequently hardcoded password
Wp2!Shell2024!, or a generated password beginning withW2s! - Email addresses at
webmaster.comorhackerone.ltd - Suspicious
oembed_cacherecords followed by a forgedcustomize_changesetcontaining"user_id": 1
One source also requested randomly named PHP files such as /wp-content/plugins/silent_audit_c789/silent_audit_c789.php. That pattern fits a tool checking whether a previously planted plugin survived and remained reachable.
The sequence that looks human, and is not
One source stood apart from the automated clusters, at least at first glance.
On July 18, 196.75.111.161 (AS36903, Maroc Telecom) worked a single sensor for the whole day and touched no other. It opened with a five-minute credential burst, went quiet, returned hours later, fired an INTO OUTFILE payload meant to write a PHP upload shell, and asked for the resulting file seven seconds afterwards. Read as a story, that is a person poking at a target, pausing, and coming back to finish the job.
The packet capture says otherwise.
14:14:37 User enumeration: /wp-json/wp/v2/users, /author/whoever/
14:15-14:19 88 requests to /wp-login.php (53 POST, 35 GET)
14:41:13 33 requests to /index.php in 26 seconds, ua: HellsKeyBot
... Seven-hour gap
21:53:47 Batch endpoint check ua: python-requests/2.31.0
21:53:55 SQL injection and file write ua: Mozilla/5.0
21:54:02 Request for /wp-content/uploads/yoru.phpThe exploitation steps ran using scripted HTTP clients. The route check went out as python-requests/2.31.0, and both the injection and the follow-up fetch used a bare Mozilla/5.0 user-agent with no platform token, a library default rather than any real browser. The seven-second delay was a sleep in a script, not a person reloading a tab.
Across the day the address presented 42 distinct user agents, one of which announces itself outright: Mozilla/5.0 (X11; Linux x86_64) HellsKeyBot. The declared operating system changes between consecutive requests inside the same second, and the most common gap between requests is zero, with 26 requests sharing a timestamp.
This is not a lone operator returning to finish the job; it is a single-target automated tool that chained user enumeration, credential attacks, wp2shell exploitation, and shell deployment into one workflow. The important signal is speed: wp2shell had entered commodity attack tooling within a day of the patch.
An INTO OUTFILE statement normally dumps query results to a file, and MySQL appends a chosen terminator after each row, so by stuffing PHP into the LINES TERMINATED BY clause the attacker gets that PHP written verbatim into a web-reachable .php file. The intended result was a small upload shell under wp-content/uploads that would print system information via php_uname() and expose a file-upload form.
The attempt failed. The injection returned HTTP 400, the retrieval returned 404, and nothing landed on the sensor.
The shell signs itself with the Telegram handle t.me/ChiefYoru - author branding compiled into the payload.
Who was behind the traffic
The sources were cloud and hosting infrastructure, essentially end to end. There was no residential botnet component (yet). By injecting sessions, the traffic concentrated in a handful of networks:
ASN Injecting sessions Unique IPs
-------------------- ------------------ ----------
AS396982 Google LLC 3,657 10
AS63473 HostHatch 440 1
AS14061 DigitalOcean 263 25
AS58061 Scalaxy 240 1
AS147049 PacketHub 203 1
AS13335 Cloudflare 176 67
AS14618 Amazon 157 1
AS28753 Leaseweb 148 1The tooling was more talkative than the infrastructure. A large share of it named itself, often after the CVE:
User agent Sessions IPs First seen (UTC)
---------------------- -------- --- ----------------
wp2shell 765 21 Jul 18 23:08
cve-2026-63030-scanner 443 2 Jul 19 15:42
wp2shell-check/1.0 390 1 Jul 19 03:11
wp2shell-scan 179 4 Jul 19 18:46
wp2shell-rce/1.0 96 3 Jul 19 05:42
cve-2026-63030 87 5 Jul 19 08:49
wp2shell-v2 56 1 Jul 19 19:28
wp2shell-verify/1.2.0 2 1 Jul 19 09:06Most volume does not announce itself: Python-urllib/3.12 alone sent 3,299 sessions, and the rest largely wore randomized browser user agents.
Detection that should outlive the tooling
This campaign gave defenders two kinds of signal to work with, and they age very differently.
The structural signals are the technique itself: a malformed first path in the batch (https://:, http://:, ///, and http:///x are the most common) and a requests array nested inside the body of another batch element. An attacker can rewrite every line of the SQL and swap out infrastructure without touching either one.
The literal signals are the payload strings: UNION, SLEEP(, INFORMATION_SCHEMA, or INTO OUTFILE inside a decoded author_exclude or author__not_in value, plus the takeover artifacts (generated usernames, the forged changeset, the ||4F4B|| marker, the shell path). These are fast to write and immediately useful, but they are the first thing an attacker will change or obfuscate. Some markers also travel hex-encoded inside the SQL, for example 0x6e61765f6d656e755f6974656d for nav_menu_item, so a literal-string rule has to match both spellings.
Only one payload in the entire corpus, a version-gated /*!50000AND*/ executable comment, would have slipped past a naive keyword filter while still running. The observed toolkits were not optimizing for WAF evasion, they were racing to inventory targets. A simple keyword filter tuned to this week's strings would have caught nearly everything.
If you own a WAF or IDS ruleset, the takeaway is to anchor on the structure and treat the string list as a supporting layer. Two things to do regardless of how you build the rules: cover both /wp-json/batch/v1 and /?rest_route=/batch/v1, since roughly 90% of the traffic used the second form; and if TLS terminates upstream, confirm the inspection point can actually see the decoded request body and full query string, not just the path.
If you do not maintain your own rules, the same principle shows up as intent-aware detection. ELLIO separates this activity into tiered tags (wp2shell_probe for reconnaissance, wp2shell_exploit for injection, wp2shell_credential_access for read-only access to the user tables, and wp2shell_takeover for administrator creation or a web-shell write) so an analyst can respond to a takeover attempt without treating every internet-wide probe as the same incident.
What defenders should do now
- Update WordPress Core immediately. Move to 7.0.2 or 6.9.5, as appropriate, or a later supported security release.
- Apply the stop-gap block if you cannot patch this hour. Per the WordPress advisory, reject requests to the path
/wp-json/batch/v1and requests carrying the query parameterrest_route=/batch/v1at your edge or web server. This is a temporary shield that closes the batch route, not a fix. Update Core as soon as you can and then remove the block if the endpoint is needed. - Investigate systems exposed to the full chain. Start a compromise assessment for any WordPress 6.9.0 - 6.9.4 or 7.0.0 - 7.0.1 system that was internet-facing after July 17. Review web, application, database, identity, and file-integrity evidence. Patch 6.8.0 - 6.8.5 to 6.8.6 for the underlying SQL flaw, noting that the observed batch-route chain did not affect that branch.
Indicators and hunting clues
Indicators are leads for investigation, not proof of compromise. Source IPs are time-sensitive, may be recycled, and should not be used as the sole basis for attribution or long-term blocking.
Category Indicator
------------------------------ ------------------------------------------------------------------------
Generated users ^(?:wp2|w2s)_[0-9a-f]{12}$
Passwords in request telemetry Wp2!Shell2024! or values beginning with W2s! (WordPress stores passwords
as hashes, so do not expect this plaintext in the database)
Extraction markers ||4F4B||, w2s4aebd4c34bcf-
Forged content Correlated oembed_cache records and customize_changeset content
containing "user_id": 1
Shell path /wp-content/uploads/yoru.php
Shell identifier t.me/ChiefYoru
Plugin-path pattern /wp-content/plugins/{word}_{word}_{4hex}/{same}.php
Tool typo wp_pssts
Self-identifying agents wp2shell, wp2shell-scan, wp2shell-rce/1.0, cve-2026-63030-scanner, and
similarThree sources reached the takeover-attempt stage in our dataset:
Source IP Observed behavior
--------------- --------------------------------------
148.222.186.127 Administrator-creation attempts
35.201.145.40 Administrator-creation attempts
196.75.111.161 Attempted INTO OUTFILE web-shell writeEvery source IP observed attempting this chain was added to the ELLIO Blocklist, so customers using ELLIO Blocklist Automation were protected from these addresses automatically. The complete observed sets are listed below.
All endpoint sources
Use the following query to find IPs associated with Reconnaissance and Exploitation of wp2shell vulnerability:
tag: "WordPress wp2shell Batch Endpoint Probe"
OR tag: "WordPress wp2shell Site Takeover"
OR tag: "WordPress wp2shell Unauthenticated SQL Injection Exploit"
OR tag: "WordPress wp2shell User Table Extraction"SQL injection sources
Use the following query to find IPs that do SQL Injection or User Table extraction:
tag: "WordPress wp2shell Unauthenticated SQL Injection Exploit"
OR tag: "WordPress wp2shell User Table Extraction"Takeover-attempt sources
Use the following query to find IPs that attempt WordPress site takeover using wp2shell vulnerability.
tag: "WordPress wp2shell Site Takeover"Coverage in ELLIO Threat Intelligence and Blocklist Automation
ELLIO tracks this campaign through four tiered tags, split by how far a source actually got:
Tag Classification Kill chain What it means
-------------------------- -------------- -------------------------- --------------------------------
wp2shell_probe promiscuous reconnaissance Touched the batch endpoint, no
injection payload
wp2shell_exploit malicious exploitation Route confusion carrying the
author__not_in SQL injection
wp2shell_credential_access malicious exploitation Injection reaching into wp_users
and wp_usermeta to resolve the
administrator account; read
only, no write to the site
wp2shell_takeover malicious exploitation, installation Administrator account creation,
customize_changeset forgery, or
a PHP web shell written with
INTO OUTFILE
All 241 sources observed against the ELLIO Deception Network between July 18 and 21 are covered: 190 carrying live injection payloads, four reaching into the user tables, and three attempting takeover.
Those addresses are present in the ELLIO Threat List and flow into Blocklist Automation, so an ELLIO customer blocks them without writing a rule or waiting on a signature update.
Final takeaway
wp2shell shows the modern vulnerability lifecycle in compressed form. A fix is published, scanners arrive, exploit logic spreads, and a small share of traffic quickly moves from validation to real takeover attempts.
Patching closes the door. Behavioral visibility shows who tried to walk through it, and how far each payload tried to go.
See how ELLIO Threat Intelligence works for you
No credit card required.
Written by
A group of researchers at ELLIO, transforming insights from mass exploitation and network reconnaissance into real-world cybersecurity defenses.
Read next
All posts →
Analyze everything or move straight to network-level blocking?
One IP. Four days. Nearly 900 user agents. Over 3,000 probes. Sometimes a single IP address tells you everything you need to know about how industrialized internet scanning has become.
From Scan to Exploit: Inside the Latest Cisco ASA/FTD Campaign
From reconnaissance to exploitation in just 48 hours. See how 75 IPs executed surgical, one-hit attacks on Cisco ASA/FTD devices - and how to disappear from target lists.
React2Shell in the Wild: Payload Analysis, Active Campaigns, and IoCs
The ELLIO sensor network has been tracking active exploitation of CVE-2025-55182 (React2Shell) in the wild. Here’s what we’re seeing.