
CVE-2024-38063 - IPv6를 통한 커널 원격 악용
드라이버 파일 전체에서 정확히 한 군데만 변경되었는데, 결국 그것이 바로 버그였습니다.
패치 설치 전후의 tcpip.sys bindiff 개요.
드라이버 전체에서 단 하나의 함수만 수정되었습니다. 보통은 어떤 함수를 봐야 하는지 찾기 위해 20개 이상의 함수 변경을 하루 종일 살펴보곤 하지만, 이번에는 그럴 필요가 없었습니다.

Ipv6pProcessOptions() 패치 전.
Ipv6pProcessOptions() . 패치 후.
변경된 것은 단일 함수뿐만 아니라 단 한 줄의 코드였습니다.
이름이 매우 긴 Feature_2660322619__private_IsEnabledDeviceUsage_3() 함수는 Microsoft가 부분 패치 롤백을 활성화하기 위해 때때로 추가하는 것입니다. 이 호출은 전역 플래그 또는 레지스트리 설정의 존재 여부를 확인하며, 설정되어 있으면 함수가 false를 반환하여 패치된 버전 대신 원래 코드가 실행됩니다.
Microsoft가 이렇게 하는 이유는 보안 패치가 때때로 의도치 않게 문제를 일으키기 때문입니다. 따라서 이 설정을 통해 관리자는 전체 월별 패치 롤업을 제거하고 시스템 보안을 크게 약화시키지 않으면서도 단일 취약점에 대한 패치를 되돌릴 수 있습니다.
이를 고려하면 이 패치가 IppSendErrorList()에 대한 호출을 IppSendError()로 대체할 뿐이라는 것이 분명하며, 문제가 일종의 목록과 관련이 있음을 암시합니다. 역대급으로 쉬운 패치 diff였습니다(적어도 제 생각에는).
패치를 리버스 엔지니어링하여 변경된 코드를 찾는 것은 도전의 절반에 불과합니다(이 경우에는 0.1% 미만이지만). 나머지 과정은 무엇이 진행되고 있는지 이해할 수 있을 만큼 코드베이스를 리버스 엔지니어링하고, 패치된 취약점의 유형을 파악하고, 대상 코드에 도달하기 위한 요청을 구성하는 방법과 어떤 상태가 익스플로잇 가능한 조건을 만드는지 알아내는 것입니다.
첫 번째 부분은 충분히 쉽습니다. 변경 사항이 Ipv6pProcessOptions()에 있으므로 IPv6이며 옵션 처리와 관련이 있음을 알 수 있습니다. RFC를 빠르게 확인하면 IPv6 옵션이 무엇이고 어디에서 찾을 수 있는지 정확히 알 수 있습니다.
Wikipedia의 대상 옵션(destination options) 헤더 레이아웃.
좋습니다. 우리가 찾는 것은 기본 IPv6 헤더 바로 뒤에 위치하는 대상 옵션 헤더인 것 같습니다. Python 라이브러리 'scapy'를 사용하여 테스트 IPv6 패킷을 구성해 보겠습니다.
참고: 스푸핑된 IP 주소를 사용한 DDoS 공격을 완화하기 위해 Windows는 원시 IP 패킷 구성 기능을 제한합니다. 따라서 저는 개념 증명을 개발하기 위해 Linux를 사용하기로 했습니다. Linux는 사용자가 원시 레이어 2 및 레이어 3 패킷을 구성하고 전송할 수 있게 해주지만, Python 스크립트를 루트로 실행해야 합니다.
import sys
import struct
from scapy.all import *
def send_ipv6_option_packet(dest_ip):
ethernet_header = Ether()
ip_header = IPv6(dst=dest_ip)
options_header = IPv6ExtHdrDestOpt()
sendp(ethernet_header / ip_header / options_header)
if len(sys.argv) < 2:
print('Use: python3 script.py <target_ipv6_address>')
exit(-1)
send_ipv6_option_packet(sys.argv[1])
tcpip!Ipv6pProcessOptions에 중단점을 설정한 후 스크립트를 실행해 보니, 취약한 함수에 도달하기 위해 필요한 것은 빈 옵션 구조를 가진 IPv6 패킷을 보내는 것뿐이라는 것이 분명해졌습니다. 그런 다음 구조에 잘못된 옵션을 추가하여 IppSendErrorList() 호출에 도달할 수 있는지 확인해 보았습니다.간단한 코드 검토 결과 거의 모든 잘못된 옵션 형식이 IppSendErrorList에 대한 호출을 트리거할 수 있음을 알 수 있었습니다. 그래서 잘못된 길이(65535바이트 미만)의 점보 패킷(Jumbo Packet) 옵션을 사용하기로 결정했습니다.
options_header = IPv6ExtHdrDestOpt(options=[Jumbo(jumboplen=0x1337)])
그렇다면 IppSendErrorList()는 실제로 무엇을 할까요? 코드는 매우 간단합니다.
전체 IppSendErrorList 함수.
코드는 연결 리스트를 반복하면서 리스트의 모든 항목에 대해 IppSendError()를 호출합니다. 다시 말하지만, 별들이 정렬되었고 지금까지는 모든 것이 순조로웠습니다. IppSendErrorList가 리스트의 각 항목에 대해 IppSendError를 호출할 뿐이고 패치가 IppSendErrorList에 대한 호출을 IppSendError로 대체한다면, 문제는 첫 번째 항목이 아닌 리스트 항목에 IppSendError가 호출될 때 발생합니다.
여기서부터 상황은 명확한 수준에서 비정상적으로 어려운 수준으로 바뀌었습니다. 다만 그 이유의 상당 부분은 제가 가진 두 개의 뇌 세포 중 하나가 심한 코로나 감염과 싸우느라 바빴기 때문이라고 생각합니다. 코드 일부를 이해하고, 잠들고, 그리고 깨달은 것을 잊어버리기를 반복하며 며칠을 허비했습니다. 무슨 일이 벌어지고 있는지 파악하기 위해 tcpip.sys의 일부를 일주일 넘게 리버스 엔지니어링해야 했습니다. 하지만 Axel의 블로그 게시물이 매우 도움이 되었습니다.
Axel이 리버스 엔지니어링한 함수와 구조, 그리고 그것들이 전달되는 다른 함수들을 살펴보면 Ipv6pProcessOptions()에 전달되는 유일한 인수가 해당 글에서 정의한 것과 동일한 packet_t 구조라는 것이 분명합니다. 본질적으로 Ipv6pProcessOptions에 전달되고 IppSendErrorList에 의해 반복되는 포인터는 패킷의 연결 리스트입니다.
그래서 Ipv6pProcessOptions()에 중단점을 설정하고 리스트를 검사했습니다.

list->Next 항목이 NULL입니다.
중단점이 적중될 때마다 리스트에는 패킷이 하나만 들어 있었습니다. 왜 그런지, 그리고 어떻게 리스트를 실제로 리스트로 만들 수 있는지 알아내는 데 인정하기 싫을 만큼 오랜 시간을 보냈습니다. 처음 생각은 IPv6 단편화였습니다. IPv6는 송신자가 큰 패킷을 여러 개의 작은 패킷으로 나눌 수 있게 해주며, 이를 리스트로 함께 유지하는 것이 합리적입니다.
광범위한 리버스 엔지니어링 후에 내 가정이 맞다는 것을 확인했지만, 단편(fragment) 리스트는 여기서 다루는 리스트와 관련이 없습니다.
실제로는 전적으로 우연히 답을 찾았습니다. 때때로 리스트가 채워지기도 했지만 그 이유는 불분명했습니다. 여러 번 헤맨 끝에 커널 중단점이 트리거되면 커널 전체가 일시 중지되어 네트워크 어댑터가 패킷을 축적한다는 것을 깨달았습니다. 커널이 재개되면 이러한 패킷은 깔끔한 리스트로 스택 아래의 tcpip.sys로 전달됩니다. 이는 패킷이 커널이 일시 중지된 동안 전송되었지만 다음 중단점이 적중되기 전에 처리되지 않은 경우에만 발생했습니다.
이 동작은 아마도 성능 최적화 때문일 것입니다. 낮은 처리량에서는 커널이 패킷을 개별적으로 처리하지만, 더 많은 양에서는 패킷을 리스트로 구성하여 일괄 처리합니다. 목록은 처리 속도를 높이기 위해 프로토콜, 출발지 주소 등의 요소에 따라 분리될 가능성이 높으므로, 우리의 리스트에는 우리가 보낸 IPv6 패킷만 포함되어야 합니다.
이제 높은 처리량 중에 패킷이 리스트로 병합된다는 것을 알았으니 가장 쉬운 방법이 무엇인지 분명합니다. 아이러니하게도 우리의 DoS PoC는 DoS 조건을 트리거하기 위해 DoS를 사용해야 합니다. 시스템에 IPv6 패킷 버스트를 대량으로 보내면 IppSendErrorList()에 전달되는 멋진 큰 리스트를 얻을 수 있을 것입니다.
처음에는 패킷을 아무리 많이 보내도 커널을 일시 중지하지 않으면 리스트가 n > 1이 되는 것을 볼 수 없었습니다. 하지만… 우리는 Python(끔찍하게 느림)을 사용하고 있고, VM 안(두 배로 끔찍하게 느림)에서 실행 중이므로 일부 설정을 조정해야 할 것 같습니다. 공격 시스템에서 발생하는 VM-ception(VM 속의 VM)에 대응하기 위해 대상 VM을 단일 CPU 코어만 사용하도록 간단히 재구성하기로 결정했습니다.

좋습니다! 패킷 리스트가 이제 많은 항목을 포함한 리스트입니다!
IppSendError()가 무엇을 하는지, 그리고 문제가 어느 부분에 있는지 알아내기만 하면 됩니다.광범위한 리버스 엔지니어링 후에 IppSendError가 무엇을 하는지 훨씬 명확해졌습니다. 일반적인 상황에서는 net_buffer_list->Status를 0xC000021B(STATUS_DATA_NOT_ACCEPTED)로 설정하여 패킷을 비활성화합니다. 그런 다음 오류 패킷에 대한 정보를 포함한 ICMP 오류를 송신자에게 다시 전송합니다.
IppSendError의 관련된 두 부분.
가장 먼저 확인한 것은 tcpip.sys에서 net_buffer_list->Status 값을 무시하는 함수가 있는지였습니다. 그런 함수가 있다면 드라이버가 정의되지 않았거나 예상치 못한 상태의 패킷을 처리하게 되어 바라건대 익스플로잇 조건으로 이어질 수 있습니다.

패킷 처리를 담당하는 주요 루프.
모든 파싱 함수를 호출하는 루프가 오류 검사로 감싸여 있기 때문에(오류 코드가 설정되면 더 이상 진행할 수 없음을 의미), 이는 잘못된 토끼굴이라고 판단했습니다. 대신 IppSendError로 돌아가 오류 코드를 설정하기 전에 패킷 상태를 수정하여 경쟁 조건을 유발할 수 있는 코드 경로가 있는지 확인하기로 했습니다.
훨씬 더 많은 리버스 엔지니어링 후에 IppSendError의 맨 아래 근처에서 다음 코드를 발견했습니다.

packet_size를 0으로 설정하는 IppSendError의 코드 경로.
IppSendErrorList(그리고 따라서 IppSendError)가 always_send_icmp 인수를 true로 설정하여 호출되면, 리스트의 모든 패킷에 ICMP 오류를 보내려고 시도하는 것으로 보입니다.
그런 다음 아마도 신만이 알 이유로, packet->packet_size 필드가 0으로 설정되는 코드 블록에 도달합니다.
always_send_icmp를 true로 설정하기 위해 필요한 것은 'Option Type' 값을 0x80보다 큰 임의의 숫자로 설정하여 옵션 헤더 처리에서 특정 오류를 발생시키는 것뿐입니다.
def build_malicious_option(next_header, header_length, option_type, option_length):
dest_options_header = 60
options_header = struct.pack('BBBB', next_header, header_length, option_type, option_length) + b'1337'
return Ether(dst=mac_addr) / IPv6(dst=ip_addr, nh=dest_options_header) / raw(options_header)
packet = build_malicious_option(next_header=59, header_length=0, option_type=0x81, option_length=0)
sendp(packet)
하지만 packet_size를 0으로 설정하면 파서가 깨져야 하지 않을까요?

패킷 처리를 담당하는 주요 루프의 일부입니다.
패킷 핸들러는 pre-parsing 중에 설정된 packet->next_header 값을 기반으로 VTable 함수를 호출합니다. 이 값은 변경되지 않으므로 패킷 처리가 계속될 수 있고, 어떤 처리가 발생할지 제어할 수도 있습니다.
packet->next_header 값은 IPv6 패킷의 'Next Header' 필드에서 가져오므로, 이를 유효한 IPv6 헤더 값으로 설정할 수 있고 루프는 해당 파서를 호출합니다. 이는 잠재적인 공격 표면을 많이 제공합니다.

IPv6 패킷 형식.
이제 남은 것은 packet_size 필드로 이상한 일을 하는 IPv6 파서의 도달 가능한 부분을 찾는 것뿐입니다.

어... 매우 가깝지만, 동시에 매우 멀다
여기에 취약점이 있기는 하지만, RCE는 아닙니다.
기본적으로 대부분의 CPU에서 레지스터는 순환적입니다. 레지스터를 최대 가능 값 이상으로 증가시키면 다시 0으로 순환합니다. 마찬가지로 최저 가능 값 아래로 감소시키면 최고 가능 값으로 순환합니다. 이를 각각 정수 오버플로(integer overflow)와 정수 언더플로(integer underflow)라고 합니다. 이 동작은 부호 있는 정수의 경우 약간 다르지만, 여기서는 그런 경우를 다루지 않습니다.
첫 번째 줄인 fragment_size = LOWORD(packet->packet_size) - 0x30은 다음 ASM 코드로 구성됩니다:

단편 크기를 계산하는 ASM 코드.
AX는 EAX 레지스터의 하위 16비트입니다. EAX 레지스터는 32비트이지만 AX는 마치 독립적인 16비트 레지스터처럼 동작하므로 오버플로나 언더플로는 AX에 국한되며 EAX 레지스터의 나머지 부분에는 영향을 미치지 않습니다. 이는 매우 편리한데, EAX 레지스터에서 언더플로가 발생하면 값이 40억이 되어 4GB 메모리 할당을 시도하게 되고, 이는 실패할 가능성이 높기 때문입니다.
packet->packet_size is zero 값이 0이므로 이 코드는 ax를 0으로 설정한 다음 0x30을 뺍니다.
정상적인 조건에서 패킷 헤더는 0x30바이트이므로 packet_size - 0x30이 단편 데이터의 크기입니다.
우리의 경우 packet->packet_size가 0이므로 여기서 1만 빼도 레지스터가 가능한 최대 16비트 정수 값(0xFFFF)으로 순환합니다. 0x30을 빼기 때문에 AX 값은 언더플로하여 MAX_VALUE - 0x2F, or 0xFFD0, 즉 65,488이 됩니다.
안타깝게도 메모리 할당과 데이터 복사에 동일한 계산이 사용되기 때문에 버퍼 오버플로가 발생하지 않습니다. RtlCopyMdlToBuffer()도 소스 버퍼에 대한 경계 검사를 수행한다고 믿기 때문에 경계를 벗어난 읽기도 발생하지 않습니다. 하지만 완전히 빈손으로 돌아가는 것은 아닙니다.
ExAllocatePoolWithTagPriority()는 할당된 메모리를 0으로 만들지 않고 RtlCopyMdlToBuffer()는 실제 사용 가능한 데이터 양만 복사하기 때문에 약 65kb의 초기화되지 않은 커널 메모리를 얻을 수 있습니다. 메모리 주소는 할당 해제 후 재사용되므로, 버퍼는 재할당 전에 해당 주소에 저장되어 있던 내용으로 채워질 가능성이 높습니다. 단편화를 사용하여 ICMP 에코 요청처럼 다시 우리에게 전송되는 패킷을 구성할 수 있다면, 임의의 커널 메모리를 유출하여 ASLR 우회로 이어질 수 있습니다.
IPv6 단편은 다음 세 가지 조건 중 하나가 발생할 때까지 메모리에 남아 있습니다:
Ipv6pReassemblyTimeout()은 조건 3에서 호출되므로, 이것이 어떻게 익스플로잇될 수 있는지 살펴보겠습니다.Ipv6pReassemblyTimeout() 조건 3에서 호출되므로, 이것이 어떻게 익스플로잇될 수 있는지 살펴보겠습니다.

이것이 바로 우리에게 필요한 것입니다!

할당 크기를 계산하는 어셈블리 코드.
여기에서 볼 수 있듯이 계산의 첫 번째 부분(fragment_list->net_buffer_length + reassembly->packet_length + 8)은 16비트 DX 레지스터를 사용하여 수행됩니다.
앞에서 기억하시겠지만, 우리는 reassembly->packet_length를 0xFFD0으로 언더플로시켰습니다. 따라서 8바이트를 추가한 후 DX 레지스터는 0xFFD8입니다. fragment_list->net_buffer_length가 0x27(39바이트)보다 크면 DX가 오버플로하여 0으로 재설정됩니다.
fragment_list->net_buffer_length는 약 0x38바이트여야 하므로 DX 레지스터가 8로 오버플로됩니다. 0x28바이트가 추가된 후에는 메모리 할당이 48바이트에 불과하게 됩니다.
이후의 memmove() 호출은 크기 값으로 변경되지 않은 reassembly->packet_length 값을 그대로 사용하므로, reassembly->payload에서 30바이트 버퍼로 65,488바이트가 복사됩니다. -또한 추가 보너스로 복사되는 데이터의 상당 부분은 우리가 제어하는 단편 페이로드에서 오며 임의의 형식일 수 있으므로, 상당히 제어 가능한 커널 풀 기반 버퍼 오버플로를 얻을 수 있습니다.
취약점을 트리거할 기회를 얻으려면 IppSendErrorList가 호출되는 시점에 연결 리스트에서 잘못된 형식의 옵션 패킷 뒤에 하나 이상의 단편 패킷이 위치해야 합니다. 하지만 제 테스트에 따르면 이것이 익스플로잇을 보장하지는 않는 것 같습니다. 충족해야 할 다른 조건도 있다고 생각합니다. IppSendError의 동기화 코드 때문에 경쟁 조건에서도 이겨야 한다고 의심되지만, 확인하지는 못했습니다.
게다가 코드는 reassembly->fragment_size를 언더플로된 16비트 정수(65,488)로 설정하므로, 이제 버퍼 오버플로를 유발하는 데 잠재적으로 사용할 수 있는 두 개의 별도 변수가 생겼습니다.
해결책(또는 적어도 그중 하나)은 Ipv6pReassemblyTimeout()입니다. 초기 단편 처리에서는 오버플로를 유발할 수 없지만, 정리 과정에서는 유발할 수 있는 것 같습니다.