Analyzing CVE-2023-21768: the LPE Vulnerability in Windows 11

이번 글에서는 Windows 11에서 동작하는 LPE취약점인 CVE-2023-21768에 대해 설명합니다.

[TOC]

1. CVE-2023-21768

취약점은 Winsock처리 드라이버인 afd.sys에서 발생합니다. 이는 afd.sys가 I/O Control (IOCTL) 요청을 처리하는 과정에서의 보안 검사 결함으로 인해 발생하게 되는데요. 공격자는 특별히 제작된 IOCTL 요청을 afd 드라이버로 보내어 이 취약점을 악용할 수 있으며, 이를 통해 상승된 권한으로 임의의 코드를 실행할 수 있습니다.

취약점의 영향을 받는 버전은 Windows 11H2, AFD.sys 10.0.22621.608 이하이며, POC는 여기서 확인하실 수 있습니다.

2. Concepts

이 섹션에서는 취약점을 이해하기 위해 필요한 개념인 I/O Ring 과 AFD.SYS 에 대해 설명합니다

A. I/O Ring

간단히 말하면 I/O ring은 Windows의 새로운 I/O 비동기 메커니즘입니다. I/O Ring은 여러 개의 I/O 작업을 I/O Ring 큐에 집어넣은 후 한꺼번에 커널 쪽에 전달하는 방식을 사용합니다.

Windows 비동기 I/O 작업이라고 하면 다들 I/O Ring이 아닌 전통적인 Overlapped I/O 방식을 떠올리실 것 같습니다. 하지만 Overlapped I/O 방식은 I/O의 Read/Write 작업이 발생할 때마다 필수적으로 커널 모드전환이 필요합니다. 이는 많은 오버헤드를 발생시킵니다.

반면에 I/O Ring의 경우 I/O 작업에 대한 호출들을 일괄 전달함으로써 단 한 번의 커널 전환만 하기 때문에 CPU 사용을 효율적으로 할 수 있습니다.

(1) I/O Ring 기본

아직 Windows I/O Ring은 모든 동작을 지원하지 않습니다. 현재까지 Windows 11 22H2에서 가능한 동작은 read, write, flush, cancle 등이 있습니다.

요청된 동작은 Submission Queue에 작성되고, 커널로 제출되어집니다. 이후 커널은 요청을 수행하고 Completion Queue에 결과 상태 코드를 작성합니다.

앞서 언급한 작업(read, write, flush ..) 외에도, 추가적으로 두 가지 타입의 작업을 큐에 넣을 수 있습니다. 바로 Preregister buffers와 Preregister files입니다. 이 옵션들을 통해 애플리케이션은 미리 모든 파일 핸들을 열거나 모든 버퍼를 생성하고, 이들을 등록한 후에 I/O Ring을 통해 큐에 넣은 I/O 작업에서 인덱스로 이들을 참조할 수 있게 됩니다. 커널이 Preregister Buffers 혹은 Preregister files을 사용하는 항목을 처리할 때, 요청된 handle/buffer를 사전등록된 배열에서 가져와서 I/O 매니저에 전달게 됩니다. 이후에는 일반 I/O Ring 동작과 동일하게 처리됩니다.

작업들이 큐잉된 Submission Queue의 예시 모습은 다음과 같습니다.

Untitled

(2) The Role of I/O Ring in CVE-2023-21768

CVE-2023-21768을 이해하기 위한 I/O Ring의 역할을 설명하겠습니다.

앞서 언급했듯이, I/O Ring을 사용하는 어플리케이션은 추후 I/O 작업에서 사용될 버퍼나 핸들들을 미리 등록해 놓을 수 있습니다. 이 Preregistered Buffers는 I/O Ring Object를 통해 참조됩니다.

I/O Ring Object의 구조체는 아래와 같습니다.

typedef struct _IORING_OBJECT
{
    USHORT Type;
    USHORT Size;
    NT_IORING_INFO UserInfo;
    PVOID Section;
    PNT_IORING_SUBMISSION_QUEUE SubmissionQueue;
    PMDL CompletionQueueMdl;
    PNT_IORING_COMPLETION_QUEUE CompletionQueue;
    ULONG64 ViewSize;
    ULONG InSubmit;
    ULONG64 CompletionLock;
    ULONG64 SubmitCount;
    ULONG64 CompletionCount;
    ULONG64 CompletionWaitUntil;
    KEVENT CompletionEvent;
    UCHAR SignalCompletionEvent;
    PKEVENT CompletionUserEvent;
    ULONG RegBuffersCount;
    PVOID RegBuffers;
    ULONG RegFilesCount;
    PVOID* RegFiles;
	} IORING_OBJECT, *PIORING_OBJECT

만약 preregister 등록 요청이 들어오면 다음과 같은 일들이 순차적으로 일어나게 됩니다. (이해를 위해 간략하게 요약했습니다. 상세한 과정을 알고 싶으신 분들은 이 글을 참고해 주세요!)

IoRing→RegBuffers와 IoRing→RegBuffersCount가 0으로 설정됩니다.

요청이 유저모드에서 온 경우, 배열의 주소가 유저모드 공간에 위치해 있는지 검증됩니다.

검증이 완료되면 커널은 페이징풀 메모리를 할당하고, 이 메모리에 사용자 모드 배열로부터 데이터를 복사합니다. 이 메모리 주소는 IoRing→RegBuffers가 가리키게 됩니다.

이렇게 하여 데이터가 오직 유저 모드로부터 왔는지 확인함으로써, 우연히 커널 영역에 read, write, overflow가 발생하는 것을 방지합니다.

그러나, arbitrary kernel write 버그를 사용할 수 있다면 어떨까요?

IoRing→RegBuffers 값을 임의로 덮어쓰기 하여, 완전히 제어할 수 있는 가짜 메모리 주소를 가리키도록 한다면 커널 주소 공간을 완전히 제어할 수 있게 됩니다. 커널은 한 번 등록된 register buffer 배열은 안전하다고 생각하고 주소 영역에 대한 추가 검사는 하지 않기 때문입니다.

즉, arbitrary kernel write bug를 활용하여 full arbitray kernel read/write 버그를 사용할 수 있는 겁니다!

(3) I/O Ring API Usage

아래는 I/O Ring을 사용하여 파일을 읽는 간단한 예제 코드입니다. POC코드를 이해하는 데에 있어서 I/O Ring API에 대한 간략한 이해가 필요하기 때문에, 각 함수에 대해 간단히 설명하고 넘어가겠습니다.

HRESULT result;
IORING_CREATE_FLAGS flags;
HIORING handle = NULL;
HANDLE hFile = NULL;
PVOID* buffer = NULL;

flags.Required = IORING_CREATE_REQUIRED_FLAGS_NONE;
flags.Advisory = IORING_CREATE_ADVISORY_FLAGS_NONE;
result = CreateIoRing(IORING_VERSION_3, flags, 0x1, 0x1, &handle);

if (!SUCCEEDED(result)) {
	printf("Failed creating IO ring handle : 0x%x\n", result);
	return 0;
}

do {
	hFile = CreateFile(L"C:\\WINDOWS\\SYSTEM32\\notepad.exe",
					GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);

	if (hFile == INVALID_HANDLE_VALUE) {
		printf("Failed opening file handle : 0x%x\n", GetLastError());
		break;
	}

	IORING_HANDLE_REF reqFile = IoRingHandleRefFromHandle(hFile);
	reqFile.Kind = IORING_REF_RAW;

	buffer = (PVOID*)VirtualAlloc(NULL, 0x200, MEM_COMMIT, PAGE_READWRITE);
	if (buffer == NULL) {
		printf("Failed to allocate memory\n");
		break;
	}

	IORING_BUFFER_REF reqBuffer = IoRingBufferRefFromPointer(buffer);
	reqBuffer.Kind = IORING_REF_RAW;

	result = BuildIoRingReadFile(handle, reqFile, reqBuffer, 0x200, 0, NULL, IOSQE_FLAGS_NONE);

	if (!SUCCEEDED(result)) {
		printf("Failed building IO ring read file structure : 0x%x\n", result);
		break;
	}

	result = SubmitIoRing(handle, 0, 0, NULL);

	if (!SUCCEEDED(result)) {
		printf("Failed submitting IO ring: 0x%x\n", result);
		break;
	}

	IORING_CQE cqe = { 0, };
	result = PopIoRingCompletion(handle, &cqe);
	if (!SUCCEEDED(result)) {
		break;
	}

	printf("Result code : %x \n", result);

	printf("Data from file:\n");
	ULONG64 endOfBuffer;
	endOfBuffer = (ULONG64)buffer + 0x200;
	for (; (ULONG64)buffer < endOfBuffer; buffer++) {
		printf("%p ", *buffer);
	}
	printf("\n");
} while (false);

if (handle != 0) {
	CloseIoRing(handle);
}

if (hFile) {
	CloseHandle(hFile);
}

if (buffer) {
	VirtualFree(buffer, NULL, MEM_RELEASE);
}
  • CreateIoRing() : IoRing 객체 생성. 유저모드와 커널모드에 각각 객체가 생성됨.
  • IoRingHandleRefFromHandle : 핸들을 통해 **IORINGHANDLEREF** 구조체 생성. 이 구조체는 I/O Ring 작업에 사용되는 핸들에 대한 참조임.
  • BuildIoRingReadFile() : IoRing의 Submission Queue에 읽기 작업을 넣음. (≒ ReadFileEx)
  • SubmitIoRing() : Submission Queue의 작업들을 커널에 제출하고 완료를 기다림.

B. AFD.SYS

앞서 언급한 대로, I/O Ring을 통해 커널 주소 공간을 제어하기 위해서는 arbitrary kernel write bug가 필요합니다. 이를 위해 afd.sys 드라이버에서 발생하는 취약점을 활용하는데요, afd.sys드라이버가 뭔지 간략하게 알아보겠습니다.

AFD.SYS 드라이버란 “Ancillary Function Driver for Winsock”의 약자로, Windows 소켓 2.0에서 제공하는 TCP/IP 프로토콜 기능을 지원하기 위해 설계된 Windows 커널 모드 드라이버 입니다.

아래는 Defcon 2015 발표 자료에서 참고한, Winsock 처리 관련 모듈들이 정리된 자료입니다.

Untitled

3. Exploit Explain

취약점이 발생하는 부분은 afd.sys 의 afd!AfdNotifyRemoveIoCompletion 함수 내부입니다.

Untitled

이때, 공격자가 unkownAFDStruct의 field_18값을 제어할 수 있다면, arbitrary kernel Write-Where primitive가 활성화됩니다. writeValue의 값은 KeRemoveQueueEx 의 반환 값이며, 이 값은 인자로 전달된 Queue에서 제거된 항목의 count값 입니다. 분석에 따르면 이 값은 항상 1이 었다고 합니다. 즉, 원하는 주소의 값을 0x1로 수정할 수 있게 됩니다.

afd!AfdNotifyRemoveIoCompletion 내부로 진입하기 위해, POC코드에서는 AFD드라이버와 직접 통신하는 방법을 사용합니다. (상응하는 Windows API는 모르지만, IOCTL Code는 알기 때문입니다.)

따라서 직접 AFD 디바이스 오브젝트를 통해 handle을 얻습니다. 아래 코드를 참고해 주세요.

Untitled

핸들을 정상적으로 얻었다면 DeviceIoContorlFile() 을 통해 드라이버와 통신합니다. 이때, unknownAFDStruct 구조체의 값은 DeviceIoControlFile에서 사용되는 InputBuffer 값입니다.(7번째 인자)

Untitled

실제로 afd!AfdNotifyRemoveIoCompletion 에 breakpoint를 걸어 확인해 보겠습니다. 이 함수에서 unknownAFDStruct 값은 세 번째 파라미터로 전달됩니다.

Untitled

세 번째 파라미터 값을 확인한 후 메모리 내용을 보면 InputBuffer로 넣은 값이 보입니다.

위 코드에서 알 수 있듯이, InputBuffer에서 0x18~0x20번째 값에는 0x1로 수정할 주소값이 들어있습니다. 수정할 주소값은 I/O RING 오브젝트의 RegBuffers + 0x3 부분이므로 (I/O Ring + 0xbb) 해당 주소를 확인해 보면, 아래 그림처럼 IORING_OBJECT를 확인하실 수 있습니다.

Untitled

결론적으로, InputBuffer 내의 데이터 일부에 쓰인 주소에 해당되는 값을 afd.sys 드라이버에서 0x1로 변경하기 때문에 일어나는 취약점입니다.

따라서 이 취약점을 사용하면, 위 I/O Ring 설명 부분에서 언급했던 IoRing→RegBuffers 의 주소의 일부를 1로 수정할 수 있습니다.! 그림과 함께 좀 더 상세히 살펴보겠습니다.

아래는 I/O Ring Object를 생성했을 때 모습입니다.

Untitled

취약점을 사용하여 RegBuffers + 0x3 바이트를 0x1로 변경해줍니다.

Untitled

이후 I/O Ring Object를 확인해보면, 아래와 같이 주소값이 0x1000000으로 변하게 됩니다.

Untitled

(설명은 생략했지만 취약점을 두 번 사용해서 바로 위 필드인 RegBuffersCount 값도 변경해 주어야합니다!)

RegBuffers 의 값이 0x1000000으로 변경되었으니, 유저 모드에서 0x1000000 주소를 할당받아 사용하면 되겠죠?

이를 활용하면, SystemToken 주소값을 구할 수 있습니다.

Untitled

BuildIoRingWriteFile() 함수를 통해 두 번째 인자인 reqFilereqBuffer의 내용이 쓰이게 됩니다.

위 코드의 118번째 라인을 참고해 보면, reqBufferIoRingBufferRefFromIndexAndOffset 을 통해 Preregister 배열의 0번째 index에서 값을 참고합니다.

0번째 index에 담겨있는 값은 위 코드에서 pMcBufferEntry 구조체에 담겨있습니다.

결론적으로, BuildIoRingWriteFile() 을 통해 SytemToken 주소값 얻을 수 있는 것입니다.

이제 이 SystemToken 값을 권한을 상승시킬 Target 프로세스의 EPROCESS 객체 Token 부분에 덮어씌우면 됩니다.!

방법은 아래 그림과 같습니다.

WriteFile()을 통해 SystemToken값을 named pipe에 작성 해 준후, BuildIoRingReadFile()을 통해 named pipe에 있는 SystemToken값을 reqBuffer가 참고하는 주소에 적습니다.

poc 코드 상에서 namedpipe를 사용함에도, 변수 이름으로 reqFile을 사용한 것에 대해 혼동이 없으셨으면 합니다.

(+) named pipe가 아닌 file로 해도 괜찮습니다만, 포렌식을 더욱 어렵게 하기 위해 pipe를 사용했다고 합니다.

Untitled

최종적으로 SystemToken 값이 권한 상승 Target 프로세스의 Token 값에 덮어씌워진 걸 확인할 수 있습니다.!

Untitled

아래는 POC 영상입니다.

Untitled

4. Ref


Written by@Hyunjung
보안과 개발을 좋아하는 학생입니다~^^

GitHubTwitter