Correndo em círculos: o pequeno bug que conseguiu

Mulher jovem olha contemplativa enquanto trabalha em um computador à noite.

O pequeno bug que conseguiu: CVE-2024-30089 é uma vulnerabilidade sutil no kernel que utilizei para explorar uma máquina Windows 11 totalmente atualizada (com todas as mitigações de segurança de Virtualization Based Security e de hardware ativadas) e garantir minha primeira vitória no Pwn2Own deste ano.

Neste artigo, descrevo minha abordagem direta para caça de bugs: escolher um ponto de partida e seguir intuitivamente um caminho até que algo chame minha atenção. Esse bug é interessante porque pode ser acionado de forma confiável devido a um erro de lógica. O erro ocorre em um estado específico dentro de um sistema de comunicação entre processos (inter-process communication, IPC), o que leva a um use-after-free. Encontrar o bug exigiu comparar os caminhos de execução do programa ao longo de seus vários estados possíveis, processo que detalho ao longo do texto. Igualmente intrigantes são a origem do bug e a abordagem da Microsoft para corrigi-lo. Esses tópicos também são abordados neste post.

Caçando 0-days: por onde começar?

Uma pergunta comum que recebo sobre pesquisa de vulnerabilidades é como começar. Na verdade, escolher um alvo e cumpri-lo pode ser uma das etapas mais difíceis do processo de pesquisa. A vulnerabilidade discutida aqui está no Microsoft Kernel Streaming Service (mskssrv.sys). Leia este post do blog para ter uma visão geral do subsistema. Nele, destaquei algumas características do subsistema MSKSSRV que podem torná-lo uma boa superfície de ataque, especialmente seu mecanismo de IPC.

A base de código do MSKSSRV é relativamente pequena, e a última vulnerabilidade nesse subsistema que encontrei acabou sendo explorada de forma independente em estado selvagem como um 0-day. Também ouvi sobre esforços adicionais de outros pesquisadores e empresas revisando esse driver. Por isso, inicialmente caí na armadilha comum de assumir que não havia mais bugs a serem descobertos nessa superfície de ataque. Mas, como eu mesmo havia sugerido no post de blog anterior, escolhi confiar em meus instintos e continuar investigando.

Lock lock: quem está aí?

Uma ótima forma de obter novas ideias de pesquisa é acompanhar pesquisas recentes. Li um excelente post de blog de k0shl que me inspirou a buscar um determinado tipo de bug. Na vulnerabilidade encontrada por k0shl, o reference count de um objeto é inicializado e incrementado sem bloqueio adequado, criando uma janela de use-after-free. Apesar do bug de k0shl estar em uma área do usuário e não no kernel, o estilo de codificação da biblioteca vulnerável me lembrou de quando auditei anteriormente o driver MSKSSRV.

O servidor MS KS (MSKSSRV) interage com um processo do espaço do usuário via objetosFSStreamReg e FSContextReg . FSStreamReg e FSContextReg são ambos derivados da classe de baseFSRegObject . Notei que FSContextReg não implementa uma função de tabela virtual de bloqueio e a implementação da classe de base (FSRegObject é simplesmente uma instrução nop . Isso significa que nenhum mecanismo de bloqueio foi implementado para os objetos FSContextReg . Por outro lado,FSStreamReg implementa uma função de bloqueio que utiliza um mutex. O mecanismo de bloqueio é usado ao acessar os objetos para limpeza, na função FSRendezvousServer::Close :

Captura de tela de uma função FSRendezvousServer::Close

Bloco de código 1: FSRendezvousServer::Fechar, bloqueio e desbloqueio para FSRegObjects

Há dois objetos derivados da classe base FSRegObject , mas um tipo de objeto implementa um mecanismo de bloqueio adequado e o outro não. Isso me pareceu suspeito. Para testar minha teoria, tentei provocar um comportamento indefinido usando referências ao objeto FSContextReg desprotegido. No entanto, devido às proteções de bloqueio no objeto FSRendezvousServer  global, que contém os ponteiros para as listas de FSRegObjects , não consegui ativar nada interessante, apesar da falta de proteção de bloqueio nos objetos FSContextReg  . Ainda assim, eu sentia que havia algo suspeito no sistema de contagem de referência do FSRegObjects Eu só ainda não sabia o quê.

IPC no servidor MS KS

Como mencionei no meu último blog post, o aspecto de compartilhamento de objetos entre processos (inter-process object sharing) do MSKSSRV é um caminho interessante para vulnerabilidades, então decidi focar ainda mais nele. O mecanismo IPC do subsistema é ilustrado no diagrama a seguir:

Diagrama do mecanismo IPC do subsistema

Diagrama 1: Comunicação entre processos no servidor MS KS

Ao abrir um identificador de arquivo para o dispositivo MSKSSRV via CreateFile cria um FILE_OBJECT que corresponde a esse identificador. Utilizando esse identificador, um processo pode inicializar um novo fluxo ou um objeto de contexto enviando um IOCTL ao dispositivo por meio da função DeviceIoControl . O processo de inicialização designa qual processo remoto pode registrar o objeto especificando a ID do processo por meio do argumentolpInBuffer . O processo remoto, usando um novo identificador de arquivo para o dispositivo MSKSSRV, agora pode registrar o objeto por meio do IOCTL do dispositivo. O ponteiro para o FSRegObject é armazenado em

Irp->CurrentStackLocation->FileObject->FsContext2.

O mesmo ponteiro para o objeto FSRegObject é armazenado duas vezes em FsContext2, uma vez no FILE_OBJECT usado pelo processo de inicialização e uma vez no FILE_OBJECT usado no processo de registro. Dessa forma, as referências ao objeto FSRegObject podem ser compartilhadas entre os processos. Por exemplo, usando um objeto FSStreamReg, vários processos podem ter acesso ao buffer de quadro de fluxo, conforme mostrado no Diagrama 1. A contagem de referência do FSRegObject é inicializada como 1 e, em seguida, incrementada novamente após a conclusão da inicialização. Registrar o FSRegObject aumenta novamente sua contagem de referências, totalizando três referências por objeto.

Diagrama de inicialização e registro de objetos FSContextReg

Diagrama 2: Inicialização e registro de objetos FSContextReg

Encontrando um bug

Decidi reexaminar os locais que auditei anteriormente, tentando encontrar vulnerabilidades de bloqueio. Notei que a funçãoFSRendezvousClose, que chamaFSRendezvousServer::Close , é chamada dentro das rotinas de limpeza e fechamento de despacho do driver:

Captura de tela de um CodeBlock com rotinas DispatchCleanup e DipatchCleanup para MS KS Server Driver

Bloco de código 2: rotinas DispatchCleanup e DipatchCleanup para o MS KS Server Driver

Uma rotinade despacho lida com um ou mais tipos de IRPs, que são solicitações de E/S agrupadas. No Windows, quando todas as referências de identificadores a um arquivo são fechadas, o driver correspondente do sistema de arquivos para o arquivo recebe uma solicitaçãoIRP_MJ_CLEANUP e IRP_MJ_CLOSE  , que são tratadas pelas rotinas de função do driver DispatchCleanup e DispatchClose.

No MSKSSRV,FSRendezvousClose é chamado dentro das rotinas de função dos drivers DispatchCleanup e DispatchClose, se o ponteiro armazenado emIrp->CurrentStackLocation->FileObject->FsContext2 não for NULO. Algo que me chamou minha atenção noFSRendezvousServer::Close , que eu já havia analisado antes, foram as várias verificações na ID do processo do chamador. O contexto do processo é importante porque todo o código em modo kernel opera dentro de um único espaço de endereço do kernel, que é separado dos espaços de endereço do modo usuário. Cada processo possui seu próprio contexto de memória em modo de usuário, e o contexto do processo no qual o encadeamento do kernel é executado determina qual espaço de endereço do modo de usuário do processo será acessado se o encadeamento acessar o endereço do usuário. O código a seguir verifica se o processo que está fazendo a chamada é o processo de inicialização ou de registro:

Captura de tela de uma função FSRendezvousServer::Close

Bloco de código 3: FSRendezvousServer::Close, verificações de processo em FSRegObjects

Informações específicas do processo são armazenadas noFSRegObject armazenado no

 Irp->CurrentStackLocation->FileObject->FsContext2

 
no momento da inicialização ou registro. O driver determina quais recursos específicos de processo (objetos

EPROCESS

 , objetos de evento e outros) precisam ser liberados verificando o ID do processo que está efetuando a chamada. Se o processo for de inicialização ou registro, será feita uma limpeza adicional para esses recursos específicos do processo.

Isso chamou minha atenção porque, geralmente, todas as rotinas de despacho são executadas em um contextode processo arbitrário, com algumas exceções. Em outras palavras, o sistema escolhe um encadeamento para realizar o trabalho de despacho; o encadeamento escolhido é arbitrário. Descobri queDispatchCleanup é chamado no contexto do processo que fechou o último identificador. Portanto, neste caso, as verificações de ID do processo fazem sentido. No entanto,DispatchClose é chamado a partir de um contexto de processo arbitrário. Isso significa que seFSRendezvousServer::Close é chamado como resultado da solicitaçãoIRP_MJ_CLOSE , ele viria de um contexto de processo arbitrário, o que anularia o propósito das verificações de ID do processo. Isso foi um grande indício de que havia algo de errado.

Além disso, como uma funcionalidade do sistema operacional Windows, os identificadores podem ser compartilhados com outros processos (por meio de herança de processos filhos ou usando a função de API DuplicateHandle ). Por meio do identificador de arquivo compartilhado, o outro processo também pode interagir com o mesmoFILE_OBJECT .

Diagrama de compartilhamento do Identificador do Dispositivo do MS KS Server

 Diagrama 3: Compartilhamento do Identificador do Dispositivo do MS KS Server

Devido a isso, também é possível que o contexto do processo durante DispatchCleanup  não seja o processo de inicialização nem o de registro. Se qualquer um desses identificadores for duplicado e compartilhado com outro processo, e esse processo for o último a fechar o identificador, DispatchCleanup será chamado no contexto desse processo externo (que não é o processo de inicialização nem de registro).

Diagrama do processo externo fechando o último identificador para um FILE_OBJECT

Diagrama 4: Processo externo fechando o último identificador para um FILE_OBJECT

Percebi que a função FSRendezvousServer::Close pode ser chamada duas vezes quando um identificador de arquivo é fechado (uma vez para a solicitação IRP_MJ_CLEANUP e uma vez para a solicitação IRP_MJ_CLOSE  , bloco de código 2). Dentro dessa função, existem duas chamadas possíveis para FSRegObject::Release (bloco de código 4), que desreferencia o objeto e libera sua memória se a contagem de referências chegar a 0. Isso significa queFSRegObject::Release poderia ser chamado até quatro chamadas para obter um identificador. Além disso, existem dois identificadores de arquivo para os quais o FILE_OBJECT  aponta para o mesmo FSRegObject via o ponteiro FSContext2 . Isso significa uma possibilidade de chamar FSRegObject::Release  até oito vezes no mesmo objeto, quatro para cada identificador. Se a contagem de referências original do objeto for apenas três, pode haver um possível uso após a liberação, desencadeado por muitas desreferências. Pelo menos, era essa minha linha de raciocínio. Eu sabia que a estrutura do programa provavelmente impediria de atingir o número máximo teórico de desreferências, mas talvez não o suficiente. Nesse momento, senti que estava no caminho certo e decidi investigar mais a fundo.

Captura de tela de FSRegObject::Release pode ser chamada duas vezes a partir de FSRendezvousServer::Close

Bloco de código 4: FSRegObject::Release pode ser chamado duas vezes a partir de FSRendezvousServer::Close

Em geral, o fato de haver mais desreferências do que referências em um objeto não é a única maneira de ocorrer um uso após liberação. No entanto, neste caso, podemos ter certeza de que se um FSRegObject foi liberado, sua contagem de referência caiu para zero. A última vez que um FSRegObject válido é acessado durante as solicitações IRP IRP_MJ_CLEANUP/CLOSE é em uma chamada para FSRegObject::Release . Portanto, se um uso após liberação for possível, uma chamada para FSRegObject::Release sempre ocorrerá depois que o objeto já tiver sido liberado. Durante a chamada, o objeto será mais uma vez desreferenciado. Por essa razão, contar o número de desreferências é uma boa heurística para encontrar usos após liberações neste caso específico.

A única coisa que restava fazer era rastrear os possíveis estados do programa, anotando quando o objeto era liberado e acessado. Fiz isso emulando mentalmente a lógica do programa durante as solicitações de IRP_MJ_CLEANUP/CLOSE , cada uma começando com as funções de despacho correspondentes (bloco de código 2), para cada um dos casos possíveis.

A seguir, são apresentados os estados com base em qual processo fecha a referência final a um identificador. Cada entrada representa o número de desreferências do objeto FSContextReg que ocorrem se o processo correspondente fechar o identificador final. Nota: não há diferença funcional entre o IDENTIFICADOR Nº 1 (identificador de inicialização) e o IDENTIFICADOR Nº 2 (identificador de registro), pois o FileObject->FSContext2 campo aponta para a mesma memória em ambos FILE_OBJECT s representados pelos respectivos identificadores.

Captura de tela de desreferenciações de FSContextReg para cada um dos possíveis estados do IPC do MSKSSRV

Desreferenciações de FSContextReg para cada um dos possíveis estados do IPC do MSKSSRV

Sucesso! O último estado resulta em quatro desreferências: duas pelo processo externo e duas pelo processo de inicialização (e também de registro), embora inicialmente haja apenas três referências, o que significa que um uso após a liberação é possível! Também repeti o mesmo exercício com os objetos do FSStreamReg , mas devido a um bug de vazamento de memória no código, na verdade não é possível liberar um objeto de FSStreamReg após ter sido registrado.

A vulnerabilidade

Durante esse exercício mental de simulação da máquina virtual, encontrei o problema. Se o processo for o processo de inicialização ou o processo de registro, a limpeza apropriada ocorre e o ponteiro armazenado em Irp->CurrentStackLocation->FileObject->FsContext2 é definido como NULL . O bloco de código abaixo mostra onde isso ocorre no caso em que o chamador é o processo de inicialização:

Captura de tela de FSRegObject::CloseInitProcess define o ponteiro FSContext como NULL

Bloco de código 5: FSRegObject::CloseInitProcess define o ponteiro FSContext como NULL

Isso significa FSRendezvousClose não será chamado novamente durante a conclusão da solicitação IRP_MJ_CLOSE no DispatchClose (SrvDispatchClose bloco de código 2).

No entanto, se o processo de chamada for um processo externo, nenhuma limpeza ocorrerá e FSRegObject::Release será chamado uma vez perto do final da função. Como FileObject->FsContext2 não é NULL, FSRendezvousServer::Close é chamado novamente durante a solicitação subsequente IRP_MJ_CLOSE e ocorre outra chamada para FSRegObject::Release.

Agora, se o segundo identificador for fechado por um processo que inicializou e registrou o objeto FSContextReg, o objeto limpará todos os seus recursos do processo armazenados, ficando vazio. Isso faz com que FSRegObject::Release  seja chamado duas vezes dentro do FSRendezvousServer::Close (bloco de código 4). A desreferência extra serve para compensar a referência de inicialização adicional quando o objeto estiver vazio.

Isso resulta em um total de quatro desreferências em um único objetoFSContextReg , indicando que ocorre um uso após a liberação.

Diagrama do CVE-2024-30089 apresentado

Diagrama 5: CVE-2024-30089 representado

O leitor que acompanha o raciocínio pode se perguntar por que, então, um processo externo não pode ser o último a fechar ambas as identificadores, já que isso aparentemente também levaria a quatro desreferências. Antes que o objeto seja destruído e liberado, ele é desvinculado de uma lista armazenada no objeto global FSRendezvousServer . No início do FSRendezvousServer::Close , é verificado se o ponteiro no FSContext2 é um membro válido da lista. Nesse caso, o objeto é liberado na segunda chamada para FSRegObject::Release ao final da função. Durante a quarta chamada para FSRendezvousServer::Close o objeto já foi desvinculado da lista, tornando-o um objeto inválido e, portanto, não podendo ser utilizado. Para que ocorra um uso após a liberação, ele deve ocorrer depois que o objeto for recuperado e validado em FSRendezvousServer::Close . O trecho de código abaixo mostra a primitiva de uso após liberação que pode ser obtida pela vulnerabilidade:

Captura de tela do caminho da primitiva do código UAF

Bloco de código 6: caminho da primitiva de UAF

Complexidade do ataque

No guia de atualização de segurança desta vulnerabilidade, a pontuação CVSS indica que a “Invasão [é] Mais Provável” e a complexidade do ataque para esta vulnerabilidade é “Baixa”. Embora a Microsoft não forneça explicações detalhadas para sua pontuação, notei alguns padrões enquanto corrija outras vulnerabilidades. A vulnerabilidade provavelmente recebeu essa pontuação porque decorre de um erro lógico, tornando-a acionável de forma confiável. Seguindo as etapas descritas no Diagrama 5, um invasor pode acionar consistentemente o cenário de uso após a liberação retratado no Bloco de código 6. No entanto, isso não significa que a exploração dela na prática seja simples. Um passo a passo detalhado das etapas de invasão será abordado na próxima parte desta série.

Uma retrospectiva

Entender como um bug ocorreu é importante para cultivar uma abordagem proativa em relação às práticas de desenvolvimento seguro. Para identificar como a vulnerabilidade foi introduzida, analisei versões anteriores do driver obtidas do Winbindex e procurei por quaisquer diferenças na lógica da função FSRendezvousServer::Close .

Na seção de vulnerabilidade, mencionei que a causa principal desse bug não estava definindo
 

Irp->CurrentStackLocation->FileObject->FsContext2

 
para 

NULL

 se o processo de chamada for um processo externo. Para minha surpresa, vi essa linha exata de código em uma versão inicial do mskssrv.sys:

Captura de tela do código da versão inicial do FSRendezvousServer::Close, FsContext2 está definido como NULL

Bloco de código 7: Versão inicial do FSrendezvousServer::Close, FsContext2 está definido como NULL

No bloco de código mostrado acima, FileObject->FsContext2 é explicitamente definido como NULL , independentemente dos resultados das verificações de ID do processo anteriores. Isso impede FSRendezvousServer::Close de ser chamado novamente na próxima solicitação IRP_MJ_CLOSE , portanto a desreferência extra não pode ocorrer. Estranho, então por que essa linha de código foi retirada? Vamos analisar uma versão posterior da função, onde o bug foi introduzido pela primeira vez:

Captura de tela do FsContext definido como NULL em uma verificação de sinalizador de funcionalidade

Bloco de código 8: FsContext definido como NULL em uma verificação de sinalizador de funcionalidade

É mostrado no bloco de código acima uma verificação para o sinalizador de funcionalidade

Feature_Servicing_TeamsUsingMediaFoundationCrashes

.
Os sinalizadores de funcionalidade são um componente do Windows que alterna várias funcionalidades e experimentos, embora não haja muitas informações públicas sobre eles. Neste post de blog anterior, discutimos como os sinalizadores de funcionalidade foram usados para patches de vulnerabilidade. Às vezes, os sinalizadores de funcionalidade são usados para testar uma funcionalidade antes de ser adotada oficialmente. Nesse caso, se o recurso

Feature_Servicing_TeamsUsingMediaFoundationCrashes

a funcionalidade está habilitada, FileObject->FsContext2 não está definido como NULL, o que introduz a vulnerabilidade.
Observou-se que essa funcionalidade estava habilitada por padrão nas instalações do Windows 10. No Windows 11, e conforme mostrado no Bloco de Código 1, essa funcionalidade de sinalização condicional não está presente e o ponteiro não está definido como NULL , tornando-a também vulnerável.

 

Devido ao nome da funcionalidade, analisei a funcionalidade do Microsoft Teams, o software de videoconferência. Confirmei que a aplicação pode usar a funcionalidade MSKSSRV para compartilhar streamings de mídia entre processos. É possível que o compartilhamento do identificador de streaming estivesse causando o travamento do Teams. Um tópico interessante para pesquisa adicional seria examinar como o Teams compartilha o dispositivo MSKSSRV entre processos e por que realizar a limpeza adequada de ponteiros poderia causar a falha da aplicação.

A correção

Esta parte da série se concentra na vulnerabilidade em si, que inclui sua correção. Eu estava particularmente interessado em examinar a correção para esse bug, já que minha correção proposta parecia desencadear falhas no Microsoft Teams. É importante mencionar que, neste momento, ainda preciso examinar como uma aplicação real usa o driver MSKSSRV na prática. A falta desse contexto introduz pontos cegos na compreensão de por que um sistema é projetado da maneira como é. Uma correção completa para esse bug exigiria alguma reestruturação do código-base e poderia revelar mais detalhes sobre como o sistema IPC deveria funcionar. Eu também esperava obter alguns insights sobre práticas seguras de codificação dos desenvolvedores da Microsoft.

Para minha decepção, o erro lógico que causou a vulnerabilidade, que foi corrigido nas atualizações de segurança de junho de 2024, não foi abordado diretamente.Em vez disso, foi adicionada uma verificação de token de acesso antes dos caminhos de código vulneráveis. Veja o código abaixo para o IOCTL de inicialização de contexto, tratado pela função FSRendezvousServer::InitializeContext :

Captura de tela da função IOCTL começa com uma verificação de funcionalidade e verifica se o processo de chamada é um servidor de frame

Bloco de código 9: a função IOCTL começa com uma verificação de sinalizador de funcionalidade e verifica se o processo de chamada é um servidor de frame

A função acima começa verificando se uma funcionalidade está habilitada. Este é provavelmente o sinalizador de funcionalidade correspondente à correção. Se a funcionalidade estiver habilitada, KsIsCurrentProcessFrameServer deve retornar TRUE, caso contrário, o valorNTSTATUS  STATUS_ACCESS_DENIED será retornado.

Vamos dar uma olhada em KsIsCurrentProcessFrameServer :

Captura de tela de KsIsCurrentProcessFrameServer realizando uma verificação SID no token de acesso do thread de chamada

Bloco de código 10: KsIsCurrentProcessFrameServer realizando uma verificação SID no token de acesso do thread de chamada

Esta função verifica o token do encadeamento de chamada em relação a dois identificadores de segurança (SIDs) específicos. Os SIDs correspondem a um token no grupo NT SERVICE\FrameServer . Se qualquer um dos SIDs estiver habilitado no token de acesso do encadeamento de chamada, o código da função vulnerável poderá ser executado.

Depois de ver isso, suspeitei que provavelmente ainda havia um bug de administrador para kernel presente. Em última análise, os problemas de corrupção de memória não foram resolvidos. Confirmei isso fazendo uma pequena modificação na minha exploração original: um usuário administrador pode iniciar o serviço FrameServer , abra um identificador para o serviço e crie o processo de exploração usando o identificador. Consegui obter uma primitiva completa de R/W do kernel em um sistema totalmente corrigido.

Embora a Microsoft não considere o Administrador para o Kernel um limite de boundary, bugs semelhantes foram usados por agentes da ameaça para obter uma primitiva de R/W do kernel e usá-la para operações de blinding e rootkit de EDR. Se você tiver interesse em saber quais tipos de coisas podem ser feitas com esse primitivo, confira minha palestra no BlackHat com FuzziSec.

Conclusão e próximas etapas

Este post se concentrou na parte de pesquisa de vulnerabilidades do meu empreendimento Pwn2Own, que consistia em encontrar uma vulnerabilidade de kernel de 0-day que pudesse ser explorada para escalonamento de privilégios. Este post descreve a jornada: inspirar-se em outras pesquisas, não conseguir encontrar um bug, escolher um novo ângulo, encontrar algo suspeito e, finalmente, identificar onde a vulnerabilidade reside. Agora que um bug foi identificado e há uma primitiva de uso após a liberação, o resto deve ser simples, certo? A Microsoft parece pensar assim, eles classificaram esse bug como "invasão mais provável" com complexidade de ataque "baixa". Eles estão certos? Vou falar sobre isso, a estratégia de invasão, e revelar o significado do título da série na próxima parte!

Agradecimentos

Andréa Piazza, pelos diagramas incríveis

Anna Erikpatrick, por explicar pacientemente o modelo de segurança do Windows para mim

Mixture of Experts | 12 de dezembro, episódio 85

Decodificando a IA: resumo semanal das notícias

Participe do nosso renomado painel de engenheiros, pesquisadores, líderes de produtos e outros enquanto filtram as informações sobre IA para trazerem a você as mais recentes notícias e insights sobre IA.