Skip to content
KitploitKITPLOIT
도구블로그
제출
도구블로그
제출

해킹, 침투 테스트 및 사이버 보안 도구를 당신의 보안 무기고에!

Kitploit은 해킹, 사이버 보안 및 침투 테스트 도구 디렉토리입니다. 최신 프로젝트 업데이트를 발견하여 취약점을 찾고, 시스템을 분석하고, 테스트를 자동화하고, 보안을 강화하세요.

··피드·문의·개인정보·© 2026 Kitploit

도구 디렉토리

카테고리

모든 카테고리 보기
Loading categories
CVE-2024-38063- — IPv6를 통한 원격 커널 익스플로잇 | Kitploit
도구/GitHubGitHub/adminpentester/cve-2024-38063-
Memory ForensicsVulnerability AnalysisExploitationReverse EngineeringLearning & EducationBinary Exploitation
GitHubadminpentester/cve-2024-38063-

CVE-2024-38063-

IPv6를 통한 원격 커널 익스플로잇

저장소 보기
211년 전아직 검토되지 않음

인기

모두 보기 →

커뮤니티에서 가장 많이 사용되는 도구를 찾아보세요.

모든 도구 탐색

도구 컬렉션을 둘러보세요

모든 도구 보기 →
공유

CVE-2024-38063-

IPv6를 통한 원격 커널 익스플로잇

CVE-2024-38063 - IPv6를 통한 원격 커널 익스플로잇 마커스 허친스

8월 13일 최신 Windows 패치가 나온 이후로 저는 tcpip.sys(TCP/IP 패킷 처리를 담당하는 커널 드라이버)에 깊이 빠져 있었습니다. Windows 커널에서 가장 쉽게 접근할 수 있는 부분에 CVSS 점수 9.8의 취약점이 있다는 것은 그냥 지나칠 수 없는 일이었습니다. 저는 이전에 IPv6(또는 이를 파싱하는 드라이버)를 본 적이 거의 없었기 때문에 이 취약점을 리버스 엔지니어링하는 것이 매우 어려울 것이라는 것을 알았지만, 좋은 학습 경험이 될 것이라고 생각했습니다.

대부분의 경우 tcpip.sys는 문서화가 거의 되어 있지 않습니다. 이전 버그에 대한 몇 가지 익스플로잇 분석 글을 찾을 수 있었습니다: 여기, 여기, 그리고 여기, 그 외에는 거의 없었습니다. 영어 Google 검색 결과 상위에 중국어로 작성된 글이 나오면, 나자신이 깊은 물에 빠져 힘들어질 것이라는 것을 즉시 알지만, 배워야 합니다. Google 번역이 평범한 성과를 냈음에도 불구하고, 그 게시물은 IPv6 조각화가 어떻게 작동하는지에 대한 엄청나게 상세한 통찰력을 제공했고, 좋은 출발점을 주었습니다.

나중에 몇몇 함수 이름을 검색하다가 Axel Souchet(일명 0vercl0k)가 작성한 동일한 2021년 취약점에 대한 또 다른 분석을 발견했습니다. 이 분석은 tcpip.sys의 내부를 훨씬 더 깊이 파고들었고, 문서화되지 않은 여러 구조체를 정의할 수 있는 충분한 정보를 제공했습니다.

가장 쉬운 패치 분석

일반적으로 취약점에 해당하는 코드 변경을 찾기 위해 패치를 리버스 엔지니어링하는 것만으로도 며칠 또는 몇 주가 걸릴 수 있지만, 이 경우에는 즉시 찾을 수 있었습니다. 사실 너무 쉬워서, 여러 사람들이 소셜 미디어에서 제가 틀렸다며 버그가 다른 곳에 있다고 말했습니다. 제가 그들의 말을 듣고 하루를 낭비하며 잘못된 드라이버를 리버스 엔지니어링했을까요? 우리는 결코 알 수 없을 것입니다.

전체 드라이버 파일에서 정확히 하나의 변경 사항이 있었고, 그것이 결국 실제 버그였습니다.

패치 설치 전후 tcpip.sys의 바인디프 개요.

전체 드라이버에서 단 하나의 함수만 수정되었습니다. 보통 하루 종일 20개 이상의 함수 변경 사항을 살펴보면서 어느 것을 봐야 하는지 알아내야 하지만, 이번에는 그렇지 않았습니다.

패치 전 Ipv6pProcessOptions().

패치 후 Ipv6pProcessOptions().

변경된 함수가 하나뿐만 아니라 코드 한 줄만 변경되었습니다.

매우 긴 이름의 Feature_2660322619__private_IsEnabledDeviceUsage_3() 함수는 Microsoft가 부분적인 패치 롤백을 가능하게 하기 위해 가끔 추가하는 것입니다. 이 호출은 전역 플래그 또는 레지스트리 설정의 존재 여부를 확인하며, 설정되어 있으면 함수가 false를 반환하여 원래 코드가 패치된 버전 대신 실행되도록 합니다.

Microsoft가 이렇게 하는 이유는 보안 패치가 때때로 의도치 않게 문제를 일으키기 때문입니다. 따라서 이 설정을 통해 관리자는 단일 취약점을 패치 해제할 수 있으며, 전체 월별 패치 롤업을 제거하고 시스템 보안을 급격히 약화시키지 않아도 됩니다.

이를 고려하면, 이 패치가 하는 일은 IppSendErrorList() 호출을 IppSendError()로 대체하는 것임이 분명하며, 이는 문제가 어떤 종류의 리스트와 관련되어 있음을 알려줍니다. 가장 쉬운 패치 diff였습니다(또는 그렇게 생각했습니다).

취약점은 선택 사항, 익스플로잇은 필수

변경된 코드를 찾기 위해 패치를 리버스 엔지니어링하는 것은 도전의 절반에 불과합니다(또는 이 경우에는 0.1% 미만). 나머지 과정은 코드베이스의 충분한 부분을 리버스 엔지니어링하여 무슨 일이 일어나고 있는지 이해하고, 어떤 종류의 취약점이 패치되었는지, 대상 코드에 도달하기 위해 요청을 어떻게 구성해야 하는지, 그리고 어떤 상태가 익스플로잇 가능한 조건을 만드는지 알아내는 것입니다.

첫 번째 부분은 충분히 쉽습니다. 변경된 부분은 Ipv6pProcessOptions()에 있습니다. 이는 IPv6이며 옵션 처리와 관련되어 있음을 알려줍니다. 따라서 RFC를 빠르게 확인하면 IPv6 옵션이 무엇인지, 어디서 찾을 수 있는지 정확히 알 수 있습니다.

위키피디아에서 가져온 대상 옵션 헤더 레이아웃.

좋습니다. 우리가 찾고 있는 것은 기본 IPv6 헤더 바로 뒤에 위치하는 대상 옵션 헤더인 것으로 보입니다. Python 라이브러리 'scapy'를 사용하여 테스트 IPv6 패킷을 구성해 보겠습니다.

참고: 스푸핑된 IP 주소를 사용한 DDoS 공격을 완화하기 위해 Windows는 원시 IP 패킷을 구성하는 기능을 제한합니다. 이러한 이유로 저는 Linux를 사용하여 개념 증명을 개발하기로 결정했습니다. Linux는 사용자가 원시 레이어 2 및 레이어 3 패킷을 구성하고 보낼 수 있지만, Python 스크립트를 root로 실행해야 합니다.

root@kitploit:~
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바이트 미만)의 점보 패킷 옵션을 사용하기로 결정했습니다.

root@kitploit:~
options_header = IPv6ExtHdrDestOpt(options=[Jumbo(jumboplen=0x1337)])

그렇다면 IppSendErrorList()는 실제로 무엇을 할까요? 코드는 매우 간단합니다.

전체 IppSendErrorList 함수.

코드는 연결 리스트를 반복하며 리스트의 모든 항목에 대해 IppSendError()를 호출합니다. 다시 한 번, 별들이 정렬되어 지금까지는 모든 것이 쉬웠습니다. IppSendErrorList가 리스트의 각 항목에 대해 IppSendError를 호출하는 것뿐이고, 패치가 IppSendErrorList 호출을 IppSendError로 대체한다면, 문제는 첫 번째 항목이 아닌 다른 리스트 항목에 대해 IppSendError가 호출될 때 발생합니다.

그렇다면 이것은 무엇의 리스트이며, 어떻게 만들 수 있을까요?

그는 리스트를 만든다, 그는 확인한다...52,567번

이 지점에서 상황은 명확에서 비정상적으로 어려움으로 바뀌었습니다. 다만 저는 이 어려움의 큰 부분이 제 두뇌 세포 중 하나가 심한 코로나 감염과 싸우느라 바빴기 때문이라고 생각합니다. 저는 코드의 일부를 이해하고, 잠이 들고, 그런 다음 알아낸 것을 잊어버리는 데 며칠을 허비했습니다. 전체 과정은 tcpip.sys의 일부를 리버스 엔지니어링하여 무슨 일이 일어나고 있는지 알아내는 데 일주일 이상이 걸렸습니다. 하지만 Axel의 블로그 게시물이 매우 도움이 되었습니다.

Axel이 리버스 엔지니어링한 함수와 구조체, 그리고 그것들이 다른 함수에 전달되는 방식을 살펴보면, Ipv6pProcessOptions()에 전달되는 유일한 인자가 해당 기사에서 정의된 것과 동일한 packet_t 구조체임이 분명합니다. 본질적으로, Ipv6pProcessOptions에 전달되고 IppSendErrorList에 의해 반복되는 포인터는 패킷의 연결 리스트입니다.

그래서 Ipv6pProcessOptions()에 중단점을 설정하고 리스트를 검사했습니다.

list->Next 항목이 NULL입니다.

중단점이 적중될 때마다 리스트에는 패킷이 하나만 포함되어 있었습니다. 저는 리스트가 실제로 리스트가 되도록 하는 방법을 알아내는 데 인정하고 싶은 것보다 훨씬 더 많은 시간을 보냈습니다. 첫 번째 생각은 IPv6 조각화였습니다. IPv6는 발신자가 큰 패킷을 별도의 작은 패킷으로 분할할 수 있으며, 이를 리스트에 함께 유지하는 것이 합리적일 것입니다.

광범위한 리버스 엔지니어링 후에 제 가정이 옳았다는 것을 확인했지만, 조각 리스트는 우리가 여기서 다루고 있는 것과 관련이 없습니다.

사실 우연히 답을 찾았습니다. 가끔 리스트가 채워지곤 했지만, 그 이유는 불분명했습니다. 많은 맴돌이 끝에, 제 커널 중단점이 트리거될 때 커널 전체가 일시 중지되어 네트워크 어댑터가 패킷을 축적한다는 것을 깨달았습니다. 커널이 다시 시작되면 이 패킷들은 깔끔한 리스트로 스택 아래로 전달되어 tcpip.sys로 전달됩니다. 이는 커널이 일시 중지된 동안 패킷이 전송되었지만 다음 중단점이 적중되기 전에 처리되지 않은 경우에만 발생했습니다.

이 동작은 아마도 성능 최적화로, 낮은 처리량에서는 커널이 패킷을 개별적으로 처리하지만, 높은 볼륨에서는 패킷이 리스트로 구성되어 배치로 처리됩니다. 리스트는 아마도 프로토콜, 소스 주소 등과 같은 요소에 따라 분리되어 처리를 가속화하며, 따라서 우리의 리스트는 우리가 보낸 IPv6 패킷만 포함해야 합니다.

야, 너 DoS 좋아하지?

이제 높은 처리량 동안 패킷이 리스트로 결합된다는 것을 알았으므로, 가장 쉬운 옵션이 무엇인지 분명합니다. 아이러니하게도 우리의 DoS PoC는 DoS 조건을 트리거하기 위해 DoS를 사용해야 합니다. IPv6 패킷의 버스트로 시스템을 플러딩하면, 우리는 IppSendErrorList()에 전달되는 멋진 큰 리스트를 얻을 수 있을 것입니다.

처음에는 아무리 많은 패킷을 보내도 커널을 일시 중지하지 않으면 리스트를 n > 1로 만들 수 없었습니다. 하지만... 우리는 Python(고통스럽게 느림)을 VM(두 배로 고통스럽게 느림)에서 사용하고 있기 때문에, 아마도 몇 가지 설정을 조정해야 할 것입니다. 제 공격 시스템에서 발생하는 VM-ception 현상을 상쇄하기 위해 대상 VM을 단일 CPU 코어만 사용하도록 재구성하기로 결정했습니다.

좋습니다! 이제 패킷 리스트는 많은 항목을 포함하는 리스트입니다!

그러므로 VM 안의 VM은 DoS에 가장 좋은 옵션이 아니라는 것이 밝혀졌습니다. 누가 생각이나 했을까요? 하지만 결국 작동하게 만들었습니다. 이제 IppSendError()가 무엇을 하는지, 그리고 문제가 어느 부분에 있는지 알아내기만 하면 됩니다.

더 많은 리버싱... 다시... 영원히...

광범위한 리버스 엔지니어링 후에 IppSendError가 무엇을 하는지 훨씬 더 명확해졌습니다. 일반적인 상황에서는 net_buffer_list->Status를 0xC000021B(STATUS_DATA_NOT_ACCEPTED)로 설정하여 패킷을 비활성화합니다. 그런 다음 오류가 있는 패킷에 대한 정보를 포함하는 ICMP 오류를 발신자에게 다시 전송합니다.

IppSendError의 두 가지 관련 부분.

제 첫 번째 접근 방식은 tcpip.sys에서 net_buffer_list->Status 값을 무시하는 함수가 있는지 확인하는 것이었습니다. 이렇게 하면 드라이버가 정의되지 않았거나 예상치 못한 상태의 패킷을 처리하게 되어 익스플로잇 조건으로 이어질 수 있습니다.

패킷 처리를 담당하는 메인 루프.

모든 파싱 함수를 호출하는 루프가 오류 검사로 둘러싸여 있기 때문에(오류 코드가 설정되면 어디로도 갈 수 없음을 의미), 저는 이것이 잘못된 토끼굴이라고 판단했습니다. 대신 IppSendError로 돌아가서 오류 코드를 설정하기 전에 패킷 상태를 수정하는 코드 경로가 있는지 확인하여 경쟁 조건을 유발할 수 있는지 알아보기로 결정했습니다.

더 많은 리버스 엔지니어링 후에, IppSendError의 매우 아래쪽에서 다음 코드를 발견했습니다.

IppSendError에서 packet_size를 0으로 설정하는 코드 경로.

IppSendErrorList가, 따라서 IppSendError가, always_send_icmp 인자가 true로 설정되어 호출되면, 리스트의 모든 패킷에 ICMP 오류를 전송하려고 시도하는 것으로 보입니다.

그런 다음, 아마도 신만이 아는 이유로, packet->packet_size 필드가 0으로 설정되는 코드 블록에 도달합니다.

always_send_icmp를 true로 설정하려면, 'Option Type' 값을 0x80보다 큰 숫자로 설정하여 옵션 헤더 처리에서 특정 오류를 발생시키면 됩니다.

root@kitploit:~
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으로 설정하면 파서가 깨지지 않을까요?

패킷 처리를 담당하는 메인 루프의 일부.

패킷 핸들러는 단순히 packet->next_header 값을 기반으로 VTable 함수를 호출합니다. 이 값은 사전 파싱 중에 설정된 이후로 변경되지 않습니다. 따라서 패킷 처리는 계속 진행될 수 있으며, 심지어 우리가 어떤 처리가 발생하는지 제어할 수도 있습니다.

packet->next_header 값은 IPv6 패킷의 'Next Header' 필드에서 얻어지므로, 유효한 IPv6 헤더 값으로 설정할 수 있으며 루프는 해당 파서를 호출합니다. 이는 많은 잠재적인 공격 표면을 제공합니다.

IPv6 패킷 형식.

이제 해야 할 일은 IPv6 파서의 도달 가능한 부분 중 packet_size 필드를 사용하여 바보 같은 짓을 하는 부분을 찾는 것입니다.

다시 조각화로

제가 가장 먼저 살펴보기로 한 곳은 IPv6 조각 파서였습니다. 왜냐하면 이전 cve-2021-24086 취약점이 있었던 곳이기 때문이며, 따라서 더 많은 엉뚱한 코드를 찾기에 좋은 장소처럼 보였습니다.

어... 매우 가깝지만, 또한 매우 멉니다.

여기에는 취약점이 있지만, RCE는 아닙니다.

기본적으로 대부분의 CPU에서 레지스터는 순환합니다. 레지스터를 최대값 이상으로 증가시키면 다시 0으로 돌아갑니다. 마찬가지로, 최소값 이하로 감소시키면 최대값으로 순환합니다. 이를 각각 정수 오버플로우와 정수 언더플로우라고 합니다. 이 동작은 부호 있는 정수의 경우 약간 다르지만, 여기서는 다루지 않습니다.

첫 번째 줄인 fragment_size = LOWORD(packet->packet_size) - 0x30은 다음 ASM 코드로 구성됩니다.

조각 크기를 계산하는 ASM 코드.

AX는 EAX 레지스터의 하위 16비트입니다. EAX 레지스터는 32비트이지만, AX는 자체 16비트 레지스터처럼 작동하므로 오버플로우나 언더플로우는 AX에 국한되며 EAX 레지스터의 나머지 부분에는 영향을 미치지 않습니다. 이는 매우 편리한데, EAX 레지스터에서 언더플로우가 발생하면 값이 40억이 되어 4GB 메모리 할당을 시도하게 되어 실패할 가능성이 높기 때문입니다.

packet->packet_size 값이 0이므로, 이 코드는 ax를 0으로 설정한 다음 0x30을 뺍니다.

정상 조건에서 패킷 헤더는 0x30바이트이므로, packet_size - 0x30은 조각 데이터의 크기입니다.

우리의 경우 packet->packet_size는 0이므로, 1을 빼기만 해도 레지스터가 가능한 최대 16비트 정수 값(0xFFFF)으로 순환합니다. 0x30을 빼면 AX의 값은 언더플로우하여 MAX_VALUE - 0x2F, 즉 0xFFD0이 되며, 이는 65,488입니다.

안타깝게도 동일한 계산이 메모리 할당과 데이터 복사 모두에 사용되므로 버퍼 오버플로우가 발생하지 않습니다. RtlCopyMdlToBuffer()도 소스 버퍼에 대한 경계 검사를 수행한다고 생각하므로, 심지어 범위를 벗어난 읽기도 발생하지 않습니다. 하지만 완전히 빈손으로 돌아가지는 않습니다.

ExAllocatePoolWithTagPriority()는 할당된 메모리를 0으로 설정하지 않고, RtlCopyMdlToBuffer()는 실제 사용 가능한 데이터 양만 복사하므로, 약 65kb의 초기화되지 않은 커널 메모리를 얻습니다. 메모리 주소는 해제 후 재사용되므로, 버퍼는 재할당 전에 해당 주소에 저장되어 있던 내용으로 채워질 가능성이 높습니다. 조각화를 사용하여 ICMP Echo 요청과 같이 우리에게 다시 전송되는 패킷을 구성할 수 있다면, 임의의 커널 메모리를 유출하여 ASLR 우회로 이어질 가능성이 있습니다.

게다가 코드는 reassembly->fragment_size를 언더플로우된 16비트 정수(65,488)로 설정하므로, 이제 버퍼 오버플로우를 유발하는 데 잠재적으로 사용할 수 있는 두 개의 개별 변수가 생겼습니다.

패배했지만, 패배하지는 않았다

안타깝게도(또는 운 좋게도, 아마도 시간을 많이 절약해 주었기 때문에) 누군가가 저보다 먼저 해결했습니다. 제가 언더플로우된 정수 중 하나를 사용하여 버퍼 오버플로우를 트리거할 곳을 찾기 전에, @ynwarcs가 답을 찾아 PoC를 공개했습니다. 이는 제 퍼즐의 마지막 조각을 해결해 줍니다.

해결책(또는 그 중 하나)은 Ipv6pReassemblyTimeout()입니다. 초기 조각 처리 중에는 오버플로우를 유발할 수 없지만, 정리 중에는 유발할 수 있는 것으로 보입니다.

IPv6 조각은 다음 세 가지 조건 중 하나가 발생할 때까지 메모리에 남아 있습니다:

  • 조각화를 충분히 엉망으로 만들어 시스템이 중지하라고 알릴 때까지.
  • 'More' 필드가 0으로 설정된 조각을 보내면, 이것이 마지막 조각임을 나타내며 시스템이 재조립을 시작합니다.
  • 시간 초과 기간(60초)이 만료되기 전에 마지막 조각을 보내지 않으면 시스템이 조각을 삭제합니다.

Ipv6pReassemblyTimeout()은 조건 3에서 호출되므로, 이것이 어떻게 익스플로잇될 수 있는지 살펴보겠습니다.

정확히 우리가 필요한 것입니다!

이전에는 코드가 메모리 할당과 복사 작업 모두에 동일한 계산을 사용하는 것이 문제였습니다. 반면에 이 코드는 그렇지 않습니다. ASM을 더 자세히 살펴보면 어떻게 익스플로잇 가능한지 알 수 있습니다.

할당 크기를 계산하는 어셈블리 코드.

여기서 볼 수 있듯이, 계산의 첫 번째 부분(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 값을 크기로 사용하므로, 65,488바이트가 reassembly->payload에서 30바이트 버퍼로 복사됩니다. 훌륭한 추가 보너스는 복사되는 데이터의 대부분이 조각 페이로드에서 오고, 우리가 제어할 수 있으며, 모든 형식의 임의 데이터일 수 있으므로, 꽤 제어 가능한 커널 풀 기반 버퍼 오버플로우를 얻을 수 있다는 것입니다.

취약점을 트리거할 가능성을 가지려면, IppSendErrorList가 호출되는 순간에 연결 리스트에서 잘못된 옵션 패킷 뒤에 하나 이상의 조각 패킷이 위치해야 합니다. 그러나 제 테스트에 따르면 이것이 익스플로잇을 보장하지는 않는 것으로 보입니다. 또한 충족되어야 할 다른 조건이 있다고 생각합니다. IppSendError의 동기화 코드가 우리가 경쟁 조건에서도 승리해야 함을 의미한다고 추측하지만, 확인하지는 않았습니다.

도구 다운로드