
Técnica de execução/injeção de código usando manipulação da estrutura do módulo DLL PEB
Execução furtiva de código através da modificação do EntryPoint de módulos carregados em tempo de execução.
Os processos Windows possuem vários módulos carregados em tempo de execução. Cada um desses módulos tem uma função DllMain() definida, que será invocada na criação/destruição de processos ou threads (quatro cenários possíveis).
Para chamar corretamente essas funções durante o ciclo de vida do processo, as funções do Loader do Windows (ntdll!Ldrp*) referenciam uma lista de entradas contendo parâmetros chave (incluindo o campo EntryPoint) para cada módulo.
Ao sobrescrever este EntryPoint para uma DLL, garantimos que a execução do código será redirecionada para um local de nossa escolha.
Isso pode ser usado tanto como uma primitiva de execução de código, quanto para proxy de API, ou seja, para executar certas APIs com uma callstack não suspeita, já que serão invocadas por funções legítimas do Windows.
Isso também pode ser usado para acionar a execução em um processo remoto, desde que o invasor tenha a capacidade de ler e escrever memória neste processo alvo. De forma similar ao Threadless Injection, isso fornece a capacidade de executar código em um processo sem invocar APIs clássicas relacionadas à execução (CreateRemoteThread, QueueUserAPC).
O carregamento/descarregamento de módulos dentro de um processo Windows é um assunto complexo que apresenta muitos desafios, potencial para instabilidade, condições de corrida e crashes. Um obstáculo bem conhecido relacionado à execução de código como parte de uma função DllMain(), por exemplo, reside no fato de que um Loader Lock está ativo e estamos executando em uma thread que não foi completamente configurada ou que está em processo de finalização.
Portanto, tentei documentar adequadamente o que é possível e o que não é. Por exemplo, embora a maioria das chamadas de API usuais possa ser realizada, executar um beacon completo exige certos requisitos para estar em um processo separado, a fim de evitar deadlocks causados pelas funções usadas em wininet.dll ou winhttp.dll.
Cada processo mantém uma lista de estruturas _LDR_DATA_TABLE_ENTRY em tempo de execução. Essas estruturas contêm muitos detalhes relevantes para a DLL, como seu EntryPoint (que sobrescreveremos), seu nome, certos hashes, timestamps, várias flags, etc. Algumas dessas estruturas são documentadas e outras não.
Elas podem ser visualizadas através deste comando do WinDbg:
dt nt!_LDR_DATA_TABLE_ENTRY 0xdeadbeef
0:006> dt nt!_LDR_DATA_TABLE_ENTRY 0x18d1c4838c0
ntdll!_LDR_DATA_TABLE_ENTRY
+0x000 InLoadOrderLinks : _LIST_ENTRY [ 0x0000018d`1c485de0 - 0x0000018d`1c4832b0 ]
+0x010 InMemoryOrderLinks : _LIST_ENTRY [ 0x0000018d`1c485df0 - 0x0000018d`1c4832c0 ]
+0x020 InInitializationOrderLinks : _LIST_ENTRY [ 0x0000018d`1c4832d0 - 0x0000018d`1c482c80 ]
+0x030 DllBase : 0x00007ffe`e87e0000 Void
+0x038 EntryPoint : 0x00007ffe`e8838d00 Void
+0x040 SizeOfImage : 0x2fe000
+0x048 FullDllName : _UNICODE_STRING "C:\WINDOWS\System32\KERNELBASE.dll"
+0x058 BaseDllName : _UNICODE_STRING "KERNELBASE.dll"
+0x068 FlagGroup : [4] "???"
+0x068 Flags : 0x8a2cc
+0x068 PackagedBinary : 0y0
+0x068 MarkedForRemoval : 0y0
+0x068 ImageDll : 0y1
(...)
+0x0e0 MappingInfoIndexNode : _RTL_BALANCED_NODE
+0x0f8 OriginalBase : 0x00007ffe`e87e0000
+0x100 LoadTime : _LARGE_INTEGER 0x01db5f86`2fa735fc
+0x108 BaseNameHashValue : 0x235bec4
+0x10c LoadReason : 0 ( LoadReasonStaticDependency )
+0x110 ImplicitPathOptions : 0x4000
+0x114 ReferenceCount : 1
+0x118 DependentLoadFlags : 0x800
+0x11c SigningLevel : 0 ''
O endereço da estrutura pode ser obtido percorrendo uma estrutura duplamente ligada referenciada no PEB do processo dentro de uma estrutura PEB_LDR_DATA.
dt nt!_PEB_LDR_DATA 0xb4b4c3c3
Observe a flag DontCallForThreads. Como o nome indica, se essa flag estiver definida, o SISTEMA OPERACIONAL NÃO chamará o DllMain() desse módulo para eventos de thread (ou seja, DLL_THREAD_ATTACH ou DLL_THREAD_DETACH).
Ao criar uma DLL, o seguinte modelo deve ser seguido para funcionar em conjunto com as funções do Loader do SO:
BOOL WINAPI DllMain(
HINSTANCE hinstDLL, // handle para o módulo DLL
DWORD fdwReason, // motivo para chamar a função
LPVOID lpvReserved ) // reservado
{
// Executa ações com base no motivo da chamada.
switch( fdwReason )
{
case DLL_PROCESS_ATTACH:
// Inicializa uma vez para cada novo processo.
// Retorna FALSE para falhar no carregamento da DLL.
break;
case DLL_THREAD_ATTACH:
// Faz inicialização específica da thread.
break;
case DLL_THREAD_DETACH:
// Faz limpeza específica da thread.
break;
case DLL_PROCESS_DETACH:
if (lpvReserved != nullptr)
{
break; // não faz limpeza se for cenário de término de processo
}
// Realiza qualquer limpeza necessária.
break;
}
return TRUE; // DLL_PROCESS_ATTACH bem-sucedido.
}
Conforme descrito acima, a técnica sobrescreve temporariamente o EntryPoint de uma DLL para redirecionar a execução. Como não temos controle sobre nada além do redirecionamento da execução, alguns arranjos precisam ser feitos à parte para lidar com o que queremos executar, com quais argumentos e como obter o valor de retorno.
Isso é feito definindo uma estrutura DATA_T no heap, de forma que ela permaneça acessível durante as várias etapas.
Essa estrutura é definida da seguinte forma:
typedef struct _DATA_T {
// Manipulação das estruturas LDR
ULONG_PTR runner; // entry point malicioso a executar
ULONG_PTR bakOriginalBase; // backup do OriginalBase sobrescrito
ULONG_PTR bakEntryPoint; // backup do EntryPoint sobrescrito
HANDLE event; // evento sinalizando que o Runner foi executado
// chamada de função
ULONG_PTR ret; // valor de retorno
DWORD createThread; // executar esta chamada de API em uma nova thread (necessário para wininet/winhttp)
ULONG_PTR function; // API do Windows a chamar
DWORD dwArgs; // número de argumentos
ULONG_PTR args[MAX_ARGS]; // array de argumentos
} DATA_T, * PDATA_T;
Para configurar uma execução de API, esses campos devem ser preparados. O valor ret é o que coletará o valor de retorno após a execução. O event é usado para sincronização, para sinalizar que a execução foi concluída. Todos os outros campos são entradas definindo qual API chamar (function), com quais argumentos (dwArgs e args[]), o endereço da função Runner() para onde a execução é redirecionada e backups das entradas originais sobrescritas da DLL (bakOriginalBase e bakEntryPoint).
O campo createThread precisa ser definido como 1 para aquelas funções de API complexas que não funcionam bem em uma configuração DllMain() (isso inclui muitas bibliotecas wininet e winhttp).
Aqui está um exemplo configurando uma chamada para MessageBoxA() como visto no PoC:
pDataT->dwArgs = 4;
pDataT->runner = (ULONG_PTR)Runner;
pDataT->function = (ULONG_PTR)MessageBoxA;
pDataT->args[0] = (ULONG_PTR)0;
pDataT->args[1] = (ULONG_PTR)"Hello";
pDataT->args[2] = (ULONG_PTR)"LDRSHUFFLE";
pDataT->args[3] = (ULONG_PTR)MB_OKCANCEL;
pDataT->event = CreateEventA(NULL, FALSE, FALSE, "ExecEvt");
A função UpdateLdr() é responsável por realizar a modificação correta no _LDR_DATA_TABLE_ENTRY do módulo alvo.
RestoreLdr() restaurará essas alterações em um estágio posterior (invocada por Runner()).
Essas funções essencialmente localizam o PEB e percorrem as estruturas do módulo para identificar a DLL correta e seus campos. Nos arquivos de cabeçalho, estou reutilizando definições usadas por Batsec em seu DarkLoadLibrary e encorajo os leitores a verificar este projeto e o post de blog associado da MDSec para se beneficiar do excelente trabalho que ele vem fazendo nos detalhes internos do carregamento de módulos no Windows.
Nota: este PoC carrega uma DLL sacrificial (SACRIFICIAL_DLL_NAME) e realiza essas alterações nesta DLL. No entanto, é perfeitamente possível alterar uma DLL já carregada. Esta é, de fato, a abordagem usada para injeção entre processos. Por razões de estabilidade, recomendo evitar tocar em DLLs importantes como ntdll ou kernel32, que também tendem a ser mais examinadas por soluções de segurança.
Na criação ou destruição de uma thread, a execução é redirecionada para Runner(), que atua como um DllMain() falso para o módulo. Esta função então:
PDATA_T)RestoreLdr() ao seu estado originalDllMain() (essencialmente fazendo proxy da chamada normal da DLL).Neste ponto, a execução "normal" do SO foi realizada. Em seguida, continua com nossos payloads:
DATA_T. Se esta API foi marcada para ser executada em uma nova thread (createThread = 1), esta chamada será realizada em uma nova thread.pDataT->event) para que nosso código principal saiba que a chamada foi realizada.Quando o Windows acaba invocando nosso EntryPoint falso (que é o endereço da função Runner()), a callstack se parece com isso:

O PoC fornecido contém um exemplo invocando MessageBoxA().
Ele também contém uma demonstração de um download HTTP usando wininet. Defina a variável HTTP para ativar esse código.

Os princípios descritos acima se resumem a ler e escrever no espaço de memória do processo, para causar execução de código em um ponto arbitrário no tempo no futuro.
Com algumas modificações, essas operações de leitura e escrita podem ser aplicadas a um processo remoto para sobrescrever o EntryPoint de uma de suas DLLs.
Um pré-requisito é a capacidade de ler e escrever no espaço de memória do processo, ou seja:
OpenProcess(PROCESS_VM_READ, FALSE, dwPid) e OpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION, FALSE, PID)
Um projeto extra está presente no PoC, chamado LdrInject, demonstrando como realizar esses passos. Resumidamente, ele faz o seguinte:
em ReadPEB(), percorre a lista _LDR_DATA_TABLE_ENTRY no processo alvo para identificar uma DLL adequada para sobrescrever. Observe que esta DLL deve ter DontCallForThreads == 0 porque queremos que o Windows invoque o EntryPoint dessa DLL na criação da thread. Também não estamos escolhendo as primeiras DLLs da lista, pois elas tendem a ser mais examinadas por produtos de segurança (ntdll.dll, kernel32.dll...).
os detalhes para essa DLL são armazenados em uma estrutura de dados PEBINJ_DATA.
shellcode (neste caso, um beacon) é escrito no espaço do processo remoto com InjectShellcodeToRemoteProcess()
duas chamadas WriteProcessMemory() sobrescrevem o EntryPoint da DLL e fazem backup em OriginalBase para que possa ser restaurado posteriormente.
Nesse ponto, o próximo evento DLL_THREAD_ATTACH ou DLL_THREAD_DETACH resultará na invocação do shellcode. Isso vem com certas limitações e ressalvas no contexto de execução de um beacon, que são detalhadas na próxima seção.
Esta técnica resulta na execução de um shellcode em uma situação muito específica. O Loader Lock está ativo (já que o SO acredita que está no processo de carregar/descarregar uma DLL); uma thread está sendo criada ou destruída; e, de modo geral, há potencial para problemas de sincronização de threads, deadlocks, etc.
Durante os testes, dois desafios foram observados:
executar um beacon típico do Cobalt Strike resultaria em deadlock ao usar APIs em wininet.dll ou winhttp.dll.
executar na destruição da thread causa problemas de estabilidade, pois estamos executando em uma thread que está em processo de ser destruída.
Para aumentar a estabilidade, precisamos:
garantir que o beacon será executado em uma nova thread. Portanto, o UDRL criará uma CreateThread antes de invocar o entry point usual da DLL reflexiva do Cobalt Strike.
executar apenas em uma thread que está sendo criada e não em uma que está morrendo. Para fazer isso, garantimos que quando EntryPoint for chamado pelo SO, a razão invocada seja fdwReason == DLL_THREAD_ATTACH:
winApi.CreateThread(NULL, 4096, (LPTHREAD_START_ROUTINE)&runner, (LPVOID)&ct_data, 0, &dwThId);
em vez do usual
((DLLMAIN)entryPoint)((HINSTANCE)loaderStart, 4, NULL);
ULONG_PTR __cdecl ReflectiveLoader(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
// executa apenas para um evento de criação de Thread
if (fdwReason != DLL_THREAD_ATTACH) {
return TRUE;
}
...
}
Esses dois passos extras foram incorporados em uma demonstração para um UDRL.
VirtualAlloc
VirtualProtect
CreateThread
Sleep
MessageBoxA
InternetOpenW (precisa executar com createThread = 1)
InternetOpenUrlA (precisa executar com createThread = 1)