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

Transbordo do segmento de pilha

O segmento de pilha tem uma capacidade máxima, correspondente ao espaço de memória existente entre o endereço inicial da pilha e o endereço final da heap. Caso seja ultrapassada essa capacidade máxima, ocorrerá um transbordo da pilha, situação que é detetada pelo sistema operativo que consequentemente termina o processo faltoso. A ocorrência do transbordo da pilha pode dever-se (i) a uma recursividade mal finalizada, em que o espaço ocupado por todos os stack frames ultrapassa a capacidade do segmento de pilha ou (ii) à utilização de variáveis locais de grandes dimensões (por exemplo vetores). Interessantemente, a designação anglo-saxónica de transbordo da pilha – stack overflow – é sobejamente conhecida dos programadores pois trata-se do nome da bem conhecida plataforma de esclarecimento de dúvidas e partilha de código na área da programação: http://www.stackoverflow.com.

Endereços do Segmento de Pilha

A Listagem 3 recorre ao operador & (“endereço de”) e à formatação %p da função printf para mostrar os endereços onde estão armazenadas as variáveis locais PI_value, E_value e f1, esta última da função F1. Da saída produzida pela execução do programa (Listagem 4), verifica-se que os endereços das variáveis locais PI_value (0xbfa2fef0) e E_value (0xbfa2fef8) estão separados por oito octetos, o que corresponde ao tamanho de uma variável do tipo double na norma de vírgula flutuante IEEE 754 (IEEE Standards Committee, 2008). Isto significa que as duas variáveis estão uma a seguir à outra no segmento de pilha. Por sua vez, a variável local f1 encontra-se um pouco mais afastada (0xbfa2fecc), o que se explica pelo facto de se encontrar noutro stack frame, concretamente no stack frame da função F1.

#include <stdio.h>
#include <string.h>
void F1(void){
         int f1 = 100;
         printf("função F1: f1=%d, endereço f1=%p\n",
                                              f1,&f1);
}
int main(void){
         double PI_value = 3.1415;
         double E_value = 2.7182;
         printf("PI=%f, endereço de PI=%p\n",
                           PI_value, &PI_value);
         printf("e=%f, endereço de E=%p\n",
                           E_value, &E_value);
         F1();
         return 0;
}
Listagem 3: endereços de variáveis locais (stack_addresses_ex2.c)
PI=3.141500, endereço de PI=0xbfa2fef0
e=2.718200, endereço de E=0xbfa2fef8
função F1: f1=100, endereço f1=0xbfa2fecc
Listagem 4: saída da execução do programa stack_addresses.c

O programa da Listagem 5 mostra algum do conteúdo do segmento de pilha, apresentando, em formato hexadecimal, o conteúdo e respetivo endereço de memória, dos endereços de memória adjacentes à variável local f1. Para o efeito, o código faz uso de um ciclo for, cuja variável de controlo (variável i) regula o deslocamento relativo ao endereço onde está a variável f1. O deslocamento relativo vai de -1 a +8, correspondendo cada unidade ao deslocamento do tamanho de um inteiro (int) no sistema de desenvolvimento e testes considerado, isto é, a quatro octetos. Note-se que no sistema considerado, como em muitos outros, o segmento de pilha cresce “de cima para baixo”, isto é, do maior para o menor endereço de memória. Deste modo, o stack frame da função main encontra-se em endereços de memória maiores do que o stack frame da função F1. Assim, quando a variável de controlo i do ciclo for assume valores positivos, os endereços mostrados correspondem a conteúdos armazenados antes do conteúdo da variável local f1.

#include <stdio.h>
#include <string.h>
void F1(void){
         int f1 = 100;
         int i=0;
         printf("[F1]: f1=%d, endereço f1=%p\n",
                                              f1,&f1);
         printf("[F1]: i=%d, endereço i=%p\n", i,&i);
         printf("----------------------------------\n");
         for(i=-1;i<8;i++){
                  printf("[F1]: f1+%d=%x, "
                           "endereço f1+%d=%p\n",
                           i,*(&f1+i),i,&f1+i);
         }
}
int main(void){
         double PI_value = 3.1415;
         double E_value = 2.7182;
         F1();
         printf("---------------------------------\n");
         printf("[MAIN] PI=%f, endereço de PI=%p\n",
                           PI_value, &PI_value);
         printf("[MAIN] e=%f, endereço de E=%p\n",
                           E_value, &E_value);
         printf("endereço da função main: %p\n", main);
         printf("endereço da função F1: %p\n", F1);
         return 0;
}
Listagem 5: Programa stack_addresses_ex3.c
[F1]: f1=100, endereço f1=0xbfc0bd78
[F1]: i=0, endereço i=0xbfc0bd7c
[F1]: f1+-1=ca0000, endereço f1+-1=0xbfc0bd74
[F1]: f1+0=64, endereço f1+0=0xbfc0bd78
[F1]: f1+1=1, endereço f1+1=0xbfc0bd7c
[F1]: f1+2=bfc0d43d, endereço f1+2=0xbfc0bd80
[F1]: f1+3=b7760000, endereço f1+3=0xbfc0bd84
[F1]: f1+4=bfc0bdb8, endereço f1+4=0xbfc0bd88
[F1]: f1+5=8048527, endereço f1+5=0xbfc0bd8c
[F1]: f1+6=1, endereço f1+6=0xbfc0bd90
[F1]: f1+7=bfc0be54, endereço f1+7=0xbfc0bd94
[MAIN] PI=3.141500, endereço de PI=0xbfc0bda0
[MAIN] e=2.718200, endereço de E=0xbfc0bda8
[MAIN] endereço da função main: 0x8048505
[MAIN] endereço da função F1: 0x804844d
Listagem 6: resultado da execução do programa stack_address_ex3.c

Os resultados da execução são apresentados na Listagem 6. Verifica-se que a variável f1 está no endereço 0xbfc0bd78 e que a variável i está no endereço adjacente 0xbfc0bd7c, ou seja quatro octetos acima, sendo esse quatro octetos precisamente o espaço ocupado pela variável i. Dos resultados produzidos pelo ciclo for observa-se que a iteração com i=1 corresponde ao endereço da variável de controlo i. Por sua vez, observa-se que o valor inteiro guardado no endereço de memória 0xbfc0bd8c, obtido com i igual a 5, é 0x8048527. Este valor está na gama de valores dos endereços de memória das funções F1 (0x804844d) e main (0x8048505), indicando que se trata pois de um endereço do segmento de código. Dado a sua localização no stack frame, é expectável que se trate do endereço de retorno a ser empregue aquando do término da função F1. Para validar esta hipótese, acrescentou-se a seguinte linha no final da função F1:

(&f1+5) = (int) F1;

A linha modifica o conteúdo da zona de memória correspondente a f1 + 5 x 4 octetos (ou seja endereço f1 + 20 bytes), atribuindo-lhe o endereço da função F1. Assim, se a hipótese estiver certa – &f1 + 5 corresponde ao endereço de retorno –, a atribuição irá alterar o endereço de retorno para a própria função F1, criando efetivamente um ciclo infinito, pois quando termina a execução da função F1, o código retorna, por via do endereço de retorno alterado, para o início da função F1 e assim sucessivamente. Importa referir que o mesmo código compilado e executado noutros ambientes (por exemplo, num sistema Linux de 64 bits com outro compilador), poderá apresentar uma organização diferente, e consequentemente o endereço de retorno estar localizado numa posição diferente (por exemplo, &f1 + 4 ou &f1+6). O código da nova versão é apresentado na Listagem 7, e a saída resultante da respetiva execução é mostrada na Listagem 8. Importa notar que a substituição do endereço de retorno pelo endereço da função F1 não respeita o protocolo de chamada de funções empregue pelo GCC, resultando no desequilíbrio da stack. De facto, observa-se nas sucessivas execuções da função F1 que a variável local f1 fica localizada em endereços da stack crescentes (diferença de 4 bytes entre chamadas – 0xbffd81b8, 0xbffd81bc, …). O desequilíbrio leva a que a aplicação seja terminada pelo sistema operativo com a notificação segmentation fault ao fim de umas centenas de execuções da função F1.

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

void F1(void){
         int f1 = 100;
         int i=0;
         printf("[F1]: f1=%d, endereço f1=%p\n",f1,&f1);
         printf("[F1]: i=%d, endereço i=%p\n",i,&i);
         printf("----------------------------------\n");
         for(i=-1;i<8;i++){
                  printf("[F1]: f1+%d=%x, "
                           "endereço f1+%d=%p\n",
                           i,*(&f1+i),i,&f1+i);
         }
         *(&f1+5) = ((int)F1);
         printf("[F1]:FIM\n");
}
int main(void){
         if( sizeof(int) != 4 ){
                  fprintf(stderr,"[OUT] "
                      "Codigo requer sizeof(int)=4\n");
                  exit(1);
         }
         if( sizeof(int*) != 4 ){
                  fprintf(stderr,"[OUT] "
               "Código requer endereços de 32 bits\n");
                  exit(2);
         }
         double PI_value = 3.1415;
         double E_value = 2.7182;
         F1();
         printf("===================================\n");
         printf("[MAIN] PI=%f, endereço de PI=%p\n",
                                  PI_value, &PI_value);
         printf("[MAIN] e=%f, endereço de E=%p\n",
                                  E_value, &E_value);
         printf("endereço da função main: %p\n", main);
         printf("endereço da função F1: %p\n", F1);
         return 0;
}
Listagem 7: Programa stack_address_argv_ex4.c
[F1]: f1=100, endereço f1=0xbffd81b8
[F1]: i=0, endereço i=0xbffd81bc
---------------------------------------
[F1]: f1+-1=ca0000, endereço f1+-1=0xbffd81b4
[F1]: f1+0=64, endereço f1+0=0xbffd81b8
[F1]: f1+1=1, endereço f1+1=0xbffd81bc
[F1]: f1+2=bffd8458, endereço f1+2=0xbffd81c0
[F1]: f1+3=b772e000, endereço f1+3=0xbffd81c4
[F1]: f1+4=bffd81f8, endereço f1+4=0xbffd81c8
[F1]: f1+5=8048540, endereço f1+5=0xbffd81cc
[F1]: f1+6=1, endereço f1+6=0xbffd81d0
[F1]: f1+7=bffd8294, endereço f1+7=0xbffd81d4
[F1]:FIM
[F1]: f1=100, endereço f1=0xbffd81bc
[F1]: i=0, endereço i=0xbffd81c0
---------------------------------------
[F1]: f1+-1=64, endereço f1+-1=0xbffd81b8
[F1]: f1+0=64, endereço f1+0=0xbffd81bc
[F1]: f1+1=1, endereço f1+1=0xbffd81c0
[F1]: f1+2=b772e000, endereço f1+2=0xbffd81c4
[F1]: f1+3=b772e000, endereço f1+3=0xbffd81c8
[F1]: f1+4=bffd81f8, endereço f1+4=0xbffd81cc
[F1]: f1+5=1, endereço f1+5=0xbffd81d0
[F1]: f1+6=bffd8294, endereço f1+6=0xbffd81d4
[F1]: f1+7=bffd829c, endereço f1+7=0xbffd81d8
[F1]:FIM
(...)
Segmentation fault (core dumped)
Listagem 8: resultado da execução do programa stack_address_argv_ex4.c

Note-se que embora seja relativamente fácil encontrar a localização do endereço de retorno da função corrente no segmento de pilha, é muito mais complexo alterá-lo por forma a que se possa tomar o controlo do processo. Ao leitor interessado recomendam-se a leitura de (One, 2007) e (Chris Anley, 2007).

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