Histórico
No Windows 10 (versão RS4), a Microsoft introduziu a API da Plataforma de Hipervisor do Windows (WHP). Esta API expõe a funcionalidade do hipervisor integrado da Microsoft para aplicativos Windows em modo de usuário. Em 2024, o autor usou esta API para criar um projeto pessoal: um emulador de MS-DOS de 16 bits chamado DOSVisor. Conforme mencionado nas notas de lançamento, sempre houve planos para levar esse conceito adiante e usá-lo para emular aplicativos do Windows. A Elastic oferece uma semana de pesquisa (ON Week) duas vezes por ano para que a equipe trabalhe em projetos pessoais, proporcionando uma ótima oportunidade para começar a trabalhar neste projeto. Este projeto será (sem imaginação) chamado WinVisor, inspirado em seu antecessor DOSVisor.
Os hipervisores fornecem virtualização em nível de hardware, eliminando a necessidade de emular a CPU via software. Isso garante que as instruções sejam executadas exatamente como seriam em uma CPU física, enquanto emuladores baseados em software geralmente se comportam de forma inconsistente em casos extremos.
Este projeto tem como objetivo construir um ambiente virtual para executar binários do Windows x64, permitindo que chamadas de sistema sejam registradas (ou interceptadas) e possibilitando a introspecção de memória. O objetivo deste projeto não é construir um sandbox abrangente e seguro. Por padrão, todas as chamadas de sistema serão simplesmente registradas e encaminhadas diretamente para o host. Em sua forma inicial, será trivial que o código executado no convidado virtualizado "escape" para o host. Proteger com segurança uma caixa de areia é uma tarefa difícil e está além do escopo deste projeto. As limitações serão descritas com mais detalhes no final do artigo.
Apesar de estar disponível há 6 anos (no momento da redação deste artigo), parece que a API WHP não foi usada em muitos projetos públicos além de bases de código complexas, como QEMU e VirtualBox. Outro projeto notável é o Simpleator de Alex Ionescu, um emulador leve de modo de usuário do Windows que também utiliza a API WHP. Este projeto tem muitos dos mesmos objetivos do WinVisor, embora a abordagem de implementação seja bem diferente. O projeto WinVisor visa automatizar o máximo possível e oferecer suporte a executáveis simples (por exemplo ping.exe
) universalmente pronto para uso.
Este artigo abordará o design geral do projeto, alguns dos problemas encontrados e como eles foram resolvidos. Alguns recursos serão limitados devido a restrições de tempo de desenvolvimento, mas o produto final será pelo menos uma prova de conceito utilizável. Links para o código-fonte e binários hospedados no GitHub serão fornecidos no final do artigo.
Noções básicas do hipervisor
Os hipervisores são alimentados por extensões VT-x (Intel) e AMD-V (AMD). Essas estruturas assistidas por hardware permitem a virtualização permitindo que uma ou mais máquinas virtuais sejam executadas em uma única CPU física. Essas extensões usam conjuntos de instruções diferentes e, portanto, não são inerentemente compatíveis entre si; um código separado deve ser escrito para cada uma.
Internamente, o Hyper-V usa hvix64.exe
para suporte Intel e hvax64.exe
para suporte AMD. A API WHP da Microsoft abstrai essas diferenças de hardware, permitindo que os aplicativos criem e gerenciem partições virtuais independentemente do tipo de CPU subjacente. Para simplificar, a explicação a seguir se concentrará apenas no VT-x.
O VT-x adiciona um conjunto adicional de instruções conhecido como VMX (Virtual Machine Extensions), contendo instruções como VMLAUNCH
, que inicia a execução de uma VM pela primeira vez, e VMRESUME
, que entra novamente na VM após uma saída da VM. Uma saída de VM ocorre quando certas condições são acionadas pelo convidado, como instruções específicas, acesso à porta de E/S, falhas de página e outras exceções.
No centro do VMX está a Estrutura de Controle de Máquina Virtual (VMCS), uma estrutura de dados por VM que armazena o estado dos contextos do convidado e do host, bem como informações sobre o ambiente de execução. O VMCS contém campos que definem o estado do processador, configurações de controle e condições opcionais que acionam transições do convidado de volta para o host. Os campos VMCS podem ser lidos ou gravados usando as instruções VMREAD
e VMWRITE
.
Durante uma saída de VM, o processador salva o estado do convidado no VMCS e faz a transição de volta para o estado do host para intervenção do hipervisor.
Visão geral do WinVisor
Este projeto aproveita a natureza de alto nível da API WHP. A API expõe a funcionalidade do hipervisor ao modo de usuário e permite que os aplicativos mapeiem a memória virtual do processo host diretamente para a memória física do convidado.
A CPU virtual opera quase exclusivamente em CPL3 (modo usuário), exceto por um pequeno bootloader que é executado em CPL0 (modo kernel) para inicializar o estado da CPU antes da execução. Isso será descrito com mais detalhes na seção CPU Virtual.
A construção do espaço de memória para um ambiente convidado emulado envolve o mapeamento do executável de destino e todas as dependências de DLL, seguido pelo preenchimento de outras estruturas de dados internas, como o Process Environment Block (PEB), o Thread Environment Block (TEB), KUSER_SHARED_DATA
, etc.
Mapear as dependências de EXE e DLL é simples, mas manter com precisão estruturas internas, como o PEB, é uma tarefa mais complexa. Essas estruturas são grandes, em sua maioria não documentadas, e seu conteúdo pode variar entre as versões do Windows. Seria relativamente simples preencher um conjunto minimalista de campos para executar um aplicativo simples "Hello World", mas uma abordagem aprimorada deve ser adotada para fornecer boa compatibilidade.
Em vez de criar manualmente um ambiente virtual, o WinVisor inicia uma instância suspensa do processo de destino e clona todo o espaço de endereço no convidado. Os diretórios de dados da Tabela de Endereços de Importação (IAT) e do Armazenamento Local de Threads (TLS) são removidos temporariamente dos cabeçalhos PE na memória para impedir que dependências de DLL sejam carregadas e para evitar que retornos de chamada TLS sejam executados antes de atingir o ponto de entrada. O processo é então retomado, permitindo que a inicialização usual do processo continue (LdrpInitializeProcess
) até atingir o ponto de entrada do executável de destino, momento em que o hipervisor é iniciado e assume o controle. Isso significa essencialmente que o Windows fez todo o trabalho duro para nós, e agora temos um espaço de endereço de modo de usuário pré-preenchido para o executável de destino que está pronto para execução.
Um novo thread é então criado em um estado suspenso, com o endereço inicial apontando para o endereço de uma função de carregador personalizada. Esta função preenche o IAT, executa retornos de chamada TLS e, finalmente, executa o ponto de entrada original do aplicativo de destino. Isso essencialmente simula o que o thread principal faria se o processo estivesse sendo executado nativamente. O contexto desse thread é então "clonado" na CPU virtual, e a execução começa sob o controle do hipervisor.
A memória é paginada no convidado conforme necessário, e as chamadas de sistema são interceptadas, registradas e encaminhadas ao sistema operacional host até que o processo de destino virtualizado saia.
Como a API WHP permite que apenas a memória do processo atual seja mapeada para o convidado, a lógica principal do hipervisor é encapsulada em uma DLL que é injetada no processo de destino.
CPU virtual
A API WHP fornece um wrapper "amigável" em torno da funcionalidade VMX descrita anteriormente, o que significa que as etapas usuais, como preencher manualmente o VMCS antes de executar VMLAUNCH
, não são mais necessárias. Ele também expõe a funcionalidade ao modo de usuário, o que significa que um driver personalizado não é necessário. Entretanto, a CPU virtual ainda deve ser inicializada adequadamente via WHP antes de executar o código de destino. Os aspectos importantes serão descritos abaixo.
Registradores de controle
Somente os registradores de controle CR0
, CR3
e CR4
são relevantes para este projeto. CR0
e CR4
são usados para habilitar opções de configuração da CPU, como modo protegido, paginação e PAE. CR3
contém o endereço físico da tabela de paginação PML4
, que será descrita com mais detalhes na seção Paginação de Memória.
Registradores específicos do modelo
Os registradores específicos do modelo (MSRs) também devem ser inicializados para garantir a operação correta da CPU virtual. MSR_EFER
contém sinalizadores para recursos estendidos, como habilitar o modo longo (64 bits) e instruções SYSCALL
. MSR_LSTAR
contém o endereço do manipulador de syscall e MSR_STAR
contém os seletores de segmento para transição para CPL0 (e de volta para CPL3) durante syscalls. MSR_KERNEL_GS_BASE
contém o endereço base shadow do seletor GS
.
Tabela de descritores globais
A Tabela de Descritores Globais (GDT) define os descritores de segmento, que essencialmente descrevem regiões de memória e suas propriedades para uso no modo protegido.
No modo longo, o GDT tem uso limitado e é basicamente uma relíquia do passado - o x64 sempre opera em um modo de memória plana, o que significa que todos os seletores são baseados em 0
. As únicas exceções a isso são os registradores FS
e GS
, que são usados para propósitos específicos de thread. Mesmo nesses casos, seus endereços base não são definidos pelo GDT. Em vez disso, MSRs (como MSR_KERNEL_GS_BASE
descrito acima) são usados para armazenar o endereço base.
Apesar dessa obsolescência, o GDT ainda é uma parte importante do modelo x64. Por exemplo, o nível de privilégio atual é definido pelo seletor CS
(Segmento de Código).
Segmento de estado da tarefa
No modo longo, o Segmento de Estado da Tarefa (TSS) é usado simplesmente para carregar o ponteiro da pilha ao fazer a transição de um nível de privilégio inferior para um superior. Como este emulador opera quase exclusivamente em CPL3, exceto pelo bootloader inicial e manipuladores de interrupção, apenas uma única página é alocada para a pilha CPL0. O TSS é armazenado como uma entrada especial do sistema dentro do GDT e ocupa dois slots.
Tabela de descritores de interrupção
A Tabela de Descritores de Interrupção (IDT) contém informações sobre cada tipo de interrupção, como os endereços do manipulador. Isso será descrito com mais detalhes na seção Tratamento de Interrupções.
Carregador de inicialização
A maioria dos campos da CPU mencionados acima podem ser inicializados usando funções de wrapper WHP, mas o suporte para certos campos (por exemplo, XCR0
) só chegou em versões posteriores da API WHP (Windows 10 RS5). Para completar, o projeto inclui um pequeno “bootloader”, que roda em CPL0 na inicialização e inicializa manualmente as partes finais da CPU antes de executar o código de destino. Diferentemente de uma CPU física, que iniciaria no modo real de 16 bits, a CPU virtual já foi inicializada para rodar no modo longo (64 bits), tornando o processo de inicialização um pouco mais direto.
As seguintes etapas são executadas pelo bootloader:
-
Carregue o GDT usando a instrução
LGDT
. O operando de origem para esta instrução especifica um bloco de memória de 10 bytes que contém o endereço base e o limite (tamanho) da tabela que foi preenchida anteriormente. -
Carregue o IDT usando a instrução
LIDT
. O operando de origem para esta instrução usa o mesmo formato que LGDT descrito acima. -
Defina o índice do seletor TSS no registrador de tarefas usando a instrução
LTR
. Conforme mencionado acima, o descritor TSS existe como uma entrada especial dentro do GDT (em0x40
neste caso). -
O registro XCR0 pode ser definido usando a instrução
XSETBV
. Este é um registro de controle adicional que é usado para recursos opcionais, como AVX. O processo nativo executa XGETBV para obter o valor do host, que é então copiado para o convidado viaXSETBV
no bootloader.
Esta é uma etapa importante porque dependências de DLL que já foram carregadas podem ter definido sinalizadores globais durante o processo de inicialização. Por exemplo, ucrtbase.dll
verifica se a CPU suporta AVX por meio da instrução CPUID
na inicialização e, em caso afirmativo, define um sinalizador global para permitir que o CRT use instruções AVX por motivos de otimização. Se a CPU virtual tentar executar essas instruções AVX sem habilitá-las explicitamente em XCR0
primeiro, uma exceção de instrução indefinida será gerada.
-
Atualize manualmente os seletores de segmento de dados
DS
,ES
eGS
para seus equivalentes CPL3 (0x2B
). Execute a instruçãoSWAPGS
para carregar o endereço base TEB deMSR_KERNEL_GS_BASE
. -
Por fim, use a instrução
SYSRET
para fazer a transição para CPL3. Antes da instruçãoSYSRET
,RCX
é definido como um endereço de espaço reservado (ponto de entrada CPL3) eR11
é definido como o valor inicial CPL3 RFLAGS (0x202
). A instruçãoSYSRET
alterna automaticamente os seletores de segmentoCS
eSS
para seus equivalentes CPL3 deMSR_STAR
.
Quando a instrução SYSRET
for executada, uma falha de página será gerada devido ao endereço de espaço reservado inválido em RIP
. O emulador detectará essa falha de página e a reconhecerá como um endereço “especial”. Os valores iniciais do registrador CPL3 serão então copiados para a CPU virtual, RIP
será atualizado para apontar para uma função de carregador de modo de usuário personalizada e a execução será retomada. Esta função carrega todas as dependências de DLL para o executável de destino, preenche a tabela IAT, executa retornos de chamada TLS e, em seguida, executa o ponto de entrada original. A tabela de importação e os retornos de chamada TLS são manipulados nesta etapa, e não antes, para garantir que seu código seja executado dentro do ambiente virtualizado.
Paginação de memória
Todo o gerenciamento de memória do convidado deve ser feito manualmente. Isso significa que uma tabela de paginação deve ser preenchida e mantida, permitindo que a CPU virtual traduza um endereço virtual em um endereço físico.
Tradução de endereço virtual
Para aqueles que não estão familiarizados com paginação em x64, a tabela de paginação tem quatro níveis: PML4
, PDPT
, PD
e PT
. Para qualquer endereço virtual fornecido, a CPU percorre cada camada da tabela, eventualmente alcançando o endereço físico de destino. CPUs modernas também suportam paginação de 5 níveis (caso os 256 TB de memória endereçável oferecidos pela paginação de 4 níveis não sejam suficientes!), mas isso é irrelevante para os propósitos deste projeto.
A imagem a seguir ilustra o formato de um endereço virtual de exemplo:
Usando o exemplo acima, a CPU calcularia a página física correspondente ao endereço virtual 0x7FFB7D030D10
por meio das seguintes entradas de tabela: PML4[0xFF]
-> PDPT[0x1ED]
-> PD[0x1E8]
-> PT[0x30]
. Por fim, o deslocamento (0xD10
) será adicionado a esta página física para calcular o endereço exato.
Os bits 48
- 63
dentro de um endereço virtual não são utilizados na paginação de 4 níveis e são essencialmente estendidos em sinal para corresponder ao bit 47
.
O registrador de controle CR3
contém o endereço físico da tabela base PML4
. Quando a paginação está habilitada (obrigatório no modo longo), todos os outros endereços dentro do contexto da CPU se referem a endereços virtuais.
Falhas de página
Quando o convidado tenta acessar a memória, a CPU virtual gera uma exceção de falha de página se a página solicitada ainda não estiver presente na tabela de paginação. Isso acionará um evento de saída da VM e passará o controle de volta para o host. Quando isso ocorre, o registro de controle CR2
contém o endereço virtual solicitado, embora a API WHP já forneça esse valor nos dados de contexto de saída da VM. O host pode então mapear a página solicitada na memória (se possível) e retomar a execução ou gerar um erro se o endereço de destino for inválido.
Espelhamento de memória host/convidado
Conforme mencionado anteriormente, o emulador cria um processo filho, e toda a memória virtual dentro desse processo será mapeada diretamente no convidado usando o mesmo layout de endereço. A API da Hypervisor Platform nos permite mapear a memória virtual do processo do modo de usuário do host diretamente para a memória física do convidado. A tabela de paginação mapeará então endereços virtuais para as páginas físicas correspondentes.
Em vez de mapear todo o espaço de endereço do processo antecipadamente, um número fixo de páginas físicas é alocado para o convidado. O emulador contém um gerenciador de memória muito básico, e as páginas são mapeadas "sob demanda". Quando ocorre uma falha de página, a página solicitada será paginada e a execução será retomada. Se todos os "slots" da página estiverem cheios, a entrada mais antiga será trocada para abrir espaço para a nova.
Além de usar um número fixo de páginas mapeadas no momento, o emulador também usa uma tabela de páginas de tamanho fixo. O tamanho da tabela de páginas é determinado pelo cálculo do número máximo possível de tabelas para a quantidade de entradas de páginas mapeadas. Esse modelo resulta em um layout de memória física simples e consistente, mas tem um custo de eficiência. Na verdade, as tabelas de paginação ocupam mais espaço do que as entradas de página propriamente ditas.
Há uma única tabela PML4 e, no pior cenário, cada entrada de página mapeada fará referência a tabelas PDPT/PD/PT exclusivas. Como cada tabela tem 4096
bytes, o tamanho total da tabela de páginas pode ser calculado usando a seguinte fórmula:
PAGE_TABLE_SIZE = 4096 + (MAXIMUM_MAPPED_PAGES * 4096 * 3)
Por padrão, o emulador permite que 256
páginas sejam mapeadas a qualquer momento (1024KB
no total). Usando a fórmula acima, podemos calcular que isso exigirá 3076KB
para a tabela de paginação, conforme ilustrado abaixo:
Na prática, muitas das entradas da tabela de paginação serão compartilhadas, e grande parte do espaço alocado para as tabelas de paginação permanecerá sem uso. Entretanto, como esse emulador funciona bem mesmo com um pequeno número de páginas, esse nível de sobrecarga não é uma grande preocupação.
A CPU mantém um cache em nível de hardware para a tabela de paginação conhecida como Translation Lookaside Buffer (TLB). Ao traduzir um endereço virtual para um endereço físico, a CPU primeiro verificará o TLB. Se uma entrada correspondente não for encontrada no cache (conhecido como “TLB miss”), as tabelas de paginação serão lidas. Por esse motivo, é importante limpar o cache TLB sempre que as tabelas de paginação forem reconstruídas para evitar que elas fiquem fora de sincronia. A maneira mais simples de liberar todo o TLB é redefinir o valor do registrador CR3
.
Tratamento de chamada de sistema
À medida que o programa de destino é executado, quaisquer chamadas de sistema que ocorram no convidado devem ser manipuladas pelo host. Este emulador manipula instruções SYSCALL
e chamadas de sistema legadas (baseadas em interrupções). SYSENTER
não é usado no modo longo e, portanto, não é suportado pelo WinVisor.
Chamada de sistema rápida (SYSCALL)
Quando uma instrução SYSCALL
é executada, a CPU transita para CPL0 e carrega RIP
de MSR_LSTAR
. No kernel do Windows, isso apontaria para KiSystemCall64
. As instruções SYSCALL
não acionarão inerentemente um evento de saída da VM, mas o emulador define MSR_LSTAR
como um endereço de espaço reservado — 0xFFFF800000000000
neste caso. Quando uma instrução SYSCALL
é executada, uma falha de página será gerada quando o RIP for definido para este endereço, e a chamada poderá ser interceptada. Este espaço reservado é um endereço de kernel no Windows e não causará nenhum conflito com o espaço de endereço do modo de usuário.
Diferentemente das chamadas de sistema legadas, a instrução SYSCALL
não troca o valor RSP
durante a transição para CPL0, então o ponteiro de pilha do modo de usuário pode ser recuperado diretamente de RSP
.
Chamadas de sistema legadas (INT 2E)
Chamadas de sistema baseadas em interrupções legadas são mais lentas e têm mais sobrecarga do que a instrução SYSCALL
, mas, apesar disso, elas ainda são suportadas pelo Windows. Como o emulador já contém uma estrutura para lidar com interrupções, adicionar suporte para syscalls legadas é muito simples. Quando uma interrupção de syscall herdada é capturada, ela pode ser encaminhada para o manipulador de syscall “comum” após algumas traduções menores — especificamente, recuperando o valor armazenado do modo de usuário RSP
da pilha CPL0.
Encaminhamento de chamada de sistema
Depois que o emulador cria o "thread principal" cujo contexto é clonado na CPU virtual, esse thread nativo é reutilizado como um proxy para encaminhar chamadas de sistema para o host. Reutilizar o mesmo thread mantém a consistência do TEB e qualquer estado do kernel entre o convidado e o host. O Win32k, em particular, depende de muitos estados específicos de thread, que devem ser refletidos no emulador.
Quando ocorre uma chamada de sistema, seja por uma instrução SYSCALL
ou uma interrupção legada, o emulador a intercepta e a transfere para uma função de manipulador universal. O número da chamada de sistema é armazenado no registro RAX
e os quatro primeiros valores de parâmetros são armazenados em R10
, RDX
, R8
e R9
, respectivamente. R10
é usado para o primeiro parâmetro em vez do registro RCX
usual porque a instrução SYSCALL
substitui RCX
pelo endereço de retorno. O manipulador de syscall legado no Windows (KiSystemService
) também usa R10
para compatibilidade, portanto não precisa ser tratado de forma diferente no emulador. Os parâmetros restantes são recuperados da pilha.
Não sabemos o número exato de parâmetros esperados para qualquer número de chamada de sistema, mas felizmente isso não importa. Podemos simplesmente usar uma quantidade fixa e, desde que o número de parâmetros fornecidos seja maior ou igual ao número real, a chamada de sistema funcionará corretamente. Um stub de montagem simples será criado dinamicamente, preenchendo todos os parâmetros, executando a chamada de sistema de destino e retornando de forma limpa.
Os testes mostraram que o número máximo de parâmetros usados atualmente pelas chamadas de sistema do Windows é 17
(NtAccessCheckByTypeResultListAndAuditAlarmByHandle
, NtCreateTokenEx
e NtUserCreateWindowEx
). O WinVisor usa 32
como o número máximo de parâmetros para permitir possível expansão futura.
Após executar a syscall no host, o valor de retorno é copiado para RAX
no convidado. RIP
é então transferido para uma instrução SYSRET
(ou IRETQ
para chamadas de sistema legadas) antes de retomar a CPU virtual para uma transição perfeita de volta ao modo de usuário.
Registro de chamada de sistema
Por padrão, o emulador simplesmente encaminha as chamadas de sistema do convidado para o host e as registra no console. Entretanto, algumas etapas adicionais são necessárias para converter as chamadas de sistema brutas em um formato legível.
O primeiro passo é converter o número da chamada de sistema em um nome. Os números de chamada de sistema são compostos de várias partes: os bits 12
- 13
contêm o índice da tabela de serviços do sistema (0
para ntoskrnl
, 1
para win32k
) e os bits 0
- 11
contêm o índice de chamada de sistema dentro da tabela. Essas informações nos permitem realizar uma pesquisa reversa dentro do módulo de modo de usuário correspondente (ntdll
/ win32u
) para resolver o nome da chamada de sistema original.
O próximo passo é determinar o número de valores de parâmetros a serem exibidos para cada chamada de sistema. Conforme mencionado acima, o emulador passa valores de parâmetros 32
para cada syscall, mesmo que a maioria deles não seja usada. Entretanto, registrar todos os valores 32
para cada chamada de sistema não seria o ideal por questões de legibilidade. Por exemplo, uma chamada simples NtClose(0x100)
seria impressa como NtClose(0x100, xxx, xxx, xxx, xxx, xxx, xxx, xxx, xxx, ...)
. Como mencionado anteriormente, não há uma maneira simples de determinar automaticamente o número exato de parâmetros para cada chamada de sistema, mas há um truque que podemos usar para estimá-lo com alta precisão.
Este truque depende das bibliotecas de sistema de 32 bits usadas pelo WoW64. Essas bibliotecas usam a convenção de chamada stdcall, o que significa que o chamador envia todos os parâmetros para a pilha, e eles são limpos internamente pelo chamado antes de retornar. Em contraste, o código x64 nativo coloca os primeiros parâmetros 4 em registradores, e o chamador é responsável por gerenciar a pilha.
Por exemplo, a função NtClose
na versão WoW64 de ntdll.dll
termina com a instrução RET 4
. Isso remove 4 bytes adicionais da pilha após o endereço de retorno, o que implica que a função recebe um parâmetro. Se a função usasse RET 8
, isso sugeriria que ela usa parâmetros 2 , e assim por diante.
Embora o emulador seja executado como um processo de 64 bits, ainda podemos carregar as cópias de 32 bits de ntdll.dll
e win32u.dll
na memória, manualmente ou mapeadas usando SEC_IMAGE
. Uma versão personalizada de GetProcAddress
deve ser escrita para resolver os endereços de exportação do WoW64, mas esta é uma tarefa trivial. A partir daqui, podemos encontrar automaticamente a exportação WoW64 correspondente para cada syscall, procurar a instrução RET
para calcular o número de parâmetros e armazenar o valor em uma tabela de consulta.
Este método não é perfeito e há várias maneiras pelas quais ele pode falhar:
- Um pequeno número de chamadas de sistema nativas não existe no WoW64, como
NtUserSetWindowLongPtr
. - Se uma função de 32 bits contiver um parâmetro de 64 bits, ela será dividida internamente em 2 parâmetros de 32 bits, enquanto a função de 64 bits correspondente exigiria apenas um único parâmetro para o mesmo valor.
- As funções stub de chamada de sistema do WoW64 no Windows podem mudar de tal forma que a pesquisa de instruções
RET
existente falhe.
Apesar dessas armadilhas, os resultados serão precisos para a grande maioria das chamadas de sistema sem precisar depender de valores codificados. Além disso, esses valores são usados apenas para fins de registro e não afetarão mais nada, portanto, pequenas imprecisões são aceitáveis neste contexto. Se uma falha for detectada, ele retornará à exibição do número máximo de valores de parâmetros.
Conexão de chamada de sistema
Se este projeto estivesse sendo usado para fins de sandbox, encaminhar cegamente todas as chamadas de sistema para o host seria indesejável por razões óbvias. O emulador contém uma estrutura que permite que chamadas de sistema específicas sejam facilmente conectadas, se necessário.
Por padrão, apenas NtTerminateThread
e NtTerminateProcess
são conectados para capturar a saída do processo convidado.
Tratamento de interrupções
As interrupções são definidas pelo IDT, que é preenchido antes do início da execução da CPU virtual. Quando ocorre uma interrupção, o estado atual da CPU é enviado para a pilha CPL0 (SS
, RSP
, RFLAGS
, CS
, RIP
) e RIP
é definido como a função do manipulador de destino.
Assim como MSR_LSTAR
para o manipulador SYSCALL, o emulador preenche todos os endereços do manipulador de interrupção com valores de espaço reservado (0xFFFFA00000000000
- 0xFFFFA000000000FF
). Quando ocorre uma interrupção, ocorre uma falha de página dentro desse intervalo, que podemos detectar. O índice de interrupção pode ser extraído dos 8 bits mais baixos do endereço de destino (por exemplo, 0xFFFFA00000000003
é INT 3
), e o host pode lidar com isso conforme necessário.
Atualmente, o emulador manipula apenas INT 1
(etapa única), INT 3
(ponto de interrupção) e INT 2E
(chamada de sistema legada). Se qualquer outra interrupção for detectada, o emulador sairá com um erro.
Quando uma interrupção é tratada, RIP
é transferido para uma instrução IRETQ
, que retorna ao modo de usuário de forma limpa. Alguns tipos de interrupções colocam um valor adicional de "código de erro" na pilha. Se for esse o caso, ele deve ser removido antes da instrução IRETQ
para evitar corrupção da pilha. A estrutura do manipulador de interrupções dentro deste emulador contém um sinalizador opcional para lidar com isso de forma transparente.
Bug de página compartilhada do hipervisor
O Windows 10 introduziu um novo tipo de página compartilhada que está localizada perto de KUSER_SHARED_DATA
. Esta página é usada por funções relacionadas ao tempo, como RtlQueryPerformanceCounter
e RtlGetMultiTimePrecise
.
O endereço exato desta página pode ser recuperado com NtQuerySystemInformation
, usando a classe de informação SystemHypervisorSharedPageInformation
. A função LdrpInitializeProcess
armazena o endereço desta página em uma variável global (RtlpHypervisorSharedUserVa
) durante a inicialização do processo.
A API WHP parece conter um bug que faz com que a função WHvRunVirtualProcessor
fique presa em um loop infinito se esta página compartilhada for mapeada para o convidado e a CPU virtual tentar lê-la.
Restrições de tempo limitaram a capacidade de investigar isso completamente; no entanto, uma solução alternativa simples foi implementada. O emulador corrige a função NtQuerySystemInformation
dentro do processo de destino e a força a retornar STATUS_INVALID_INFO_CLASS
para solicitações SystemHypervisorSharedPageInformation
. Isso faz com que o código ntdll
retorne aos métodos tradicionais.
Demos
Alguns exemplos de executáveis comuns do Windows sendo emulados neste ambiente virtualizado abaixo:
Limitações
O emulador tem várias limitações que o tornam inseguro para uso como um sandbox seguro em sua forma atual.
Questões de segurança
Existem várias maneiras de "escapar" da VM, como simplesmente criar um novo processo/thread, agendar chamadas de procedimento assíncronas (APCs), etc.
As chamadas de sistema relacionadas à interface gráfica do usuário do Windows também podem fazer chamadas aninhadas diretamente de volta ao modo de usuário a partir do kernel, o que atualmente ignoraria a camada do hipervisor. Por esse motivo, executáveis de GUI, como notepad.exe, são apenas parcialmente virtualizados quando executados no WinVisor.
Para demonstrar isso, o WinVisor inclui uma opção de linha de comando -nx
no emulador. Isso força toda a imagem EXE de destino a ser marcada como não executável na memória antes de iniciar a CPU virtual, fazendo com que o processo trave se o processo host tentar executar qualquer código nativamente. Entretanto, ainda não é seguro confiar nisso — o aplicativo de destino pode tornar a região executável novamente ou simplesmente alocar memória executável em outro lugar.
Como a DLL do WinVisor é injetada no processo de destino, ela existe no mesmo espaço de endereço virtual que o executável de destino. Isso significa que o código executado na CPU virtual é capaz de acessar diretamente a memória dentro do módulo do hipervisor do host, o que pode corrompê-lo.
Memória de convidado não executável
Embora a CPU virtual esteja configurada para oferecer suporte ao NX, todas as regiões de memória estão atualmente espelhadas no convidado com acesso RWX total.
Somente thread único
Atualmente, o emulador só oferece suporte à virtualização de um único thread. Se o executável de destino criar threads adicionais, eles serão executados nativamente. Para dar suporte a múltiplos threads, um pseudo-agendador poderia ser desenvolvido para lidar com isso no futuro.
O carregador paralelo do Windows é desabilitado para garantir que todas as dependências do módulo sejam carregadas por um único thread.
Exceções de software
Exceções de software virtualizado não são suportadas atualmente. Se ocorrer uma exceção, o sistema chamará a função KiUserExceptionDispatcher
nativamente, como de costume.
Conclusão
Como visto acima, o emulador funciona bem com uma ampla gama de executáveis em sua forma atual. Embora atualmente seja eficaz para registrar chamadas de sistema e interrupções, muito trabalho adicional seria necessário para torná-lo seguro para uso em análises de malware. Apesar disso, o projeto fornece uma estrutura eficaz para o desenvolvimento futuro.
Links do projeto
https://github.com/x86matthew/WinVisor
O autor pode ser encontrado no X em @x86matthew.