2026-09-10 10:07:34 -05:00
2026-09-10 10:07:34 -05:00
2026-09-10 10:07:34 -05:00
2026-09-10 10:07:34 -05:00
2026-09-10 10:07:34 -05:00
2026-09-10 10:07:34 -05:00
2026-09-10 10:07:34 -05:00

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

Usage

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

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:

$ ./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"
      }
    ]
  }
]

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.

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

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

S
Description
Safely detect Citrix NetScaler SAML auth bypass CVE-2026-19490
Readme MIT
41 KiB
0 Stars 1 Watchers 0 Forks
Languages
Python 100%