An off-by-one in njs that nginx-saml reaches before checking the signature
A heap buffer overflow in xml.exclusiveC14n(). The off-by-one in namespace prefix parsing, and how nginx-saml reaches it before verifying a signature.
CVE-2026-78689 is a heap buffer overflow in NGINX JavaScript (njs) reported to F5 by Sujal Tuladhar. F5 published advisory K000162602 on September 2, 2026, and credits Sujal together with Vladimir Tokarev of Cyera. The bug is in the small parser that turns the namespace prefix list argument of xml.exclusiveC14n() into a C array. It affects njs 0.7.10 through 1.0.0 on both JavaScript engines, and the official nginx-saml reference implementation lets an unauthenticated attacker reach it with a forged SAML message. The fix shipped in njs 1.0.1.
This post walks through the exact line that writes out of bounds, explains why only certain prefix counts trigger it, and shows how nginx-saml feeds attacker input into it before any signature is checked.
What F5's advisory says
F5's advisory is specific enough to point straight at the code. It names the function (xml.exclusiveC14n()), the input (the namespace prefix list), and the primitive (a write past the end of a heap allocation). It also describes different effects per engine. On the njs engine, which is the one you get when the js_engine directive is absent, the write corrupts neighboring objects and crashes the worker. On QuickJS the same call also leaks the prefix list every time, so worker memory keeps growing across requests.
The last part of the advisory matters most for real deployments. nginx-saml reads InclusiveNamespaces/@PrefixList out of the incoming SAML message and passes it to xml.exclusiveC14n() before the signature has been verified, so a valid signature is not needed.
The rest of this post fills in the details: what exactly goes out of bounds, and what input shape makes it happen.
Where the fix landed
To follow along in the source, diff the last vulnerable tag against the fixed one and filter to the XML module:
git clone https://github.com/nginx/njs.git && cd njs
git log --oneline 1.0.0..1.0.1 -- external/njs_xml_module.c external/qjs_xml_module.cExactly one commit comes back: 1308b320, "XML: fix off-by-one in namespace prefix list parsing". Its message says the parser allocated room for a fixed number of pointers but stored the terminating NULL at the index equal to the number of tokens parsed. When the token count matched the current capacity, the terminator landed one pointer past the allocation. The commit message also mentions a fix for "potential in place modification of QuickJS string value", which we come back to below.
The vulnerable parser
Here is the njs engine version of the parser as it was in 1.0.0 (external/njs_xml_module.c, trimmed):
static u_char **
njs_xml_parse_ns_list(njs_vm_t *vm, njs_str_t *src)
{
...
size = 8;
buf = njs_mp_alloc(njs_vm_memory_pool(vm), size * sizeof(char *));
...
out = buf;
while (*p != '\0') {
idx = out - buf;
if (idx >= size) {
size *= 2;
/* allocate a bigger array, copy the old one over */
...
out = &buf[idx];
}
*out++ = p;
while (*p != ' ' && *p != '\0') {
p++;
}
if (*p == ' ') {
*p++ = '\0';
}
}
*out = NULL;
return buf;
}The function copies the prefix list, splits it on spaces, and stores a pointer to each token. The result has to end in NULL because libxml2's xmlC14NExecute() takes the inclusive namespace prefixes as a NULL-terminated array.
The capacity check runs at the top of the loop, before a token is stored. The terminator write after the loop has no check at all. Walk through a list with exactly eight tokens: the array starts with eight slots (indexes 0 to 7), each token goes into the next slot, and idx never reaches 8, so the array never grows. When the loop ends, out points at index 8, and *out = NULL writes eight bytes (on a 64-bit build) past the end of a 64-byte allocation.
With nine tokens, the ninth iteration sees idx == 8, grows the array to 16 slots, and the terminator ends up safely at index 9. So the overflow only happens when the token count lands exactly on the capacity:
| Tokens in the list | Capacity at the end | Index of the terminator | Result |
|---|---|---|---|
| 0 to 7 | 8 | 0 to 7 | in bounds |
| 8 | 8 | 8 | 8 bytes past the end |
| 9 to 15 | 16 | 9 to 15 | in bounds |
| 16 | 16 | 16 | 8 bytes past the end |
| 32, 64, 128 ... | same as count | same as count | 8 bytes past the end |
Real SAML messages usually carry one or two prefixes, so normal traffic never lands on a power of two of eight or more, which helps explain how the bug went unnoticed since early 2023. The old splitter also turned every extra space into an empty token, and empty tokens still counted. The token count was therefore fully under the control of whoever wrote the string, regardless of how many real prefixes it named.
The value written is always NULL and the offset is always the end of the array. Where that lands depends on the allocator. The njs engine allocates from its per-VM memory pool, so the write hits whatever the pool placed next, which matches F5's description of corrupted adjacent objects followed by a worker crash.
While reading this function we noticed a second, unrelated problem in the growth path of the njs version: after allocating the bigger array into n, it checks buf == NULL instead of n == NULL. A failed allocation would go unnoticed and the following memcpy() would write to a NULL pointer. It is not part of the CVE, and the rewrite in 1308b320 removes that code path entirely.
The QuickJS side
The QuickJS engine has its own copy of the parser in external/qjs_xml_module.c, with the same eight-slot start, the same growth check, and the same unchecked terminator. The call site in 1.0.0 adds a second problem:
pref = (u_char *) JS_ToCString(cx, prefixes);
...
prefix_list = qjs_xml_parse_ns_list(cx, pref);JS_ToCString() results must be released with JS_FreeCString(), and this code never does. Every call that passes a prefix list leaks it, which is the memory growth F5 describes for the QuickJS engine. The parser also writes '\0' bytes into the buffer it receives, and that buffer can be the JavaScript string's own storage, so a call could change the caller's string in place. The fix frees the C string right after parsing and works on a private copy.
How nginx-saml reaches it before authentication
XML signature verification has two steps. You compute a digest over the referenced element, using the transforms the message declares, and you verify the signature value over SignedInfo. In nginx-saml, verifySAMLSignature() in saml_sp.js runs them in that order:
const digestResult = await digestSAML(rootSignature);
const signatureResult = await signatureSAML(rootSignature, keyDataArray[i]);digestSAML() pulls the prefix list straight out of the message and hands it to the vulnerable function:
const namespaces = transforms[1].InclusiveNamespaces;
const prefixList = namespaces ? namespaces.$attr$PrefixList: null;
...
const c14n = xml.exclusiveC14n(parent, signature, withComments, prefixList);Nothing in the message has been authenticated at this point. Anyone who can send a SAML message to the service provider controls prefixList, and the digest step runs at least once for every message. The CVE record notes that a crafted SAML Response, Assertion, LogoutRequest or LogoutResponse is enough, so both the assertion consumer and single logout endpoints are entry points.
There is some history here too. Before July 2024 the first line above read transformAlgs[1].InclusiveNamespaces. transformAlgs is an array of algorithm URI strings, so that expression was always undefined, and nginx-saml never passed a prefix list at all. Commit e229702 (July 5, 2024) corrected it to read the real InclusiveNamespaces element so that digests would match for IdPs that use it. That was a legitimate bug fix, and it is also the change that connected attacker-supplied prefix lists to the overflow.
What the fix changes
Commit 1308b320 rewrites both parsers the same way. It counts the tokens first, using the same rules it later uses to split them. It then makes one allocation big enough for n + 1 pointers followed by a writable copy of the list, with an overflow check on that size calculation:
buf = njs_mp_alloc(njs_vm_memory_pool(vm),
(n + 1) * sizeof(u_char *) + src->length + 1);
...
dst = (u_char *) &buf[n + 1];The splitter now treats any XML whitespace (space, tab, CR, LF) as a separator and no longer produces empty tokens. On the QuickJS side the C string is freed right after parsing.
The regression tests in test/xml/xml.t.mjs pin the boundary cases: prefix lists with 8, 16 and 128 entries, lists with leading, trailing and repeated whitespace, tabs and line breaks, and a check that the caller's prefix string is unchanged after the call.
Impact
F5 rates this 8.1 (CVSS v3.1) and 9.2 (CVSS v4.0). The advisory describes denial of service through repeated worker restarts on the njs engine or through worker memory growth on QuickJS, and it does not rule out code execution. The issue is in the data plane only, with no control plane exposure.
The primitive is narrow: the attacker decides when the write happens but not what is written or where. That limits what can be built on top of it, but since F5 leaves code execution open, it is safer to prioritize this as more than a crash bug, especially on internet-facing SAML endpoints.
Are you exposed?
Start with the njs version. If you use the packages from nginx.org, the module version is in the package name:
# Debian / Ubuntu
dpkg -l | grep -iE 'njs|mod-js'
# RHEL / Fedora / Amazon Linux
rpm -qa | grep -iE 'njs|mod-js'
# if the njs CLI is installed
njs -vAnything from 0.7.10 up to and including 1.0.0 is vulnerable. Next, check whether any of your scripts pass a fourth argument to exclusiveC14n(). Searching the directories your js_import directives point at is usually enough:
grep -rn "exclusiveC14n" /etc/nginx/If the prefix list comes from request data, or from any XML document you did not produce yourself, treat that location as reachable. If you run nginx-saml (saml_sp.js) on a vulnerable njs build, assume anyone who can reach your assertion consumer or single logout endpoints can trigger the bug.
Fixing it
Upgrade njs to 1.0.1 or later. There is no fixed release on the 0.x branch, so 0.x users need to move to 1.x. For SAML deployments, njs 1.0.2 (October 14, 2026) is the better target: it also fixes a memory leak when the njs engine parses malformed XML, and a bug where attribute values containing entity references were returned incomplete, which affected signed XML validation.
There is no configuration switch that turns this off in nginx-saml, so upgrading is the fix. If you maintain your own njs code that calls exclusiveC14n() with a prefix list, validate the list against the prefixes you actually expect before passing it in. If you maintain a fork of nginx-saml, verifying the signature over SignedInfo before computing the reference digest is a useful extra layer, because SignedInfo covers the transforms, so the PrefixList would be authenticated before it is ever used.
Timeline
| Date | Event |
|---|---|
| 2023-01-25 | Commit 9161a840 adds the xml module to njs (first released in 0.7.10) |
| 2024-07-05 | nginx-saml commit e229702 starts passing InclusiveNamespaces/@PrefixList to xml.exclusiveC14n() |
| 2026-08-12 | Fix committed to njs (1308b320) |
| 2026-09-02 | njs 1.0.1 released; F5 publishes K000162602 and the out-of-band notification K000162872 |
| 2026-10-14 | njs 1.0.2 released with further XML fixes |
Credits
F5 acknowledges Sujal Tuladhar (EvilGenius) and Vladimir Tokarev of Cyera for reporting this issue through coordinated disclosure. The fix was written by Dmitry Volyntsev of the njs team.
References
- F5 advisory K000162602: https://my.f5.com/manage/s/article/K000162602
- F5 out-of-band security notification K000162872: https://my.f5.com/manage/s/article/K000162872
- njs 1.0.1 release: https://github.com/nginx/njs/releases/tag/1.0.1
- Fix commit: https://github.com/nginx/njs/commit/1308b320821aa209a7985cc8f52c09a515130ba6
- njs changelog: https://nginx.org/en/docs/njs/changes.html
- njs security advisories: https://nginx.org/en/docs/njs/security.html
- nginx-saml reference implementation: https://github.com/nginxinc/nginx-saml
- CVE record: https://www.cve.org/CVERecord?id=CVE-2026-78689
The PenCore teamMore posts