Page cover

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"

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:

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

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:

  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:

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:

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:

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.

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.


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.

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:


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á

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

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

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