## https://sploitus.com/exploit?id=C335E9AF-68CB-5EFD-9DC7-C02CA5B4B451
# Citrix NetScaler SAML nFactor Context Authentication Bypass β Vulnerability Detection Script
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](https://support.citrix.com/external/article/CTX696939/netscaler-adc-and-netscaler-gateway-secu.html),
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](#a-non-match-is-never-a-claim-of-safety).
## Usage
```bash
# 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?](#is-it-safe-to-run) sets out.
### Options
| 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) |
### Examples
**A vulnerable appliance** (the default two-line output). The `[!]` marker and `VULNERABLE` render
red on a TTY:
```console
$ ./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:**
```console
$ ./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:
```console
$ ./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:
```console
$ ./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:
```console
$ ./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"
}
]
}
]
```
## Is it Safe to Run?
Yes. It is designed for production and assessment use:
- **One fixed length, never a sweep.** The probe's `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.
- **Validated by measurement.** The 20-byte probe was run 10 times consecutively against an
unpatched `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.
- **No authentication, no credential material, no state change.** No assertion, signature,
timestamp, session, or client certificate is presented anywhere in this check. The `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.
- **False-positive guard.** `VULNERABLE` is never reported on a single response β see
[the control](#the-control-is-what-makes-a-500-mean-something).
> **If you modify the probe, do not change the decoded `RelayState` length 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.
### Request and log footprint
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.
## How it Works
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.
### The control is what makes a 500 mean something
`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*.
### It answers on IdP-only virtual servers too
`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.
### A non-match is never a claim of safety
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*.
## Verdicts
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.
## Exit codes
| 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.
## Limitations
- **A WAF or load balancer can skew a `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.
- **This checks one CVE, not the appliance's patch level.** `PATCHED` says nothing about any other
NetScaler vulnerability, including CVE-2026-19489 in the same bulletin.
- **Not an exploitation check, and not a compromise check.** `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.
- **It cannot tell you whether the denial-of-service branches are reachable.** The tool sends one
validated length and never explores the others, by design.
- **Reachability.** A result reflects what the appliance exposes to the network position you run it
from.
## Remediation
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](https://support.citrix.com/external/article/CTX696939/netscaler-adc-and-netscaler-gateway-secu.html).
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:
- **Patch both nodes of an HA pair.** An unpatched secondary is a fully exposed appliance the moment
it takes over.
- **If you cannot patch today, check the one global knob that bounds the impact.** The session this
bug mints on a Gateway virtual server skips session-policy evaluation, so it does not pick up the
`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.
- **Scope your inventory by SAML configuration, not by virtual server type.** Check the running
configuration for `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.
## License
This code is distributed under an [MIT license](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
- [Citrix CTX696939 β NetScaler security bulletin](https://support.citrix.com/external/article/CTX696939/netscaler-adc-and-netscaler-gateway-secu.html)
- [NVD β CVE-2026-19490](https://nvd.nist.gov/vuln/detail/CVE-2026-19490)
- [NVD β CVE-2026-19489](https://nvd.nist.gov/vuln/detail/CVE-2026-19489)
- [NVD β CVE-2026-8452](https://nvd.nist.gov/vuln/detail/CVE-2026-8452)