Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Citrix NetScaler SAML PrefixList Heap Overflow — Patch-State Detection Script

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.

Is it Safe to Run?

Yes. It is designed for production and assessment use:

  • The probe stays below the corruption threshold. 575 bytes is long enough that patched and unpatched builds answer differently, and well below the length at which an unpatched appliance's memory corruption begins. Validated by measurement on appliances spanning both supported branches and both patch states, including both fix builds.
  • The threshold it clears is a fixed code constant, not a property of one deployment. Fixed builds accept a 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.
  • Patching does not make appliances reject long SAML values generally. The limit applies to the 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.
  • No memory is corrupted and no process is restarted. On a patched build the probe is rejected at the size check; on an unpatched build it fails benignly inside the parser. Neither reaches the overflow.
  • Nothing sensitive enters scan output. The probe carries only synthetic namespace-prefix tokens, and the tool reports a verdict, not response bodies.
  • Two fixed lengths, never a sweep. The 575-byte probe, plus a 35-byte control on the route that answers. The tool never sweeps a range of lengths and never sends any other length.

If you modify the probe, do not change PROBE_PREFIXES and do not sweep lengths. 575 bytes is load-bearing. Other PrefixList lengths 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.

How it Works

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/samlauthSAMLResponse, 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 AuthnRequest must be signed. An unsigned one returns 200 Malformed Assertion sent to Netscaler on 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 its SignedInfo is what carries the PrefixList into the canonicalizer.

Behaviour at the fix boundary

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.

Why not fingerprint the build?

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.

Requirements

  • Python 3.8+, standard library only — no third-party packages.

Usage

# 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.

Options

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)

Examples

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)"
    ]
  }
]

Verdicts

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.

Every verdict is confirmed against a control

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.

Recognized non-diagnostic answers

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.

Exit codes

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.

Limitations

  • This checks one CVE, not the appliance's patch level. 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.
  • A non-vulnerable verdict is scoped to the endpoint you probed. The precondition is per virtual server. UNAFFECTED means "not reachable here", not "this appliance is safe".
  • Policy matching gates the parser. /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.
  • nFactor deployments with SAML behind a first factor report 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.
  • Not an exploitation check. A 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.
  • Reachability only. A result reflects what the appliance exposes to the network position you run it from. A WAF in front of the appliance can mask the response.

Remediation

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.

License

This code is distributed under an MIT license.

Legal Disclaimer

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.

See Also

About

Safely detect Citrix NetScaler CVE-2026-8452

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages