Pular para o conteúdo principal

ROP (Return-Oriented Programming)

ROP é uma técnica de exploração que permite contornar proteções como DEP/NX, onde a stack não é executável e isso impede execução de shellcode.

O princípio do ROP é bem simples: Ao invés de injetarmos código para ser executado no programa, reutilizamos instruções já existentes no binário (chamadas de gadgets) que terminam com ret. Ao encadear esses gadgets (colocar um após o outro), podemos fazer o que quisermos. É como brincar com Lego.

Lógica do ROP

Imagine que temos um objetivo: Queremos chamar a função puts(const char *s) e dar a ela como parâmetro minha_string. Estamos na arquitetura x86-64 e pela convenção de chamada o primeiro argumento vai no registrador RDI.

Para fazer o ROP, precisamos:

  1. Colocar endereço de minha_string no registrador RDI (1° parâmetro)
  2. Chamar puts()

Antes de continuar, precisamos entender que o ROP ocorre sempre no retorno de uma função. Você se lembra das duas instruções mágicas que ficam no retorno de toda função, leave e ret?

  • leave - Instrução compacta:
    • MOV RSP, RBP - RSP = RBP. Isso destrói o stack frame da função, descartando todas as variáveis locais. (agora, o topo da stack é o RBP antigo)
    • POP RBP - O valor no topo da stack (RBP antigo) é desempilhado e colocado no registrador RBP. Isso faz o stack frame "voltar para trás". (agora, o topo da stack é o return address)
  • ret - Instrução compacta:
    • POP RIP - O valor no topo da stack agora é RBP+0x8, o return address que contém o ROP. Desempilhamos o endereço do ROP para a RIP, e assim levamos a execução para lá.

Assim, quando colocamos o ROP no return address, isso tudo acontece. A função do ret para o ROP é justamente possibilitar a chain: tira da stack e coloca no RIP, retorna, tira da stack e coloca no RIP, retorna, e assim por diante.

E como vamos fazer para colocar o endereço de minha_string em RDI? Precisamos de um ROP para colocar o parâmetro no RDI, e colocar o endereço da string do próximo bloco.

  • pop rdi - Valor no topo da stack agora nesse momento é o return address + 0x8. Ao dar pop, estamos colocando o que está ali no RDI. A ideia é, com BOF, colocar o endereço de minha_string ali. Isso queima esse bloco da stack, e o próximo valor é return address + 0x10.
  • ret - Pega topo da stack return address + 0x10 e coloca na RIP (pode ser um próximo gadget, mas aqui será a função que queremos chamar)

Para finalizar, só falta chamar a função, agora que já colocamos o parâmetro no registrador. Após tudo isso, teremos a seguinte Stack:

[RBP-0x20] = AAAA...                          (bytes de padding)
[RBP+0x00] = RBP antigo (8 bytes)
[RBP+0x08] = RET com gadget ← RIP vai aqui primeiro!
[RBP+0x10] = Endereço da string para gadget ← Coletado pelo gadget
[RBP+0x18] = Função alvo ← RIP vai aqui depois!

Semelhantemente:

# Como a stack fica:
Stack: [pop_rdi][parametro][puts_plt][...]
↑ ↑ ↑
| | |
1. POP RDI 2. Parâmetro 3. Chamada
para RDI da função

Gadgets típicos

Pop registers

Usados para controlar valores nos registradores. Permitem controlar os parâmetros que as funções a serem chamadas recebem.

# x86-64 (64-bit)
pop rdi; ret # Coloca valor no RDI (1º argumento)
pop rsi; ret # Coloca valor no RSI (2º argumento)
pop rdx; ret # Coloca valor no RDX (3º argumento)
pop rax; ret # Coloca valor no RAX (syscall number)

# x86 (32-bit)
pop eax; ret # Coloca valor no EAX
pop ebx; ret # 1º argumento
pop ecx; ret # 2º argumento
pop edx; ret # 3º argumento

Exemplo chamando write(1, buf, 100) no pwntools:

payload += p64(pop_rdi) + p64(1)      # fd = STDOUT
payload += p64(pop_rsi) + p64(buf) # buffer
payload += p64(pop_rdx) + p64(100) # count
payload += p64(write_addr) # chamar write

Gadgets com múltiplos pop_rsi

Úteis para limpar a stack ou configurar múltiplos registradores.

# Exemplo típico encontrado em libc
pop rdi; pop rsi; pop rdx; ret

# Ou com registradores extras
pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret

Load/Store (memória)

Para ler ou escrever na memória.

# Escrever na memória
mov [rdi], rsi; ret # *rdi = rsi
mov [rax], rdx; ret # *rax = rdx

# Ler da memória
mov rax, [rbx]; ret # rax = *rbx
mov rdi, [rsp+0x10]; ret # rdi = *(rsp+0x10)

Aritméticos/Lógica

Para ajustar ponteiros.

add rax, rbx; ret          # rax += rbx
sub rax, 0x10; ret # rax -= 0x10
xor rax, rax; ret # rax = 0 (limpa registrador)
inc rax; ret # rax++
dec rdi; ret # rdi--

syscall

Para fazer chamadas de sistema diretamente.

# x86-64
syscall; ret

# x86 (32-bit)
int 0x80; ret

# Preparar syscall
mov eax, 0x3b; syscall; ret # execve syscall number

Stack pivoting

Para mover o stack pointer para uma área controlada por nós

# Comuns em ROP avançado
xchg rsp, rax; ret # Troca RSP com RAX
mov rsp, rbx; ret # RSP = RBX
add rsp, 0x100; ret # Ajusta RSP
leave; ret # mov rsp, rbp; pop rbp

Control Flow

Para mudar o fluxo de execução.

jmp rax; ret                  # Salta para RAX
call rax; ret # Chama função em RAX
test eax, eax; je 0x1234; ret # Condicional (raro mas útil)

Ferramentas úteis

  • ROPgadget: Encontra gadgets em binários
# Buscar gadgets específicos
ROPgadget --binary ./programa | grep "pop rdi"
ROPgadget --binary ./programa --only "pop|ret"
ROPgadget --binary ./programa --range 0x400000-0x401000
  • pwntools - Possui classe ROP que facilita muito a criação de ROP chains.
from pwn import *

# Configuração básica
context.binary = './vuln' # Binário alvo
context.log_level = 'debug' # Nível de log
context.arch = 'amd64' # Arquitetura (ou 'i386')

# Iniciar processo
p = process('./vuln') # Local
# p = remote('host', porta) # Remoto

# Carregar binário para análise
elf = ELF('./vuln')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6') # Carregar libc (ajustar caminho)

# Usar ROPgadget (externo) para achar gadgets
# $ ROPgadget --binary ./vuln

# Ou usar o rop do pwntools
rop = ROP(elf)

# Gadgets úteis (Se não encontrar, buscar manualmente)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0] # Retorna endereço do gadget (cuidado, pode dar indexError se não existir. Faça if pop_rdi: pop_rdi = pop_rdi[0]
pop_rsi = rop.find_gadget(['pop rsi', 'ret'])[0]
pop_rdx = rop.find_gadget(['pop rdx', 'ret'])[0]
ret = rop.find_gadget(['ret'])[0]

# Fazendo payload
payload = b'A' * padding
payload += p64(pop_rdi)
payload += p64(hello) # puts("Hello World!") (hello = endereço da string)
payload += p64(elf.plt['puts']) # Chama função puts pela tabela PLT que existe no binário

# Método mais simples do pwntools
rop.raw(b'A' * padding) # Padding primeiro
rop.call('puts', [hello]) # Adiciona chamada com função
rop.call('main') # Adiciona retorno
print(rop.dump()) # Visualizar chain
payload = rop.chain()

Fazendo um ataque ROP

Para fazer um ataque rop, fazemos o seguinte:

  1. Gadgets: Obtemos os endereços dos gadgets que queremos usar
  2. Buffer Overflow: Sobrescrevemos return address com o endereço de um gadget
  3. ROP Chain: Quando a instrução ret do gadget é executada, o RIP pula para o bloco posterior ao return address na Stack. Podemos colocar outro gadget lá, e assim por diante, inclusive podendo terminar a ROP Chain chamando uma função.