
Safely detect Citrix NetScaler SAML auth bypass CVE-2026-19490
A safe, unauthenticated vulnerability check for CVE-2026-19490, the pre-authentication
authentication bypass in the Citrix NetScaler ADC / NetScaler Gateway SAML service-provider path
(CTX696939,
published 2026-08-19). CWE-288, CVSS v4.0 9.3
(AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:L/SI:L/SA:L). Reported by Samarth Vashisht of the
JPMorgan Chase penetration testing team.
The appliance base64-decodes the RelayState parameter of a SAML response and, when the plaintext
begins with ctx=, hands the remainder to the nFactor context deserializer. On an unpatched build a
deserialization failure propagates the decoded RelayState length as the request's internal
disposition code instead of an error, so an unauthenticated attacker chooses which internal branch
the appliance takes next purely by choosing how long the RelayState is. Some branches mint a real
Gateway session; others crash the packet engine and restart the appliance. This script does neither
— it sends the one length validated to create no session and leave the packet engine alone, and
answers a single question per target: is this appliance vulnerable? A result other than
VULNERABLE is not by itself a clean bill of health.
# single target
./cve_2026_19490_check.py https://gateway.example.com
# a specific Gateway or AAA virtual server
./cve_2026_19490_check.py https://gateway.example.com:9443
# several targets; the scheme defaults to https://
./cve_2026_19490_check.py gw-a.example.com gw-b.example.com:9443
# scan a list, one target per line ('#' comments allowed), compact output
./cve_2026_19490_check.py -f targets.txt --brief
# machine-readable output for pipelines
./cve_2026_19490_check.py -f targets.txt --json > results.json
Python 3.8+, standard library only — no third-party packages.
Point the tool at the Gateway or AAA virtual server, not the management interface. Exposure is
per virtual server, so an appliance with several VIPs needs each one tested. The probe carries a
hard safety envelope — one validated RelayState length, never swept — which
Is it Safe to Run? sets out.
| Flag | Description |
|---|---|
TARGET | One or more [https://]HOST[:PORT] targets; the scheme defaults to https:// |
-f, --targets-file FILE | Read targets from a file (one per line; # comments) |
--timeout SECS | Per-request timeout (default: 15) |
--workers N | Concurrent targets (default: 16); output stays in input order |
-b, --brief | Single aligned line per target — ideal for scanning many hosts |
--json | Emit structured JSON, including every request sent per target |
--no-color | Disable coloured output (also honours NO_COLOR and non-TTY) |
A vulnerable appliance (the default two-line output). The [!] marker and VULNERABLE render
red on a TTY:
$ ./cve_2026_19490_check.py https://gateway.example.com:9443
[!] https://gateway.example.com:9443: VULNERABLE [internal-error-43524]
HTTP 500 / 43524: the decoded RelayState length was propagated as the internal error, so the CTX696939 fix is absent
A patched appliance:
$ ./cve_2026_19490_check.py https://vpn.example.com
[+] https://vpn.example.com: PATCHED [fixed-error-returned]
HTTP 200 "Malformed Assertion": the fixed error was returned on the path this probe reached, so the CTX696939 fix is present (>= 13.1-63.21 / 14.1-73.32)
The false-positive guard firing. The probe and the same-length control both returned the unpatched signal, so the reply does not depend on what was sent and the decisive-looking answer is withdrawn:
$ ./cve_2026_19490_check.py https://sp-strict.example.com
[?] https://sp-strict.example.com: INCONCLUSIVE [flat-response]
the probe and the same-length control both answered HTTP 500 / 43524, so the reply does not depend on what was sent and the fix was never exercised; unknown, not patched
Sweeping an estate (--brief). The two gateway.example.com rows are the SP and IdP-only
virtual servers on the same appliance — both answer, which a configuration-precondition check would
not manage:
$ ./cve_2026_19490_check.py -f targets.txt --brief; echo "exit: $?"
VULNERABLE https://gateway.example.com:9443 internal-error-43524
VULNERABLE https://gateway.example.com:9444 internal-error-43524
PATCHED https://vpn.example.com fixed-error-returned
INCONCLUSIVE https://sp-strict.example.com flat-response
UNAFFECTED https://lb.example.com no-saml-endpoint
ERROR https://www.example.com not-identified
exit: 1
Machine-readable output (--json). Every request is included, so a finding can be re-derived
from the evidence rather than trusted. The control is recorded by its relation to the probe rather
than as a verdict of its own, because a control that reads like a patched build is the expected
result on every build:
$ ./cve_2026_19490_check.py https://gateway.example.com:9443 --json
[
{
"target": "https://gateway.example.com:9443",
"verdict": "VULNERABLE",
"reason": "internal-error-43524",
"detail": "HTTP 500 / 43524: the decoded RelayState length was propagated as the internal error, so the CTX696939 fix is absent",
"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)"
],
"attempts": [
{
"kind": "probe",
"path": "/cgi/samlauth",
"status": 500,
"state": "unpatched",
"detail": "HTTP 500 / 43524: the decoded RelayState length was propagated as the internal error"
},
{
"kind": "control",
"path": "/cgi/samlauth",
"status": 200,
"state": "differs-from-probe",
"detail": "same-length control: HTTP 200 \"Malformed Assertion\": the fixed error was returned"
}
]
}
]
Yes. It is designed for production and assessment use:
RelayState decodes to exactly 20 bytes and the
tool sends no other length under any flag. On an unpatched appliance the decoded length is the
branch selector, and the branches include ones that create a session and ones that SIGSEGV the
packet engine — restarting the whole NetScaler and dropping traffic for roughly 45 seconds. Twenty
bytes lands on a clean internal-error branch that creates no session, and the payload builder
refuses to construct any other length, so an edit elsewhere cannot quietly widen the probe.13.1-63.18 with zero cores generated and no packet-engine restart. The check itself
has since been run against both maintenance branches, on both sides of the fix, and against
service-provider and IdP-only virtual servers alike, with no packet-engine restart on any of
them.SAMLResponse
parameter must be present for the RelayState branch to be reached, but its content is
irrelevant, so it is four bytes of junk. The branch issues no cookie and writes no configuration.VULNERABLE is never reported on a single response — see
the control.