
Prueba de concepto de exploit para CVE-2023-21768, una vulnerabilidad de escritura arbitraria en el kernel del controlador de función auxiliar de Windows (AFD.sys) que permite la escalada local de privilegios mediante el anillo de E/S.
Según la descripción detallada de CVE-2023-21768 publicada por Microsoft Security Response Center (MSRC), la vulnerabilidad existe en el Ancillary Function Driver (AFD), cuyo nombre de archivo en el sistema es afd.sys. El módulo AFD es el punto de entrada del kernel de WinSock API. En este análisis, lo usaremos para explotar la escalada de privilegios en Windows 11.
Descargue dos versiones de afd.sys desde Winbindex, una versión cercana anterior al parche y una versión posterior al parche. Luego use Bindiff para comparar estas dos versiones.

Comparando las dos versiones en general, vemos que solo una función tiene diferencias: AfdNotifyRemoveIoCompletion. Vea más detalles sobre las diferencias de esta función entre las dos versiones.

No hay muchas diferencias entre las dos versiones. En la versión posterior al parche, se agregan instrucciones assembly para establecer parámetros y llamar a la función ProbeForWrite. Según la documentación de Microsoft, esta función se usa para verificar si una dirección realmente pertenece al modo usuario, tiene permiso de escritura y está alineada correctamente. Analicemos más en detalle este código:
Pre-patch afd.sys version 10.0.22621.608

Post-patch afd.sys version 10.0.22621.1105

Ambas verifican el valor de r15_1, si es 0 escriben el valor de var_304 en el puntero indicado en un campo de struct_1. Si es distinto de 0, se llama a ProbeForWrite para asegurar que el puntero apunte a una dirección válida. En la versión pre-patch, luego se escribe el valor de var_304 en el puntero, esta verificación falta. A partir de este parche, podemos suponer que podemos llamar a este código con el valor de arg3_1->field_18 controlado. Si podemos establecer un valor de dirección del kernel en field_18, entonces podemos escribir var_304 en una dirección de memoria del kernel.
=> tipo de bug: escritura-Where arbitraria en el kernel
Ahora necesitamos encontrar cómo provocar el bug. La función AfdNotifyRemoveIoCompletion se llama directamente dentro de la función AfdNotifySock.

De manera similar, al buscar la referencia cruzada de AfdNotifySock, vemos que no se llama directamente desde ninguna otra función, pero la dirección de la función se almacena en una dirección en .rdata.

Esta dirección se encuentra justo antes de AfdIrpCallDispatch.

Para provocar el bug, llamaré a DeviceIoControl con IOCTL_AFD_NOTIFY_SOCK y se llamará a 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
);
Para cada driver, se crea un objeto DRIVER_OBJECT en el kernel, definido así:
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;
El último componente, MajorFunction, es un array que contiene las funciones de dispatch del driver para manejar la comunicación entre kernel y modo usuario. La función dispatch correspondiente a la llamada a DeviceIoControl se almacena en MajorFunction[IRP_MJ_DEVICE_CONTROL].
#define IRP_MJ_DEVICE_CONTROL 0x0e
Desde la función DriverEntry de afd.sys, podemos ver que el driver creó el objeto de dispositivo "\Device\Afd":

Asigna MajorFunction[IRP_MJ_DEVICE_CONTROL] = AfdDispatchDeviceControl, por lo que al llamar a DeviceIoControl para comunicarse con el kernel, se llamará a esta función.

En AFD hay dos tablas de dispatch: AfdIrpCallDispatch y AfdImmediateCallDispatch.

Se puede ver fácilmente que AfdDispatchDeviceIoControl calcula un subíndice mediante IoControlCode y obtiene el valor correspondiente al subíndice de AfdIoctlTable para verificarlo con IoControlCode.

Desde la distancia entre la dirección de inicio de AfdImmediateCallDispatch y la dirección donde se almacena AfdNotifySock, calculamos el índice como 73, con el código de control 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!

Como se dijo al principio, la vulnerabilidad ocurre cuando podemos pasar un puntero no validado a través de una estructura. Esta estructura se pasa directamente desde el modo usuario mediante lpInBuffer de DeviceIoControl. Luego se pasa a AfdNotifySock como el cuarto parámetro y se pasa a AfdNotifyRemoveIoCompletion como el tercer parámetro.

Como aún no sé qué contiene la estructura, dejo que IDA cree la estructura automáticamente. Ahora necesito encontrar cómo pasar datos a esta estructura y omitir las verificaciones necesarias para llegar al código con el error. Empezando desde la función AfdNotifySock:

Primero, el tamaño de la estructura debe ser de 0x30 bytes.

Los valores deben ser diferentes de 0:

Otra cosa es que al depurar vi que saltaba a fallo en la verificación de UserBuffer anterior, por lo que al llamar a DeviceIoControl este valor se establece en NULL. Después de establecer los valores anteriores, pasé la verificación check2.

Siguiente verificación a omitir:

ObReferenceObjectByHandle debe devolver STATUS_SUCCESS para pasar esta verificación. Es decir, debo pasar un handle válido. Buscando, no encontré ninguna parte que hable sobre cómo crear IoCompletionObjectType. Así que seguí el análisis de https://securityintelligence.com/posts/patch-tuesday-exploit-wednesday-pwning-windows-ancillary-function-driver-winsock/. Usando la función NtCreateIoCompletion para crear un IoCompletionObjectType y pasar su handle a ObReferenceObjectByHandle.
Después de omitir esta verificación, el flujo del programa entra en un bucle, en el cual no hay ninguna parte que cause un fallo, así que simplemente establezco el valor en dword20 como 0x1 para salir del bucle.

Después de salir del bucle, el programa llama a AfdNotifyRemoveIoCompletion. Continuamos analizando la función AfdNotifyRemoveIoCompletion:

Primero, el programa verifica otro campo de la estructura, debe ser diferente de 0. Luego se multiplica por 0x20, y se usa como parámetro para llamar a ProbeForWrite junto con otro campo de la estructura. Aquí solo necesito usar una dirección en la memoria de modo usuario con permiso de escritura y dwLen = 1. La última verificación antes de poder provocar el error es que el valor de retorno de la llamada a la función IoRemoveCompletion debe ser STATUS_SUCCESS. Después de buscar, supe que la función NtRemoveIoCompletion, al ser llamada, invoca a IoRemoveCompletion. Según esta documentación, la función NtRemoveIoCompletion actúa como una "llamada de espera" y finaliza cuando hay al menos un registro completado en un objeto Io Completion especificado. El registro se agrega cuando se completa el proceso de E/S.
NtRemoveIoCompletion(
IN HANDLE IoCompletionHandle,
OUT PULONG CompletionKey,
OUT PULONG CompletionValue,
OUT PIO_STATUS_BLOCK IoStatusBlock,
IN PLARGE_INTEGER Timeout OPTIONAL );
Además, hay otro parámetro opcional, Timeout, cuando se alcanza el valor de tiempo de espera, la función finaliza. Sin embargo, solo establecer timeout = 0 no es suficiente para que la función retorne, sino que devuelve un código de error de tiempo de espera. Podemos usar la función NtSetIoCompletion para incrementar el contador de E/S pendientes en IoCompletionObjectType en 1 y finalizar la función NtRemoveIoCompletion antes del tiempo de espera. Después de varios intentos, vi que el valor escrito siempre es 0x1.
Con la capacidad de escribir el valor 0x1 en una dirección del kernel, podemos usar este error para tener capacidad completa de lectura/escritura arbitraria de direcciones aprovechando I/O ring (un nuevo mecanismo de E/S lanzado por Microsoft). Yarden Shafir escribió un análisis muy detallado sobre este método, puedes leerlo aquí. Una de las operaciones que una aplicación puede realizar es asignar todos los búferes para sus futuras operaciones de E/S y luego registrarlos con I/O ring. Los búferes previamente registrados se referencian a través del objeto 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;
Si una vulnerabilidad, como la mencionada en este artículo, permite actualizar/modificar los campos RegBuffersCount y RegBuffers, entonces se puede usar la API estándar de I/O ring para leer y escribir en el kernel. Sin embargo, el uso de la función NtQuerySystemInformation requiere un privilegio Medium IL. Para LPE desde Low IL, se necesita alguna forma de filtrar una dirección del kernel.
Después de que IoRing->RegBuffers apunte a un fakeBuffer controlado por el usuario, podemos usar las operaciones normales de I/O ring para crear lecturas y escrituras en cualquier dirección que deseemos especificando un índice en el fake para usarlo como buffer:
Para entender mejor, puedes leer el análisis de Yarden Shafir en el enlace anterior.
Después de intentar crear un objeto IO Ring y escribir usando el código POC anterior, Windows se bloqueó después de llamar a DeviceIOControl /_ \ así que usé el método de llamar directamente a las funciones Ntfunction (˘・_・˘)
ProbeForWrite