
Citrix NetScaler SAML 인증 우회 CVE-2026-19490을 안전하게 탐지
Citrix NetScaler ADC / NetScaler Gateway SAML 서비스 제공자 경로의 사전 인증 인증 우회인 CVE-2026-19490에 대한 안전한 비인증 취약점 점검 도구입니다
(CTX696939,
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). JPMorgan Chase 침투 테스트 팀의 Samarth Vashisht가 보고했습니다.
어플라이언스는 SAML 응답의 RelayState 파라미터를 base64 디코딩하고, 평문이 ctx=로 시작하면 나머지를 nFactor 컨텍스트 역직렬화기로 넘깁니다. 패치되지 않은 빌드에서는 역직렬화 실패 시 오류 대신 디코딩된 RelayState 길이가 요청의 내부 처리 코드로 전파되므로, 비인증 공격자는 단순히 RelayState의 길이를 선택하는 것만으로 어플라이언스가 다음에 어떤 내부 분기를 택할지 정할 수 있습니다. 일부 분기는 실제 Gateway 세션을 생성하고, 다른 분기는 패킷 엔진을 충돌시켜 어플라이언스를 재시작합니다. 이 스크립트는 둘 다 하지 않습니다 — 세션을 생성하지 않고 패킷 엔진에 영향을 주지 않는 것으로 검증된 단 하나의 길이를 전송하며, 대상마다 단 하나의 질문에 답합니다: 이 어플라이언스는 취약한가? 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+, 표준 라이브러리만 사용 — 서드파티 패키지 없음.
도구를 Gateway 또는 AAA 가상 서버에 지정하세요, 관리 인터페이스가 아닙니다. 노출은 가상 서버별로 이루어지므로, 여러 VIP를 가진 어플라이언스는 각각을 테스트해야 합니다. 프로브는 강력한 안전 범위를 지닙니다 — 검증된 단 하나의 RelayState 길이만 사용하며, 절대 훑지 않습니다 — 이는 실행해도 안전한가?에서 설명합니다.
| 플래그 | 설명 |
|---|---|
TARGET | 하나 이상의 [https://]HOST[:PORT] 대상; 스킴은 기본적으로 https:// |
-f, --targets-file FILE | 파일에서 대상 읽기 (한 줄에 하나; # 주석) |
--timeout SECS | 요청당 타임아웃 (기본값: 15) |
--workers N | 동시 대상 수 (기본값: 16); 출력은 입력 순서 유지 |
-b, --brief | 대상당 정렬된 한 줄 — 많은 호스트 스캔에 이상적 |
--json | 대상별로 전송된 모든 요청을 포함한 구조화된 JSON 출력 |
--no-color | 컬러 출력 비활성화 (NO_COLOR 및 비-TTY도 존중) |
취약한 어플라이언스 (기본 두 줄 출력). [!] 마커와 VULNERABLE은 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
패치된 어플라이언스:
$ ./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)
오탐 방지 가드가 작동하는 경우. 프로브 와 동일 길이 대조군이 모두 미패치 신호를 반환했으므로, 응답이 전송된 내용에 의존하지 않으며 결정적으로 보이는 답변이 철회됩니다:
$ ./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
전체 자산 훑기 (--brief). 두 개의 gateway.example.com 행은 동일한 어플라이언스의 SP 및 IdP 전용 가상 서버입니다 — 둘 다 응답하며, 이는 구성 사전 조건 점검으로는 처리할 수 없습니다:
$ ./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
기계 판독 가능 출력 (--json). 모든 요청이 포함되므로, 발견 사항을 신뢰하기보다 증거로부터 재도출할 수 있습니다. 대조군은 자체 판정이 아니라 프로브와의 관계로 기록됩니다. 패치된 빌드처럼 읽히는 대조군이 모든 빌드에서 예상되는 결과이기 때문입니다:
$ ./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"
}
]
}
]
예. 프로덕션 및 평가 용도로 설계되었습니다:
RelayState는 정확히 20바이트로 디코딩되며, 도구는 어떤 플래그로도 다른 길이를 전송하지 않습니다. 패치되지 않은 어플라이언스에서 디코딩된 길이가 분기 선택자이며, 분기에는 세션을 생성하는 것과 패킷 엔진에 SIGSEGV를 일으키는 것이 포함됩니다 — 이는 전체 NetScaler를 재시작하고 약 45초 동안 트래픽을 끊습니다. 20바이트는 세션을 생성하지 않는 깨끗한 내부 오류 분기에 도달하며, 페이로드 빌더는 다른 길이를 구성하는 것을 거부하므로 다른 곳의 편집이 프로브를 조용히 확장할 수 없습니다.13.1-63.18에 대해 연속 10회 실행되었으며 코어 생성이 전혀 없고 패킷 엔진 재시작도 없었습니다. 점검 자체는 이후 두 유지보수 브랜치에 대해, 수정 전후 양쪽에서, 그리고 서비스 제공자 및 IdP 전용 가상 서버 모두에 대해 실행되었으며, 어느 경우에도 패킷 엔진 재시작이 없었습니다.RelayState 분기에 도달하려면 SAMLResponse 파라미터가 존재해야 하지만 그 내용은 무관하므로, 4바이트의 쓰레기 데이터입니다. 이 분기는 쿠키를 발급하지 않고 구성을 기록하지 않습니다.VULNERABLE은 단일 응답으로 절대 보고되지 않습니다 — 대조군을 참조하세요.프로브를 수정하는 경우, 디코딩된
RelayState길이를 변경하지 말고 길이를 훑지 마세요. 20바이트는 핵심이며, 더 짧다고 더 안전하지 않습니다. 다른 길이는 최선의 경우 미검증이고 최악의 경우 어플라이언스 중단입니다.
대상당 세 개의 HTTP 요청, 그리고 어플라이언스가 미패치 신호를 반환할 때만 네 번째 요청:
| # | 요청 | 목적 |
|---|---|---|
| 1 | GET /logon/LogonPoint/tmindex.html | NetScaler 식별 (Content-Security-Policy 토큰) |
| 2 | GET /vpn/js/rdx/core/rdx.js | NetScaler 식별 |
| 3 | POST /cgi/samlauth | 프로브 |
| 4 | POST /cgi/samlauth | 대조군, 미패치 신호 이후에만 전송 |
리다이렉트는 따라가지 않으며 쿠키는 절대 되돌려 보내지 않습니다 — NetScaler는 만료된 NSC_DLGE를 제공하는데, 재생 클라이언트가 이를 요청 4로 가져가면 POST가 로그온 페이지로 바뀝니다.
nsppe는 SAML POST를 /var/log/ns.log에 정보 수준으로 기록하며, 디버그 설정이 필요 없고, nFactor 컨텍스트 역직렬화 실패로 다음 줄을 포함합니다
nFactor: ctx deserialize aaa_info, base64 decode failed. 이 길이에서는 세션 생성이나 로그인 줄이 없으며, 이것이 스캔과 우회 시도를 구분합니다.
수정은 명령어 하나입니다. 컨텍스트 역직렬화 실패 시 취약한 빌드는 디코딩된 RelayState 길이를 요청의 상태로 전달하고, 수정된 빌드는 상수 오류 0xe0005를 전달하며, 이는 Malformed Assertion으로 렌더링됩니다. 따라서 하나의 POST로 둘을 구분합니다:
| 프로브에 대한 응답 | 빌드 | 판정 |
|---|---|---|
500 Internal Server Error 43524 | 미패치 | VULNERABLE |
200 Malformed Assertion sent to Netscaler | 패치됨 | PATCHED |
패치된 빌드는 모든 길이에서 Malformed Assertion으로 응답하며, 이것이 미패치 응답을 우연이 아닌 오라클로 만듭니다.
VULNERABLE은 단일 구별 응답에 의존하므로, 도구는 응답이 실제로 전송된 내용에 의존하는지 검증합니다. 미패치 신호 이후 **동일한 20바이트 디코딩 길이의 대조군 RelayState**로 요청을 반복하며, ctx= 뒤의 16바이트만 다릅니다. 그 내용은 역직렬화기 앞의 가드에 의해 거부되므로, 테스트된 모든 빌드 — 패치됨과 미패치 — 가 200 Malformed Assertion으로 응답합니다. 두 응답이 다를 때만 판정이 유지됩니다. 일치하면 무언가가 입력과 무관하게 500/43524를 반환하는 것이며, 결과는 거짓 VULNERABLE이 아니라 flat-response 태그가 붙은 INCONCLUSIVE입니다. 대조군을 동일한 디코딩 길이로 유지하면 검증된 안전 범위 내에 머물고 내용을 유일한 변수로 격리합니다.
가드는 단방향입니다. PATCHED 판정을 검증할 수 없습니다. 진정으로 패치된 어플라이언스는 프로브와 대조군에 동일하게 응답하기 때문입니다 — 여기서 패치되었다는 것의 의미가 바로 그것입니다. 제한 사항을 참조하세요.