
Behavioral patch-state detector for Citrix NetScaler CVE-2026-8452. Sends crafted SAML requests to determine whether the PrefixList size check is present, without exploiting or corrupting memory.
A safe, non-destructive patch-state check for CVE-2026-8452, the pre-authentication heap overflow
in the Citrix NetScaler ADC / NetScaler Gateway SAML signature canonicalizer
(CTX696604,
CVSS 8.8). An oversized exclusive-canonicalization PrefixList overflows a fixed-size buffer during
canonicalization, which NetScaler performs before validating the signature that carries it — so the
whole path is reachable with no credentials, no session, and no valid signature. Reported by Michael
Tucker of the JPMorgan Chase XOR team; root-cause and exploitation analysis credit to
watchTowr Labs.
This script does not exploit the bug and does not corrupt memory. It answers one question per target: is the fix present on this appliance? — determined behaviourally, by observing the patch rather than guessing the build.
Yes. It is designed for production and assessment use:
PrefixList of 512 bytes and reject 513 or more. That limit was located to the byte,
is identical on both supported branches, and does not move with the appliance's configuration or
with the shape of the surrounding SAML message — confirmed by probing both routes, which wrap the
value in substantially different amounts of XML, and finding they change behaviour at the same
byte. 575 clears the limit by 63 bytes, so the verdict does not depend on how a target happens to
be set up.PrefixList attribute specifically. Inflating other fields past it — assertion consumer service URLs,
issuer names, algorithm identifiers, digest and signature values — changes nothing on a fixed build, so
applying the fix should not cause a working SAML configuration to start failing.If you modify the probe, do not change
PROBE_PREFIXESand do not sweep lengths. 575 bytes is load-bearing. OtherPrefixListlengths can destabilize an appliance, in at least one case on a build that carries this fix, so a length sweep is not a safe way to explore this bug and shorter is not safer.
Patched builds reject an oversized PrefixList cleanly, with a distinctive message. Unpatched builds
fall through the parser and return a generic internal error. One identical request, two different
answers:
575-byte PrefixList | Response |
|---|---|
| Unpatched | 500 Internal Server Error 43549 |
| Patched | 200 Malformed Assertion sent to Netscaler |
Two routes are tried, IdP first, stopping as soon as one gives an answer. Either is sufficient alone, and together they cover both SAML roles:
| Route | Request | Requires |
|---|---|---|
| 1 (first) | POST /saml/login — signed AuthnRequest, PrefixList in ds:SignedInfo | a SAML IdP policy bound to the targeted vserver |
| 2 (fallback) | POST /cgi/samlauth — SAMLResponse, PrefixList in the assertion signature | a SAML SP assertion consumer service on the targeted vserver |
The IdP route goes first because it is the more robust of the two. It is insensitive to the Issuer
value, to the AssertionConsumerServiceURL, and to clock skew — an IssueInstant well outside the
appliance's skew tolerance still discriminates correctly, because canonicalization precedes the time
check as well as the signature check.
The route-1
AuthnRequestmust be signed. An unsigned one returns200 Malformed Assertion sent to Netscaleron patched and unpatched builds, which is byte-identical to the patched signal, so a probe that omits the signature block reports every appliance as patched. The signature does not need to be valid, and this tool's is not; it only has to be present, because itsSignedInfois what carries thePrefixListinto the canonicalizer.
Both supported branches change behaviour exactly at their fix build, on both routes:
| Build | Verdict | |
|---|---|---|
13.1-63.16 | last vulnerable 13.1 | VULNERABLE |
13.1-63.18 | first fixed 13.1 | PATCHED |
14.1-66.59 | vulnerable 14.1 | VULNERABLE |
14.1-72.61 | first fixed 14.1 | PATCHED |
13.1-63.16 and 63.18 are consecutive releases, so the change is attributable to the patch itself
rather than to drift across intervening builds.
Those are the builds where this fix first appeared, and the probe detects exactly that transition.
They are no longer the builds to upgrade to: later bulletins have superseded them, so 13.1-63.18 and
14.1-72.61 both answer PATCHED here while remaining exposed to newer issues. See
Remediation for the current fixed builds.
Because it cannot work on this bug, even in principle. 13.1-63.16 and 13.1-63.18, the builds
immediately either side of the fix, serve byte-identical tmindex.html, base.css and
resources.js — the fix touches no web asset. Static asset hashes also collide across branches, so a
hash-based approach can resolve a vulnerable appliance to a patched build and report it as clean,
which is the worst failure mode a detection tool has. Build fingerprinting is therefore deliberately
not implemented. Patch state comes from the probe, or from show ns version where you have
credentials.
# single target
./cve_2026_8452_check.py https://gateway.example.com
# a specific AAA / Gateway virtual server
./cve_2026_8452_check.py https://gateway.example.com:9443
# scan a list, one target per line ('#' comments allowed), compact output
./cve_2026_8452_check.py -f targets.txt --brief
# machine-readable output for pipelines
./cve_2026_8452_check.py -f targets.txt --json > results.json
Point the tool at the Gateway or AAA virtual server, not the management interface. The precondition is per virtual server, so an appliance with several VIPs needs each one tested.
| Flag | Description |
|---|---|
URL | One or more https://HOST[:PORT] targets |
-f, --targets-file FILE | Read targets from a file (one per line; # comments) |
-b, --brief | Single aligned line per target — verdict, target, reason tag — for scanning many hosts |
--json | Emit structured JSON results |
--no-color | Disable coloured output (also honours NO_COLOR and non-TTY) |
--timeout SECS | Per-request timeout (default: 15) |
An unpatched appliance, answered on the IdP route and confirmed against the control:
$ ./cve_2026_8452_check.py https://gateway.example.com:9443
====================================================================
CVE-2026-8452 - NetScaler SAML PrefixList patch-state check
https://gateway.example.com:9443
====================================================================
>> Identifying the appliance
[ OK ] NetScaler indicators: 5 (CSP contains citrixng://)
>> Probing patch state (64 prefixes / 575 bytes, confirmed against a 35-byte control)
idp /saml/login HTTP 500 / 43549: no size check present
idp /saml/login 35-byte control: HTTP 200 "message timestamp outside the appliance's skew tolerance": a different SAML condition, not the size check
[FAIL] Size check absent (via the IDP route)
====================================================================
RESULT: VULNERABLE
====================================================================
https://gateway.example.com:9443 via IDP [size-check-absent]
The size check is absent. This appliance is unpatched for
CVE-2026-8452. Upgrade to 13.1-63.21+ / 14.1-73.32+ (12.1 and
13.0 are EOL and never fixed).
====================================================================
The control line is worth reading closely: the 35-byte request clears the size check
and is then rejected for its stale IssueInstant, while the 575-byte probe never got
that far. That is the ordering the whole method rests on — canonicalization runs before
the time check, just as it runs before the signature check.
A patched appliance, the same request against the fix build. Only the probe line differs — the
oversized PrefixList is rejected by name instead of falling through to the internal error:
>> Probing patch state (64 prefixes / 575 bytes, confirmed against a 35-byte control)
idp /saml/login HTTP 200 "Malformed Assertion": size check rejected the probe
idp /saml/login 35-byte control: HTTP 200 "message timestamp outside the appliance's skew tolerance": a different SAML condition, not the size check
[ OK ] Size check present (via the IDP route)
RESULT: PATCHED
https://vpn.example.com via IDP [size-check-present]
Falling back to the SP route. Here the IdP endpoint is reachable but no IdP policy is bound to
this virtual server, so route 1 abstains and route 2 answers. When neither route matches a policy
the verdict is INCONCLUSIVE tagged no-policy-match — never PATCHED, which is the whole reason
that verdict exists:
>> Probing patch state (64 prefixes / 575 bytes, confirmed against a 35-byte control)
idp /saml/login HTTP 200 "Matching policy not found": parser not reached
sp /cgi/samlauth HTTP 500 / 43549: no size check present
sp /cgi/samlauth 35-byte control: HTTP 200 "assertion rejected before the size check": a different SAML condition, not the size check
[FAIL] Size check absent (via the SP route)
RESULT: VULNERABLE
The control caught it. Here the endpoint answered with the patched message at both lengths, so the size check was never exercised and the decisive-looking answer is withdrawn. This is the false-positive guard firing, and the reason it fired is stated rather than left to be inferred:
>> Probing patch state (64 prefixes / 575 bytes, confirmed against a 35-byte control)
idp /saml/login HTTP 200 "Malformed Assertion": size check rejected the probe
idp /saml/login 35-byte control: HTTP 200 "Malformed Assertion": size check rejected the probe
[WARN] Probe and control answered alike, so the size check was never exercised
RESULT: INCONCLUSIVE
https://sp-strict.example.com via IDP [flat-response]
The 575-byte probe and the 35-byte control got the same answer,
so this endpoint replies the same way whatever it is sent and
the size check was never exercised. Unknown, not patched.
Scanning a fleet (--brief), one aligned line per target ending in the reason tag. Exit status is
1 if any target is VULNERABLE:
$ ./cve_2026_8452_check.py -f targets.txt --brief; echo "exit: $?"
VULNERABLE https://gateway.example.com:9443 size-check-absent
VULNERABLE https://gateway.example.com:9444 size-check-absent
PATCHED https://vpn.example.com size-check-present
INCONCLUSIVE https://sp-strict.example.com flat-response
INCONCLUSIVE https://gw-nopolicy.example.com no-policy-match
UNAFFECTED https://mgmt.example.com no-saml-endpoint
ERROR https://offline.example.com not-identified
exit: 1
Machine-readable output (--json), which records every route attempted. verdict, reason and
detail are the authoritative fields; attempts is the raw evidence, so an individual attempt may
read patched on a target whose verdict is INCONCLUSIVE:
$ ./cve_2026_8452_check.py https://vpn.example.com --json
[
{
"target": "https://vpn.example.com",
"verdict": "PATCHED",
"reason": "size-check-present",
"route": "idp",
"detail": "HTTP 200 \"Malformed Assertion\": size check rejected the probe",
"attempts": [
{
"route": "idp",
"path": "/saml/login",
"state": "patched",
"detail": "HTTP 200 \"Malformed Assertion\": size check rejected the probe"
},
{
"route": "idp",
"path": "/saml/login",
"state": "control:known-error",
"detail": "35-byte control: HTTP 200 \"message timestamp outside the appliance's skew tolerance\": a different SAML condition, not the size check"
}
],
"netscaler_indicators": [
"CSP contains citrixng://",
"CSP contains com.citrix.nsgclient://",
"CSP contains nsgcepa://",
"CSP report-uri /nscsp_violation/report_uri",
"/vpn/js/rdx/ present (HTTP 404)"
]
}
]
Every verdict carries a short reason tag naming the condition behind it. --brief prints the tag as
its third column, and --json carries it as reason.
| Verdict | Reason tag | Meaning |
|---|---|---|
VULNERABLE | size-check-absent | The size check is absent. This appliance is unpatched — patch it. |
PATCHED | size-check-present | The size check fired on the code path the probe reached. Scoped to this CVE: it does not mean the appliance is on a current build. |
UNAFFECTED | no-saml-endpoint | No SAML endpoint answered on this virtual server, so the vulnerable path is not reachable here. Per-vserver, not per-appliance: SAML may be configured on another vserver or VIP on the same box. |
INCONCLUSIVE | flat-response | The endpoint answered the 575-byte probe and the 35-byte control identically, so the size check was never exercised. The decisive-looking answer is withdrawn — this is the false-positive guard firing. |
INCONCLUSIVE | no-policy-match | A SAML endpoint answered but no bound policy matched the probe, so neither route reached the canonicalizer. |
INCONCLUSIVE | other-saml-error | A recognized but non-diagnostic SAML condition rejected the probe before the size check — a different length limit, a signature policy, a timestamp. |
INCONCLUSIVE | unrecognized-reply | A SAML surface answered with something outside the recognized set. |
ERROR | not-identified | Not identified as a NetScaler, or unreachable. |
All four INCONCLUSIVE reasons mean the same thing for decision-making — unknown, not patched.
Confirm with show ns version. The tag exists to tell an operator which condition to fix before
re-running: point the probe at a different virtual server, or bind a matching policy.
INCONCLUSIVE exists as a distinct verdict, with its own exit code, because a vulnerable appliance can
decline to answer the probe. If the SAML policy bound to a virtual server does not match the probe's
request, the appliance short-circuits ahead of the canonicalizer and returns nothing diagnostic. A
scanner that merely fails to match goes silent on such a host, and silence reads as "patched". This
tool reports it as unknown instead.
PATCHED and VULNERABLE both rest on a single distinguishing response, so the tool verifies that the
response actually depends on what was sent. After a decisive answer it repeats the request with a
short 35-byte PrefixList — below any size check — and the verdict stands only if the two answers differ.
If they match, the endpoint replies the same way whatever it receives, the size check was never exercised,
and the result is INCONCLUSIVE.
This is not hypothetical. A service provider configured with samlRejectUnsignedAssertion STRICT rejects
the probe for a missing signature before canonicalization, and answers with the patched message at every
length. Without the control, such an appliance reports PATCHED with exit 0 — observed on a genuinely
vulnerable build. It now reports INCONCLUSIVE with the reason tag flat-response, and the run states
in as many words that the size check was never exercised. The same control catches the mirror-image
case, where an endpoint returns the generic internal error to requests it never parsed.
A NetScaler SAML endpoint has a large set of possible replies, and only two of them establish patch state. The tool recognizes 20 of the others and names the condition on the per-route line rather than echoing a response body, for example:
idp /saml/login HTTP 200 "post body over the appliance's maximum": a different length limit rejected the probe first
sp /cgi/samlauth HTTP 200 "assertion rejected before the size check": a different SAML condition, not the size check
Three of them are length limits — an oversized post body, an oversized RelayState, an over-long
extracted username. Those matter most, because they mean the request was rejected by a different
length check before reaching the one that distinguishes patch state. That usually means the probe
needs to be pointed at a different virtual server, not that the appliance is fine.
One of the 20 shows up on every IdP-route run: the control's message timestamp outside the appliance's skew tolerance. The probe carries a fixed IssueInstant, so a control that clears the
size check is then rejected for its age — a property of the probe, not of the appliance. A reply
outside the set of 20 is printed as its own first line instead of a named condition.
All of them still produce INCONCLUSIVE, tagged other-saml-error. Recognizing a response never
upgrades it to PATCHED: only an explicit patched-build answer does that, and every unrecognized reply
falls through to INCONCLUSIVE as well — as unrecognized-reply. The recognition exists to tell an
operator why a target could not be classified, not to classify it.
| Code | Meaning |
|---|---|
0 | Patched, or unaffected on the targeted virtual server |
1 | At least one target is VULNERABLE |
2 | Usage error (bad arguments / unreadable targets file) |
3 | At least one target is INCONCLUSIVE, none vulnerable |
4 | At least one target errored, none vulnerable or inconclusive |
2 is argparse's own exit code for a bad invocation, so the verdict codes skip it. A wrapper script
can therefore tell "this appliance could not be classified" (3) from "I called the tool wrong" (2),
which a scheme that overloaded 2 could not.
Across a multi-target sweep the code is chosen by priority, not by the worst status:
VULNERABLE > INCONCLUSIVE > ERROR > clean. An unreachable host therefore never masks a
vulnerable finding in the exit code.
PATCHED and exit code 0 mean the
CVE-2026-8452 size check is present on the path the probe reached. They say nothing about any other
NetScaler vulnerability, including ones disclosed after this bug and fixed in later builds. Do not
read exit 0 from this tool as a clean bill of health for an appliance.UNAFFECTED means "not reachable here", not "this appliance is safe"./saml/login is appliance-wide, but on a virtual server with
no IdP policy bound it answers Matching policy not found and short-circuits ahead of the
canonicalizer. An IdP whose policy uses an expression rule that the probe's request does not satisfy
will land in INCONCLUSIVE rather than give an answer.INCONCLUSIVE is not a clean bill of health. It is deliberately distinguished from PATCHED
with a separate exit code so that silence is never mistaken for a passing result.INCONCLUSIVE. Where the SAML policy sits
in a policy label reached by nextFactor rather than bound directly to the virtual server, an
unsolicited assertion finds no matching policy and short-circuits ahead of the canonicalizer.
Verified on a vulnerable build, which reported INCONCLUSIVE. Since SAML behind a device-posture
or login-schema first factor is a common pattern, treat INCONCLUSIVE on an nFactor gateway as
"probably reachable, confirm with show ns version", not as a curiosity.VULNERABLE verdict confirms the missing size check, which is the
patch state. It does not measure how far an attacker could take the corruption on your build.Upgrade to 13.1-63.21 or later, or 14.1-73.32 or later (FIPS and NDcPP: 14.1-73.32 FIPS, or 13.1-37.277 for 13.1-FIPS and 13.1-NDcPP).
The fix for CVE-2026-8452 itself first shipped in 13.1-63.18 / 14.1-72.61 per
CTX696604,
and that is the transition this tool detects. Those builds have since been superseded by
CTX696939
(2026-08-19), which adds CVE-2026-19489 and CVE-2026-19490, the latter a CVSS 9.3
pre-authentication authentication bypass. Its precondition on builds from 14.1-43.56 / 13.1-61.28
onward is a configured SAML action — so an appliance in scope for the bug this tool checks is likely
in scope for that one too, and a PATCHED verdict here is not a reason to defer the upgrade. Both
bulletins are cleared by the builds above.
Appliances on 12.1 or 13.0 have no fix and will not receive one — those branches are end of life and should be treated as permanently vulnerable and migrated to a supported branch.
Two further notes:
Patch both nodes of an HA pair. An unpatched secondary is a fully exposed appliance the moment it takes over.
Scope your inventory by SAML configuration, not by virtual server type. The vendor advisory's
wording (Gateway or AAA virtual server) is broader than the trigger condition. Check the running
configuration for add authentication samlAction and add authentication samlIdPProfile alongside
add authentication vserver and add vpn vserver.
This is tested, not inferred. On a confirmed-vulnerable appliance we removed every SAML object,
bound a non-SAML authentication factor in its place, and left the AAA virtual servers up and serving:
the SAML endpoints then returned 404 to every request. They are not merely policy-gated without SAML
configuration — they do not exist. So a virtual server with no SAML on it is genuinely out of scope for
this bug, and UNAFFECTED on such a target is a real answer rather than a blind spot. The caveat that
still applies is the scoping one above: it is per virtual server, so confirm each VIP rather than
concluding anything about the appliance.
CVE-2026-8452 shipped alongside five siblings in the same bulletin. The one worth tracking next to it is CVE-2026-8451, a pre-authentication memory overread in the SAML IdP path that has seen active exploitation in the wild. The two share an attack surface, so the same configuration audit covers both.
This code is distributed under an MIT license.
Usage of this tool for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state, and federal laws. Developers assume no liability and are not responsible for any misuse or damage caused by this program.