NEW RESEARCH! From WordPress Patch to Mass Exploitation in 2 days. Read here.
Threat/Vulnerability News

wp2shell in the Wild: From Patch to Mass Exploitation in Under 48 hours

14 min read

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.

wp2shell hero image

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_in parameter in WP_Query. The posts REST controller maps the public author_exclude field 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
wp2shell Recon and Exploitation Timeline
wp2shell source IPs

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

wp2shell exploitation ladder

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:

javascript
Form                         Sessions  Path most logs record
---------------------------  --------  ---------------------
POST /?rest_route=/batch/v1    10,337  /
POST /wp-json/batch/v1          1,193  /wp-json/batch/v1
wp2shell by request form
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:

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

javascript
First path in the batch  Distinct payloads
-----------------------  -----------------
https://:                              868
///                                    190
http://:                                95
http:///x                               47
http://                                 11

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

wp2shell sleep durations

The dominant construction fired as a true/false pair, 1=0 against 1=1, so ordinary network jitter cannot be mistaken for a hit:

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

sql
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) = 10

A 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_pssts instead of wp_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:

  1. A UNION query fabricates content objects inside WordPress.
  2. WordPress's own caching and persistence behavior saves a forged customize_changeset (the object that holds pending Customizer changes) whose JSON names user_id 1, which the payload assumes is an administrator.
  3. While WordPress temporarily applies that user's authority, a crafted post makes WordPress invoke its existing parse_request hook and process the batch a second time. The replayed /wp/v2/users request can then create a persistent administrator.

Stripped down, the injected row carries JSON like this:

json
{
  "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:

json
{"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_ or w2s_, followed by 12 hexadecimal characters
  • The frequently hardcoded password Wp2!Shell2024!, or a generated password beginning with W2s!
  • Email addresses at webmaster.com or hackerone.ltd
  • Suspicious oembed_cache records followed by a forged customize_changeset containing "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.

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

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

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

The tooling was more talkative than the infrastructure. A large share of it named itself, often after the CVE:

javascript
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:06

Most 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

  1. Update WordPress Core immediately. Move to 7.0.2 or 6.9.5, as appropriate, or a later supported security release.
  2. Apply the stop-gap block if you cannot patch this hour. Per the WordPress advisory, reject requests to the path /wp-json/batch/v1 and requests carrying the query parameter rest_route=/batch/v1 at 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.
  3. 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.

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

Three sources reached the takeover-attempt stage in our dataset:

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

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

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

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

typescript
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
wp2shell tags in platform

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.

Start free trial

Written by

ELLIO Threat Research Lab
ELLIO Threat Research Lab

A group of researchers at ELLIO, transforming insights from mass exploitation and network reconnaissance into real-world cybersecurity defenses.

Read next

All posts →