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; }
[INFO]A preparar cópia [INFO]'ABCDEFGHI' (10 octetos) para zona com 8 bytes [INFO]Cópia efetuada [INFO]string origem='I'

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.

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.

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; }
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
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; }
[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
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; }
[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)
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).