
Exploit de prova de conceito para CVE-2023-21768, uma vulnerabilidade de escrita arbitrária no kernel do Driver de Função Auxiliar do Windows (AFD.sys) que possibilita escalonamento de privilégio local via I/O ring.
De acordo com a descrição detalhada da CVE-2023-21768 publicada pelo Microsoft Security Response Center (MSRC), a vulnerabilidade existe no Ancillary Function Driver (AFD), cujo nome de arquivo no sistema é afd.sys. O módulo AFD é o ponto de entrada no kernel da WinSock API. Nesta análise, usarei-a para explorar a elevação de privilégio no Windows 11.
Baixe duas versões do afd.sys do Winbindex, uma versão mais recente antes do patch e uma versão após o patch. Em seguida, use o Bindiff para comparar essas duas versões.

Comparando as duas versões de forma geral, vemos que apenas uma função tem diferença: AfdNotifyRemoveIoCompletion. Veja mais detalhes sobre as diferenças desta função entre as duas versões.

Não há muitas diferenças entre as duas versões. Na versão pós-patch, há instruções assembly adicionais para definir parâmetros e chamar a função ProbeForWrite. De acordo com a documentação da Microsoft, esta função é usada para verificar se um endereço realmente pertence ao modo de usuário, tem permissão de escrita e está alinhado corretamente. Análise mais detalhada deste código:
pré-patch afd.sys version 10.0.22621.608

pós-patch afd.sys version 10.0.22621.1105

Ambas verificam o valor de r15_1; se for 0, escrevem o valor de var_304 no ponteiro especificado em um campo de struct_1. Se for diferente de 0, ProbeForWrite é chamado para garantir que o ponteiro aponte para um endereço válido. Na versão pré-patch, o valor de var_304 é escrito no ponteiro após essa verificação, que está ausente. A partir deste patch, podemos inferir que podemos chamar este código com o valor de arg3_1->field_18 sob controle. Se for possível definir um valor de endereço de kernel em field_18, podemos escrever var_304 em um endereço de memória do kernel.
=> tipo de bug: escrita arbitrária no kernel (Write-Where)
Agora precisamos encontrar uma forma de acionar o bug. A função AfdNotifyRemoveIoCompletion é chamada diretamente na função AfdNotifySock.

Da mesma forma, ao procurar a referência cruzada de AfdNotifySock, vemos que ela não é chamada diretamente por nenhuma outra função, mas o endereço da função está armazenado em um endereço no .rdata

Este endereço está imediatamente antes de AfdIrpCallDispatch.

Para acionar o bug, chamarei DeviceIoControl com IOCTL_AFD_NOTIFY_SOCK e AfdNotifySock será chamado.
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, um objeto DRIVER_OBJECT é criado no kernel, definido da seguinte forma:
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;
O último componente MajorFunction é uma matriz contendo as funções de despacho do driver para lidar com a comunicação entre kernel e modo de usuário. A função de despacho correspondente à chamada DeviceIoControl é armazenada em MajorFunction[IRP_MJ_DEVICE_CONTROL].
#define IRP_MJ_DEVICE_CONTROL 0x0e
A partir da função DriverEntry do afd.sys, podemos ver que o driver criou o objeto de dispositivo "\Device\Afd":

Atribui MajorFunction[IRP_MJ_DEVICE_CONTROL] = AfdDispatchDeviceControl, então ao chamar DeviceIoControl para se comunicar com o kernel, essa função será chamada.

No AFD, existem duas tabelas de despacho: AfdIrpCallDispatch e AfdImmediateCallDispatch.

É fácil ver que AfdDispatchDeviceIoControl calcula um índice a partir do IoControlCode e obtém o valor correspondente ao índice da tabela AfdIoctlTable para validar com o IoControlCode.

A partir da distância entre o endereço inicial de AfdImmediateCallDispatch e o endereço onde AfdNotifySock está armazenado, calculamos que o índice é 73, com código de controle 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;
}
funciona!

Como dito no início, a vulnerabilidade ocorre quando podemos passar um ponteiro não validado por meio de uma struct. Essa struct é passada diretamente do modo de usuário através de lpInBuffer do DeviceIoControl. Em seguida, é passada para AfdNotifySock como o quarto parâmetro e para AfdNotifyRemoveIoCompletion como o terceiro parâmetro.

Como ainda não sabemos o que a struct contém, deixei o IDA criar a struct automaticamente. Agora precisamos encontrar uma maneira de passar dados para essa struct e contornar as verificações necessárias para alcançar o código com falha. Começando pela função AfdNotifySock:

Primeiro, o tamanho da struct deve ser igual a 0x30 bytes.

Os valores precisam ser diferentes de zero:

Outra coisa: ao depurar, vi que ele pulava para falha na verificação do UserBuffer anterior, então ao chamar DeviceIoControl, esse valor deve ser definido como NULL. Após definir os valores acima, consegui passar pela verificação 2.

Próxima verificação a ser contornada:

ObReferenceObjectByHandle deve retornar STATUS_SUCCESS para passar por essa verificação. Ou seja, preciso passar um handle válido. Tentei pesquisar, mas não encontrei onde falava sobre como criar IoCompletionObjectType. Então segui a análise de https://securityintelligence.com/posts/patch-tuesday-exploit-wednesday-pwning-windows-ancillary-function-driver-winsock/. Usei a função NtCreateIoCompletion para criar um IoCompletionObjectType e passar para ObReferenceObjectByHandle o handle dele.
Após contornar essa verificação, o fluxo do programa entra em um loop. Nesse loop, não há nenhum ponto que faça o fluxo ir para falha, então simplesmente defini o valor em dword20 como 0x1 para sair do loop.

Depois de sair do loop, o programa chama AfdNotifyRemoveIoCompletion. Continuando a análise da função AfdNotifyRemoveIoCompletion:

Primeiro, o programa verifica outro campo da struct; ele deve ser diferente de zero. Em seguida, é multiplicado por 0x20 e usado como parâmetro para chamar a função ProbeForWrite junto com outro campo da struct. Aqui, basta usar um endereço pertencente à memória do modo de usuário com permissão de escrita e dwLen = 1. A última verificação antes de podermos acionar o bug é que o valor retornado pela chamada da função IoRemoveCompletion deve ser STATUS_SUCCESS. Após pesquisar, descobri que a função NtRemoveIoCompletion, quando chamada, invoca a função IoRemoveCompletion. De acordo com esta documentação, a função NtRemoveIoCompletion funciona como uma "chamada de espera" e termina quando pelo menos um registro de conclusão é adicionado em um Io Completion Object especificado. O registro é adicionado quando uma operação de E/S é concluída.
NtRemoveIoCompletion(
IN HANDLE IoCompletionHandle,
OUT PULONG CompletionKey,
OUT PULONG CompletionValue,
OUT PIO_STATUS_BLOCK IoStatusBlock,
IN PLARGE_INTEGER Timeout OPTIONAL );
Além disso, há um parâmetro opcional Timeout; quando o tempo limite é atingido, a função termina. No entanto, definir apenas timeout = 0 não é suficiente para a função retornar; ela retornará um código de erro de tempo limite. Podemos usar a função NtSetIoCompletion para incrementar o contador de E/S pendentes no IoCompletionObjectType em 1 e encerrar a função NtRemoveIoCompletion antes do tempo limite. Após várias tentativas, vi que o valor escrito é sempre 0x1.
Com a capacidade de escrever o valor 0x1 em um endereço no modo kernel, podemos usar esse bug para obter capacidades completas de leitura/escrita arbitrária de endereços, aproveitando o anel de E/S (um novo mecanismo de E/S introduzido pela Microsoft). Yarden Shafir escreveu uma análise muito detalhada sobre isso, que você pode ler aqui. Uma das operações que um aplicativo pode realizar é alocar todos os buffers para operações futuras de E/S e, em seguida, registrá-los com o anel de E/S. Os buffers pré-registrados são referenciados por meio do 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;
Se uma vulnerabilidade de segurança, como a mencionada neste artigo, permitir que você atualize/modifique os campos RegBuffersCount e RegBuffers, então é possível usar a API padrão do anel de E/S para ler e escrever no kernel. No entanto, usar a função NtQuerySystemInformation requer privilégio Medium IL. Para LPE a partir de Low IL, é necessário alguma forma de vazar um endereço do kernel.
Depois que IoRing->RegBuffers aponta para um fakeBuffer controlado pelo usuário, podemos usar operações normais do anel de E/S para criar leitura e escrita em qualquer endereço desejado, especificando um índice no fake para usar como buffer:
Para entender melhor, você pode ler a análise de Yarden Shafir no link acima.
Após tentar criar um objeto IO Ring e escrever usando o código POC acima, o Windows travou após chamar DeviceIOControl /_ \ então usei a abordagem de chamar diretamente as funções Nt (˘・_・˘)
ProbeForWrite