Proteção ao Segmento de Pilha
Porventura o maior perigo associado a um transbordo de zona de memória no segmento de pilha é a possibilidade de corrupção maliciosa do endereço de retorno da função corrente. Deste modo, após terminar a função corrente, em vez da execução do programa retornar para o código da função chamante, a execução será desviada para outra zona do código, o que poderá permitir a um utilizador malicioso controlar a restante execução do processo.
Por forma a detetar a corrupção de variáveis do segmento de pilha, alguns compiladores recorrem a valores especiais (marcadores) que são escritos no segmento de pilha, por exemplo, logo a seguir ao espaço de memória afeto a uma variável local do tipo string. Caso ocorra a alteração do valor dos marcadores, isso significa que a realização de uma operação de escrita numa zona do segmento de pilha que não corresponde a nenhuma variável do processo, indiciando uma anomalia na execução. Os marcadores são designados por canários, pois servem de alertas, um pouco à semelhança dos canários outrora empregues nas minas para a deteção da perigosa degradação da qualidade do ar (C. Cowan, 1998).
O compilador GCC implementa um mecanismo de canário para variáveis locais. O seu funcionamento será verificado durante a execução do código da Listagem 9 que efetua a cópia da string recebida através do primeiro (e único) argumento da linha de comando aquando da execução do programa. Esta string é copiada para a zona de memória reservada para a variável char string[4]
, que apenas tem capacidade para armazenar três caracteres e o marcador \0
. Deste modo, sempre que o programa for lançado com um argumento que tenha mais do que três caracteres (e.g., 12345
) ocorrerá o transbordo da variável string
. Por sua vez, a variável int counter
ao ser declarada imediatamente antes da variável string
é colocada no segmento de pilha em na zona de memória seguinte à reservada para a variável string
. Deste modo, um transbordo da variável string
deve originar a corrupção da memória reservada para a variável counter
e consequentemente alteração do valor guardado na variável. Com vista à deteção dessa alteração, o programa mostra o valor inicial da variável counter
, efetua a operação de cópia do argumento da linha de comando (argv[1]
) para a variável string
e, por fim, volta a mostrar o valor da variável counter
.
A Listagem 10 apresenta o resultado da execução do programa, tendo a aplicação sido compilada com a seguinte linha de comando:
gcc -w -Wall -g ex3.c -o ex3.exe
Na execução do programa com o argumento 12345
(Listagem 10) observa-se que é detetado um stack smash, sendo produzido um ficheiro do tipo core que pode ser empregue com um depurador (e.g., GDB) para análise mais detalhada à situação que levou ao stack smash. Para observar os efeitos do transbordo de memória da variável string
, é necessário desativar a proteção de pilha, acrescentando-se para o efeito a opção -fno-stack-protector
na compilação com o GCC.
Os resultados da execução (Listagem 11) mostram que a corrupção da variável counter
ocorre somente quando o transbordo da variável string
for de 10 ou mais octetos. De facto, com um argumento de 13 octetos (12 caracteres mais o terminador \0
) o valor da variável counter
mantém-se inalterado. Contudo, quando é passado um parâmetro com 14 octetos (transbordo de 10 octetos), o valor da variável counter
é alterado de -1 para -256, o que significa que o octeto menos significativo passou para zero. Concretamente, tal se deve à escrita do marcador \0
na zona de memória onde é guardado o octeto menos significativo da variável counter
.
Cuidados a Observar
Os cuidados a observar para minimizar a possibilidade de vulnerabilidade de uma aplicação a situações de transbordo de memória passam por um cuidado tratamento das entradas fornecidas direta ou indiretamente pelo utilizador. Este tratamento é frequentemente designado por sanatização dos dados de entrada. O objetivo é filtrar conteúdo que possa prejudicar a aplicação. Por exemplo, o código apresentado na Listagem 9 está (intencionalmente) vulnerável a um transbordo de memória do segmento de pilha, dado que copia para uma variável local com capacidade para armazenar três caracteres (mais o marcador \0
), os dados fornecidos pelo utilizador, sem previamente verificar o tamanho dos dados a copiar. Uma abordagem correta passa pela validação do tamanho dos dados introduzidos pelo utilizador, assinalando como erro dados com tamanho superior a três caracteres. Outra abordagem, mais flexível passa pelo uso de memória dinâmica. Concretamente, determina-se, por exemplo, através da função strlen
o número de caracteres da string passada pelo utilizador através do parâmetro da linha de comando e aloca-se um bloco de memória dinâmica com a capacidade necessária para armazenar a string, isto é, com um número de octetos igual ao número de caracteres da string acrescido em uma unidade para o marcador \0
.
Outro cuidado passa pelo uso de funções que permitam estabelecer limites no número de elementos a copiar e/ou a acrescentar. Por exemplo, a função char *strcpy(char *dest, const char *src)
efetua a cópia de uma cadeia de caracteres que se inicia no endereço apontado por src
para o endereço de memória apontado por dest
, não sendo possível limitar a cópia a um número máximo de caracteres. Como alternativa poderá utilizar-se a função char *strncpy (char *dest, const char *dest, size_t n)
, que permite indicar, através do parâmetro n
, o número máximo de octetos que podem ser copiados para o endereço de destino. Deste modo, se o parâmetro n
for especificado com o tamanho máximo da zona de memória de destino, o uso da função strncpy
garante que não existe transbordo da zona de memória. Contudo, no caso em que a string a copiar ocupa um número de octetos superior a n
, a função strncpy
não termina a string de destino com o marcador \0
, pelo que a string de destino fica efetivamente aberta. Para evitar esta última situação, o programador deve garantir que a string de destino é devidamente fechada, escrevendo o marcador \0
na posição de índice n-1
da zona de memória de destino. A Listagem 12 exemplifica o uso da função strncpy
de modo a garantir que a string de destino é sempre terminada com o marcador \0
.
Para além da função strcpy
, outras funções da biblioteca da linguagem C expõem os programas a situações de transbordo de memória, pelo que o seu uso deve ser evitado. São exemplos, entre muitas outras, as funções strcat
(concatenação de strings) e sprintf
(escrita com formatação para uma zona de memória). Sempre que existam, deverão ser utilizadas as versões das funções que permitam limitar o número de caracteres escritos no destino. Para o caso da função strcat
, a versão com limitador designa-se por strncat
, apresentando o seguinte protótipo:
char *strncat(char *dest, const char *src, size_t n);
Apesar da função strncat
assegurar a escrita do terminador \0
na zona de memória da string de destino, o programador terá de garantir que a zona de memória de destino possui o espaço suficiente para acrescentar ao seu conteúdo n
caracteres da string src
e ainda o terminador \0
. Caso a capacidade da zona de memória de destino não seja suficiente, poderá ocorrer um transbordo de memória.
A função sprintf
poderá ser substituída pela variante int snprintf(char *dest, size_t n, const char *format, ...)
. Esta função garante que não são escritos mais do que n
octetos a zona de memória de destino, acrescentando sempre o terminador \0
dentro do limite dos n
octetos. Contudo a função snprintf
não faz parte das funções definidas pela norma da linguagem C, pelo que a sua existência não é garantida, o que pode trazer problemas com a portabilidade do código.
A norma C11
A revisão à linguagem C ocorrida em 2011, deu origem à norma C11 (ISO, 2011). A norma C11 teve em conta o problema do transbordo de memória que pode ser causado por várias funções da linguagem. Para o efeito foram criadas versões ditas seguras das funções mais frequentemente associadas a situações de transbordo de memória. Contudo, essas versões seguras estão marcadas como opcionais, pertencendo ao anexo K da norma C11, pelo que não existem em todos os ambientes de desenvolvimento C11. Uma omissão relevante é a da glibc, empregue pelo sistema operativo Linux, pelo que essas versões seguras não se encontram disponibilizadas nessa plataforma. A norma C11 indica que o suporte para as versões seguras pode ser verificado através da existência da constante do pré-processador __STDC_LIB_EXT1__
. Caso esta constante esteja definida, o ambiente de desenvolvimento implementa as versões seguras definidas no anexo K da norma C11 (Peter Prinz, 2015).
As versões seguras definidas na norma C11 são facilmente reconhecíveis, pois o nome é composto pelo nome original da função acrescido do sufixo _s
(Peter Prinz, 2015). Assim strcpy_s
corresponde à versão segura da função strcpy
e strcat_s
corresponde à versão segura de strcat
. Importa notar que as versões seguras diferem substancialmente das versões originais. Por exemplo, a função strcpy_s
, cujo protótipo se encontra na Listagem 13, devolve um valor do tipo errno_t
, recebendo um ponteiro para a zona de escrita (parâmetro dest
), um limitador de tamanho (parâmetro destmax
) do tipo rsize_t
e o ponteiro para a string que se pretende copiar (parâmetro src
). Antes de efetuar a operação de cópia, a função strcpy_s
efetua as seguintes verificações:
- Confirma se os ponteiros
dest
esrc
não sãoNULL
; - Confirma se o valor de
destmax
se encontra compreendido entre zero eRSIZE_MAX
; - Confirma se o tamanho da string a copiar (
src
) é inferior aRSIZE_MAX
; - Confirma se os endereços de memória indicados por
src
edest
não tem nenhuma sobreposição. Este requisito é expresso através da palavra chaverestrict
que, em certas circunstâncias, permite ao compilador aplicar certas otimizações.
Caso não se verifique uma das condições, a função strcpy_s
devolve um valor diferente de zero, escrevendo o terminador \0
no endereço apontado por dest
, isto caso dest
não seja NULL
e destmax
se encontre compreendido entre zero e RSIZE_MAX
. Caso a execução da função decorra normalmente é devolvido o valor zero. Relativamente à constante RSIZE_MAX
, o anexo K da norma C11 indica que deve ter um valor correspondente a metade do valor máximo que pode ser representado pelo tipo de dados size_t
.
Nota final
Este artigo abordou a vasta temática do transbordo de memória em programas escritos na linguagem C. Tendo em consideração as nefastas consequências que poderão existir ao nível da segurança das aplicações, é fundamental que o programador esteja sensível e atento à problemática do transbordo de memória.
Bibliografia
- Cowan, C. P. (1998). Automatic Detection and Prevention of Buffer-Overflow Attacks. 7th USENIX Security Symposium. San Antonio, Texas, USA.: USENIX.
- Chris Anley, J. H. (2007). The Shellcoder’s Handbook: Discovering and Exploiting Security Holes (2ª ed.). Wiley Publishing, Inc.
- IEEE Standards Committee. (2008). 754-2008 IEEE standard for floating-point arithmetic.
- ISO. (2011). ISO/IEC 9899:2011 – Information technology – Programming languages – C.
- Knuth, D. (1997). The Art of Computer Programming, Volume 1: Fundamental Algorithms (3ª ed.).
- O’Donell, C. (02 de 2016). CVE-2015-7547 – glibc getaddrinfo() stack-based buffer overflow. Obtido de https://sourceware.org/ml/libc-alpha/2016-02/msg00416.html
- One, A. (2007). Smashing the stack for fun and profit. Phrack, 7(49), 32.
- Peter Prinz, T. C. (2015). C in a Nutshell: The Definitive Reference (2ª ed.). O’Reilly.