Debugging com GDB
GDB, um debbuger do GNU, é uma ótima ferramenta para analisar binários em execução, permitindo analisar o código em assembly, ler variáveis na memória, ler a stack, etc. É realmente essencial para se trabalhar com rev.
Normalmente, o combo é Ghidra + GDB: Abra o Ghidra para ter uma ideia do que o código faz e use o GDB para tentar explorar as vulnerabilidades e analisar o binário em execução.
Instalando e executando
Há três versões do gdb. O gdb clássico já vem instalado junto com o compilador do C ou C++.
O gdb-gef (gdb enhanced features), por sua vez, extende as funcionalidades do gdb padrão, e é muito mais útil para análise de software por engenharia reversa. Essa é a versão utilizado no tutorial do nightmare, por isso abordei ela aqui. O repositório do projeto pode ser encontrado aqui.
Baixe o gdb-gef com: bash -c "$(wget https://gef.blah.cat/sh -O -)"
E execute com: gdb ./meu_binario
Já a terceira versão é o pwndbg, que provavelmente é a melhor das três, e a mais completa. O repositório do pwndbg pode ser encontrado aqui.
Baixe o pwndbg-gdb com: curl -qsL 'https://install.pwndbg.re' | sh -s -- -t pwndbg-gdb
E execute com: pwndbg ./meu_binario
Como usar
O intuito do GDB é parar a execução do programa em certos lugares, analisar e prosseguir.
Para navegar na execução do programa no GDB:
continue- Para somente em breakpoints e no fim do programanext- Linha por linha, mas pula chamadas de função como putsstep- Linha por linha, mas entra em chamadas de funçãostepi- Instrução por instrução, entrando em chamadas de função
Para ver uma função em assembly, digite disassemble (ou disass) mais o nome da função. Vamos dar um disassemble (ou seja, ver o código em assembly) da main:
disass main
gef➤ disass main
Dump of assembler code for function main:
0x080483fb <+0>: lea ecx,[esp+0x4]
0x080483ff <+4>: and esp,0xfffffff0
0x08048402 <+7>: push DWORD PTR [ecx-0x4]
0x08048405 <+10>: push ebp
0x08048406 <+11>: mov ebp,esp
0x08048408 <+13>: push ecx
0x08048409 <+14>: sub esp,0x4
0x0804840c <+17>: sub esp,0xc
0x0804840f <+20>: push 0x80484b0
0x08048414 <+25>: call 0x80482d0 <puts@plt>
0x08048419 <+30>: add esp,0x10
0x0804841c <+33>: mov eax,0x0
0x08048421 <+38>: mov ecx,DWORD PTR [ebp-0x4]
0x08048424 <+41>: leave
0x08048425 <+42>: lea esp,[ecx-0x4]
0x08048428 <+45>: ret
End of assembler dump.
Breakpoints
No GDB podemos usar breakpoints, que são lugares onde o GDB para a execução para permitir examinar o programa. O mais comum é colocarmos o primeiro breakpoint na main (a função principal que contém todo o código fonte). Podemos colocar break main ou b main .
Agora, suponha que queremos dar um break na call do puts (função que imprime texto na tela), podemos fazer isso colocando um breakpoint para a instrução:
b *main+25(+25 é relativo ao início do código) oub *0x08048414(endereço de memória da instrução)
Ainda não rodamos o binário, apenas estabelecemos onde o GDB deve parar para que possamos analisar o código. Quando rodarmos o binário, o processo vai pausar e mostrar o debugger exatamente na instrução assembly onde colocamos os breakpoints (quando colocamos um breakpoint em uma função, paramos exatamente na primeira instrução dela).
Para executar o código desde o começo, execute run (ou r). Isso vai parar no primeiro breakpoint. Para prosseguir, execute continue (ou c). Esse comando continua a execução até encontrar um breakpoint, fim do programa ou sinal (como segfault).
Para mostrar os breakpoints que colocamos:
info breakpoints
Para deletar um breakpoint:
delete 2oud 2
Podemos colocar breakpoints em funções como puts :
b *puts(* significa endereço) erun
Console de debug
Quando rodamos o binário com run e o GDB tenta executar a instrução na qual colocamos o break, o processo vai pausar e dropar na ela o console de debug:
gef➤ r
Starting program: /home/devey/nightmare/modules/02-intro_tooling/hello_world
[ Legend: Modified register | Code | Heap | Stack | String ]
────────────────────────────────────────────────────────────────────── registers ────
$eax : 0xf7fb9dd8 → 0xffffd19c → 0xffffd389 → "CLUTTER_IM_MODULE=xim"
$ebx : 0x0
$ecx : 0xffffd100 → 0x00000001
$edx : 0xffffd124 → 0x00000000
$esp : 0xffffd0d0 → 0x080484b0 → "hello world!"
$ebp : 0xffffd0e8 → 0x00000000
$esi : 0xf7fb8000 → 0x001d4d6c
$edi : 0x0
$eip : 0x08048414 → 0xfffeb7e8 → 0x00000000
$eflags: [zero carry PARITY ADJUST SIGN trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x0023 $ss: 0x002b $ds: 0x002b $es: 0x002b $fs: 0x0000 $gs: 0x0063
────────────────────────────────────────────────────────────────────────── stack ────
0xffffd0d0│+0x0000: 0x080484b0 → "hello world!" ← $esp
0xffffd0d4│+0x0004: 0xffffd194 → 0xffffd34e → "/home/devey/nightmare/modules/02-intro_tooling/hel[...]"
0xffffd0d8│+0x0008: 0xffffd19c → 0xffffd389 → "CLUTTER_IM_MODULE=xim"
0xffffd0dc│+0x000c: 0x08048451 → <__libc_csu_init+33> lea eax, [ebx-0xf8]
0xffffd0e0│+0x0010: 0xf7fe59b0 → push ebp
0xffffd0e4│+0x0014: 0xffffd100 → 0x00000001
0xffffd0e8│+0x0018: 0x00000000 ← $ebp
0xffffd0ec│+0x001c: 0xf7dfbe81 → <__libc_start_main+241> add esp, 0x10
──────────────────────────────────────────────────────────────────── code:x86:32 ────
0x8048409 <main+14> sub esp, 0x4
0x804840c <main+17> sub esp, 0xc
0x804840f <main+20> push 0x80484b0
→ 0x8048414 <main+25> call 0x80482d0 <puts@plt>
↳ 0x80482d0 <puts@plt+0> jmp DWORD PTR ds:0x80496bc
0x80482d6 <puts@plt+6> push 0x0
0x80482db <puts@plt+11> jmp 0x80482c0
0x80482e0 <__gmon_start__@plt+0> jmp DWORD PTR ds:0x80496c0
0x80482e6 <__gmon_start__@plt+6> push 0x8
0x80482eb <__gmon_start__@plt+11> jmp 0x80482c0
──────────────────────────────────────────────────────────── arguments (guessed) ────
puts@plt (
[sp + 0x0] = 0x080484b0 → "hello world!",
[sp + 0x4] = 0xffffd194 → 0xffffd34e → "/home/devey/nightmare/modules/02-intro_tooling/hel[...]"
)
──────────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "hello_world", stopped 0x8048414 in main (), reason: BREAKPOINT
────────────────────────────────────────────────────────────────────────── trace ────
[#0] 0x8048414 → main()
─────────────────────────────────────────────────────────────────────────────────────
Breakpoint 1, 0x08048414 in main ()
gef➤
O console de debug se divide em abas. Veremos o que cada uma mostra de informação.
Registers
Aqui podemos ver o conteúdo dos registradores da CPU. Eles serão usados o tempo inteiro.
────────────────────────────────────────────────────────────────────── registers ────
$eax : 0xf7fb9dd8 → 0xffffd19c → 0xffffd389 → "CLUTTER_IM_MODULE=xim"
$ebx : 0x0
$ecx : 0xffffd100 → 0x00000001
$edx : 0xffffd124 → 0x00000000
$esp : 0xffffd0d0 → 0x080484b0 → "hello world!"
$ebp : 0xffffd0e8 → 0x00000000
$esi : 0xf7fb8000 → 0x001d4d6c
$edi : 0x0
$eip : 0x08048414 → 0xfffeb7e8 → 0x00000000
$eflags: [zero carry PARITY ADJUST SIGN trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x0023 $ss: 0x002b $ds: 0x002b $es: 0x002b $fs: 0x0000 $gs: 0x0063
No exemplo acima, temos, por exemplo:
- $eax: 0xf7fb9dd8 → 0xffffd19c → 0xffffd389 → "CLUTTER_IM_MODULE=xim"
eaxcontém o endereço0xf7fb9dd80xf7fb9dd8contém o endereço0xffffd19c0xffffd19ccontém o endereço0xffffd3890xffffd389contém a a string "CLUTTER_IM_MODULE=xim" (processos internos não relevantes do C)
- $ebx
- 0x0 - Vazio
- $esp (stack pointer): 0xffffd0d0 → 0x080484b0 → "hello world!"
espcontém o endereço0xffffd0d0(topo da stack atual)0xffffd0d0contém o endereço0x080484b00x080484b0aponta para a string "Hello World!"
- $ebp (base stack pointer): 0xffffd0e8 → 0x00000000
ebpcontém o endereço 0xffffd0e8 (base da stack atual)0xffffd0e8contém o valor 0x0 (zero)
Além disso, temos o registrador com as flags:
- $eflags: [zero carry PARITY ADJUST SIGN trap INTERRUPT direction overflow resume virtualx86 identification]
Se a flag é mostrada, seu valor é 1. Se não for mostrada, seu valor é 0. Como, por exemplo:
zeroaparece - Última operação resultou em zero
Stack
A stack é um dos pontos mais importantes do programa, pois uma série de vulnerabilidades envolvem explorar a maneira como a stack funciona. Nela estarão as variáveis locais, chamadas de função, retorno, etc.
────────────────────────────────────────────────────────────────────────── stack ────
Endereço Offset Valor → Conteúdo
0xffffd0d0│+0x0000: 0x080484b0 → "hello world!" ← $esp
0xffffd0d4│+0x0004: 0xffffd194 → 0xffffd34e → "/home/devey/nightmare/modules/02-intro_tooling/hel[...]"
0xffffd0d8│+0x0008: 0xffffd19c → 0xffffd389 → "CLUTTER_IM_MODULE=xim"
0xffffd0dc│+0x000c: 0x08048451 → <__libc_csu_init+33> lea eax, [ebx-0xf8]
0xffffd0e0│+0x0010: 0xf7fe59b0 → push ebp
0xffffd0e4│+0x0014: 0xffffd100 → 0x00000001
0xffffd0e8│+0x0018: 0x00000000 ← $ebp
0xffffd0ec│+0x001c: 0xf7dfbe81 → <__libc_start_main+241> add esp, 0x10
Acima, podemos ver:
0xffffd0d0│+0x0000: 0x080484b0 → "hello world!" ← $esp(topo da stack)$espestá apontando para este endereço,0xffffd0d0. Lembra que o mesmo endereço estava nos registradores$esp, visto anteriormente?- Mesmo que seja o primeiro valor a ser mostrado, esse é o topo da stack. Lembre-se que
$espsempre aponta para o topo da stack. - Temos um offset +0x0000. Isso quer dizer quantos bytes estamos longe do primeiro endereço,
0xffffd0d0(topo da stack). 0xffffd0d0(endereço da stack) contém0x080484b0, que aponta para a string "Hello World!".
0xffffd0e8│+0x0018: 0x00000000 ← $ebp$ebpaponta para este endereço da stack,0xffffd0e8.0xffffd0e8contem0x0, ou seja, zero. Isso indica que é um valor nulo (não há ebp salvo, o que indica que não havia função anterior com stack e variáveis locais para a qual o código irá voltar. Isso é válido, já que estamos na função principal, a main)
0xffffd0ec│+0x001c: 0xf7dfbe81 → <__libc_start_main+241> add esp, 0x10- Note que estamos falando de registradores com prefixo
e, ou seja, x86 = 32 bits. Isso significa que a stack está de 4 em 4 bytes, como podemos ver. Isso também quer dizer que o endereço de retorno para a função anterior sempre estará em ebp+0x4. E cá estamos. em0xffffd0e8temosebp, e em0xffffd0ec(0xffffd0ec + 0x4) temos o endereço da função<__libc_start_main+241>. Esta é a função que inicializa a main.
- Note que estamos falando de registradores com prefixo
Note também que a stack realmente cresce negativamente, indo desde endereços mais altos (ebp) até endereços mais baixos (esp).
Code
É aqui onde fica o código em Assembly. Em cima podemos ver a arquitetura do programa: x86 = 32 bits, ou 4 bytes.
A setinha mostra em qual instrução paramos (esta instrução não foi executada ainda, mas será a próxima a ser executada).
──────────────────────────────────────────────────────────────────── code:x86:32 ────
0x8048409 <main+14> sub esp, 0x4
0x804840c <main+17> sub esp, 0xc
0x804840f <main+20> push 0x80484b0
→ 0x8048414 <main+25> call 0x80482d0 <puts@plt>
↳ 0x80482d0 <puts@plt+0> jmp DWORD PTR ds:0x80496bc
0x80482d6 <puts@plt+6> push 0x0
0x80482db <puts@plt+11> jmp 0x80482c0
0x80482e0 <__gmon_start__@plt+0> jmp DWORD PTR ds:0x80496c0
0x80482e6 <__gmon_start__@plt+6> push 0x8
0x80482eb <__gmon_start__@plt+11> jmp 0x80482c0
Arguments (guessed)
Essa aba aparece quando o GDB deduz que uma função está preste a ser chamada.
──────────────────────────────────────────────────────────── arguments (guessed) ────
puts@plt (
[sp + 0x0] = 0x080484b0 → "hello world!",
[sp + 0x4] = 0xffffd194 → 0xffffd34e → "/home/devey/nightmare/modules/02-intro_tooling/hel[...]"
)
- [sp + 0x0] = 0x080484b0 → "hello world!"
- Primeiro argumento (
sp+0). Está em$esp+0x0, que é o ponteiro0x080484b0que aponta para a string "hello world". - Segundo argumento (
sp+4). Provavelmente é um artefato na stack (lixo de memória), e não um argumento real, poisputs()só recebe um argumento (a string).
- Primeiro argumento (
Threads
Exibe informações de threads relacionadas ao sistema operacional.
[#0] Id 1, Name: "hello_world", stopped 0x8048414 in main (), reason: BREAKPOINT
#0- Thread número 0 (thread principal)id 1- ID do thread no sistema operacionalName: "hello_world"- Nome do programa/threadstopped 0x8048414 in main ()- Parado no endereço 0x8048414 na função main()reason: BREAKPOINT- Motivo da parada: breakpoint atingido
Trace
Backtrace da execução. Mostra pilha de chamadas (call stack)
────────────────────────────────────────────────────────────────────────── trace ────
[#0] 0x8048414 → main()
#0- Frame atual: executandomain()no endereço 0x8048414
Se houvesse mais funções, mostraria algo como:
[#0] 0x... → função_atual()
[#1] 0x... → função_chamadora()
[#2] 0x... → main()
Visualizando
É possível ver que o registrador esp armazena o valor 0xffffd0d0 , que é um ponteiro. (visível no começo, na aba de registradores)
$esp : 0xffffd0d0 → 0x080484b0 → "hello world!"
Para examinar as coisas com x , você tem que especificar o que vai ser examinado, e ele mostra o conteúdo:
x/a- Endereço,x/10c- Número de caracteres,x/s- String,x/g- Qword,x/w- Dword
Para visualizar o conteúdo dos registradores:
info registers
Para ver o stack frame (seção da stack alocada para uma única função)
info frame
Mudando valores
Se quisermos mudar o valor armazenado em 0x08048451 para 0xfacade :
set 0x08048451 = 0xfacade
Assim, podemos manipular o código do jeito que quisermos.
Arquivos compilados e Debugging symbols
Debugging symbols são metadados embutidos no arquivo executável (binário) que fornecem informações de mapeamento entre o código binário e o código fonte original. Caso essas informações não estejam presentes no arquivo, a análise fica um pouco mais difícil, pois perdemos algumas informações.
Compilando com -g
No comando abaixo, estamos compilando o arquivo main.c, que pode ser qualquer coisa, como um simples printf("Hello World!").
gcc -g -o meu_programa main.c
Note que usamos a opção -g. Essa opção ativa os metadados de Debugging Symbols. É bem útil para desenvolvimento de programas, de modo que fica mais fácil debugar o código.
Ao usar -g:
- Inclui informações de debugging no executável
- Mapeamento completo entre código fonte e assembly
Debug Symbol:
Endereço 0x8048410 → programa.c:15 (linha no código fonte)
- Nomes de variáveis, funções, structs são preservados
- Arquivo maior em tamanho
Comandos principais navegar no GDB em um executável com -g:
(gdb) list # Mostra código fonte
(gdb) break main # Breakpoint por nome de função
(gdb) break 10 # Breakpoint por linha
(gdb) print variavel # Mostra valor de variáveis
(gdb) next # Próxima linha de código
(gdb) step # Entra em funções
(gdb) info locals # Variáveis locais
(gdb) info args # Argumentos da função
Compilando sem -g
Sem -g:
- Sem informações de debugging
- Apenas código executável
- Arquivo menor e otimizado
- Uso de strip adicional:
strip program
Comandos principais navegar no GDB em um executável sem -g:
(gdb) break *0x8048400 # Breakpoint por endereço
(gdb) info functions # Lista funções (limitado)
(gdb) disas main # Disassembly da função
(gdb) nexti # Próxima instrução assembly
(gdb) stepi # Entra em calls
(gdb) info registers # Registradores
(gdb) x/10i $eip # Examina instruções
É importante também saber que alguns comandos não funcionam quando o programa é compilado sem -g:
break linha_codigo_fonteprint variávelnextinfo locals
Compilando sem -g + stripped
Outra ocasião é quando o arquivo foi completamente stripped - Todos os símbolos foram removidos, incluindo os símbolos básicos das funções. As funções perdem seus nomes, inclusive a main. Assim, b main não irá funcionar.
gcc -o teste teste.c
strip --strip-all teste
Antes do strip, tínhamos algumas seções no executável:
.text → Código executável
.data → Dados inicializados
.rodata → Strings constantes
.symtab → Tabela de símbolos
.strtab → Tabela de strings
Depois do strip, sobra apenas:
.text (código ainda funciona)
.data
.rodata
Para navegar com o GDB em arquivos assim, você terá que usar o entry point:
(gdb) break *entry_point
(gdb) si/ni para navegar
Entry point é o endereço de memória onde o sistema começa a executar um programa. É a primeira instrução do código que a ser executada.
Verificando nível de strip de um binário
Para verificar, use o comando do file arquivo
Com símbolos, no final haverá not stripped:
ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, with debug_info, not stripped
Sem símbolos com stripped parcial: ..., stripped
Completamente stripped: ..., no symbols
Observação: Códigos não iniciam na main
Os códigos não começam direto na função main, que é a primeira que o programador escreve. Na verdade, primeiro temos:
- Inicialização do ambiente C (Libc)
- Inicializar alocador de memória (malloc/free)
- Configurar buffers de I/O (Input/Output) (stdin, stdout, stderr)
- Inicializar variáveis globais da libc
- Setup de localization (locale)
- Processamento de argumentos (do kernel para main, por exemplo)
- Inicialização de variáveis globais (fora da
main) - Construtores de objetos C++
- Alinhamento e Setup da Stack
- Segurança e ASLR - Há algumas seguranças que dificultam a vida dos ganeshers como nós. Aprenderemos elas mais para frente.