
Эксплойт с доказательством концепции для CVE-2023-21768, уязвимость произвольной записи в ядро драйвера вспомогательных функций Windows (AFD.sys), позволяющая локальное повышение привилегий через кольцо ввода-вывода.
Согласно подробному описанию CVE-2023-21768, опубликованному Microsoft Security Response Center (MSRC), уязвимость существует в Ancillary Function Driver (AFD), файл которого в системе называется afd.sys. AFD модуль является kernel entry point для WinSock API. В этом анализе я буду использовать его для повышения привилегий в Windows 11.
Скачиваем две версии afd.sys из Winbindex: одну – самую последнюю до исправления, и одну – после исправления. Затем используем Bindiff для сравнения этих версий.

Сравнивая две версии в целом, видим, что только одна функция имеет различия – AfdNotifyRemoveIoCompletion. Посмотрим подробнее на различия этой функции между двумя версиями.

Различий не так много. В версии после исправления добавлены инструкции ассемблера для установки параметров и вызова функции ProbeForWrite. Согласно документации Microsoft, эта функция проверяет, что адрес действительно принадлежит user-mode, имеет права на запись и правильно выровнен. Детальный анализ этого кода:
до исправления afd.sys version 10.0.22621.608

после исправления afd.sys version 10.0.22621.1105

Обе версии проверяют значение r15_1, если равно 0, записывают значение var_304 по указателю, заданному в поле struct_1. Если не равно 0, вызывается ProbeForWrite, чтобы убедиться, что указатель указывает на корректный адрес. В версии до исправления затем запись значения var_304 по указателю происходит без проверки. Из этого исправления можно предположить, что мы можем вызвать этот код с контролируемым значением arg3_1->field_18. Если можно установить адрес ядра в field_18, то мы сможем записать var_304 в область памяти ядра.
=> bug type: arbitrary kernel Write-Where
Теперь нужно найти способ вызвать баг. Функция AfdNotifyRemoveIoCompletion вызывается непосредственно в функции AfdNotifySock.

Аналогично, ищем перекрёстные ссылки на AfdNotifySock – она не вызывается напрямую ни из какой другой функции, но её адрес хранится по адресу в .rdata.

Этот адрес находится непосредственно перед AfdIrpCallDispatch.

Чтобы вызвать баг, я вызову DeviceIoControl с IOCTL_AFD_NOTIFY_SOCK, и будет вызвана 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 – это массив функций диспетчеризации драйвера для обработки взаимодействий между ядром и user-mode. Функция диспетчеризации, соответствующая вызову DeviceIoControl, хранится в MajorFunction[IRP_MJ_DEVICE_CONTROL].
#define IRP_MJ_DEVICE_CONTROL 0x0e
Из функции DriverEntry afd.sys видно, что драйвер создаёт объект устройства "\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;
}
it works!

Как было сказано в начале, уязвимость возникает, когда мы можем передать непроверенный указатель через структуру. Эта структура передаётся непосредственно из user-mode через lpInBuffer в DeviceIoControl. Затем она передаётся в AfdNotifySock как четвёртый параметр и в AfdNotifyRemoveIoCompletion как третий параметр.

Поскольку структура неизвестна, я позволю IDA создать её автоматически. Теперь нужно найти способ передать данные в эту структуру и обойти необходимые проверки, чтобы добраться до уязвимого кода. Начнём с функции AfdNotifySock:

Сначала размер структуры должен быть равен 0x30 байт.

Ненулевые значения:

Кроме того, при отладке я заметил, что происходит переход к сбою при предыдущей проверке UserBuffer, поэтому при вызове DeviceIoControl это значение устанавливается в NULL. После установки указанных значений я прошёл после проверки 2.

Следующая проверка, которую нужно обойти:

ObReferenceObjectByHandle должна вернуть STATUS_SUCCESS, чтобы пройти эту проверку. То есть нужно передать корректный handle. Поискав, я не нашёл, как создать IoCompletionObjectType. Поэтому я последовал анализу https://securityintelligence.com/posts/patch-tuesday-exploit-wednesday-pwning-windows-ancillary-function-driver-winsock/. Использую функцию NtCreateIoCompletion для создания IoCompletionObjectType и передаю его handle в ObReferenceObjectByHandle.
После обхода этой проверки поток программы переходит в цикл; в этом цикле нет места, где можно перейти к сбою, поэтому я просто устанавливаю значение dword20 в 0x1, чтобы выйти из цикла.

После выхода из цикла программа вызывает AfdNotifyRemoveIoCompletion. Продолжим анализ функции AfdNotifyRemoveIoCompletion:

Сначала программа проверяет другое поле структуры – оно должно быть ненулевым. Затем оно умножается на 0x20 и используется как параметр для вызова ProbeForWrite вместе с другим полем структуры. Здесь достаточно использовать адрес из user-mode с правами на запись и 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, чтобы увеличить счётчик ожидающих операций I/O в IoCompletionObjectType на 1 и завершить NtRemoveIoCompletion до истечения тайм-аута. После многократных попыток я заметил, что записываемое значение всегда равно 0x1.
Имея возможность записать значение 0x1 по адресу в kernel-mode, мы можем использовать эту ошибку для получения полного произвольного чтения/записи, используя I/O ring (новый механизм I/O, выпущенный Microsoft). 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, то можно использовать стандартный API I/O ring для чтения и записи памяти ядра. Однако для использования функции NtQuerySystemInformation требуются права Medium IL. Для повышения с Low IL нужен способ утечки адреса ядра.
После того как IoRing->RegBuffers указывает на поддельный буфер, контролируемый пользователем, мы можем использовать обычные операции I/O ring для чтения и записи в любой адрес, указав индекс в поддельном буфере в качестве буфера:
Для более подробного понимания прочитайте анализ Yarden Shafir по ссылке выше.
После попытки создать объект IO Ring и выполнить запись с помощью приведённого выше PoC-кода Windows вылетела после вызова DeviceIOControl /_ \ поэтому я использовал способ прямого вызова функций Ntfunction (˘・_・˘)
ProbeForWrite