
Return Address Spoofing
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"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/HTTPSws2_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:

Se a DLL tivesse sido carregada por um módulo legitimo teriamos o seguinte resultado na stack:

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).
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:
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.
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:
Agora vamos estar criando uma função que vai estar preenchendo os valores dessa estrutura:
Na nossa função SetupConfig, ela recebe um total de 7 argumentos:
pGadget ← Endereço do gadget obtido de
FindGadgetpConfig ← 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:
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.
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:
Se malloc retornar NULL, a função retorna FALSE.
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.
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:
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.
Registradores principais no fluxo de execução:
RDI - Endereço de retorno original:
• 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
• 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
• 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
• Vão conter: Os 4 argumentos que serão passados para a função alvo
RAX - Endereço do gadget
RBX - Ponteiro para pRbx (usado pelo gadget)
• Usado pelo gadget: jmp [rbx] lê [rbx] (que é cleanup) e pula para lá
RSP - Ponteiro da stack
• Vai ser manipulado: Para criar/destruir o shadow space obrigatório x64
Fluxo de Execução
Pula para a função alvo:
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:
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
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
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)
• Agora está de volta ao nosso código assembly • A stack está "limpa"
Finalização
Libera shadow space
Remove os 32 bytes reservados no início Restaura RSP para o estado antes da chamada
Retorna ao chamador original
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:
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:
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:

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.

Last updated