최근 주변에서 MSRC 버그바운티를 다들 열심히 하길래 나도 윈도우를 공부해봐야겠다는 생각이 들었다.
그래서 1-Day 취약점 분석을 먼저 해보기 위해 CVE-2023-28218을 분석해보았다.

해당 취약점은 Theori가 23년도 Hexacon 컨퍼런스에서 발표했다고 하는데, Windows 커널의 네트워크 드라이버인 afd.sys 에서 힙 오버플로우 취약점을 찾은 것이다.
정수 오버플로우 하나가 아니라, 크기 계산과 복사 사이의 더블 페치, wild copy를 멈추는 User Fault Handling, 그리고 커널 객체를 이용한 임의 감소 primitive가 하나의 권한 상승 경로로 연결된다는 점이 특징이다.

배경

afd.sys는 Windows의 Ancillary Function Driver for WinSock으로, 소켓 통신을 처리하는 커널 드라이버이다.

afd.sys는 일반적인 IRP 경로와 별도로 Fast I/O를 사용한다. Fast I/O는 IRP를 생성하면서 커널 내부의 큰 객체를 초기화하고 시스템 메모리를 할당하는 과정을 거치지 않고 장치와 직접 통신한다.

NtDeviceIoControlFile이 호출되면 IopXxxControlFile은 핸들에 연결된 DriverObject에서 FastIoDispatch 테이블을 찾는다. 테이블과 FastIoDeviceControl 함수 포인터가 존재하면 UserLand에서 전달한 입력 버퍼와 출력 버퍼를 그대로 넘겨 해당 함수를 호출한다.

afd.sys는 다음과 같은 Fast I/O 경로를 등록한다.

NtDeviceIoControlFile
└── IopXxxControlFile
    ├── FastIoDispatch
    └── FastIoDeviceControl
        └── AfdFastIoDeviceControl

취약점이 발생한 IOCTL

취약점은 AfdFastIoDeviceControl이 IOCTL 코드를 처리하는 과정에서 발생한다. 문제가 되는 IOCTL은 0x120d3인 AfdSendMessage로, UserLand에서 전달한 CMSG Buffer를 커널 구조체 형식으로 복사한다.

처리 흐름은 다음과 같이 정리할 수 있다.

cmsgSize = AfdComputeCMSGLength(userBuf, userBufSize);
kernelBuf = allocate(cmsgSize);
AfdCopyCMSGBuffer(kernelBuf, userBuf, cmsgSize);

먼저 AfdComputeCMSGLength가 사용자 버퍼를 읽어 필요한 크기를 계산한다. 이후 계산된 크기만큼 커널 메모리를 할당하고, AfdCopyCMSGBuffer가 같은 사용자 버퍼를 커널 메모리로 복사한다.

CMSG Buffer는 대략 다음과 같은 청크의 배열로 처리된다.

struct chunk {
    unsigned int chunkSize;
    char data[];
};

struct chunk CMSG[];

AfdComputeCMSGLength는 각 청크의 chunkSize를 읽고, 4바이트 정렬을 적용한 뒤 다음 청크로 이동한다. 전체 커널 버퍼 크기를 계산할 때는 청크 내부 데이터에 필요한 크기를 다시 8바이트 단위로 정렬한다.

여기서 크기를 계산하는 함수와 실제 복사 함수가 사용자 메모리를 서로 다른 시점에 다시 읽는데 이 부분이 정수 오버플로우와 결합되면서 취약점이 발생한다.

정수 오버플로우로 무력화되는 길이 검사

AfdCopyCMSGBuffer는 사용자 청크의 크기에 4를 더한 kernelChunkSize를 만들고, 여기에 7을 더한 뒤 8바이트 정렬을 한다.

kernelChunkSize = userChunkSize + 4;
alignedChunkSize = (kernelChunkSize + 7) & 0xfffffff8;

그 다음 남은 커널 버퍼의 크기가 alignedChunkSize보다 작은지 검사하는데, 사용자 영역의 chunkSize로 0xfffffff5부터 0xfffffffb 사이의 값을 전달하면 32비트 정수 오버플로우가 발생한다.

이러면 alignedChunkSize가 0이 된다. 따라서 다음 검사는 항상 통과하게된다.

if (sizeRemain < alignedChunkSize)
    return 0;

검사를 통과한 후 실제 memmove에 사용되는 길이는 userChunkSize - 12다. 앞의 범위가 사용되면 이 값은 0xffffffe9부터 0xffffffef 사이가 된다.

memmove(kernelBuf + 16, userBuf + 3, userChunkSize - 12);

결과적으로 커널은 실제로 할당된 버퍼의 남은 크기랑 상관 없이 사용자 입력에서 계산된 매우 긴 길이만큼 데이터를 복사하려고 한다. Theori는 이처럼 복사 길이를 사실상 통제할 수 없는 형태의 복사를 wild copy라고 했다.

더블 페치로 할당 크기와 복사 조건을 분리한다

정수 오버플로우만으로는 원하는 크기의 커널 힙 버퍼를 만들기 어렵다. 오버플로우를 발생시키는 값은 AfdComputeCMSGLength에서 계산하는 크기에도 영향을 주기 때문이다. 또한 Windows 힙에서 덮어쓸 대상을 고르려면 오버플로우를 발생시킨 청크와 비슷한 크기의 객체가 인접하도록 만들어야 한다.

여기서 userBuf가 사용자 영역 포인터라는 점이 중요하다. AfdComputeCMSGLength가 크기를 계산할 때 읽은 값과 AfdCopyCMSGBuffer가 복사할 때 읽는 값은 같은 메모리에 저장되어 있지만, 두 함수가 읽는 시점은 다르다.

따라서 다른 스레드에서 chunkSize를 빠르게 바꾸면 다음과 같은 상태를 만들 수 있다.

AfdComputeCMSGLength  → calculate the desired allocation size
allocate               → kernel buffer allocation
AfdCopyCMSGBuffer      → overflow-triggering read

이처럼 검사 또는 계산에 사용된 값과 실제 사용되는 값이 달라지는 패턴을 더블 페치라고 한다. Theori의 익스플로잇은 DeviceIoControl을 호출하는 동시에 다른 스레드에서 청크 크기를 바꾸어 이 경쟁 조건을 발생시킨다.

이 방법을 사용하면 크기 계산 단계에서는 원하는 크기의 커널 버퍼를 할당하고, 복사 단계에서는 0xfffffffa와 같은 오버플로우 유발 값을 사용해 wild copy를 발생시킬 수 있다.

User Fault Handling으로 wild copy를 멈춘다

wild copy는 복사 길이가 매우 크기 때문에, 단순히 실행하면 목표 객체를 지나 원하지 않는 메모리까지 훼손할 수 있다. 따라서 원하는 지점에서 복사를 멈추는 방법이 필요하다.

Theori는 memmove의 원본 주소가 사용자 영역 포인터라는 점을 이용했다. Windows 커널은 사용자 메모리에 직접 접근할 수 있고, 사용자 메모리를 읽는 과정에서 발생하는 페이지 폴트가 커널 전체로 전파되지 않도록 __try와 __except를 사용해 예외를 처리한다.

이를 이용하면 복사 원본 버퍼 뒤에 매핑되지 않은 메모리를 배치할 수 있다. 복사가 매핑된 영역의 끝에 도달하면 페이지 폴트가 발생하고, 커널의 예외 처리 경로가 이를 처리하면서 wild copy를 중단한다.

Theori가 제시한 사용자 버퍼 구성은 다음과 같은 형태다.

BYTE *page = VirtualAlloc((PVOID)0x12340000, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
BYTE *userBuf = page + 0x1000 - copyBytes;

한 페이지를 할당한 뒤 userBuf를 페이지 끝에서 copyBytes만큼 앞에 배치한다. 그러면 지정한 만큼의 데이터가 복사된 직후 다음 페이지에 접근하게 되고, 매핑되지 않은 영역에서 fault가 발생한다.

이 과정은 원하는 길이로 제한하기 어려운 복사를 지정한 지점에서 종료시키는 역할을 한다. 따라서 공격자는 오버플로우의 크기를 줄이는 대신, 사용자 메모리 경계를 이용해 실제 복사되는 데이터의 양을 조절할 수 있다.

IO_COMPLETION_CONTEXT를 이용한 임의 감소

다음 단계는 wild copy로 어떤 커널 객체를 덮을지 정하는 것이다. Theori는 IO_COMPLETION_CONTEXT 객체를 선택했다. 이 객체의 크기는 0x18바이트이며 구조는 다음과 같다.

오프셋멤버역할
0x0PortI/O completion object 포인터
0x8Key키 값
0x10UsageCount사용 횟수

이 객체는 NtSetInformationFile 시스템 콜을 통해 사용자에 의해 반복적으로 할당하고 해제할 수 있다. FileCompletionInformation을 전달하면 IO_COMPLETION_CONTEXT가 할당되고, 파일 객체에 연결된다. 할당 과정에서 Port에는 주어진 IoCompletionObject 객체의 포인터가 저장된다.

반대로 FileReplaceCompletionInformation을 전달하면 IopReplaceCompletionPort가 호출된다. 이 함수는 파일 객체에서 CompletionContext를 찾고, 대기 중인 IRP가 없으며 UsageCount가 0인 경우 CompletionContext->Port를 역참조한다.

ObfDereferenceObjectWithTag(CompletionContext->Port, tag);

wild copy로 Port 멤버를 원하는 주소로 덮은 뒤 해당 IO_COMPLETION_CONTEXT를 해제하면, 그 주소에 있는 값이 1 감소한다. Theori는 이 동작을 임의 주소 감소 primitive로 사용했다.

이 객체는 IO_COMPLETION_CONTEXT와 같은 크기의 커널 풀 할당을 반복해서 만들 수 있기 때문에 spray 대상으로 사용할 수 있다. 글에서 제시한 흐름은 NtCreateIoCompletion으로 completion object를 만들고, 파이프 핸들에 NtSetInformationFile을 적용해 여러 개의 IO_COMPLETION_CONTEXT를 생성한 뒤, 핸들을 닫아 객체를 해제하는 방식이다.

previousMode를 이용한 권한 상승

임의 감소 primitive를 확보한 뒤에는 현재 스레드의 previousMode를 표적으로 삼는다. Theori는 현재 스레드 객체의 thread + 0x232 위치에 있는 previousMode 값을 0으로 만드는 방식으로 현재 스레드를 커널 모드로 전환한다고 설명한다.

이 값이 바뀌면 커널의 권한 검사를 우회할 수 있고, 글에서는 메모리 접근을 포함한 권한 검사가 우회된다고 설명한다. INFILTR8 LAB 역시 이 과정을 IO_COMPLETION_CONTEXT 손상에서 임의 감소를 거쳐 권한 상승으로 이어지는 경로로 정리한다.

전체 흐름은 다음과 같다.

integer overflow
    ↓
wild copy
    ↓
IO_COMPLETION_CONTEXT.Port corruption
    ↓
arbitrary address -= 1
    ↓
previousMode = 0
    ↓
kernel privilege escalation

패치

패치는 AfdComputeCMSGLength와 AfdCopyCMSGBuffer에 존재하던 정수 연산을 안전한 산술 래퍼로 감싼 형태다. Theori가 제시한 패치 코드에는 다음과 같은 검사가 추가되어 있다.

  • RtlULongSub로 userChunkSize - 0xC의 언더플로우를 검사한다.
  • RtlSizeTAdd로 복사 길이와 커널 버퍼 오프셋의 덧셈을 검사한다.
  • RtlSizeTAlignUp으로 정렬 과정의 오버플로우를 검사한다.
  • RtlSizeTSub로 남은 버퍼 크기에서 정렬된 크기를 뺄 때의 오류를 검사한다.

모든 연산이 성공한 뒤에야 memmove가 수행된다. 기존 코드에서 오버플로우가 발생할 수 있었던 뺄셈, 덧셈, 정렬, 뺄셈의 각 단계를 분리해 검사하는 방식이다.

참고