No mês passado, a Microsoft corrigiu uma vulnerabilidade no Microsoft Kernel Streaming Server, um componente do kernel do Windows usado na virtualização e compartilhamento de dispositivos de câmera. A vulnerabilidade, CVE-2023-36802, permite que um invasor local escale privilégios para o SYSTEM.
Este post de blog detalha meu processo de explorar uma nova superfície de ataque no kernel do Windows, encontrar uma vulnerabilidade de dia 0, explorar uma classe de bug interessante e criar uma exploração estável. Esta publicação não requer nenhum conhecimento especializado do kernel do Windows para acompanhar, embora seja útil ter um entendimento básico de corrupção de memória e conceitos de sistema operacional. Também abordarei noções básicas da realização de análise inicial em um kernel desconhecido e simplificarei o processo de análise de um novo alvo.
O Microsoft Kernel Streaming Server (mskssrv.sys) é um componente de um serviço do Windows Multimedia Framework, o Frame Server. O serviço virtualiza o dispositivo de câmera e permite que ele seja compartilhado entre várias aplicações.
Comecei a explorar essa superfície de ataque depois de notar CVE-2023-29360, que foi inicialmente listada como uma vulnerabilidade do driver. Na verdade, o bug está no Microsoft Kernel Streaming Server. Embora na época eu não estivesse familiarizado com o MS KS Server, o nome dessa motivação foi suficiente para manter meu interesse. Apesar de não saber nada sobre o propósito ou a funcionalidade, achei que um servidor de streaming no kernel poderia ser um lugar frutífero para procurar vulnerabilidades. Partindo do zero, busquei responder às seguintes perguntas:
Para responder à primeira pergunta, comecei analisando o binário em um desassemblador. Identifiquei rapidamente a vulnerabilidade mencionada anteriormente — um bug lógico simples e elegante. A falha parecia fácil de acionar e totalmente explorável, então desenvolvi uma proof-of-concept rápida para compreender melhor o funcionamento interno do driver mskssrv.sys.
Boletim informativo do setor
Mantenha-se atualizado sobre as tendências mais importantes (e intrigantes) do setor em IA, automação, dados e muito mais com o boletim informativo Think. Consulte a Declaração de privacidade da IBM.
Sua assinatura será entregue em inglês. Você pode encontrar um link para cancelar a assinatura em todos os boletins informativos. Você pode gerenciar suas inscrições ou cancelar a inscrição aqui. Consulte nossa Declaração de privacidade da IBM para obter mais informações.
Primeiro, precisamos acessar o driver a partir de uma aplicação de espaço do usuário. A função vulnerável pode ser acessada a partir da rotina DispatchDeviceControl do driver, o que significa que ela pode ser acessada emitindo um IOCTL para o driver. Para fazer isso, um identificador para o dispositivo do driver precisa ser obtido por meio de uma chamada para CreateFile usando o caminho do dispositivo. Normalmente, encontrar o nome/caminho do driver é simples de identificar: encontre uma chamada para IoCreateDevice no driver e examine o terceiro parâmetro que contém o nome do driver.
Função dentro do mskssrv.sys que chama IoCreateDevice com um ponteiro NULL para o nome do dispositivo
Neste caso, o parâmetro para o nome do dispositivo é NULL. O nome da função de chamada sugere que mskssrv é um driver PnP e a chamada para IoAttachDeviceToDeviceStack indica que o objeto de dispositivo criado faz parte de uma pilha de dispositivos. Na verdade, isso significa que vários drivers são chamados quando uma solicitação de E/S é enviada a um dispositivo. Para dispositivos PnP, o caminho da interface do dispositivo é necessário para acessá-lo.
Usando o WinDbg kernel debugger, podemos ver quais dispositivos pertencem ao driver mskssrv e à stack de dispositivos:
Saída dos comandos !drvobj e !devobj mostrando os dispositivos superior e inferior
Acima, vemos que o dispositivo do mskssrv está anexado ao objeto do dispositivo inferior pertencente ao swenum.sys e tem um dispositivo superior anexado pertencente ao ksthunk.sys.
No Gerenciador de dispositivos, podemos encontrar o ID da instância do dispositivo de destino:
Gerenciador de dispositivos mostrando a ID de instância do dispositivo e o GUID da interface
Agora temos informações suficientes para obter o caminho da interface do dispositivo usando o gerenciador de configuração ou as funções SetupApi. Usando o caminho da interface do dispositivo recuperado, podemos abrir um identificador para o dispositivo.
Por fim, agora podemos acionar a execução de código dentro do mskssrv.sys. Quando o dispositivo é criado, a função dispatch create de PnP do driver é chamada. Para acionar a execução adicional de código, podemos enviar IOCTLs para comunicar com o dispositivo, que será executado na função dispatch device control do driver.
Ao realizar análise de binários, a melhor prática é usar uma combinação de ferramentas estáticas (disassemblador, descompilador) e dinâmicas (depurador). WinDbg pode ser usado para depurar o kernel do driver. É possível definir alguns breakpoints em pontos onde se espera que a execução de código ocorra (como dispatch create e dispatch device control).
No início, tive algumas dificuldades — nenhum dos breakpoints que eu configurava dentro do driver estava sendo acionado. Passei a suspeitar que eu poderia estar abrindo o dispositivo errado ou fazendo algo incorreto. Mais tarde percebi que meus breakpoints estavam sendo removidos porque o driver estava sendo descarregado. Procurei respostas na internet, mas há poucos resultados ao pesquisar por mskssrv, apesar de ele ser carregado e acessível por padrão no Windows. Entre os poucos resultados encontrados, havia uma discussão no OSR, onde outra pessoa havia enfrentado um problema semelhante.
Acontece que os filtros de motivação PnP podem ser descarregados se não tiverem sido usados por um tempo e carregados novamente sob demanda, quando necessário.
Resolvi os problemas que estava tendo definindo pontos de interrupção depois que um identificador para o dispositivo foi aberto, mas antes de chamar o DeviceIoControl, para garantir que o driver tenha sido carregado recentemente.
O driver mskssrv é um binário de apenas 72 KB e é compatível com códigos de controle de E/S de dispositivo que chamam as seguintes funções:
Ao olhar para esses nomes de símbolos, podemos inferir alguma funcionalidade do driver, algo relacionado à transmissão e recebimento de fluxos. Neste ponto, aprofundei-me na funcionalidade pretendida do driver. Encontrei esta apresentação de Michael Maltsev sobre o framework do Windows, onde percebi que o driver faz parte de um mecanismo entre processos para compartilhar streamings de câmera.
Como o driver não é muito grande e não há muitos IOCTLs, eu poderia analisar cada função para ter uma ideia do funcionamento interno do driver. Cada função IOCTL opera em um objeto de registro de contexto ou em um objeto de registro de fluxo, que é alocado e inicializado por meio de seus IOCTLs de "inicialização" correspondentes. O ponteiro para o objeto é armazenado Irp->CurrentStackLocation->FileObject->FsContext2. FileObject aponta para o objeto de arquivo do dispositivo criado para cada arquivo aberto, e FsContext2 é um campo destinado a armazenar metadados por objeto de arquivo.
Detectei esse bug enquanto tentava entender como se comunicar diretamente com o driver, primeiro evitando a análise dos componentes, fsclient.dll e frameserver.dll. Quase perdi o bug, porque presumi que os desenvolvedores instanciaram uma verificação simples que passou despercebida. Vamos dar uma olhada na função PublishRx IOCTL:
Trecho de decompilação FSrendezvousServer::PublishRx
Após o objeto de fluxo ser recuperado do FsContext2, a função FSRendezvousServer::FindObject é chamada para verificar se o ponteiro corresponde a um objeto encontrado em duas listas armazenadas pelo FSRendezvousServer global. Inicialmente, presumi que essa função teria alguma forma de verificar o tipo de objeto solicitado. No entanto, a função retorna TRUE se o ponteiro for encontrado na lista de objetos de contexto ou na lista de objetos de fluxo. Observe que nenhuma informação sobre o tipo que o objeto deveria ser é passada para FindObject. Isso significa que um objeto de contexto pode ser passado como um objeto de fluxo. Essa é uma vulnerabilidade de confusão de tipo de objeto! Ela ocorre em todas as funções IOCTL que operam em objetos de fluxo. Para corrigir a vulnerabilidade, a Microsoft substituiu FSRendezvousServer::FindObject por FSRendezvousServer::FindStreamObject, que primeiro verifica se o objeto é um objeto de fluxo, verificando um campo de tipo.
Como os objetos de registro de contexto são menores que (0x78 bytes) objetos de registro de fluxo (0x1D8 bytes), as operações de objeto de fluxo podem ser executadas em memória fora dos limites:
Ilustração de vulnerabilidade de confusão de tipo de objeto
Para aproveitar a vulnerabilidade da primitiva, precisamos da capacidade de controlar a memória fora dos limites que é acessada. Isso pode ser feito acionando a alocação de muitos objetos na mesma área de memória do objeto vulnerável. Essa técnica é chamada de pulverização de heap ou pool. O objeto vulnerável é alocado em um pool de heap de baixa fragmentação não paginada. Podemos usar a técnica clássica de Alex Ionescu para pulverizar buffers que dão controle total do conteúdo da memória abaixo de um cabeçalho DATA_QUEUE_ENTRY de 0x30 bytes. Ao aplicar essa técnica, podemos obter o layout de memória mostrado no diagrama:
Utilizando o método escolhido de pulverização de pool, os campos nos deslocamentos de objetos dentro dos intervalos 0xC0-0x10F e 0x150-0x19F podem ser controlados. Revisitei as funções IOCTL para objetos de fluxo em busca de primitivas de exploração. Procurei por locais onde os campos controláveis do objeto são acessados e manipulados.
Encontrei um bom write-where primitive com escrita constante no IOCTL PublishRx. Essa primitiva pode ser usada para escrever um valor constante lidando com memória arbitrário. Vamos dar uma olhada em um trecho da função FSStreamReg::PublishRx:
Trecho de decompilação FSStreamReg::PublishRx
O objeto de fluxo contém um cabeçalho de lista no deslocamento 0x188, que descreve uma lista de objetos FSFrameMdl. No trecho de descompilação acima, essa lista é iterada e, se o valor da tag no objeto FSFrameMdl corresponder à tag no buffer do sistema transmitido pela aplicação, a função FSFrameMdl::UnmapPages é chamada.
Usando a primitiva de exploração mencionada acima, a FSFrameMdlList e, portanto, o objeto FsFrameMdl apontado pelo pFrameMdl podem ser totalmente controlados. Vamos agora dar uma olhada em UnmapPages:
Decompilação de FSFrameMdl:UnmapPages
Na última linha da função descompilada acima, o valor constante 2 está sendo gravado em um valor de deslocamento disso (objetoFSFrameMdl) que é controlável. Essa gravação constante pode ser usada em conjunto com a técnica I/O Ring para obter leitura, gravação de kernel arbitrária e escalonamento de privilégios. Você pode ler mais sobre como essa técnica funciona aqui e aqui.
Embora eu tenha optado por utilizar a primitiva de escrita constante, outra primitiva de exploração útil também aparece nessa função. Tanto os argumentos BaseAddress e MemoryDescriptorList da chamada para MmUnmapLockedPages são controláveis. Isso poderia ser usado para desmapear um mapeamento em um endereço virtual arbitrário e construir uma primitiva semelhante a um use-after-free .
Neste ponto, foram identificadas várias primitivas de exploração adequadas que oferecem leitura-escrita do kernel. Você deve ter notado que há várias verificações no conteúdo do objeto de fluxo que devem ser passados para acionar o caminho de código desejado. Na maior parte, o estado adequado do objeto pode ser alcançado por meio da pulverização em conjunto. No entanto, encontrei um problema que causou algumas dificuldades. Abaixo mostra um trecho de código de FSStreamReg::PublishRx após a conclusão do loop através do FSFrameMdlList:
Trecho de decompilação FSStreamReg::PublishRx
Na descompilação acima, bPagesUnmapped é uma variável booleana que é definida se FSFrameMdl::UnmapPages for chamado. Em caso afirmativo, o deslocamento 0x1a8 do objeto de fluxo é recuperado e, se não for nulo, KeSetEvent é chamado nele.
Esse deslocamento corresponde à memória fora dos limites e aos pontos dentro de um POOL_HEADER, a estrutura de dados que separa as alocações de buffer no pool. Em particular, ele aponta para o campo ProcessBilled, que é usado para armazenar um ponteiro para o objeto _EPROCESS para processo que é "carregado" com a alocação. É usado para contabilizar quantas alocações de pool um processo específico pode ter. Nem todas as alocações de pool são "cobradas" em relação a um processo e aquelas que não têm o campo ProcessBilled definido como NULL no POOL_HEADER. Além disso, o ponteiro EPROCESS armazenado no ProcessBilled é, na verdade, XOR'd com um cookie aleatório, portanto, ProcessBilled não contém um ponteiro válido.
Isso apresenta uma dificuldade, pois os buffers NpFr são carregados para o processo de chamada e, portanto, ProcessBilled é definido. Ao acionar a primitiva de exploração necessária, bPagesUnmapped será definido como TRUE. Se um ponteiro inválido for passado para o KeSetEvent, o sistema falhará. Portanto, é necessário garantir que POOL_HEADER seja para uma alocação não cobrada. Neste ponto, notei que o próprio objeto de registro de contexto (Creg) não está carregado. No entanto, esse objeto não permite controle sobre o conteúdo da memória no deslocamento FSFrameMdl. Portanto, os objetos NpFr e Creg precisam ser pulverizados, mas também precisam ser sequenciados corretamente.
Diferentemente das alocações do big pool, não é possível vazar endereços de alocações do LFH pool por meio de NtQuerySystemInformation. Além disso, a ordem das alocações é aleatória. Portanto, não há como saber se os buffers adjacentes ao objeto vulnerável estão na ordem correta para, ao mesmo tempo, acionar o primitivo de exploração e evitar a queda do sistema. Felizmente, a vulnerabilidade pode ser usada para desencadear um vazamento de pool dos buffers adjacentes. Vamos dar uma olhada na função IOCTL para ConsumeTx:
Trecho de decompilação FSrendezvousServer::ConsumeTx
Acima, a função FSStreamReg::GetStats é chamada:
Descompilação de FSStreamReg::GetStats
Aqui, o conteúdo da memória fora dos limites do objeto de fluxo vulnerável é copiado para o SystemBuffer, que é retornado à aplicação de espaço do usuário que fez a chamada. Essa primitiva de vazamento de informações do pool pode ser usada para executar uma verificação de assinatura em buffers adjacentes ao objeto vulnerável. Uma varredura de muitos objetos vulneráveis pode ser realizada até que o objeto dentro do layout de memória desejado seja localizado. Depois que o objeto desejado é localizado, o layout da memória é o seguinte:
CVE-2023-36802 Layout de estúdio de heap de baixa fragmentação
Agora, tendo localizado o objeto vulnerável de destino na posição correta na memória, a primitiva de exploração mencionada no objeto de destino pode ser acionada sem travar o sistema.
Depois de relatar o problema ao MSRC, a invasão da vulnerabilidade foi descoberta.
Os métodos de invasão apresentados neste post de blog são algumas das muitas abordagens que poderiam ser adotadas. Atualmente, não há informações públicas sobre como os invasores em estado selvagem exploraram essa vulnerabilidade. Você pode encontrar código de exploração aqui.
A análise retroativa de patches revelou que uma grande parte do novo código foi adicionada a mskssrv.sys na versão de 1809 do Windows 10. O monitoramento de novas adições de código geralmente é útil para encontrar vulnerabilidades.
Outra lição gasta, mas clássica, a ser aprendida com essa análise: não faça suposições sobre as verificações realizadas. Um amigo e colega sugeriu que a confusão de tipos usando FsContext2 poderia ser uma "classe de bug comum, mas pouco pesquisada". Acredito que uma análise mais aprofundada das variantes seja necessária para essa classe de bugs, especialmente em drivers que lidam com comunicação entre processos.
A descoberta dessa vulnerabilidade ocorreu durante a simples tentativa de interagir com uma superfície de ataque desconhecida. Ter “conhecimento criticamente próximo de zero” de um sistema também pode significar ter a mentalidade nova para quebrá-lo.