Programação (in)Segura – Transbordo de Memória

Introdução

O software assume cada vez mais uma importância primordial no nosso dia-a-dia. De facto, é crescente o número de dispositivos com o qual interagimos quotidianamente e cujo funcionamento está dependente de software. Exemplos incluem, obviamente, computadores e tablets, bem como dispositivos ditos inteligentes, como telemóveis, relógios e televisões. Outros exemplos abarcam sistemas de transportes como automóveis, aeronaves e barcos, e sistemas de domótica, para citar apenas alguns dos mais conhecidos. Dado a complexidade associada não só à criação e manutenção de programas informáticos como ainda dos sistemas que pretendem controlar, o software está sujeito a ocorrência de erros. Alguns desses erros podem ser aproveitados por indivíduos ou entidades com intenções maliciosas para subverter os dispositivos controlados, comprometendo deste modo, parcial ou totalmente, a segurança dos sistemas.

Este artigo analisa os erros do tipo transbordo de memória, em particular os que poderão ocorrer no segmento de pilha. O artigo foca alguns dos problemas de segurança que estão associados a situações de transbordo de memória afeta ao segmento de pilha. Os exemplos de código apresentados foram testados num sistema Linux – Lubuntu 14.04 / 32 bits, com kernel versão 3.13.04. Os exemplos foram compilados com a versão 4.8.2 do compilador de linguagem C GNU Collection Compiler (GCC).

Transbordo de zona de memória

O termo transbordo de zona de memória é mais conhecido pela designação Anglo-Saxónica de buffer overflow (Chris Anley, 2007). Trata-se de um dos erros de programação que mais frequentemente está associada a falhas de segurança como se pode constatar numa qualquer lista de falhas de segurança informática, e em particular na lista CVE (Common Vulnerabilities and Exposures — https://cve.mitre.org/). A falha CVE-2015-7547, publicamente divulgada em fevereiro de 2016, afeta a importante GNU C Library (glibc), sendo mais um exemplo de vulnerabilidade devido a um transbordo de memória. Assim, nos sistemas afetados, e em certas circunstâncias, o uso da função getaddrinfo pode levar a um transbordo de memória que deixa o sistema vulnerável à execução remota de código. O facto da glibc estar presente na maioria dos sistemas Linux, da função getaddrinfo ser empregue por um número significativo de aplicações e da falha existir desde 2008 tornaram a vulnerabilidade particularmente preocupante (O’Donell, 2016).

Um transbordo de zona de memória ocorre quando uma operação escreve fora da zona de memória que lhe foi previamente atribuída, corrompendo a informação armazenada na zona indevidamente acedida. Por não possuir suporte nativo para verificação de fronteiras de zonas de memória (bound checking), a linguagem C pode originar programas que efetuem transbordos de zonas de memória. Tal ocorre, por exemplo, quando se utiliza a função strcpy para efetuar a cópia de uma string para um vetor de caracteres sem o espaço suficiente para armazenar todos os caracteres da string. Em particular, é relativamente comum o esquecimento de que uma string na linguagem C requer sempre o terminador \0, o que acresce em uma unidade o número de octetos necessários para o armazenamento da string. Este tipo de erro – transbordo em uma unidade de uma zona de memória – é tão comum que é identificado na terminologia anglo-saxónica como off-by-one error (Chris Anley, 2007).

O que sucede quando ocorre um transbordo de uma zona de memória numa operação de cópia num programa escrito em linguagem C? Simplesmente a operação de cópia é efetuada, existindo escrita na memória para além da zona de memória alvo. Deste modo, é corrompida a informação armazenada na zona de memória indevidamente acedida, o que pode levar à alteração do conteúdo de variáveis que estejam guardadas nessa zona de memória. O programa mostrado na Listagem 1 exemplifica um transbordo de zona de memória. Concretamente, a cadeia de caracteres ABCDEFGHI acessível através da variável source_S é copiada pela função strcpy para a zona de memória reservada para a variável destination_S. O problema é que esta zona de memória apenas tem espaço para armazenar 8 octetos, pois foi declarada como char destination_S[8]. Deste modo, a zona de memória apontada por destination_S apenas pode receber string com 7 caracteres mais o caracter de terminação \0. Dado que a cadeia de caracteres apontada por source_S ocupa 10 octetos (9 caracteres mais a terminação \0), a operação de cópia realizada pela função strcpy leva ao transbordo em dois octetos da zona de memória destination_S. Da análise à saída produzida pela execução do programa (Listagem 2) constata-se que após a chamada à função strcpy, a string source_S contém somente I em lugar do original ABCDEFGHI. Tal deve-se ao transbordo da zona de memória apontada por destination_S aquando da operação de cópia. De facto, ao copiarem-se 10 octetos (ABCDEFGHI mais o marcador de fim de string \0) para uma zona de memória com apenas oito octetos, os últimos dois octetos (caracter I e \0) são copiados para a zona de memória adjacente a destination_S, que neste caso corresponde ao início da zona de memória reservada para source_S. Deste modo, os dois primeiros octetos da zona de memória source_S passam a ser, respetivamente, I e o marcador de fim de string \0. Quando a função printf acede à string source_S, apenas mostra I, pois encontra logo na segunda posição o marcador \0 que interpreta forçosamente como o fim da cadeia de caracteres. A Figura 1 ilustra o transbordo de memória provocado pelo código da Listagem 1, com a) a corresponder à situação pré-transbordo e b) à situação pós-transbordo.

#include <stdio.h>
#include <string.h>
int main(void){
         char destination_S[8];
         char source_S[] = "ABCDEFGHI";
         printf("[INFO]A preparar cópia\n");
         printf("[INFO]'%s' (%zu octetos) para "
                           "zona com %zu bytes\n",
                           source_S,
                           strlen(source_S)+1,
                           sizeof(destination_S));
         strcpy(destination_S,source_S);
         printf("[INFO]Cópia efetuada\n");
         printf("[INFO]string origem='%s'\n", source_S);
         return 0;
}
Listagem 1: transbordo de zona de memória em operação de cópia de string (strcpy_overflow_ex1.c)
[INFO]A preparar cópia
[INFO]'ABCDEFGHI' (10 octetos) para zona com 8 bytes
[INFO]Cópia efetuada
[INFO]string origem='I'
Listagem 2: saída da execução do programa strcpy_overflow_ex1.c
Representação da situação de transbordo que ocorre com a execução do código da Listagem 1 (a) antes da execução de strcpy; (b) depois da execução de strcpy

Figura 1: representação da situação de transbordo que ocorre com a execução do código da Listagem 1 (a) antes da execução de strcpy; (b) depois da execução de strcpy

Segmento de Pilha

No exemplo da Listagem 1, as variáveis source_S e destination_S são locais, pelo que os respetivos conteúdos são armazenados na zona de memória do processo que é designada como segmento de pilha. A designação de pilha deve-se ao facto de se tratar de uma estrutura de armazenamento de dados do tipo LIFO (Last In First Out) (Knuth, 1997). Como o nome indica, neste tipo de estrutura apenas é possível retirar elementos pela ordem inversa em que foram colocados. No contexto da memória afeta a um processo, uma dessas zonas é designada por segmento de pilha, seguindo a metodologia LIFO. Em termos práticos, o sistema implementa uma pilha mantendo dois endereços: o endereço inicial e o endereço corrente. O endereço inicial corresponde ao endereço zero da pilha, ao passo que o endereço corrente corresponde ao topo da pilha (endereço do último elemento inserido). O endereço corrente é alterado sempre que são inseridos ou retirados elementos da pilha.

Para além das variáveis locais, o segmento de pilha de um processo armazena (i) os parâmetros passados nas chamadas a funções e ainda (ii) o endereço de retorno para cada chamada de função efetuada durante a execução do processo. O endereço de retorno corresponde ao endereço de memória onde se encontra o código a executar quando terminar a chamada da função. A zona de memória da pilha associada a cada chamada de uma função designa-se por stack frame. Esta zona é criada dinamicamente sempre que ocorrer uma chamada à função, sendo aí armazenados os parâmetros passados na chamada à função, o endereço de retorno, e as variáveis locais da função. É criado um novo stack frame sempre que é efetuada a chamada de uma função. Por sua vez, quando termina a execução de uma função, o endereço de retorno é lido do stack frame associado à função, e empregue para posicionar o fluxo de execução do programa na instrução imediatamente a seguir à da chamada da função terminada. Adicionalmente, o stack frame da função que terminou é marcado como indisponível. A Figura 2 ilustra o segmento de pilha considerando os stack frames das funções main e F1 (figura da esquerda) e as alterações provocadas pela chamada à função F2 efetuada pela função F1, nomeadamente, a criação de um terceiro stack frame e a correspondente deslocação do ponteiro de pilha (figura da direita). A Figura 2 assume que o segmento de pilha cresce dos endereços maiores para os endereços menores de memória, como normalmente sucede nos sistemas informáticos.

Segmento de pilha com os stacks frames das funções main e F1 (esquerda) e depois, com três stack frames (direita) após a chamada da função F2 por parte da função F1

Figura 2: segmento de pilha com os stacks frames das funções main e F1 (esquerda) e depois, com três stack frames (direita) após a chamada da função F2 por parte da função F1

O segmento de pilha é um dos vários segmentos que existem naquilo que é designado por imagem de processo em memória. Os restantes segmentos são: (i) segmento de texto que contém o código do processo; (ii) segmento de dados onde são armazenadas as variáveis globais e as variáveis declaradas com o modificador static; (iii) segmento de heap que mantém as zonas de memória dinâmica alocada ao processo e, finalmente, (iv) o segmento de pilha. A Figura 3 representa a imagem de um processo em memória. O segmento de dados não está explicitado na figura, pois é composto pelas secções .BSS e .DATA. A secção .DATA guarda as variáveis globais/static cujo valor inicial é diferente de zero, ao passo que a secção .BSS está reservada às variáveis cujo valor inicial é zero ou que foram definidas sem valor inicial. A distinção entre .BSS e .DATA tem origem no ficheiro executável: os valores iniciais das variáveis inicializadas têm forçosamente que existir no ficheiro executável, sendo empregues aquando do lançamento do programa.

Representação esquemática da imagem de um processo

Figura 3: representação esquemática da imagem de um processo

Publicado na edição 52 (PDF) da Revista PROGRAMAR.