Compromise: 21 July 2026, 07:56:34–07:56:42 UTC
Wider campaign against this host: 18–21 July 2026
Affected system: cerespir.ro (WordPress front-end)
Impact on uRADMonitor sensor network, API and measurement data: none
Status: contained, cleaned, patched

What cerespir.ro is, and what it is not

cerespir.ro (“Ce respir” what I breathe) is our Romanian-language public platform for air quality and radiation data. It renders a live map of the uRADMonitor sensors deployed across Romania, pulling everything it displays from the uRADMonitor API as a read-only consumer. It also hosts a Romanian-language blog where we document the work behind uRADMonitor including new hardware, firmware releases, deployments, field results and lessons learned. You can browse it at www.cerespir.ro/blog.

That architecture matters for understanding this incident. The cerespir.ro server holds no sensor data, no device credentials, no API keys with write access, and no customer records. It is a presentation layer. The uRADMonitor backend, the device fleet, the data ingestion pipeline and the customer-facing infrastructure run on entirely separate systems, with separate credentials, and were never in scope of this event.

On 21 July 2026 the WordPress installation behind cerespir.ro was compromised, at the end of a four-day campaign against the host by at least six separate automated actors. This is the full technical account of what happened, reconstructed from server access logs, WordPress’s PHP debug.log, the WordPress database, and the attacker’s own artifacts. We are publishing it in full , including the parts that are unflattering , because we think that is what a company that builds environmental monitoring infrastructure owes its users.

The bulletin that preceded the attack

This did not come out of nowhere. Two days before the incident, on 19 July 2026, Security Affairs published a bulletin on a pair of critical WordPress Core vulnerabilities, collectively branded wp2shell:

  • CVE-2026-63030 : a REST API batch-route confusion bug introduced in WordPress 6.9
  • CVE-2026-60137 : a high-severity SQL injection in the author__not_in parameter of WP_Query

Chained together, the two flaws yield pre-authentication remote code execution on a default WordPress install running 6.9.x or 7.0.x , the research team that found them noted the attack has no preconditions and works against a stock install with no plugins at all. The full exploit chain affects WordPress 6.9.0–6.9.4 and 7.0.0–7.0.1, and is fixed in 7.0.2 and 6.9.5; WordPress enabled forced auto-updates for affected versions because of the severity.

Source: Security Affairs — Attackers Can Take Over WordPress Sites Using Newly Released wp2shell Exploits, 19 July 2026.

cerespir.ro was running WordPress 7.0.1. Every core asset requested in our own logs during the incident window carries the version fingerprint:

/wordpress/wp-includes/js/dist/dom-ready.min.js?ver=7.0.1

7.0.1 is the last vulnerable release before the fix. We were, precisely and unambiguously, in scope.

Security Affairs article: CVE-2026-63030 / CVE-2026-60137 with affects 6.9.0–6.9.4, 7.0.0–7.0.1

Exploitation started before the bulletin was published

This is the finding we least expected, and it comes from WordPress’s own debug.log.

The Security Affairs article was published at 05:04 UTC on 19 July 2026. The first rogue administrator account was created 55 minutes later, at 05:59:29 , already a brutal disclosure-to-exploitation window.

But the log shows the attack surface was being worked the day before:

[18-Jul-2026 13:48:46 UTC] PHP Notice:  Undefined offset: 1 in
  /home/cerespir/public_html/wordpress/wp-includes/rest-api/class-wp-rest-server.php on line 1836
[18-Jul-2026 13:48:46 UTC] PHP Notice:  Trying to access array offset on value of type null in
  /home/cerespir/public_html/wordpress/wp-includes/rest-api/class-wp-rest-server.php on line 1848

Those two notices are the fingerprint of the CVE-2026-63030 batch-route parser being fed malformed input. They appear at 13:48 on 18 July , roughly fifteen hours before the public bulletin. Fifty-three minutes after that, the SQL injection component starts firing in volume.

So the honest sequence is: private exploitation was already in progress before the write-up went live; publication then widened the field from whoever had the exploit early to everyone with a scanner. Both halves of that matter. A patching policy built around “I’ll act when I read about it” is starting the clock too late, and the interval after publication is measured in tens of minutes, not days.

Timeline of the intrusion

All times UTC.

Sources marked (debug.log) have no IP attribution: our retained Apache access log begins at 19 Jul 03:44, so the 18 July activity is visible only in WordPress’s PHP error log.

TimeSourceEvent
18 Jul 13:48:46(debug.log)First malformed input to the batch-route parser. 15 hours before public disclosure.
18 Jul 14:41:28(debug.log)112 blind SQL injection queries in a single second, extracting CONCAT_WS(0x7c, user_login, user_pass) — an attempt to dump WordPress credentials. All 112 logged attempts return SQL syntax errors.
19 Jul 05:04wp2shell bulletin published
19 Jul 05:59:21–3534.71.230.24 (Google Cloud)wp2shell run #1. Rogue admin wpsvc_9544603bc3f4 (ID 4) created at 05:59:29. Login attempt fails on wrong path.
20 Jul 00:31:00–5243.249.38.40 (Singapore)wp2shell run #2. Rogue admin w2s_4a9c5cfc0ac5 (ID 5) created at 00:31:53. Follows up with plugin-enumeration sweep.
20 Jul 17:46:05–3491.202.233.61 (Russian Federation)wp2shell run #3. Rogue admin wpenginebot (ID 6) created at 17:46:35.
20 Jul 22:55:18(debug.log)112 blind SQL injection queries in a single second, enumerating INFORMATION_SCHEMA.TABLES — database schema mapping.
21 Jul 02:42:43–5035.204.145.27 (Google Cloud)wp2shell run #4 — byte-identical to run #1. Rogue admin wpsvc_c4a812fa9714 (ID 7) created at 02:42:47.
21 Jul 03:06:36, 03:50:59(debug.log)Error-based SQL injection via XPATH (~414243 marker).
21 Jul 03:42:03–082a0e:d604:1:f6::2Interactive-style login as wpenginebot. Attempts plugin upload at /wp-admin/…, receives 404 three times, abandons.
21 Jul 07:35:10–07:37:29203.175.125.118 (Indonesia)wp2shell run #5, 130 requests. Rogue admin JLG_90e5950a3fca (ID 8) created at 07:37:24. Logs in, but fails to reach the admin panel.
21 Jul 07:56:01–07:56:29154.92.130.89 (Hong Kong)wp2shell run #6. Rogue admin 7ef49fe74ce3 (ID 9) created at 07:56:29.
21 Jul 07:56:34–42154.92.130.89Full compromise. Login → wp-admin → plugin upload → activation → webshell execution. 41 seconds end to end; 8 seconds from login to shell.
21 Jul 07:56:42 → 19:21No further attacker activity of any kind. The backdoor is never touched again.
21 Jul 19:2186.120.200.157 (us)Incident response begins.
21 Jul 19:25usMalicious plugin and webshell exported for forensic preservation.
21 Jul 19:46usSSH authorized_keys rotated.
21 Jul 19:53–20:30usBackdoor removed, rogue accounts purged, process/socket/cron/passwd audit, hardening.

Stage 1: Automated exploitation of the REST batch endpoint

Every one of the six runs follows the same template: a burst of POST requests to the WordPress batch endpoint, in one of its two addressable forms.

203.175.125.118 - - [21/Jul/2026:07:35:12 +0000] "POST /?rest_route=/batch/v1 HTTP/1.1" 207 1017 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/126.0 Safari/537.36"
203.175.125.118 - - [21/Jul/2026:07:35:12 +0000] "POST /?rest_route=/batch/v1 HTTP/1.1" 207 1017 "-" "..."
203.175.125.118 - - [21/Jul/2026:07:35:13 +0000] "POST /?rest_route=/batch/v1 HTTP/1.1" 207 1017 "-" "..."

HTTP 207 Multi-Status is the batch endpoint’s normal response code, which is exactly why this traffic is so easy to overlook: nothing here is an error. There are no 4xx, no 5xx, no failed logins. To a conventional log review , or to fail2ban , this looks like an application talking to itself.

Across the whole log we counted 1,300+ requests to /batch/v1 from 40+ distinct source IPs in three days. The heaviest single source, 62.60.130.128 (Serbia), fired 843 requests over 36 hours using a path-confusion variant (POST /wp-includes/id3/license.txt/?rest_route=/batch/v1) and never succeeded. This was internet-wide, indiscriminate, opportunistic mass exploitation. cerespir.ro was not targeted; it was enumerated.

The response-size fingerprint

The single most useful forensic artifact in this whole incident is the response size column. The exploit’s requests produce a very tight band of response sizes while it is probing and then one visibly different response at the exact moment a privileged account is created.

154.92.130.89, annotated:

07:56:01  POST /?rest_route=/batch/v1   207     619   ← handshake / capability probe
07:56:02  POST /?rest_route=/batch/v1   207 1767759   ← SQLi stage, verbose error output
07:56:05  POST /?rest_route=/batch/v1   207 1767789
07:56:09  GET  /?rest_route=%2Fwp%2Fv2%2Fposts&per_page=1&_fields=link
                                        200     112   ← learns the real site URL
07:56:09  POST /?rest_route=/batch/v1   207    6319
07:56:12  POST /?rest_route=/batch/v1   207 1767977
07:56:16  POST /?rest_route=/batch/v1   207 1768026
07:56:19  POST /?rest_route=/batch/v1   207 1767973
07:56:22  POST /?rest_route=/batch/v1   207 1767973
07:56:26  POST /?rest_route=/batch/v1   207 1767973
07:56:29  POST /?rest_route=/batch/v1   207    3337   ← ★ ADMIN ACCOUNT CREATED
07:56:30  POST /?rest_route=/batch/v1   207 1767879
07:56:33  GET  /?rest_route=/           200  192629   ← endpoint enumeration

The ~1.77 MB responses are the injection stage returning a large verbose payload. The 3,337-byte response at 07:56:29 is the outlier and the WordPress database confirms user ID 9 was created at exactly 07:56:29.

That signature holds across every successful run:

RunIP“Success” response sizeAccount createdDB timestamp
#134.71.230.243391wpsvc_9544603bc3f419 Jul 05:59:29
#243.249.38.403370w2s_4a9c5cfc0ac520 Jul 00:31:53
#391.202.233.613339wpenginebot20 Jul 17:46:35
#435.204.145.273391wpsvc_c4a812fa971421 Jul 02:42:47
#5203.175.125.1183371JLG_90e5950a3fca21 Jul 07:37:24
#6154.92.130.8933377ef49fe74ce321 Jul 07:56:29

A 207 response in the 3.3–3.4 KB band from /batch/v1 is a reliable indicator that account creation succeeded. If you run WordPress and you were on an affected version, this is the single grep worth running against your archived logs.

Note also that runs #1 and #4 produced a byte-identical sequence of response sizes (619, 2879, 3043, 3008, 3116, 817, 3063, 3063, 3063, 3391) from two different Google Cloud IPs, 44 hours apart. Same tool, same wordlist, same payload : a hosted scanning service working through a target list.

Both CVEs, caught in our own error log

WordPress had WP_DEBUG_LOG enabled on this site. It turned out to be the single most valuable forensic asset we had, because it captured the exploit chain executing, line by line.

Baseline noise in this log is 1–14 entries per day. Then:

DateEntries
17 Jul 20261
18 Jul 2026128
19 Jul 20262,597
20 Jul 20261,049
21 Jul 2026655

CVE-2026-63030 : the batch-route confusion

Here is the stack trace WordPress recorded at the exact second the final rogue admin was created. Read the end of it:

[21-Jul-2026 07:56:29 UTC] WordPress database error You have an error in your SQL syntax ...
  for query SELECT SQL_CALC_FOUND_ROWS wp_posts.*
    FROM wp_posts
    WHERE 1=1  AND wp_posts.post_author NOT IN (1) AND 1=0 UNION ALL SELECT 0,1,0x3230...
  made by require('wp-blog-header.php'), wp, WP->main, WP->parse_request,
  do_action_ref_array('parse_request'), WP_Hook->do_action, WP_Hook->apply_filters,
  rest_api_loaded, WP_REST_Server->serve_request, WP_REST_Server->dispatch,
  WP_REST_Server->respond_to_request, WP_REST_Server->serve_batch_request_v1,
  WP_REST_Server->respond_to_request, WP_REST_Server->serve_batch_request_v1,

serve_batch_request_v1 appears twice. The batch endpoint dispatched into itself , a nested batch request. That recursion is the confusion bug: on the inner pass, the request is no longer evaluated in the security context that the outer pass established, and the permission check that should have rejected an anonymous user creating an administrator never runs.

We counted 233 of these nested dispatch traces across the three days.

The accompanying parser damage is logged too:

[21-Jul-2026 07:56:29 UTC] PHP Notice:  Undefined offset: 4 in
  .../wp-includes/rest-api/class-wp-rest-server.php on line 1836
[21-Jul-2026 07:56:29 UTC] PHP Notice:  Trying to access array offset on value of type null in
  .../wp-includes/rest-api/class-wp-rest-server.php on line 1848

Undefined offset: N at class-wp-rest-server.php:1836, with a companion null-offset notice at line 1848, is the cleanest single detection signature for CVE-2026-63030 exploitation attempts. On 21 July alone we logged 287 instances of offset 2, 139 of offset 3, and 3 of offset 4 — and they continue at roughly half-hourly intervals long after our incident ended, as other scanners keep sweeping.

CVE-2026-60137 : the author__not_in SQL injection

The injected query above shows the second CVE in the same request:

WHERE 1=1 AND wp_posts.post_author NOT IN (1) AND 1=0 UNION ALL SELECT 0,1,0x3230...

The post_author NOT IN (...) clause is generated from WP_Query‘s author__not_in parameter. The attacker closed the list and appended AND 1=0 UNION ALL SELECT ..., with all literals hex-encoded to survive quoting. Decoded, the payload forges a post row:

0x323032302d30312d30312030303a30303a3030  →  "2020-01-01 00:00:00"
0x5b656d6265642077696474683d...           →  '[embed width="500" height="750"]https://www.cerespir.ro/…'

They fabricate a post whose content is an shortcode , forcing WordPress to run the forged row through its embed-handling path. That is the bridge from “I can read your database” to “I can make your server do things”.

Three distinct injection techniques appear across the campaign:

TechniqueWhenVolumeTarget
Blind boolean (ASCII(SUBSTRING(...)))18 Jul 14:41:28112 queries in 1 seconduser_login, user_pass
Blind boolean (ASCII(SUBSTRING(...)))20 Jul 22:55:18112 queries in 1 secondINFORMATION_SCHEMA.TABLES
Error-based via XPATH (~414243)21 Jul 03:06:36, 03:50:592version/config disclosure
UNION ALL SELECT row forgeryat each account creation1 eachRCE bridge

The batch endpoint is an amplifier

Note what “112 queries in a single second” means. It is not 112 HTTP requests. It is one HTTP request carrying ~112 batched sub-requests, each one a separate injection probe.

That single fact explains three things at once: why our access log shows so few requests for so much activity; why the successful batch responses are 1.7 MB while the failures are ~1 KB; and why rate-limiting by request count is useless against this exploit. One log line, one POST, one hundred-plus database queries. A WAF counting requests per second sees nothing worth flagging.

Stage 2 : Six rogue administrators, and what they tell us

The database export makes the seeding phase unambiguous:

ID  user_login             user_email                                     user_registered
1   cerespir               ***@***                                        2020-06-27 07:44:42   ← legitimate
2   Tiberiu                ***@***                                        2021-10-19 18:16:07   ← legitimate
3   Marius                 ***@***                                        2021-11-11 11:41:35   ← legitimate
4   wpsvc_9544603bc3f4     wpsvc_9544603bc3f4@wordpress-svc.internal      2026-07-19 05:59:29   ← rogue
5   w2s_4a9c5cfc0ac5       w2s_4a9c5cfc0ac5@shellcode.lol                 2026-07-20 00:31:53   ← rogue
6   wpenginebot            wpenginebot@wpengine.com                       2026-07-20 17:46:35   ← rogue
7   wpsvc_c4a812fa9714     wpsvc_c4a812fa9714@wordpress-svc.internal      2026-07-21 02:42:47   ← rogue
8   JLG_90e5950a3fca       JLG_90e5950a3fca@wp2shell.local                2026-07-21 07:37:24   ← rogue
9   7ef49fe74ce3           7ef49fe74ce3@google.com                        2026-07-21 07:56:29   ← rogue

All six carried full administrator capabilities:

wp_capabilities = a:1:{s:13:"administrator";b:1;}
wp_user_level   = 10

This is not something WordPress would ever do on its own. Site registration was closed and the default role was subscriber, so no normal registration path could produce these accounts.

And WordPress logged every single creation as it happened:

[19-Jul-2026 05:59:29 UTC] PHP Warning:  wp_insert_user(): The user_pass field is required when creating a new user. ...
[20-Jul-2026 00:31:53 UTC] PHP Warning:  wp_insert_user(): The user_pass field is required when creating a new user. ...
[20-Jul-2026 17:46:34 UTC] PHP Warning:  wp_insert_user(): The user_pass field is required when creating a new user. ...
[21-Jul-2026 02:42:47 UTC] PHP Warning:  wp_insert_user(): The user_pass field is required when creating a new user. ...
[21-Jul-2026 07:37:24 UTC] PHP Warning:  wp_insert_user(): The user_pass field is required when creating a new user. ...
[21-Jul-2026 07:56:29 UTC] PHP Warning:  wp_insert_user(): The user_pass field is required when creating a new user. ...

Six warnings. Six accounts. Timestamps matching the database to the second, and matching the 3.3 KB response-size anomaly in the access log to the second. Three independent sources, one result.

wp_insert_user() firing on a site with registration disabled is, by itself, a compromise alert. If you take one detection rule away from this report, take that one: it costs nothing, it has no false positives on a closed-registration site, and it would have told us on 19 July what we did not learn until 21 July.

There is one thing these warnings raise that we cannot fully close out. The exploit creates each account without a password that is precisely what the warning means. Yet valid authenticated sessions were established for three of those accounts within seconds. The toolkit must therefore set credentials through a follow-up call that WordPress does not log, and we can see the request in the access log but not its body. We are stating the open question rather than inventing a mechanism to close it.

The email domains are the giveaway, and they are almost insultingly candid: wordpress-svc.internal, shellcode.lol, wp2shell.local. Only wpenginebot@wpengine.com makes any attempt to look plausible and it is the account that got the most use. These are machine-generated identities from an exploit kit, not the work of someone typing.

Which rogue accounts were actually used

WordPress stores active sessions in wp_usermeta.session_tokens, and this is where the DB export earns its keep. Three of the six accounts have no session token at all , accounts 4, 5 and 7 were created and never logged into. They were inventory: an operator seeding access to sell or use later.

Three were used:

user 6 (wpenginebot)
  ip    2a0e:d604:1:f6::2
  ua    Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ... Chrome/146.0.0.0
  login 1784605324  → 2026-07-21 03:42:04 UTC

user 8 (JLG_90e5950a3fca)
  ip    203.175.125.118
  ua    Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/126.0
  login 1784619447  → 2026-07-21 07:37:27 UTC   (and a second token at 07:37:29)

user 9 (7ef49fe74ce3)
  ip    154.92.130.89
  ua    Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ... Chrome/137.0.0.0
  login 1784620595  → 2026-07-21 07:56:35 UTC

Note the mismatch on user 6: the account was created on 20 July at 17:46 from 91.202.233.61, but logged into nine hours later from an entirely different IPv6 network with a different browser fingerprint. Either the same operator rotating infrastructure, or more likely given the pattern , access created by one party and consumed by another.

What helped

cerespir.ro runs WordPress in a subdirectory (/wordpress/), not at the document root. Three of the actors hard-coded document-root paths and simply broke:

2a0e:d604:1:f6::2 - - [21/Jul/2026:03:42:07 +0000] "GET /wp-admin/plugin-install.php HTTP/1.1" 404 57953
2a0e:d604:1:f6::2 - - [21/Jul/2026:03:42:07 +0000] "GET /wp-admin/plugin-install.php HTTP/1.1" 404 57953
2a0e:d604:1:f6::2 - - [21/Jul/2026:03:42:08 +0000] "GET /wp-admin/plugin-install.php HTTP/1.1" 404 57953

Three 404s and the session ends. Same story for 203.175.125.118, which logged in successfully at 07:37:27 and then threw itself at POST /wp-admin/ and GET /wp-admin/users.php, collecting 302s and a 404 before giving up:

203.175.125.118 - - [21/Jul/2026:07:37:27 +0000] "POST /wp-admin/ HTTP/1.1" 302 -
203.175.125.118 - - [21/Jul/2026:07:37:29 +0000] "GET /wp-admin/users.php HTTP/1.1" 404 57949

The tool that succeeded was the only one that resolved the real path first, using the REST posts endpoint to read back the canonical site URL at 07:56:09 before switching every subsequent request to /wordpress/….

This is worth internalizing: a non-default install path is not a security control, and we do not present it as one. But against commodity automation it is a real and repeatedly effective speed bump. Three of six attackers were defeated by a directory name. Keep this in mind!

Stage 3 : The 41-second compromise

At 07:56:34, one tool got everything right. Here is the entire breach, verbatim:

154.92.130.89 - - [21/Jul/2026:07:56:34 +0000] "GET /wordpress/wp-login.php HTTP/1.1" 200 6932 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
154.92.130.89 - - [21/Jul/2026:07:56:35 +0000] "POST /wordpress/wp-login.php HTTP/1.1" 302 - "-" "..."
154.92.130.89 - - [21/Jul/2026:07:56:36 +0000] "GET /wordpress/wp-admin/ HTTP/1.1" 200 120060 "-" "..."
154.92.130.89 - - [21/Jul/2026:07:56:37 +0000] "GET /wordpress/wp-admin/plugin-install.php?tab=upload HTTP/1.1" 200 66256 "-" "..."
154.92.130.89 - - [21/Jul/2026:07:56:38 +0000] "POST /wordpress/wp-admin/update.php?action=upload-plugin HTTP/1.1" 200 59018 "-" "..."
154.92.130.89 - - [21/Jul/2026:07:56:40 +0000] "GET /wordpress/wp-admin/plugins.php?action=activate&plugin=wp2p_265bd548%2Fwp2p_265bd548.php&_wpnonce=78797da9f3 HTTP/1.1" 302 - "-" "..."
154.92.130.89 - - [21/Jul/2026:07:56:41 +0000] "GET /wordpress/wp-admin/plugins.php?activate=true&plugin_status=all&paged=1&s= HTTP/1.1" 200 101936 "-" "..."
154.92.130.89 - - [21/Jul/2026:07:56:42 +0000] "GET /wordpress/wp-content/plugins/wp2p_265bd548/4b0958118c8f.php HTTP/1.1" 200 1862 "-" "..."

Login form to executing webshell: eight seconds. Total session including the exploit phase: 41 seconds, 21 requests.

Human or script? The timing says script, unambiguously

One of our early working hypotheses and the one the raw log sequence superficially supports , was that this was a person driving a browser, because the request order is exactly what an administrator clicking through wp-admin would produce. On close reading, that is wrong. Six independent lines of evidence say this was a non-interactive HTTP client:

1. Nothing but HTML documents. Across all 21 requests from 154.92.130.89: zero CSS, zero JavaScript, zero images, zero favicon.ico, zero admin-ajax.php, zero heartbeat POSTs. A real Chrome session loading /wp-admin/ fires dozens of subresource requests and then keeps a heartbeat going every 15–60 seconds. There is not one.

2. Every referer is empty. All 21 requests log "-" in the referer field. A browser navigating from plugin-install.php to update.php sends a Referer. A scripted POST does not.

3. The upload is one second after the form. 07:56:37 fetches the upload form; 07:56:38 posts the ZIP. A human has to open a file picker, navigate a filesystem, select a file and click. That is not a one-second operation for anyone.

4. The nonce was parsed and reused in two seconds. The activation URL carries _wpnonce=78797da9f3, a value that only exists after the upload response is rendered. It was extracted from HTML and replayed within 2 s. That is a regex, not a mouse.

5. Uniform machine cadence. The exploit phase runs at a metronomic 3–4 s per request; the post-auth phase at exactly 1 s per step. Human interaction is bursty and irregular. This is a sleep() in a loop.

6. The failed runs expose the mechanism. 203.175.125.118 sent POST /wp-login.php twice, one second apart, then POST /wp-admin/ — a browser would issue a GET there. That is a hard-coded request list with no redirect handling and no session logic. Its two session tokens, created two seconds apart at 07:37:27 and 07:37:29, are the same script authenticating twice because it did not notice it was already logged in.

The Chrome/137.0.0.0 macOS user-agent is therefore forged, and forged sloppily — the same operator’s other runs claim Chrome 126, 131 and 146. Conclusion: fully automated, end to end, with no human in the loop at any point. Nobody read our blog, nobody looked at the sensor map, nobody went looking for anything. An exploit kit found a version string it liked and executed a fixed script.

Stage 4 : The payload: wp2p_265bd548 and the “chinafans” signature

The uploaded plugin is a two-file package. The plugin stub is four functional lines:

<?php
/*
Plugin Name: WP2P
Description: temp
Version: 1.0
*/
@file_put_contents(ABSPATH.'0x.txt','chinafans');

That is the entire plugin. On activation it drops a file called 0x.txt in the WordPress root, containing the single string chinafans.

Why the marker matters

This file has no offensive function whatsoever. It does not help the attacker do anything to the site. Its purpose is inventory: once https://<victim>/0x.txt returns chinafans, the operator can confirm a successful compromise with a single unauthenticated GET, and more importantly can re-discover the site later, at scale, by querying internet-wide scanning services for that string. It is a flag planted on conquered ground, readable from orbit.

The same string is echoed by the webshell itself on every page load. chinafans is the campaign’s calling card, and it is the strongest link between the artifacts on our server and the wider wp2shell exploitation wave. It is a campaign signature, not an attribution. It tells us which kit was used. It tells us nothing reliable about who used it, the string is hard-coded in a tool that anyone can download and run, and the six source IPs we observed span Google Cloud (US and Netherlands), Hong Kong, and several bulletproof-hosting ranges.

For anyone auditing their own WordPress install after wp2shell, this is the fastest possible check:

ls -la /path/to/wordpress/0x.txt
grep -rl 'chinafans' /path/to/wordpress/

Summary analysis of the webshell

The second file, 4b0958118c8f.php (4,894 bytes), is a browser-based file manager backdoor. We are describing its capabilities rather than publishing its source; the file has been preserved intact for evidentiary purposes.

Behaviour:

  • Anonymous. No password, no token, no IP allowlist, no obfuscation. Anyone who knows the URL has full use of it. This is the aspect we considered most serious , the backdoor was as available to third parties as to the operator who installed it.
  • Full filesystem navigation anywhere the apache user can read, via a ?dir= parameter passed straight to realpath(). Not confined to the web root.
  • Read, write, create, rename and delete on any file or directory reachable by the web server user, including an in-browser editor that overwrites file contents from a POST body.
  • Arbitrary file upload to any writable directory , the mechanism for staging a second-stage payload.
  • Permission reconnaissance: it colour-codes every listed entry green/red/white by readability and writability, so the operator sees at a glance which directories are viable drop points.

Forensic tells:

  • The file begins with the literal bytes GIF89A; before the PHP block , a magic-byte prefix used to slip past naive upload filters and file(1)-based scanners that classify by header. Harmless here, since it arrived inside a plugin ZIP, but it confirms the file is a stock item from a shell collection rather than something written for this target.
  • echo "chinafans" on every request , the same campaign marker.
  • It contains a genuine bug: the rename function references an undefined constant DIRECTORY_SEPARATO (missing R), which would throw on use. This is copy-pasted, minimally-tested commodity code.

What it is not: there is no command execution primitive (system, exec, passthru, shell_exec, proc_open, backticks), no reverse-shell code, no privilege-escalation logic, no persistence mechanism beyond its own existence as an active plugin, and no network callback. It is a file manager. A file manager with full write access to a web root is still a total compromise of that application but it is a meaningfully smaller blast radius than a command shell, and it is consistent with everything else we found on the host.

What the attacker did not do

This section exists because incident reports that only list the bad news are not honest reports.

The webshell was fetched exactly once, at 07:56:42, returning 1,862 bytes , the size of an empty directory listing. It was never requested again, by that IP or any other, for the remaining 11½ hours it existed on disk. We grepped the full access log for the plugin path; the only other hits are our own forensic exports at 19:25 from our office IP.

Confirmed absent, on evidence:

  • Attempted credential extraction , revised finding. Our first internal assessment concluded there was no data exfiltration. debug.log forced us to soften that. On 18 July at 14:41 and 20 July at 22:55, the attacker ran two batches of 112 blind SQL injection queries each, aimed squarely at CONCAT_WS(0x7c, user_login, user_pass) and at INFORMATION_SCHEMA.TABLES. Every one of the 224 logged attempts returned a MariaDB syntax error, so those specific probes retrieved nothing. But a successful injection produces no error and therefore no log entry we can see what failed, not what worked. We have accordingly treated all three legitimate WordPress password hashes as compromised and rotated them, and we recommend anyone else auditing a wp2shell incident do the same rather than reasoning from the absence of evidence. The mitigating factor is what the wp_posts and wp_users tables on this host actually contain: three staff accounts and public blog content. No sensor data, no device credentials, no API keys, no customer records, no personal data of readers.
  • No bulk content exfiltration. No database dump, no archive creation, no large outbound transfer, no mass content reads.
  • No content modification. Posts, pages, theme files and uploads are all intact and unmodified. No defacement, no SEO spam, no injected redirects, no cryptominer.
  • No OS-level compromise. /etc/passwd shows exactly one non-system account (cerespir) , no accounts added. A full process listing shows no miners, no processes running from /tmp, /var/tmp or /dev/shm, no unexplained interpreters, no reverse shells. Listening sockets are Apache, Dovecot, Postfix, Webmin and SSH (on a non-standard port), all expected. Cron is unmodified: /etc/cron.d, cron.daily, cron.hourly, cron.weekly, cron.monthly and the user crontab contain only the entries that have been there since 2019–2021.
  • No unauthorized SSH key. The authorized_keys backup taken during remediation contains one entry our own long-standing operator key. The attacker did not touch SSH. The key rotation at 19:46 was precautionary, not remediation of a live foothold.
  • No uRADMonitor impact. The uRADMonitor API, the device fleet, firmware distribution, the data pipeline and all customer-facing systems are on separate infrastructure with separate credentials. None were reachable from the compromised host, and none show any anomaly across the same period.

Honest framing of the exposure window. The attacker’s active interaction with our system lasted 41 seconds. The backdoor file then sat on disk, unused, for approximately 11½ hours until we removed it. We are not going to describe that as “a few minutes” the hands-on-keyboard window was 41 seconds, the file’s lifetime was eleven and a half hours, and both numbers are in this report because both are true. An unauthenticated webshell sitting unused on a public server for eleven hours is a real exposure, and the fact that nothing came of it is partly evidence of a low-value target and partly luck.

Our response

Incident response began at 19:21 UTC on 21 July. Sequence:

  1. Preserve first. Before removing anything, the malicious plugin and webshell were exported intact and the raw Apache logs, the WordPress debug.log and the relevant database tables were archived. The extraction itself is in the record: the first attempt to pull the plugin directory as a ZIP failed because the ZipArchive PHP extension is not installed on this host — [21-Jul-2026 19:25:39 UTC] PHP Fatal error: Uncaught Error: Class 'ZipArchive' not found in .../wp-content/plugins/theme-editor/app/controller/theme_controller.php:419 — matching a 500 response in the access log at the same second. Both files were then exported individually at 19:25:46 and 19:25:55. You cannot analyse what you have already deleted, and evidence preserved before cleanup is the only version anyone else will trust.
  2. Cut the access. All six rogue administrator accounts and their metadata were deleted, invalidating the associated sessions. Credentials rotated for the three legitimate accounts.
  3. Remove the backdoor. wp-content/plugins/wp2p_265bd548/ deleted in full, along with the 0x.txt marker; the remaining plugin set verified against known-good checksums.
  4. Close the hole. WordPress updated past the vulnerable branch. Anonymous access to /wp-json/batch/v1 and ?rest_route=/batch/v1 blocked at the web server, per the mitigation advice in the wp2shell advisory.
  5. Rotate secrets. SSH authorized_keys rotated (19:46), WordPress salts and keys regenerated, database credentials changed.
  6. Verify the host. Full audit of processes, listening sockets, cron across all users, /etc/passwd, recently modified files under the web root and in temp directories, and SUID binaries. Clean throughout.
  7. Reduce the attack surface. theme-editor , a plugin that grants in-dashboard file write access, and which we had installed for convenience was removed. Convenience tooling that duplicates a webshell’s capabilities has no business on a production site.
  8. Detect next time. File integrity monitoring on wp-content/plugins/, alerting on WordPress user creation, alerting on 207 responses from /batch/v1, and source IPs blocked.

Indicators of compromise

Publishing these so other WordPress operators can check their own logs.

Source IPs — successful account creation

34.71.230.24        19 Jul 05:59 UTC   Google Cloud (US)
43.249.38.40        20 Jul 00:31 UTC
91.202.233.61       20 Jul 17:46 UTC
35.204.145.27       21 Jul 02:42 UTC   Google Cloud (NL)
203.175.125.118     21 Jul 07:37 UTC
154.92.130.89       21 Jul 07:56 UTC   ← plugin upload + webshell (Hong Kong geolocation)
2a0e:d604:1:f6::2   21 Jul 03:42 UTC   ← session reuse of a previously created account

Files

wp-content/plugins/wp2p_265bd548/wp2p_265bd548.php
wp-content/plugins/wp2p_265bd548/4b0958118c8f.php
0x.txt                                   (WordPress root, content: "chinafans")

Note that wp2p_<8 hex> / <12 hex>.php are generated per-target. Match on the pattern and on the chinafans string, not on these exact filenames.

Rogue account patterns

wpsvc_<12 hex>@wordpress-svc.internal
w2s_<12 hex>@shellcode.lol
<random>@wp2shell.local
wpenginebot@wpengine.com
<12 hex>@google.com

Log signatures

POST /?rest_route=/batch/v1                             → 207, 3.3–3.4 KB   ★ account created
POST /wp-json/batch/v1                                  → 207, 3.3–3.4 KB   ★ account created
POST /wp-includes/id3/license.txt/?rest_route=/batch/v1 → 207               path-confusion variant

debug.log signatures (the highest-fidelity detections here)

PHP Warning:  wp_insert_user(): The user_pass field is required when creating a new user
   → account creation; on a closed-registration site this is a compromise, full stop

PHP Notice:  Undefined offset: N in .../wp-includes/rest-api/class-wp-rest-server.php on line 1836
PHP Notice:  Trying to access array offset on value of type null in ... on line 1848
   → CVE-2026-63030 batch-route parser being probed

... WP_REST_Server->serve_batch_request_v1, WP_REST_Server->respond_to_request,
    WP_REST_Server->serve_batch_request_v1,
   → nested batch dispatch: the confusion bug actually firing

WordPress database error ... wp_posts.post_author NOT IN (1) AND 1=0 UNION ALL SELECT
WordPress database error ... ASCII(SUBSTRING(COALESCE((SELECT CONCAT_WS(0x7c, user_login, user_pass
WordPress database error XPATH syntax error: '~414243'
   → CVE-2026-60137 injection through author__not_in

Quick triage on any WordPress host that ran an affected version:

grep -c 'wp_insert_user()' wp-content/debug.log
grep -c 'class-wp-rest-server.php on line 1836' wp-content/debug.log
grep -c 'user_login, user_pass' wp-content/debug.log

Database queries

SELECT ID, user_login, user_email, user_registered
FROM wp_users ORDER BY user_registered DESC;

SELECT user_id, meta_value FROM wp_usermeta
WHERE meta_key = 'wp_capabilities' AND meta_value LIKE '%administrator%';

SELECT user_id, meta_value FROM wp_usermeta
WHERE meta_key = 'session_tokens';   -- reveals which rogue accounts were actually used, and from where

SELECT * FROM wp_options WHERE option_value LIKE '%wp2p%';

Attribution caveat. The chinafans string and the Hong Kong geolocation of 154.92.130.89 identify a toolkit, not an actor. Commercial IP geolocation is not attribution: VPN exit nodes, compromised VPS instances, rented proxy infrastructure and anonymising networks all sit between an IP address and a person. Two of our six source IPs are Google Cloud addresses, which nobody would suggest implicates Google. We make no attribution claim.

What we take from this

The patch window can close before it opens. Exploitation of cerespir.ro began roughly fifteen hours before the public bulletin, and the first rogue administrator was created fifty-five minutes after it. Neither number leaves room for a human decision loop. Automatic security updates are not a convenience feature; on a mass-deployed CMS they are the only control operating on the right timescale.

Successful exploitation can be invisible to conventional monitoring. Every request in the exploitation phase returned HTTP 207. No 4xx, no 5xx, no failed logins, nothing for fail2ban to see, nothing to trip a threshold alert. And because the batch endpoint packs a hundred-plus operations into one request, request-rate limiting sees nothing either 112 SQL injections arrived as a single POST. If your detection is built on error rates, failed authentication counts and requests per second, an attack of this shape walks straight through all three.

Leave the debug log on. WP_DEBUG_LOG was enabled on this site by accident and forgotten. It is normally considered a mild misconfiguration, and it gave us the entire case: the nested serve_batch_request_v1 traces proving the confusion bug, the injected SQL in full, six wp_insert_user() warnings timestamped to the second, and the pre-disclosure activity on 18 July that nothing else in our evidence set could see. Keep it enabled, keep it outside the web root, keep it rotated and read it.

Auxiliary systems are still your systems. cerespir.ro is a presentation layer with no operational data. That architectural separation is exactly why this incident stayed small, and it was a deliberate design decision. But a compromised site under our domain is a compromised site, and we treat it as such it can be used to serve malware to visitors, to damage the trust the uRADMonitor project depends on, and as a foothold to probe from. There is no such thing as a component that “doesn’t matter”.

Preserve before you clean. Almost everything of value in this report the response-size fingerprint, the session token IPs, the six-account seeding pattern, the retraction of the SSH finding came from artifacts that would have been destroyed by a fast cleanup. The instinct under pressure is to delete the bad thing immediately. Copy it out first. Close the breach. Analyze later.

Say what happened. uRADMonitor exists because people need environmental data they can verify and trust. That standard does not stop at the sensors. When something goes wrong on our infrastructure, the response is a full technical account with the timestamps, the log lines, the indicators, and the parts we got wrong published, so that other operators running the same software can check their own systems tonight.

Questions or a related incident on your own infrastructure? The IOCs above are free to use. If you run WordPress 6.9.x or 7.0.x and have not yet patched to 7.0.2 / 6.9.5, stop reading and do that now , then grep your logs for chinafans.