
Détecter en toute sécurité le contournement d'authentification SAML de Citrix NetScaler CVE-2026-19490
Une vérification de vulnérabilité sûre et non authentifiée pour CVE-2026-19490, le contournement
d'authentification pré-authentification dans le chemin du fournisseur de services SAML de Citrix
NetScaler ADC / NetScaler Gateway
(CTX696939,
publié le 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). Signalé par Samarth Vashisht de l'équipe
de tests d'intrusion de JPMorgan Chase.
L'appliance décode en base64 le paramètre RelayState d'une réponse SAML et, lorsque le texte en
clair commence par ctx=, transmet le reste au désérialiseur de contexte nFactor. Sur une version
non corrigée, un échec de désérialisation propage la longueur du RelayState décodé comme code
de disposition interne de la requête au lieu d'une erreur, de sorte qu'un attaquant non authentifié
choisit la branche interne que l'appliance emprunte ensuite uniquement en choisissant la longueur du
RelayState. Certaines branches créent une véritable session Gateway ; d'autres font planter le
moteur de paquets et redémarrent l'appliance. Ce script ne fait ni l'un ni l'autre — il envoie la
seule longueur validée pour ne créer aucune session et ne pas toucher au moteur de paquets, et
répond à une seule question par cible : Un résultat autre
que n'est .
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+, bibliothèque standard uniquement — aucun paquet tiers.
Pointez l'outil vers le serveur virtuel Gateway ou AAA, pas vers l'interface de gestion.
L'exposition est propre à chaque serveur virtuel, donc une appliance comportant plusieurs VIP doit
être testée pour chacune. La sonde porte une enveloppe de sécurité stricte — une seule longueur de
RelayState validée, jamais balayée — que
Est-il sûr de l'exécuter ? expose.
| Option | Description |
|---|---|
TARGET | Une ou plusieurs cibles [https://]HOST[:PORT] ; le schéma par défaut est https:// |
-f, --targets-file FILE | Lire les cibles depuis un fichier (une par ligne ; commentaires #) |
--timeout SECS | Délai d'attente par requête (par défaut : 15) |
--workers N | Cibles simultanées (par défaut : 16) ; la sortie reste dans l'ordre d'entrée |
-b, --brief | Une seule ligne alignée par cible — idéal pour scanner de nombreux hôtes |
--json | Émettre du JSON structuré, incluant chaque requête envoyée par cible |
--no-color | Désactiver la sortie colorée (respecte aussi NO_COLOR et les non-TTY) |
Une appliance vulnérable (la sortie par défaut sur deux lignes). Le marqueur [!] et
VULNERABLE s'affichent en rouge sur un 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
Une appliance corrigée :
$ ./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)
Le garde-fou anti-faux-positif qui se déclenche. La sonde et le contrôle de même longueur ont tous deux renvoyé le signal non corrigé, donc la réponse ne dépend pas de ce qui a été envoyé et la réponse d'apparence décisive est retirée :
$ ./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
Balayage d'un parc (--brief). Les deux lignes gateway.example.com sont les serveurs virtuels
SP et IdP-only sur la même appliance — les deux répondent, ce qu'une vérification de précondition de
configuration ne parviendrait pas à faire :
$ ./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
Sortie lisible par machine (--json). Chaque requête est incluse, de sorte qu'une conclusion
peut être redérivée à partir des preuves plutôt que d'être crue sur parole. Le contrôle est
enregistré par sa relation à la sonde plutôt que comme un verdict qui lui est propre, car un
contrôle qui se lit comme une version corrigée est le résultat attendu sur toutes les versions :
$ ./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"
}
]
}
]
Oui. Il est conçu pour un usage en production et en évaluation :
RelayState de la sonde se décode en exactement
20 octets et l'outil n'envoie aucune autre longueur sous aucun drapeau. Sur une appliance non
corrigée, la longueur décodée est le sélecteur de branche, et les branches incluent celles qui
créent une session et celles qui provoquent un SIGSEGV du moteur de paquets — redémarrant tout le
NetScaler et interrompant le trafic pendant environ 45 secondes. Vingt octets aboutissent à une
branche d'erreur interne propre qui ne crée aucune session, et le constructeur de charge utile
refuse de construire toute autre longueur, de sorte qu'une modification ailleurs ne peut pas
élargir discrètement la sonde.13.1-63.18 sans aucun core généré et sans redémarrage du moteur de paquets.
La vérification elle-même a depuis été exécutée contre les deux branches de maintenance, des deux
côtés du correctif, et contre des serveurs virtuels fournisseur de services et IdP-only, sans
redémarrage du moteur de paquets sur aucun d'entre eux.SAMLResponse doit être présent pour que la branche RelayState soit
atteinte, mais son contenu est sans importance, donc ce sont quatre octets de remplissage. La
branche n'émet aucun cookie et n'écrit aucune configuration.VULNERABLE n'est jamais signalé sur une seule réponse — voir
le contrôle.Si vous modifiez la sonde, ne changez pas la longueur du
RelayStatedécodé et ne balayez pas les longueurs. Vingt octets sont porteurs, et plus court n'est pas plus sûr. D'autres longueurs sont au mieux non testées et au pire une panne d'appliance.
Trois requêtes HTTP par cible, et une quatrième uniquement lorsque l'appliance renvoie le signal non corrigé :
| # | Requête | Objectif |
|---|---|---|
| 1 | GET /logon/LogonPoint/tmindex.html | Identification NetScaler (jetons Content-Security-Policy) |
| 2 | GET /vpn/js/rdx/core/rdx.js | Identification NetScaler |
| 3 | POST /cgi/samlauth | la sonde |
| 4 | POST /cgi/samlauth | le contrôle, envoyé uniquement après un signal non corrigé |
Les redirections ne sont pas suivies et aucun cookie n'est jamais renvoyé — NetScaler sert un
NSC_DLGE expiré qu'un client rejouant la requête transporterait dans la requête 4, transformant le
POST en page de connexion.
nsppe journalise le POST SAML dans /var/log/ns.log au niveau informationnel, sans nécessiter de
commutateur de débogage, comme un échec de désérialisation de contexte nFactor incluant la ligne
nFactor: ctx deserialize aaa_info, base64 decode failed. Il n'y a aucune ligne de création de
session ou de connexion à cette longueur, ce qui distingue un scan d'une tentative de contournement.
Le correctif est une seule instruction. Lors d'une désérialisation de contexte échouée, la version
vulnérable reporte la longueur du RelayState décodé comme statut de la requête ; la version
corrigée reporte l'erreur constante 0xe0005, rendue comme Malformed Assertion. Ainsi un seul POST
les sépare :
| Réponse à la sonde | Version | Verdict |
|---|---|---|
500 Internal Server Error 43524 | non corrigée | VULNERABLE |
200 Malformed Assertion sent to Netscaler | corrigée | PATCHED |
La version corrigée répond Malformed Assertion à toutes les longueurs, ce qui fait de la réponse
non corrigée un oracle plutôt qu'une coïncidence.
VULNERABLE repose sur une seule réponse distinctive, donc l'outil vérifie que la réponse dépend
réellement de ce qui a été envoyé. Après le signal non corrigé, il répète la requête avec un
RelayState de contrôle de la même longueur décodée de 20 octets, ne différant que par les
16 octets après ctx=. Ce contenu est rejeté par un garde en amont du désérialiseur, donc toutes les
versions testées — corrigées et non corrigées — y répondent 200 Malformed Assertion. Le verdict ne
tient que si les deux réponses diffèrent. Si elles correspondent, quelque chose renvoie
500/43524 indépendamment de l'entrée, et le résultat est INCONCLUSIVE étiqueté flat-response
plutôt qu'un faux VULNERABLE. Maintenir le contrôle à la même longueur décodée le garde à
l'intérieur de l'enveloppe de sécurité validée et isole le contenu comme seule variable.
Le garde est unidirectionnel. Il ne peut pas valider un verdict PATCHED, car une appliance
véritablement corrigée répond de manière identique à la sonde et au contrôle — c'est ce que signifie
être corrigé ici. Voir Limitations.
La désérialisation de RelayState s'exécute avant la correspondance de politique SAML, donc un
serveur virtuel ne portant qu'un samlIdPPolicy répond à la sonde, pas seulement celui avec un
samlAction de fournisseur de services lié. C'est une couverture plus large qu'une vérification de
précondition de configuration ne peut offrir.
La réponse vulnérable est la seule auto-corroborante : elle porte un marqueur spécifique, et le
contrôle prouve que la réponse dépend de ce qui a été envoyé. PATCHED est aussi une preuve
positive — l'erreur corrigée de l'appliance elle-même sur le chemin atteint par la sonde — mais elle
est limitée à ce CVE et à ce chemin. INCONCLUSIVE et ERROR ne sont pas du tout des preuves : la
sonde n'a jamais atteint le désérialiseur, donc l'état du correctif est inconnu, et c'est pourquoi
INCONCLUSIVE est un verdict distinct plutôt que fusionné dans PATCHED. Rapporter le silence comme
une réussite se trompe dans la direction dangereuse. Là où vous avez besoin de certitude, confirmez
avec show ns version par rapport aux versions dans Remédiation.
Chaque verdict porte une courte étiquette reason. --brief l'affiche comme troisième colonne et
--json la porte comme reason.
| Verdict | Étiquette reason | Signification |
|---|---|---|
VULNERABLE | internal-error-43524 | L'appliance a propagé la longueur du RelayState décodé comme son erreur interne, et un contrôle de même longueur a prouvé que la réponse dépend de ce qui a été envoyé. Le correctif CTX696939 est absent — corrigez-la. |
PATCHED | fixed-error-returned | L'appliance a renvoyé l'erreur corrigée sur le chemin atteint par la sonde. Limité à ce CVE, et voir Limitations sur les dispositifs interposés. |
UNAFFECTED | no-saml-endpoint | /cgi/samlauth a renvoyé 404, donc le consommateur d'assertion SAML n'est pas servi ici. Par vserver, pas par appliance, et voir la mise en garde sur les versions plus anciennes dans Limitations. |
INCONCLUSIVE | flat-response | La sonde et le contrôle de même longueur ont répondu de manière identique, donc la réponse ne dépend pas de ce qui a été envoyé. Le garde-fou anti-faux-positif se déclenche. |
INCONCLUSIVE | generic-internal-error | Le point de terminaison a renvoyé 43549, son erreur interne générique, que les versions corrigées et non corrigées renvoient. Ce n'est pas le discriminant — et notez que c'est bien le marqueur non corrigé pour le CVE-2026-8452 sans rapport, donc cela ressemble à une détection et n'en est pas une. |
INCONCLUSIVE | unrecognized-reply | Le point de terminaison a répondu avec quelque chose hors de l'ensemble reconnu. |
INCONCLUSIVE | no-probe-response | L'hôte a répondu à l'identification mais pas à la sonde — un délai d'attente, une réinitialisation, ou un middlebox qui abandonne le POST. Réessayez. |
ERROR | not-identified | Non identifié comme un NetScaler, ou injoignable. |
Les quatre raisons INCONCLUSIVE signifient la même chose pour la prise de décision — inconnu, pas
corrigé. L'étiquette nomme la condition à corriger avant de relancer.
| Code | Signification |
|---|---|
0 | Aucune cible n'était VULNERABLE |
1 | Au moins une cible est VULNERABLE |
2 | Erreur d'utilisation (arguments invalides / fichier de cibles illisible) |
Le code de sortie 0 n'est pas un certificat de bonne santé. PATCHED, UNAFFECTED et les
quatre raisons INCONCLUSIVE s'y fondent, et une seule d'entre elles est un résultat positif. Un
wrapper qui doit distinguer « corrigé » de « impossible à classer » doit lire le verdict — première
colonne de --brief, ou le champ verdict de --json — et non le code de sortie.
PATCHED. Malformed Assertion est
ce que l'appliance renvoie chaque fois que la sonde n'atteint pas le désérialiseur de contexte, et
être corrigée n'est qu'une des raisons pour lesquelles cela se produit. Un dispositif interposé qui
supprime, altère ou court-circuite le paramètre RelayState produit donc PATCHED sur une
appliance vulnérable, et aucune requête supplémentaire que l'outil pourrait envoyer ne séparerait
les deux. Là où quelque chose peut se trouver devant l'appliance, confirmez avec show ns version.UNAFFECTED est limité aux versions actuelles. Sur 14.1-43.55 / 13.1-61.27 et
antérieures, l'avis indique que l'exposition ne nécessite pas du tout d'action SAML — tout
serveur virtuel Gateway ou AAA est affecté — donc un 404 sur une telle version ne met pas
l'appliance hors de portée. Ce point est tiré de l'avis plutôt que mesuré.PATCHED ne dit rien sur
aucune autre vulnérabilité NetScaler, y compris CVE-2026-19489 dans le même bulletin.VULNERABLE établit que le correctif est absent sur le chemin atteint par la sonde. Cela ne mesure
pas jusqu'où un attaquant pourrait pousser le contournement sur votre configuration, et cela ne
vous dira pas si quelqu'un l'a déjà fait — traquez cela séparément dans /var/log/ns.log.Mettez à niveau vers 13.1-63.21 ou ultérieure, ou 14.1-73.32 ou ultérieure (FIPS et NDcPP : 13.1-37.277 pour 13.1-FIPS et 13.1-NDcPP, 14.1-73.32 FIPS pour 14.1-FIPS), selon CTX696939. Les appliances sur 12.1 ou 13.0 n'ont pas de correctif et n'en recevront pas — ces branches sont en fin de vie et devraient être migrées vers une branche prise en charge.
Trois notes supplémentaires :
defaultAuthorizationAction d'un
vpn sessionAction lié au vserver — elle retombe sur le set vpn parameter -defaultAuthorizationAction global. La valeur par défaut livrée est DENY ; ALLOW est
courant sur le terrain car c'est ce vers quoi les administrateurs se tournent au lieu d'écrire des
politiques d'autorisation par utilisateur, et ALLOW est ce qui permet à la session non
authentifiée d'atteindre les ressources internes. C'est global, pas par vserver. Cela borne
l'impact ; ce n'est pas un correctif.add authentication samlAction et
add authentication samlIdPProfile aux côtés de add authentication vserver et
add vpn vserver — sous réserve de la mise en garde sur les versions plus anciennes ci-dessus.CTX696939 corrige aussi CVE-2026-19489, et cette surface chevauche fortement CVE-2026-8452 du bulletin précédent : sur les versions à partir de 14.1-43.56 / 13.1-61.28, les deux problèmes sont conditionnés à une action SAML configurée, donc un seul audit de configuration délimite les deux.
Ce code est distribué sous une licence MIT.
L'utilisation de cet outil pour attaquer des cibles sans consentement mutuel préalable est illégale. Il est de la responsabilité de l'utilisateur final de respecter toutes les lois locales, étatiques et fédérales applicables. Les développeurs n'assument aucune responsabilité et ne sont pas responsables de tout usage abusif ou dommage causé par ce programme.