
CVE-2023-21768에 대한 개념 증명 익스플로잇, Windows 보조 함수 드라이버(AFD.sys)의 임의 커널 쓰기 취약점으로 I/O 링을 통해 로컬 권한 상승을 가능하게 합니다.
CVE-2023-21768에 대한 Microsoft Security Response Center(MSRC)가 공개한 상세 설명에 따르면, 취약점은 Ancillary Function Driver (AFD) (시스템 파일 이름 afd.sys)에 존재합니다. AFD 모듈은 WinSock API의 커널 진입점입니다. 이 분석에서는 이를 사용하여 Windows 11에서 권한 상승을 악용할 것입니다.
afd.sys의 두 버전을 Winbindex에서 다운로드합니다. 하나는 패치 이전의 최신 버전이고, 다른 하나는 패치 이후 버전입니다. 그런 다음 Bindiff를 사용하여 두 버전을 비교합니다.

두 버전을 전체적으로 비교하면, 오직 하나의 함수 AfdNotifyRemoveIoCompletion에서만 차이가 있음을 알 수 있습니다. 이 함수의 두 버전 간 차이를 자세히 살펴봅니다.

두 버전 간에 큰 차이는 없습니다. 패치 후 버전에서는 매개변수를 설정하고 ProbeForWrite 함수를 호출하는 어셈블리 명령어가 추가되었습니다. Microsoft 문서에 따르면 이 함수는 주소가 실제로 사용자 모드에 속하는지, 쓰기 권한이 있는지, 올바르게 정렬되었는지 확인하는 데 사용됩니다. 이 코드를 더 자세히 분석합니다:
패치 전 afd.sys 버전 10.0.22621.608

패치 후 afd.sys 버전 10.0.22621.1105

둘 다 r15_1의 값을 확인하며, 0이면 var_304의 값을 struct_1의 특정 필드에 지정된 포인터에 씁니다. 0이 아니면 ProbeForWrite가 호출되어 포인터가 유효한 주소를 가리키는지 확인합니다. 패치 전 버전에서는 그런 다음 var_304 값을 포인터에 씁니다. 이 검사가 누락되어 있습니다. 이 패치로부터 우리는 제어 가능한 arg3_1->field_18 값을 사용하여 이 코드를 호출할 수 있다고 추측할 수 있습니다. 만약 field_18에 커널 주소 값을 설정할 수 있다면 var_304를 해당 커널 메모리 주소에 쓸 수 있습니다.
=> 버그 유형: 임의의 커널 Write-Where
이제 버그를 트리거할 방법을 찾아야 합니다. AfdNotifyRemoveIoCompletion 함수는 AfdNotifySock 함수 내에서 직접 호출됩니다.

마찬가지로 AfdNotifySock의 교차 참조를 찾으면 다른 함수에서 직접 호출되지 않지만 함수 주소는 .rdata의 한 주소에 저장됩니다.

이 주소는 AfdIrpCallDispatch 바로 앞에 위치합니다.

버그를 트리거하기 위해 IOCTL_AFD_NOTIFY_SOCK과 함께 DeviceIoControl을 호출하면 AfdNotifySock이 호출됩니다.
BOOL DeviceIoControl(
[in] HANDLE hDevice,
[in] DWORD dwIoControlCode,
[in, optional] LPVOID lpInBuffer,
[in] DWORD nInBufferSize,
[out, optional] LPVOID lpOutBuffer,
[in] DWORD nOutBufferSize,
[out, optional] LPDWORD lpBytesReturned,
[in, out, optional] LPOVERLAPPED lpOverlapped
);
각 드라이버에 대해 커널에 DRIVER_OBJECT 객체가 생성되며, 다음과 같이 정의됩니다:
typedef struct _DRIVER_OBJECT {
CSHORT Type;
CSHORT Size;
PDEVICE_OBJECT DeviceObject;
ULONG Flags;
PVOID DriverStart;
ULONG DriverSize;
PVOID DriverSection;
PDRIVER_EXTENSION DriverExtension;
UNICODE_STRING DriverName;
PUNICODE_STRING HardwareDatabase;
PFAST_IO_DISPATCH FastIoDispatch;
PDRIVER_INITIALIZE DriverInit;
PDRIVER_STARTIO DriverStartIo;
PDRIVER_UNLOAD DriverUnload;
PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];
} DRIVER_OBJECT, *PDRIVER_OBJECT;
마지막 멤버 MajorFunction은 커널과 사용자 모드 간의 통신을 처리하는 드라이버의 디스패치 함수 배열입니다. DeviceIoControl 호출에 해당하는 디스패치 함수는 MajorFunction[IRP_MJ_DEVICE_CONTROL]에 저장됩니다.
#define IRP_MJ_DEVICE_CONTROL 0x0e
afd.sys의 DriverEntry 함수에서 드라이버가 "\Device\Afd" 디바이스 객체를 생성한 것을 볼 수 있습니다:

MajorFunction[IRP_MJ_DEVICE_CONTROL] = AfdDispatchDeviceControl로 설정되므로, DeviceIoControl을 호출하여 커널과 통신할 때 이 함수가 호출됩니다.

AFD에는 AfdIrpCallDispatch와 AfdImmediateCallDispatch라는 두 개의 디스패치 테이블이 있습니다.

AfdDispatchDeviceIoControl이 IoControlCode를 통해 첨자를 계산하고 AfdIoctlTable에서 첨자에 해당하는 값을 가져와 IoControlCode로 확인하는 것을 쉽게 알 수 있습니다.

AfdImmediateCallDispatch의 시작 주소와 AfdNotifySock이 저장된 주소 사이의 거리로부터 인덱스가 73이고 컨트롤 코드가 0x12127임을 계산할 수 있습니다.

int main() {
WSADATA WSAData;
SOCKET s;
SOCKADDR_IN sa;
int ierr;
WSAStartup(0x2, &WSAData);
s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
memset(&sa, 0, sizeof(sa));
sa.sin_port = htons(135);
sa.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
sa.sin_family = AF_INET;
ierr = connect(s, (const struct sockaddr*)&sa, sizeof(sa));
char outBuf[100];
DWORD bytesRet;
DWORD inbuf1[100];
memset(inbuf1, 0, sizeof(inbuf1));
DeviceIoControl((HANDLE)s, 0x12127, (LPVOID)inbuf1, 0x30, outBuf, 0, &bytesRet, NULL);
return 0;
}
작동합니다!

처음부터 말했듯이, 취약점은 struct를 통해 검증되지 않은 포인터를 전달할 때 발생합니다. 이 struct는 DeviceIoControl의 lpInBuffer를 통해 사용자 모드에서 직접 전달됩니다. 그런 다음 네 번째 매개변수에 해당하는 AfdNotifySock에 전달되고, 세 번째 매개변수에 해당하는 AfdNotifyRemoveIoCompletion에 전달됩니다.

struct의 구성을 아직 모르기 때문에 IDA가 struct를 자동으로 생성하도록 했습니다. 이제 struct에 데이터를 전달하고 필요한 검사를 우회하여 취약한 코드에 도달하는 방법을 찾아야 합니다. AfdNotifySock 함수부터 시작합니다:

첫째, struct의 크기는 0x30바이트여야 합니다.

0이 아니어야 하는 값들:

또 한 가지는 디버그할 때 이전 UserBuffer 검사에서 실패로 점프하는 것을 확인했기 때문에 DeviceIoControl을 호출할 때 이 값을 NULL로 설정했습니다. 위의 값들을 설정한 후 check2를 통과했습니다.

우회해야 할 다음 검사:

ObReferenceObjectByHandle이 STATUS_SUCCESS를 반환해야 이 검사를 통과할 수 있습니다. 즉, 유효한 핸들을 전달해야 합니다. IoCompletionObjectType을 생성하는 방법에 대한 정보를 찾을 수 없었습니다. 그래서 다음 분석을 따라 진행했습니다: https://securityintelligence.com/posts/patch-tuesday-exploit-wednesday-pwning-windows-ancillary-function-driver-winsock/. NtCreateIoCompletion 함수를 사용하여 IoCompletionObjectType을 생성하고 그 핸들을 ObReferenceObjectByHandle에 전달했습니다.
이 검사를 우회한 후 프로그램 흐름은 루프로 진입합니다. 이 루프 내에서는 실패 흐름으로 전환되는 지점이 없으므로 간단히 dword20의 값을 0x1로 설정하여 루프를 빠져나옵니다.

루프를 빠져나오면 프로그램이 AfdNotifyRemoveIoCompletion을 호출합니다. AfdNotifyRemoveIoCompletion 함수를 계속 분석합니다:

먼저 프로그램은 struct의 다른 필드를 확인합니다. 이 필드는 0이 아니어야 합니다. 그런 다음 0x20을 곱한 후, struct의 다른 필드와 함께 ProbeForWrite 함수의 매개변수로 사용됩니다. 여기서는 쓰기 권한이 있는 사용자 모드 메모리 영역의 주소와 dwLen = 1만 있으면 됩니다. 버그를 트리거하기 전 마지막 검사는 IoRemoveCompletion 함수 호출 시 반환 값이 STATUS_SUCCESS여야 한다는 것입니다. 검색 결과 NtRemoveIoCompletion 함수가 호출되면 IoRemoveCompletion 함수가 호출된다는 것을 알게 되었습니다. 이 문서에 따르면 NtRemoveIoCompletion 함수는 "대기 호출"로서, 지정된 Io Completion Object에서 최소한 하나의 완료 레코드가 있을 때 종료됩니다. 레코드는 I/O 작업이 완료될 때 추가됩니다.
NtRemoveIoCompletion(
IN HANDLE IoCompletionHandle,
OUT PULONG CompletionKey,
OUT PULONG CompletionValue,
OUT PIO_STATUS_BLOCK IoStatusBlock,
IN PLARGE_INTEGER Timeout OPTIONAL );
또한 선택적 매개변수 Timeout이 있으며, 시간 초과 값에 도달하면 함수가 종료됩니다. 그러나 timeout = 0만 설정하는 것으로는 함수가 반환되기에 충분하지 않으며, 시간 초과 오류 코드를 반환합니다. NtSetIoCompletion 함수를 사용하여 IoCompletionObjectType에서 보류 중인 I/O 카운터를 1 증가시키고 시간 초과 전에 NtRemoveIoCompletion 함수를 종료할 수 있습니다. 여러 번 시도한 결과 기록되는 값은 항상 0x1임을 확인했습니다.
커널 모드 주소에 0x1 값을 쓸 수 있게 됨으로써, I/O ring(Microsoft가 새로 도입한 I/O 메커니즘)을 활용하여 전체 읽기/쓰기 임의 주소 기능을 얻을 수 있습니다. Yarden Shafir가 이 방법에 대해 매우 자세한 분석을 작성했으며, 여기에서 읽을 수 있습니다. 애플리케이션이 수행할 수 있는 작업 중 하나는 향후 I/O 작업을 위한 모든 버퍼를 할당한 다음 I/O ring에 등록하는 것입니다. 사전 등록된 버퍼는 I/O 객체를 통해 참조됩니다:
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;
이 글에서 다루는 취약점과 같은 보안 취약점이 RegBuffersCount 및 RegBuffers 필드를 업데이트/수정할 수 있게 한다면, 표준 I/O ring API를 사용하여 커널 메모리를 읽고 쓸 수 있습니다. 그러나 NtQuerySystemInformation 함수를 사용하려면 Medium IL 권한이 필요합니다. Low IL에서 LPE를 하려면 커널 주소를 유출할 방법이 필요합니다.
IoRing->RegBuffers가 사용자 제어 하에 있는 fakeBuffer를 가리키면, 표준 I/O ring 작업을 사용하여 fake의 인덱스를 버퍼로 지정함으로써 원하는 모든 주소에 대한 읽기 및 쓰기를 생성할 수 있습니다:
자세한 내용은 위 링크에 있는 Yarden Shafir의 분석을 읽을 수 있습니다.
위의 POC 코드로 IO Ring 객체를 생성하고 쓰기를 시도한 후 DeviceIOControl 호출 시 Windows가 충돌했습니다 /_ \ 그래서 Nt 함수를 직접 호출하는 방법을 사용했습니다 (˘・_・˘)
ProbeForWrite를 호출하는 코드를 추가했습니다.