
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: A result other than
is .
VULNERABLE# 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.If you modify the probe, do not change the decoded
RelayStatelength and do not sweep lengths. Twenty bytes is load-bearing, and shorter is not safer. Other lengths are untested at best and an appliance outage at worst.
Three HTTP requests per target, and a fourth only when the appliance returns the unpatched signal:
| # | Request | Purpose |
|---|---|---|
| 1 | GET /logon/LogonPoint/tmindex.html | NetScaler identification (Content-Security-Policy tokens) |
| 2 | GET /vpn/js/rdx/core/rdx.js | NetScaler identification |
| 3 | POST /cgi/samlauth | the probe |
| 4 | POST /cgi/samlauth | the control, sent only after an unpatched signal |
Redirects are not followed and no cookie is ever sent back — NetScaler serves an expired NSC_DLGE
that a replaying client would carry into request 4, flipping the POST to a logon page.
nsppe logs the SAML POST to /var/log/ns.log at informational level, no debug knob required, as
an nFactor context deserialization failure including the line
nFactor: ctx deserialize aaa_info, base64 decode failed. There are no session-creation or login
lines at this length, which is what distinguishes a scan from an attempt at the bypass.
The fix is one instruction. On a failing context deserialization the vulnerable build carries the
decoded RelayState length forward as the request's status; the fixed build carries the constant
error 0xe0005, rendered as Malformed Assertion. So one POST separates them:
| Response to the probe | Build | Verdict |
|---|---|---|
500 Internal Server Error 43524 | unpatched | VULNERABLE |
200 Malformed Assertion sent to Netscaler | patched | PATCHED |
The patched build answers Malformed Assertion at every length, which is what makes the unpatched
answer an oracle rather than a coincidence.
VULNERABLE rests on a single distinguishing response, so the tool verifies the response actually
depends on what was sent. After the unpatched signal it repeats the request with a control
RelayState of the same 20-byte decoded length, differing only in the 16 bytes after ctx=. That
content is rejected by a guard ahead of the deserializer, so every build tested — patched and
unpatched — answers it 200 Malformed Assertion. The verdict stands only if the two answers differ.
If they match, something is returning 500/43524 regardless of input, and the result is
INCONCLUSIVE tagged flat-response rather than a false VULNERABLE. Holding the control at the
same decoded length keeps it inside the validated safety envelope and isolates content as the only
variable.
The guard is one-directional. It cannot validate a PATCHED verdict, because a genuinely
patched appliance answers probe and control identically — that is what being patched means here. See
Limitations.
RelayState deserialization runs before SAML policy matching, so a virtual server carrying only a
samlIdPPolicy answers the probe, not just one with a service-provider samlAction bound. That is
broader coverage than a configuration-precondition check can offer.
The vulnerable answer is the only self-corroborating one: it carries a specific marker, and the
control proves the reply depends on what was sent. PATCHED is positive evidence too — the
appliance's own fixed error on the path the probe reached — but it is scoped to this CVE and to
that path. INCONCLUSIVE and ERROR are not evidence at all: the probe never reached the
deserializer, so patch state is unknown, and that is why INCONCLUSIVE is a separate verdict
rather than folded into PATCHED. Reporting silence as a pass gets it wrong in the dangerous
direction. Where you need certainty, confirm with show ns version against the builds in
Remediation.
Every verdict carries a short reason tag. --brief prints it as the third column and --json
carries it as reason.
| Verdict | Reason tag | Meaning |
|---|---|---|
VULNERABLE | internal-error-43524 | The appliance propagated the decoded RelayState length as its internal error, and a same-length control proved the reply depends on what was sent. The CTX696939 fix is absent — patch it. |
PATCHED | fixed-error-returned | The appliance returned the fixed error on the path the probe reached. Scoped to this CVE, and see Limitations on interposed devices. |
UNAFFECTED | no-saml-endpoint | /cgi/samlauth returned 404, so the SAML assertion consumer is not served here. Per-vserver, not per-appliance, and see the older-build caveat in Limitations. |
INCONCLUSIVE | flat-response | Probe and same-length control answered identically, so the reply does not depend on what was sent. The false-positive guard firing. |
INCONCLUSIVE | generic-internal-error | The endpoint returned 43549, its generic internal error, which both patched and unpatched builds return. Not the discriminator — and note it is the unpatched marker for the unrelated CVE-2026-8452, so it looks like a hit and is not one. |
INCONCLUSIVE | unrecognized-reply | The endpoint answered with something outside the recognized set. |
INCONCLUSIVE | no-probe-response | The host answered identification but not the probe — a timeout, a reset, or a middlebox dropping the POST. Retry. |
ERROR | not-identified | Not identified as a NetScaler, or unreachable. |
All four INCONCLUSIVE reasons mean the same thing for decision-making — unknown, not patched.
The tag names which condition to fix before re-running.
| Code | Meaning |
|---|---|
0 | No target was VULNERABLE |
1 | At least one target is VULNERABLE |
2 | Usage error (bad arguments / unreadable targets file) |
Exit 0 is not a clean bill of health. PATCHED, UNAFFECTED and all four INCONCLUSIVE
reasons collapse into it, and only one of those is a positive result. A wrapper that needs to tell
"patched" from "could not be classified" must read the verdict — first column of --brief, or the
verdict field of --json — not the exit code.
PATCHED verdict. Malformed Assertion is what the
appliance returns whenever the probe fails to reach the context deserializer, and being patched is
only one reason that happens. An interposed device that strips, mangles, or short-circuits the
RelayState parameter therefore produces PATCHED on a vulnerable appliance, and no additional
request the tool could send would separate the two. Where something may sit in front of the
appliance, confirm with show ns version.UNAFFECTED is scoped to current builds. On 14.1-43.55 / 13.1-61.27 and earlier the
advisory states that exposure does not require a SAML action at all — any Gateway or AAA virtual
server is affected — so a 404 on such a build does not put the appliance out of scope. That leg
is taken from the advisory rather than measured.PATCHED says nothing about any other
NetScaler vulnerability, including CVE-2026-19489 in the same bulletin.VULNERABLE establishes that the fix
is absent on the path the probe reached. It does not measure how far an attacker could take the
bypass on your configuration, and it will not tell you whether anyone already has — hunt that in
/var/log/ns.log separately.Upgrade to 13.1-63.21 or later, or 14.1-73.32 or later (FIPS and NDcPP: 13.1-37.277 for 13.1-FIPS and 13.1-NDcPP, 14.1-73.32 FIPS for 14.1-FIPS), per CTX696939. Appliances on 12.1 or 13.0 have no fix and will not receive one — those branches are end of life and should be migrated to a supported branch.
Three further notes:
defaultAuthorizationAction of any vpn sessionAction bound to the vserver — it falls back to the
global set vpn parameter -defaultAuthorizationAction. The shipped default is DENY; ALLOW
is common in the field because it is what administrators reach for instead of writing per-user
authorization policies, and ALLOW is what lets the unauthenticated session reach internal
resources. It is global, not per-vserver. This bounds the impact; it is not a fix.add authentication samlAction and add authentication samlIdPProfile alongside
add authentication vserver and add vpn vserver — subject to the older-build caveat above.CTX696939 also fixes CVE-2026-19489, and this surface overlaps heavily with CVE-2026-8452 from the previous bulletin: on builds from 14.1-43.56 / 13.1-61.28 onward both issues are gated on a configured SAML action, so one configuration audit scopes 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.