Recebi algum feedback a respeito da série sobre desenvolvimento para vulnerabilidades e percebi que muitas pessoas estão com dificuldades com o GDB (GNU Debugger), que é uma excelente ferramenta, de código aberto, para estudo e depuração de problemas em softwares. Por isso, resolvi escrever esse post, como uma tentativa de apresentar o básico das funcionalidades desse depurador.
Em primeiro lugar, vale salientar, que o que irei listar aqui é apenas o básico de depuração usando o GDB em relação a imensidão de recursos que essa ferramenta provê e que é muito bem documentado [1], [2] e [3].
Esse depurador, que já possui 21 anos, foi escrito por Richard Stallman em 1988 sob a licença GPL General Public License da GNU para auxiliar no desenvolvimento de aplicações em C, C++ e Fortran. Ele oferece suporte a diversas arquiteturas como: Alpha, ARM, H8/300, System/370, System 390, X86 e X86-64, IA-64 “Itanium”, Motorola 68000, MIPS, PA-RISC, PowerPC, SuperH, SPARC, VAX. Em fim, é uma ferramenta já madura, usada e aperfeiçoada por muitas gerações de programadores como apoio ao desenvolvimento de softwares.
Muitas pessoas confundem os termos disassembler (decompilador) com debugger (depurador) e há uma diferença significativa entre essas duas categorias de ferramentas. A primeira é usada para fazer engenharia reversa de código para análise de malwares e forense, essas ferramentas tentam tornar mais simples a análise de um executável fazendo compilação de linguagem de máquina apara uma linguagem cuja sintaxe seja mais próxima da natural ou da linguagem de programação original do código. Os depuradores funcionam como ferramenta de apoio ao desenvolvimento permitindo ao programador fazer pausas na execução e alterações no ambiente para análise do comportamento da execução do sistema. Em suma, uma é mais apropriada para entender o significado de um código e a outra para auxílio ao desenvolvimento e observação do comportamento da execução de um código.
Para todos os exemplos desse artigo será usado o seguinte código:
#include <stdio.h>
#include <stdlib.h>
int soma (int a, int b) {
return a + b;
}
int main() {
printf("\n\n\t[+] A soma de 2 + 3 = %d\n\n", soma(2,3));
return 0;
}
Vamos mostrar o funcionamento de duas ferramentas: o IDApro (decompilador e depurador) e o GDB (depurador).
O IDApro [4], é sem sombra de dúvidas é o engenho mais usado para fazer engenharia reversa do mundo. Um ponto muito positivo é que o IDA é altamente extensível, possibilitando a construção de plugins para análise estática e visualização de código. O IDA é um software privado (módica quantia de U$ 515,00), desenvolvido por Ilfak Guilfanov e é distribuído pela Hex-Rays[5].
Em primeiro lugar, vamos mostrar o uso do IDApro (a versão para Linux) para decompilar o programa acima exibido. Note como a interface gráfica torna a leitura do código Assembly mais simples, mostrando os segmentos do programa em memória de uma forma que permite você navegar e fazer saltos para qualquer endereço exibido na tela apenas clicando em cima dos símbolos.

Fig 01 - Decompilação da função main pelo IDApro

Fig 02 - Decompilação da função "soma"

Fig 03 - Visualização de contexto dos registradores
A versão para Windows possui um output mais intuitivo em forma de grafo, explicitando o fluxo do software. Segue um screenshot do IDA usando um plugin de visualização chamado Binnavi desenvolvido pela Zynamics [6]:

Fig. 04 - IDA usando o plugin Binnavi
Essa foi uma visão geral sobre o IDA, porém, como foco desse post é depurar usando o GDB, agora vamos separar alguns grupos de funções dessa ferramenta e tentar detalhar cada categoria de funções através de exemplos. Apesar de ter integração com diversas IDEs como o Eclipse e o Kdevelop o GDB possui um ponto fraco que é o fato de não possuir uma interface gráfica oficial. Porém, a maior parte dos usuários do GDB preferem usá-lo através da sua interface texto, que uma vez aprendida a sintaxe dos comandos se ganha uma boa flexibilidade e agilidade no processo de depuração.
* Execução:
Vamos compilar e executar nosso programa passando parâmetros para o nosso executável (mesmo que ele não esteja esperando). Vamos mostrar como se inicia o processo de depuração usando o GDB e a sintáxe para passagem de parâmetros através deste.
$ gcc -ggdb -o teste teste.c
$ ./teste
[+] A soma de 2 + 3 = 5
$ gdb teste
GNU gdb 6.8-debian
Copyright (C) 2008 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "i486-linux-gnu"...
(gdb)
Aqui nós “caímos” no prompt de comando do GDB. O nosso binário “teste” ainda não foi executado. O que acontece é que agora o GDB está funcionando como um “proxy” entre o software depurado e o sistema operacional. Isso significa que toda chamada de sistema, antes de chegar no sistema operacional, passará por dentro do GDB e com isso ele pode controlar e examinar o nosso processo. Para fazer tal façanha o GDB faz uso de uma biblioteca chamada ptrace [7].

Fig. 05 - Posição do GDB em relação ao SO e ao processo depurado
Para executar o programa no GDB usamos o comando “run” ou simplesmente o atalho “r“:
(gdb) run
Starting program: /home/mabj/tmp/gdb/teste
[+] A soma de 2 + 3 = 5
Program exited normally.
(gdb) run param1 param2 < --- Executando o nosso binário passando parâmetros
Starting program: /home/mabj/tmp/gdb/teste param1 param2
[+] A soma de 2 + 3 = 5
Program exited normally
Note a sintaxe para passar parâmetros, pois isso será muito útil em diversas situações. Nós podemos também usar código shell para formar nossos parâmetros e com isso gerar entradas que não são triviais de serem geradas na mão:
(gdb) run $'\x41\x41'
Starting program: /home/mabj/tmp/gdb/teste $'\x41\x41'
[+] A soma de 2 + 3 = 5
Program exited normally.
(gdb) r $(perl -e "print 'marcos'")
Starting program: /home/mabj/tmp/gdb/teste $(perl -e "print 'marcos'")
[+] A soma de 2 + 3 = 5
Program exited normally.
* Exibindo o código:
Nessa sessão vamos mostrar os comandos de visualização e navegação no código Assembly do processo depurado. O comando principal é o “disassemble“, que mostra o código referente a um símbolo conhecido no nosso programa. No nosso caso nós temos duas funções no nosso programa: “main” e “soma”. Vamos mostrar o Assembly da nossa função ” main”.
(gdb) disassemble main
Dump of assembler code for function main:
0x080483d1 <main+0>: lea 0x4(%esp),%ecx
0x080483d5 <main+4>: and $0xfffffff0,%esp
0x080483d8 <main+7>: pushl -0x4(%ecx)
0x080483db <main+10>: push %ebp
0x080483dc <main+11>: mov %esp,%ebp
0x080483de <main+13>: push %ecx
0x080483df <main+14>: sub $0x14,%esp
0x080483e2 <main+17>: movl $0x3,0x4(%esp)
0x080483ea <main+25>: movl $0x2,(%esp)
0x080483f1 <main+32>: call 0x80483c4 <soma>
0x080483f6 <main+37>: mov %eax,0x4(%esp)
0x080483fa <main+41>: movl $0x80484e0,(%esp)
0x08048401 <main+48>: call 0x80482f8 <printf@plt>
0x08048406 <main+53>: mov $0x0,%eax
0x0804840b <main+58>: add $0x14,%esp
0x0804840e <main+61>: pop %ecx
0x0804840f <main+62>: pop %ebp
0x08048410 <main+63>: lea -0x4(%ecx),%esp
0x08048413 <main+66>: ret
End of assembler dump.
Podemos exibir o código das outras funções presentes dentro do nosso bloco de código principal.
(gdb) disassemble soma
Dump of assembler code for function soma:
0x080483c4 <soma+0>: push %ebp
0x080483c5 <soma+1>: mov %esp,%ebp
0x080483c7 <soma+3>: mov 0xc(%ebp),%edx
0x080483ca <soma+6>: mov 0x8(%ebp),%eax
0x080483cd <soma+9>: add %edx,%eax
0x080483cf <soma+11>: pop %ebp
0x080483d0 <soma+12>: ret
End of assembler dump.
(gdb) disassembe printf
Dump of assembler code for function printf:
0xb7f70c10 <printf+0>: push %ebp
0xb7f70c11 <printf+1>: mov %esp,%ebp
0xb7f70c13 <printf+3>: push %ebx
0xb7f70c14 <printf+4>: call 0xb7f3d4bf
0xb7f70c19 <printf+9>: add $0x1103db,%ebx
0xb7f70c1f <printf+15>: sub $0xc,%esp
0xb7f70c22 <printf+18>: lea 0xc(%ebp),%eax
0xb7f70c25 <printf+21>: mov %eax,0x8(%esp)
0xb7f70c29 <printf+25>: mov 0x8(%ebp),%eax
0xb7f70c2c <printf+28>: mov %eax,0x4(%esp)
0xb7f70c30 <printf+32>: mov -0xbc(%ebx),%eax
0xb7f70c36 <printf+38>: mov (%eax),%eax
0xb7f70c38 <printf+40>: mov %eax,(%esp)
0xb7f70c3b <printf+43>: call 0xb7f66990 <vfprintf>
0xb7f70c40 <printf+48>: add $0xc,%esp
0xb7f70c43 <printf+51>: pop %ebx
0xb7f70c44 <printf+52>: pop %ebp
0xb7f70c45 <printf+53>: ret
End of assembler dump.
Quando o processo é carregado em memória existe uma sessão específica chamada “.text” que é onde ficará o código da nossa aplicação. Nós podemos exibir trechos essa sessão através do endereço direto. Por exemplo, nós sabemos que o endereço da nossa função “soma” é “0×080483c4“, nós podemos solicitar ao GDB o Assembly dessa região, em específico, através do comando “x/(n)i” no lugar de “n” nós colocamos a quantidade de posições que após aquele endereço, será exibido o código.
(gdb) x/i 0x080483c4
0x80483c4 <soma>: push %ebp
(gdb) x/5i 0x080483c4
0x80483c4 <soma>: push %ebp
0x80483c5 <soma+1>: mov %esp,%ebp
0x80483c7 <soma+3>: mov 0xc(%ebp),%edx
0x80483ca <soma+6>: mov 0x8(%ebp),%eax
0x80483cd <soma+9>: add %edx,%eax
Com esses comandos nós podemos visualizar qualquer parte do espaço de endereçamento do processo que contenha código, não apenas a região “.text” mas qualquer região que possua instruções válidas.
* Manipulação de Breakpoints:
A função dos breakpoints é solicitar ao GDB que ele suspenda a execução do software em um determinado ponto, para que nós analisemos o contexto de memória e registradores alterados. O comando para colocarmos um breakpoint é o “breakpoint *n“, onde “n” é o endereço onde você quer que o breakpoint seja colocado e o “*” é para indicar que ali você esta passando um endereço diretamente para o depurador. Para exemplificar, vamos colocar um endereço antes de entrar na função “soma” (0×080483f1 <main +32>) e outro antes do printf (0×08048401 <main +48>).
Para visualizar os breakpoints podemos usar o comando “info breakpoint“. Cada breakpoint tem um identificador, mostrado na coluna “Num“, quando executarmos nosso programa, o fluxo dos breakpoints percorridos será indicado por esse índice.
(gdb) break *0x080483f1
Breakpoint 1 at 0x80483f1: file teste.c, line 9.
(gdb) break *0x08048401
Breakpoint 2 at 0x8048401: file teste.c, line 9.
(gdb) info breakpoint
Num Type Disp Enb Address What
1 breakpoint keep y 0x080483f1 in main at teste.c:9
2 breakpoint keep y 0x08048401 in main at teste.c:9
Para excluir um breakpoint é usado o comando “delete breakpoint ID“, onde ID é o número identificador do breakpoint.
(gdb) delete breakpoint 2
(gdb) info breakpoint
Num Type Disp Enb Address What
1 breakpoint keep y 0x080483f1 in main at teste.c:9
Com isso só restou o breakpoint 1 que é acionado antes da chamada a função “soma“. Vamos ver como que isso funciona ao executarmos o nosso programa exemplo com o comando de execução que já vimos.
(gdb) run
Starting program: /home/mabj/tmp/gdb/teste
Breakpoint 1, 0x080483f1 in main () at teste.c:9
9 printf("\n\n\t[+] A soma de 2 + 3 = %d\n\n", soma(2,3));
O fluxo do nosso programa esta paralisado no endereço 0×080483f1, que é o local onde temos a chamada para a função “soma“. Para darmos continuidade a execução do software podemos fazer isso passo-a-passo (step-by-step) que executa o comando chamando uma instrução por vez e parando o fluxo novamente após a chamada de cada instrução, essa modalidade é realizada através do comando “step“. Ou podemos mandar o programa continuar a execução até que ache outro breakpoint através do comando “continue“.
(gdb) run
Starting program: /home/mabj/tmp/gdb/teste
Breakpoint 1, 0x080483f1 in main () at teste.c:9
9 printf("\n\n\t[+] A soma de 2 + 3 = %d\n\n", soma(2,3));
(gdb) step
soma (a=2, b=3) at teste.c:5
5 return a + b;
(gdb) step
6 }
Coloquei novamente o breakpoint 2 para que possamos testar o comando “continue“.
(gdb) run
Starting program: /home/mabj/tmp/gdb/teste
Breakpoint 1, 0x080483f1 in main () at teste.c:9 < -- Parou em "soma"
9 printf("\n\n\t[+] A soma de 2 + 3 = %d\n\n", soma(2,3));
(gdb) continue
Continuing.
Breakpoint 2, 0x08048401 in main () at teste.c:9 < -- Parou em "printf"
9 printf("\n\n\t[+] A soma de 2 + 3 = %d\n\n", soma(2,3));
(gdb) continue
Continuing.
[+] A soma de 2 + 3 = 5
Program exited normally.
Com esses comandos nós podemos parar o fluxo do programa e ir executando passo-a-passo para analisar o contexto e as modificações realizadas na memória e nos registradores. Aprendemos até agora a visualizar o código e controlar o fluxo de execução. Vamos mostrar como visualizar informações importantes para o entendimento do código depurado.
* Visualizando informações:
Vamos mostrar alguns comandos para visualizar conteúdo na memória, isso é bem útil para visualizarmos o estado de variáveis e de endereços específicos na memória ao decorrer da execução do programa. Usando o nosso modo de compilação específico para depuração, usando o (”gcc -ggdb“), nós podemos ver as variáveis diretamente pelo seu símbolo. Para exemplificar coloquei um breakpoint na adição realizada na função “soma“. Vamos visualizar os valores dos parâmetros da função “soma” armazenados nas variáveis “a” e “b“. Vamos alterar algum valor e mandar continuar a execução do código.
(gdb) run
Starting program: /home/mabj/tmp/gdb/teste
Breakpoint 4, soma (a=2, b=3) at teste.c:5
5 return a + b;
(gdb) print a
$4 = 2 <-- O valor do parâmetro "a"
(gdb) print b
$5 = 3 <-- O valor do parâmetro "b"
(gdb) set variable a=50 <; -- Alterei o valor da variável "a"
(gdb) continue
Continuing.
[+] A soma de 2 + 3 = 53 <-- Mudei o resultado do programa
Program exited normally.
É possível também, mandar alterar uma região de memória especificada pelo seu endereço diretamente. Vamos exemplificar com a variável “a” do parâmetro de “soma“. Vamos obter o endereço dessa variável e a partir deste obtermos o seu conteúdo.
(gdb) run
Starting program: /home/mabj/tmp/gdb/teste
Breakpoint 4, soma (a=2, b=3) at teste.c:5
5 return a + b;
(gdb) x/x &a <-- Consegui o endereço de "a"
0xbf98bef0: 0x00000002
(gdb) x/x 0xbf98bef0 <-- Obtive diretamente o conteúdo de "a"
0xbf98bef0: 0x00000002
(gdb) set variable *0xbf98bef0=0x30
(gdb) continue
Continuing.
[+] A soma de 2 + 3 = 51 <-- Note que alteramos o valor de "a"
Program exited normally.
Observe que a mesma notação e semântica de ponteiro que temos em C/C++ pode ser usada com variáveis no GDB.
Para visualizar os valores que estão nos registradores durante a depuração, nós podemos usar o comando “info register” para visualizar uma tabela com todos os registradores e seus respectivos valores ou o comando “info register r” onde “r” é o nome do registrador a ser visualizado.
(gdb) info register
eax 0xbfe49c64 -1075536796
ecx 0xbfe49be0 -1075536928
edx 0x1 1
ebx 0xb7f19ff4 -1208901644
esp 0xbfe49bb0 0xbfe49bb0
ebp 0xbfe49bc8 0xbfe49bc8
esi 0x8048430 134513712
edi 0x8048310 134513424
eip 0x80483e2 0x80483e2
eflags 0x282 [ SF IF ]
cs 0x73 115
ss 0x7b 123
ds 0x7b 123
es 0x7b 123
fs 0x0 0
gs 0x33 51
(gdb) info register eax <-- Vendo o valor armazenado no registrador eax
eax 0xbfe49c64 -1075536796
* Examinando a Pilha:
A pilha é a região da memória alocada para armazenar os valores de variáveis locais a um determinado escopo (funções, métodos, blocos), ela funciona de acordo com um algoritmo de LIFO (ultimo a entrar será o primeiro a sair) [8]. Um frame na pilha, significa a porção de memória reservada para armazenar os valores presentes dentro de um escopo durante sua execução. O GDB organiza e exibe a pilha em forma de frames, durante a depuração de problemas, nós podemos acompanhar e manipular a o fluxo de frames na pilha. Assim podemos fazer backtraces para observarmos qual o caminho percorrido pelo nosso código até então ou o aninhamento de chamadas a funções e blocos.
Para exemplificar, colocamos um breakpoint dentro da função “soma” e digitamos o comando backtrace para exibir a lista de frames armazenadas na pilha, nós podemos também apenas digitar o comando “frame” para exibir o frame atual.
(gdb) run
Starting program: /home/mabj/tmp/gdb/teste
Breakpoint 4, soma (a=2, b=3) at teste.c:5
5 return a + b;
(gdb) backtrace
#0 soma (a=2, b=3) at teste.c:5
#1 0x080483f6 in main () at teste.c:9
(gdb) frame
#0 soma (a=2, b=3) at teste.c:5
5 return a + b;
Nós podemos mudar o contexto do nosso frame atual para depurar dentro de um escopo mais externo com o comando “frame n”. No exemplo anterior nós suspendemos a execução com um breakpoint dentro da função “soma”, com isso nós podemos investigar todas as variáveis dentro dessa função. Para investigar o contexto de “main” temos que mudar o nosso frame atual.
(gdb) r
Starting program: /home/tmp/gdb/teste
Breakpoint 4, soma (a=2, b=3) at teste.c:5
5 return a + b;
(gdb) backtrace
#0 soma (a=2, b=3) at teste.c:5
#1 0x080483f6 in main () at teste.c:9
(gdb) frame
#0 soma (a=2, b=3) at teste.c:5 <-- Frame atual é o "0"
5 return a + b;
(gdb) print a <-- Conseguimos enxergar a variável "a"
$6 = 2
(gdb) frame 1 <-- Mudamos o contexto atual para o frame "1"
#1 0x080483f6 in main () at teste.c:9
9 printf("\n\n\t[+] A soma de 2 + 3 = %d\n\n", soma(2,3));
(gdb) frame
#1 0x080483f6 in main () at teste.c:9
9 printf("\n\n\t[+] A soma de 2 + 3 = %d\n\n", soma(2,3));
(gdb) print a <-- Agora não enxergamos mais a variável "a"
No symbol "a" in current context.
Vamos depurar agora usando como referência o nosso stack pointer (esp), nesse registrador temos o endereço do topo da nossa pilha. Nós sabemos que os parâmetros da função “soma” estão na pilha e podemos navegar por endereços acima do nosso stack pointer (durante o breakpoint interno a função “soma”) até encontrarmos esse valor e depois manipulá-lo diretamente através do seu endereço.
(gdb) i r esp
esp 0xbfc0bc18 0xbfc0bc18
(gdb) x/x esp+4
No symbol "esp" in current context.
(gdb) x/x $esp+4
0xbfc0bc1c: 0x080483f6
(gdb) x/x $esp+4
0xbfc0bc1c: 0x080483f6
(gdb)
0xbfc0bc20: 0x00000002 <-- Variável "a"
(gdb)
0xbfc0bc24: 0x00000003 <-- Variável "b"
(gdb) set variable *0xbfc0bc24=0x30 <-- colocamos 48 dentro do valor de b
(gdb) continue
Continuing.
[+] A soma de 2 + 3 = 50 <-- Note que alteramos o valor de "b"
Program exited normally.
Bom, esse artigo foi uma visão bem geral de algumas funcionalidades do GNU Debugger, espero que essas dicas sejam úteis para depuração de problemas em softwares reais. Recomendo fortemente a leitura dos artigos indicados nas referências. Aprender a depurar é semelhante a aprender a programar, sem prática não temos resultados, por isso é necessário que você reserve um tempo para praticar os conceitos apresentados. Espero que esse artigo ajude as pessoas que estavam com dificuldades nos artigos de desenvolvimento para vulnerabilidades. ; ]
[Referências]
[1] Documentação de uso do GDB
[2] Documentação do engenho interno ao GDB
[3] Tutorial sobre o GDB
[4] Site do IDApro
[5] Site da empresa hex-rays que fornece o IDApro
[6] Site da empresa Zynamics que fornece o Binnave
[7] Manpage da biblioteca ptrace
[8] Algoritmo usado para a construção de uma pilha