
Uma implementação de PoC para uma técnica de evasão que encerra a thread atual e a restaura antes de retomar a execução, enquanto implementa alterações de proteção de página durante a não execução.
██████╗ ███████╗ █████╗ ████████╗██╗ ██╗███████╗██╗ ███████╗███████╗██████╗
██╔══██╗██╔════╝██╔══██╗╚══██╔══╝██║ ██║██╔════╝██║ ██╔════╝██╔════╝██╔══██╗
██║ ██║█████╗ ███████║ ██║ ███████║███████╗██║ █████╗ █████╗ ██████╔╝
██║ ██║██╔══╝ ██╔══██║ ██║ ██╔══██║╚════██║██║ ██╔══╝ ██╔══╝ ██╔═══╝
██████╔╝███████╗██║ ██║ ██║ ██║ ██║███████║███████╗███████╗███████╗██║
╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚══════╝╚═╝
Uma implementação PoC para uma técnica de evasão que termina a thread atual e a restaura antes de retomar a execução, enquanto implementa alterações de proteção de página durante a não execução.

Métodos de suspensão e ofuscação são bem conhecidos na comunidade de maldev, com diferentes implementações. Eles têm o objetivo de se esconder de scanners de memória durante a suspensão, geralmente alterando proteções de página e até adicionando recursos interessantes como criptografar o shellcode, mas há outro ponto importante para esconder nosso shellcode: esconder a thread de execução atual. Spoofing da pilha é legal, mas depois de pensar um pouco sobre isso, pensei que não há necessidade de fazer spoofing da pilha... se não houver pilha :)
A usabilidade desta técnica fica a critério do leitor, mas de qualquer forma, acho que é uma maneira legal de revisar alguns tópicos e aprender um pouco de maldev para quem, como eu, está começando neste mundo.
A implementação principal mostrada aqui contém tudo que precisamos retirar da pilha na seção de dados, como variáveis globais, mas uma implementação movendo tudo para o heap será publicada em breve. Ela visa mostrar algumas modificações-chave que precisam ser feitas para tornar este código pic e injetável.
Este repositório espelhado entre GitHub e GitLab.
Tudo o que foi declarado aqui vem do meu entendimento dos diferentes tópicos abordados, seja por leitura ou por experiência durante o desenvolvimento. Estou ciente de que não sou um especialista e a última coisa que quero é espalhar desinformação, então se você acha que algo não está correto, adoraria que você me avisasse, pode me contatar no twitter ou abrir issues neste repositório. Muito obrigado pela sua compreensão. :)
O objetivo principal desta técnica é claro: encerrar a thread atual e restaurá-la antes de retomar a execução, mas o que exatamente isso significa e quais novas restrições impõe?
Para ser capaz de restaurar a execução, precisamos salvar duas coisas antes de encerrar a thread: primeiro, o estado da CPU, e segundo, a pilha, e efetivamente configurá-los novamente depois que a nova thread for lançada.
Eu falei sobre novas restrições que surgirão nesta técnica, e há duas grandes: Primeiro, precisamos armazenar fora da pilha qualquer coisa necessária desde o momento em que a thread termina até que a pilha seja restaurada, e como você verá, isso cria alguns novos desafios.
Segundo, sempre precisamos de pelo menos outra thread em execução em nosso processo, já que estamos encerrando nossa thread; se não houver outras threads, o processo terminará. Não acho que isso seja um grande problema, já que a maioria dos agentes é injetada em outros processos, podemos assumir que esse processo manterá pelo menos uma thread em execução.
Podemos ver neste PoC 4 funções principais:
Quando estamos prestes a salvar a pilha, surge uma pergunta: quanto da pilha precisa ser salvo?
Vamos revisar primeiro o que está na pilha depois que chamamos a função DeathSleep (esta é a função que salva o contexto, a pilha e prepara tudo para a ofuscação e restauração)

Como podemos ver, cada função tem três partes:
A porção mínima da pilha que obviamente precisamos salvar é tudo dentro do nosso programa principal, ou seja, seu espaço de sombra, seu endereço de retorno e tudo até a função DeathSleep. Qualquer coisa antes disso não é realmente necessária (salvar a pilha usada pela função de entrada tem suas vantagens, mas discutiremos isso depois), pois essa é a pilha usada pelas rotinas do Windows para lançar nossa nova thread. Além disso, decidi também armazenar o espaço de sombra da função DeathSleep (não realmente necessário, mas facilita o cálculo do Rsp no momento do despertar).
Então, no final, estamos salvando isto:

Cada função em uma compilação padrão deve ser composta de 3 partes: prólogo, código da função e epílogo.
O Rsp (ponteiro de pilha) deve ser modificado apenas no prólogo e epílogo da função. O prólogo aumenta o ponteiro de pilha (lembre-se, aumentar a pilha significa reduzir endereços, já que eles vão em direções opostas), para salvar registradores, armazenar todas as suas variáveis locais e depois armazenar o espaço de sombra, e o epílogo faz exatamente o oposto.
Isso significa que o ponteiro de pilha dentro do código da função deve sempre apontar para o final do espaço de sombra (roxo na imagem acima), e a soma do tamanho da pilha da função e do espaço de sombra pode ser encontrada calculando quanto o prólogo aumenta o ponteiro de pilha. Esse valor pode ser facilmente calculado usando as informações contidas nas tabelas de desenrolamento; uma explicação sobre seu uso não será abordada aqui, mas, em resumo, essas tabelas são usadas para permitir que qualquer outra thread ou processo mova-se corretamente pela pilha para ver seu conteúdo, tratar exceções ou analisá-la.
Capturar o contexto é provavelmente uma das coisas mais fáceis de fazer, pois podemos simplesmente usar RtlCaptureContext() na primeira linha do DeathSleep, antes que qualquer modificação seja feita nos registradores não voláteis. Ainda precisamos fazer duas modificações no contexto onde iremos restaurar a execução.
A primeira será modificar seu Rip, como você lembra, este é o registrador que contém a próxima instrução a ser executada, e se apenas deixarmos isso inalterado, a execução será retomada dentro da função DeathSleep. O que faremos é alterar o Rip para conter o endereço de retorno do DeathSleep, que é apontado pelo Rsp atual deslocado pelo tamanho aumentado no epílogo (áreas verde + roxa nas imagens acima).
A segunda modificação será feita quando a thread for restaurada, e implica definir o Rsp para apontar para o topo da nossa pilha restaurada; isso será feito durante a fase de restauração, já que não sabemos onde nossa nova pilha será colocada. O valor será simplesmente o endereço final da nossa pilha restaurada, já que, como discutimos depois, copiamos também o espaço de sombra reservado pelo chamador do DeathSleep, e esse é exatamente o valor de RSP antes da chamada para DeathSleep.
Quando chegamos ao ponto de despertar, pouco antes de retomar a execução, precisamos colocar nossa pilha salva no lugar. Como já sabemos que nossa pilha salva começa no endereço capturado pela função awake, o novo endereço capturado será o ponto de partida onde colocaremos nossa pilha salva, mas isso levanta um problema: qualquer chamada para uma função depois que colocamos nossa pilha antiga a modificaria e quebraria, e fazer a limpeza aqui é realmente conveniente, especialmente liberando o heap usado para armazenar nosso backup da pilha. Isso significa que precisamos mover nosso Rsp atual e também as partes da pilha que estamos usando atualmente para um local fora de onde colocaremos nossa pilha restaurada. Tentando tornar mais claro, aqui está o problema:

E aqui está minha solução, apenas mover tudo para longe:

Depois de todo o trabalho duro, a última coisa a fazer é usar NtContinue. Essa função nos permite alterar o contexto atual com nosso contexto previamente capturado e modificado, definindo o RIP logo após a chamada para DeathSleep. Todos os registradores devem ter os mesmos valores que tinham ao chamar DeathSleep, e RSP deve apontar para o topo da pilha.
Ok, sabemos o básico do que precisamos fazer para armazenar e restaurar a thread atual, mas precisamos de alguma forma ser capazes de executar tudo isso mesmo quando não temos threads. É aqui que encontramos nossa adorável API de pool de threads, uma ferramenta fornecida pelo Windows, que nos permitirá enfileirar tarefas (funções com no máximo um argumento) para um grupo de threads (um pool) que será totalmente gerenciado pelo sistema operacional. Se você viu o Ekko, pode ver que ele usa essa API, então... vamos implementá-lo da mesma forma.
Tudo funcionou bem, mas havia um problema: um worker ainda estava ativo mesmo após terminar a execução de suas tarefas enfileiradas. Isso era um problema, já que eu queria destruir todas as threads que nosso programa pudesse gerar, então essa não era a maneira correta.
Depois de pesquisar um pouco, descobri que a API de pool de threads usada no Ekko era uma versão antiga, e havia uma nova com algumas capacidades adicionais e, entre elas, uma função que resolveria efetivamente nosso problema: CloseThreadPool(). Essa nova API nos permite criar nosso próprio pool e destruí-lo após o uso, terminando todos os workers utilizados. Ela oferece outras duas vantagens: definir um número máximo de threads e grupos de limpeza. Definir um número máximo de threads nos permitiria executar todas as nossas tarefas sequencialmente, desde que sejam enfileiradas com qualquer diferença de tempo. Os grupos de limpeza são úteis para facilitar a limpeza após tudo estar pronto.
Então... está tudo feito? Bem, neste ponto a thread é terminada, e estamos enfileirando a função rebirth que cria a nova thread com awake como seu ponto de entrada, restaura o estado anterior e fecha o pool, até agora tudo bem!
Quando terminei tudo o que discutimos antes, pensei que a parte difícil estava resolvida, já que essa parte já havia sido resolvida por técnicas anteriores, mas oh céus, eu não sabia o que estava por vir.
O principal problema é que precisamos descarregar isso para fora do nosso código, já que estamos alterando a proteção de memória para RW (leitura-escrita). Se chamarmos VirtualProtect(), quando a função retornar, nosso processo irá travar (não podemos executar instruções em páginas RW), então precisamos encontrar uma maneira de executar isso de outro lugar, e fazê-lo retornar também para páginas RX (leitura-execução) (e o mesmo acontece ao voltar). Obviamente, usaremos a API de pool de threads para isso também, mas há um problema: só podemos dar um argumento para nossas tarefas, e VirtualProtect() recebe 4.
Para isso, usaremos NtContinue() novamente. A primeira vez que vi esse uso para esta função foi no Foliage, mas também é usado no Ekko. NtContinue(), como vimos antes, nos permite definir algum contexto para a thread que o chama, e com alguns ajustes inteligentes, pode "chamar" uma função com múltiplos argumentos, usando apenas um (muito conveniente para a API de pool de threads). A ideia principal é definir o RIP para o endereço inicial da função e, como a convenção de chamada x64 do Windows passa os primeiros quatro argumentos em registradores (rcx, rdx, r8, r9, nessa ordem), basta colocar seus argumentos na estrutura de contexto que você passará para NtContinue, e isso efetivamente simulará uma chamada para uma função. A última coisa que precisamos cuidar ao usar NtContinue é o Rsp, pois, como vimos antes, este endereço deve conter o endereço de retorno quando uma função é chamada.
Então, a primeira coisa que precisamos para que NtContinue funcione é obter um contexto. Poderíamos criá-lo manualmente, mas encontraríamos um problema: encontrar o valor para Rsp, que, quando passado para nossa função, apontará para o endereço que será usado por RET para retornar. Nossas tarefas funcionarão em uma thread diferente, então não sabemos onde sua pilha será colocada. A solução (cuidadosamente roubada do Ekko, muito obrigado :P) é tirar uma cópia do contexto dentro de um worker com RtlCaptureContext(), e aumentar o ponteiro de pilha do contexto obtido em 8, para que ele aponte para o endereço introduzido na pilha por CALL RtlCaptureContext(), que é o endereço de retorno desta última função, e podemos usá-lo como o endereço de retorno de todas as nossas funções.
Ok, isso é legal, mas o que acontece quando não podemos fazer essa modificação no Rsp? É o que acontece quando desofuscamos: estaremos em uma nova thread, então o antigo Rsp do contexto é inútil. Precisamos de um novo contexto, tirado da nova thread, mas não podemos usar o truque antigo de modificar o Rsp para apontar para o endereço correto.
Então, não podemos modificar o contexto obtido, mas isso não significa que ele seja inútil. Na realidade, vamos usá-lo, mas de uma maneira diferente. Se apenas restaurarmos esse contexto com NtContinue(), sem modificar seu Rip, ele apenas redirecionará a execução para a próxima instrução após a chamada para RtlCaptureContext(), e com um Rsp correto, então podemos usá-lo após nossas chamadas para NtContinue() com contextos modificados, para poder terminar corretamente a execução de nossas tarefas. Para fazer isso, usaremos uma cadeia ROP, definindo o Rsp do nosso primeiro contexto para apontar para uma pilha manualmente construída, que conterá tudo o que precisamos para redirecionar a execução até a segunda chamada NtContinue() que definirá o contexto correto para terminar.
É assim que nossa pilha construída deve se parecer:

Estamos usando 2 gadgets ROP, um para corrigir ou "pular" sobre o espaço de sombra da nossa função, e o segundo é responsável por colocar o argumento para NtContinue em rcx, e depois retornar para ele.
Encontrar esses 2 gadgets ROP é bastante fácil. O primeiro para corrigir o espaço de sombra é apenas o epílogo de quase qualquer função (encontrei mais de 500 hits apenas no Ntdll), já que, como vimos antes, os epílogos são projetados principalmente para reduzir o Rsp, e o segundo é apenas um pop rcx; ret; que tem 2 bytes, e também encontrei alguns entre as DLLs Ntdll e Kernel32.
Como vimos, usar NtContinue requer apenas que seu primeiro argumento seja preenchido para funcionar, e isso é perfeito com a API de pool de threads antiga, mas na nova API de pool de threads, os argumentos são passados na segunda posição, então sim, isso sozinho não funcionará.
Depois de algumas horas sem saber como resolver esse último problema, veio à minha mente que ambas as APIs usavam as mesmas funções em alguns casos, e isso me fez pensar que elas poderiam ser mais semelhantes do que pareciam, então decidi investigar qual era a relação entre elas.
Para a API antiga, usamos CreateTimerQueueTimer() para enfileirar nossas tarefas, e na nova, precisamos de duas funções para fazer o mesmo: CreateThreadpoolTimer(), que receberá a função de callback e o argumento a ser passado para ela, e retornará um ponteiro para uma estrutura TP_TIMER que descreve a tarefa, e uma segunda função para enfileirar a tarefa: SetThreadpoolTimer(), que receberá o ponteiro anterior e um ponteiro para uma estrutura FILETIME que descreve quando a tarefa será executada.
Se fizermos engenharia reversa dessas funções, encontraremos isto:

Então, como podemos ver, CreateThreadpoolTimer() é apenas um wrapper sofisticado para TpAllocTimer(), e SetThreadpoolTimer() é apenas um encaminhador para TpSetTimer().
Agora vamos verificar o interior de CreateTimerQueueTimer(). No início, é apenas outro wrapper sofisticado para uma função em Ntdll, RtlCreateTimer(), e é aqui que a mágica acontece. Esta é uma função maior, mas aqui está o ouro que estávamos procurando:

Como você pode ver, dentro desta função há efetivamente uma chamada para TpAllocTimer() e para TpSetTimer(), o que é semelhante a dizer que está chamando CreateThreadpoolTimer() e SetThreadpoolTimer() internamente. Como podemos ver, a função que estamos enfileirando não é diretamente o callback que demos à função, ela está definindo RtlpTpTimerCallback() como o callback. Se você ainda não percebeu o que tudo isso significa, é que estamos usando CreateThreadpoolTimer() para enfileirar uma função que recebe seus argumentos na segunda posição, RtlpTpTimerCallback(), que executará outra função com seus argumentos na primeira posição.
Então, a única coisa que ainda precisamos entender é como a informação do callback é passada para RtlpTpTimerCallback(), e depois de alguma engenharia reversa, terminei com a seguinte estrutura, que SURPRESA, FUNCIONA!

Agora podemos chamar funções que recebem seus argumentos na primeira posição e, ao mesmo tempo, podemos fechar nossos pools e não deixar threads em execução, vitória dupla. É importante notar que esta função não é exportada no Ntdll, então decidi encontrá-la pela sua forma de byte dentro da DLL.
Então este é o fim, e com tudo revisado, acho que dei as ideias centrais que passaram pela minha mente enquanto desenvolvia este PoC, e por que tudo foi feito da maneira que fiz.
Este código foi testado apenas com o compilador e linker MSCV, já que este PoC depende fortemente de como foi compilado. Recomendo usar essa mesma ferramenta e não garanto que funcionará com outros compiladores sem modificações.