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

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.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[]){
         int counter = -1;
         char string[4];
         char *argv_ptr;
         if( argc != 2 ){
                  printf("[usage]%s <string>\n",
                                              argv[0]);
                  exit(EXIT_FAILURE);
         }
         argv_ptr = argv[1];
         size_t argv_len = strlen(argv_ptr);
         printf("[INFO]:'%s' "
                  "(%zu caracteres, %zu bytes)\n",
                  argv_ptr, argv_len, argv_len+1);
         printf("[ANTES] Counter=%d\n", counter);
         strcpy(string, argv_ptr);
         printf("[DEPOIS] Counter=%d\n", counter);
         return 0;
}
Listagem 9: programa vulnerável à corrupção do segmento de pilha (strcpy_argv_ex5.c)
./ex5.exe 12345
[INFO]:'12345' (5 caracteres, 6 bytes)
[ANTES] Counter=-1
[DEPOIS] Counter=-1
*** stack smashing detected ***: ./ex5.exe terminated
Aborted (core dumped)
Listagem 10: resultado da execução de strcpy_argv_ex5.c
./ex5_no.exe 12345789012
[INFO]:'12345789012' (11 caracteres, 12 bytes)
[ANTES] Counter=-1
[DEPOIS] Counter=-1

./ex5_no.exe 123457890123
[INFO]:'123457890123' (12 caracteres, 13 bytes)
[ANTES] Counter=-1
[DEPOIS] Counter=-256

./ex5_no.exe 1234578901234
[INFO]:'1234578901234' (13 caracteres, 14 bytes)
[ANTES] Counter=-1
[DEPOIS] Counter=-65484
Listagem 11: resultado da execução de strcpy_argv_ex5.c compilado sem proteção de segmento de pilha (-fno-stack-protector)

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.

#include <stdio.h>
#include <string.h>
#define TAM_DEST  (4)      /* tamanho buffer destino */
int main(void){
         char src[] = "0123456789";
         char dest[TAM_DEST];
         strncpy(dest,src,TAM_DEST);
         /* Garante que dest é NULL-terminated */
         dest[TAM_DEST-1] = '\0';
         printf("dest='%s'\n", dest);
         return 0;
}
Listagem 12: uso da função strncpy com terminação da string de destino (strncpy_ex6.c)

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:

  1. Confirma se os ponteiros dest e src não são NULL;
  2. Confirma se o valor de destmax se encontra compreendido entre zero e RSIZE_MAX;
  3. Confirma se o tamanho da string a copiar (src) é inferior a RSIZE_MAX;
  4. Confirma se os endereços de memória indicados por src e dest não tem nenhuma sobreposição. Este requisito é expresso através da palavra chave restrict 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.

errno_t <strong>strcpy_s</strong>(char * restrict dest, rsize_t destmax, const char * restrict src);

errno_t <strong>strcat_s</strong>(char * restrict s1, rsize_t s1max, const char * restrict s2);
Listagem 13: protótipos das funções strcpy_s e strcat_s (Norma C11)

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

  1. Cowan, C. P. (1998). Automatic Detection and Prevention of Buffer-Overflow Attacks. 7th USENIX Security Symposium. San Antonio, Texas, USA.: USENIX.
  2. Chris Anley, J. H. (2007). The Shellcoder’s Handbook: Discovering and Exploiting Security Holes (2ª ed.). Wiley Publishing, Inc.
  3. IEEE Standards Committee. (2008). 754-2008 IEEE standard for floating-point arithmetic.
  4. ISO. (2011). ISO/IEC 9899:2011 – Information technology – Programming languages – C.
  5. Knuth, D. (1997). The Art of Computer Programming, Volume 1: Fundamental Algorithms (3ª ed.).
  6. 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
  7. One, A. (2007). Smashing the stack for fun and profit. Phrack, 7(49), 32.
  8. Peter Prinz, T. C. (2015). C in a Nutshell: The Definitive Reference (2ª ed.). O’Reilly.

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