Conversão de Tipos Em C (Parte I)

Introdução

Uma coisa que tenho notado é que o assunto sobre conversão de tipos na linguagem C não é muito abordado nos cursos e tutoriais sobre essa linguagem. Visto que esse assunto é muito importante por levar a uma série de bugs graves, resolvi escrever esse post para esclarecer alguns detalhes sobre o assunto.

Abordarei esse assunto em duas partes. A primeira, o post de hoje, é sobre a teoria por trás da conversão de tipos e os erros decorrentes disso, como overflow de tipos e extensão de sinais. Na segunda e última parte teremos contato prático em relação a esses erros. Iremos analisar códigos vulneráveis e se possível, criaremos exploits para eles.

Os tipos primários do C

Um tipo define o tamanho de uma variável na memória e como seu valor será interpretado e usado. O tamanho de um tipo varia entre arquiteturas, sendo que na maioria das vezes, o tamanho de um int corresponde ao tamanho da word da máquina. Neste post irei considerar a arquitetura x86-32 cuja word é de 32 bits (ou 4 bytes), assim como o tamanho do inteiro.

Os tipos primários do C você provavelmente já conhece: char, int, float e double. A tabela a seguir mostra o tipo e seu tamanho na arquitetura x86-32:

+--------+--------------------------+
| Tipo   |          Tamanho         |
+--------+--------------------------+
| char   |   sizeof(char) = 1 byte  |
+--------+--------------------------+
|  int   |   sizeof(int) = 4 bytes  |
+--------+--------------------------+
| float  |  sizeof(float) = 4 bytes |
+--------+--------------------------+
|double  | sizeof(double) = 8 bytes |
+--------+--------------------------+

Vale lembrar que o C também fornece os modificadores de tipo que são responsáveis por aumentar ou diminuir o tamanho de um tipo. Esses modificadores podem ser short (para int), long (para int e double) e long long (para int). Abaixo uma tabela atualizada dos tipos e tamanhos:

+---------------+---------------------------------+
|     Tipo      |             Tamanho             |
+---------------+---------------------------------+
|     char      |      sizeof(char) = 1 byte      |
+---------------+---------------------------------+
|  short int    |   sizeof(short int) = 2 bytes   |
+---------------+---------------------------------+
|    float      |     sizeof(float) = 4 bytes     |
+---------------+---------------------------------+
|     int       |      sizeof(int) = 4 bytes      |
+---------------+---------------------------------+
|   long int    |    sizeof(long int) = 4 bytes   |
+---------------+---------------------------------+
|long long int  | sizeof(long long int) = 8 bytes |
+---------------+---------------------------------+
|    double     |     sizeof(double) = 8 bytes    |
+---------------+---------------------------------+
| long double   |  sizeof(long double) = 12 bytes |
+---------------+---------------------------------+

Com isso temos várias maneiras de declararmos um inteiro. Podemos restringir seu tamanho com short e aumentá-lo com long long, se desejável.

Tipos com e sem sinal

Sempre houve a necessidade de se representar números negativos por suas implicações matemáticas. Sabendo que o processador só enxerga zeros e uns, como fazemos para representar números negativos? Diante dessa pergunta, os engenheiros tiveram a ideia de utilizar o bit mais significativo (o bit MSB) para representar o sinal. Se esse bit estiver ativo, indica que esse número pode ser considerado negativo. A representação completa de um número negativo se dá através do complemento de 2 [2].

Para indicar se um tipo tem sinal utilizamos a palavra signed e para sem sinal, a palavra unsigned. Veja que só podemos utilizar essas palavras para os tipos char e int, pois os tipos float e double já possuem uma representação própria de acordo com o padrão IEEE 754.

Algo que deve ser notado sobre sinalização de inteiros é que, internamente, não há diferença entre números positivos e negativos, e é devido a isso que muitos problemas ocorrem, pois frequentemente programadores utilizam números negativos onde se espera somente positivos e vice-versa. Você seria capaz, por exemplo, de me dizer qual número é representado pela sequência de bits abaixo?

10011011010000100110100010000011

Somente observando os bits não podemos dizer se o número é negativo ou positivo, mesmo que, embora, o bit de sinal esteja ativo. O que realmente diferencia o sinal de um número são as instruções de máquina. Algumas dessas instruções entendem que o bit de sinal representa um número negativo bem pequeno, enquanto outras consideram esse bit como um número positivo bem grande.

Sabendo que aquela sequência de bits representa o número 0x9b426883, esse número depende do contexto para ser entendido como negativo ou positivo:

int num1 = 0x9b426883 ;
unsigned num2 = 0x9b426883 ;

if(num1 > 5) { return 0; }
if(num2 > 5) { return 1; }

A diferença é sutil. Como num1 foi declarado como signed, o bit de sinal deve ser analisado pela instrução de salto que compõe o primeiro if. No caso de num2, esse bit pode ser ignorado e tratado como bit de “dados” normalmente.

Para entender melhor, veja o assembly do código acima:

$ gdb ex.o
(gdb) disas main
   0x080483d0 :    push   %ebp
   0x080483d1 :    mov    %esp,%ebp
   0x080483d3 :    sub    $0x10,%esp
   0x080483d6 :    movl   $0x9b426883,-0x4(%ebp)
   0x080483dd :    movl   $0x9b426883,-0x8(%ebp)
   0x080483e4 :    cmpl   $0x5,-0x4(%ebp)
   0x080483e8 :    jle    0x80483f1 
   0x080483ea :    mov    $0xffffffff,%eax
   0x080483ef :    jmp    0x8048403
   0x080483f1 :    cmpl   $0x5,-0x8(%ebp)
   0x080483f5 :    jbe    0x80483fe 
   0x080483f7 :    mov    $0xfffffffe,%eax
   0x080483fc :    jmp    0x8048403
   0x080483fe :    mov    $0x0,%eax
   0x08048403 :    leave
   0x08048404 :    ret

As instruções movl reforçam a idéia de que não há diferença entre signed e unsigned internamente. Porém, para as instruções de pulo, é necessário dois sets de instruções, um para tratar números negativos e outro para positivos. Instruções “less/greater than” (ex: jle) tratam de números negativos, enquanto instruções “above/below” (ex: jbe) tratam de números sem sinal. [3]

Essa parte foi para mostrar a leve diferença entre números com e sem sinal. Essa diferença, muitas das vezes, passa desapercebida pelos programadores e pode ocasionar alguns erros, como uma expansão de sinal inesperável.

Promoção de tipos

Quando tipos de tamanho diferentes são usados em uma mesma expressão matemática, o compilador precisa decidir como resolver essa expressão. Nesse tipo de situação, a regra usada é a de promoção de tipos: se há dois tipos de tamanhos diferentes, então o tipo de menor tamanho é promovido para o de maior tamanho. Em relação ao sinal, se o tipo destino for sem sinal (unsigned) então os outros tipos serão promovidos para sem sinal.

Esquematicamente, a promoção de tipos é representada assim:

char < int < long int < float < long long int < double < long double

obs: signed é promovido para unsigned quando necessário.

A promoção de tipos pode ocorrer de duas formas: coerção e cast [5]. A coerção é o que vimos e segue o esquema gráfico acima, e é feita de maneira implícita pelo compilador. Por outro lado, temos o cast. Nesse caso, o programador faz explicitamente a conversão de tipos. A sintaxe de um cast é:

(tipo) variável

O compilador será o responsável por truncar ou estender um tipo de acordo com o que foi especificado pelo programador.

Em alguns casos, o cast pode ser evitado porque a coerção promoverá para o tipo adequado automaticamente:

char ca ;
unsigned int uia ;
int ia, ib ;

ib = uia + (unsiged int)ca + (unsigned int)ib ;

Como a variável uia contém o maior tipo na expressão, automaticamente as outras variáveis serão convertidas para unsinged int pelo compilador, sem a necessidade explícita do cast.

Truncamento

Quando um tipo de maior tamanho é assinalado em um de tamanho menor, pode-se acontecer um truncamento de dados:

int i = 0xdeadbeef ;
short int s = i;

printf("%hx\n", s) ;

/* s = 0xbeef */

Ao assinalar-se um inteiro para um short int, somente os 2 bytes menos significativos dele serão armazenados. Lembre-se que o inteiro é armazenado no formato little-endian onde a ordem dos bytes é invertida.

Se o valor do int fosse menor que 2 bytes (tamanho do short) o truncamento iria ocorrer, mas sem perda de dados.

Extensão de sinal

A extensão ocorre como inverso do truncamento quando um tipo menor é assinalado a um tipo maior. Nesse caso, o compilador precisa saber o que fazer com os bits em excesso. Exemplos comuns envolvem o tipo short e int:

int i ;
short s = 0xbeef ;

i = s ;

printf("%x\n", i);

/* i = 0xffffbeef */

Como o short s é do tipo signed e o bit de sinal está ativo (0xbeef = 1011111011101111), o compilador ao assinalar esse short no int, estenderá o bit de sinal no inteiro.

Veja o assembly:

$ gdb ex1.o
(gdb) disas main
   0x08048400 :    push   %ebp
   0x08048401 :    mov    %esp,%ebp
   0x08048403 :    and    $0xfffffff0,%esp
   0x08048406 :    sub    $0x20,%esp
   0x08048409 :    movw   $0xbeef,0x1e(%esp)
   0x08048410 :    movswl 0x1e(%esp),%eax
   0x08048415 :    mov    %eax,0x18(%esp)
   0x08048419 :    mov    0x18(%esp),%eax
   0x0804841d :    mov    %eax,0x4(%esp)
   0x08048421 :    movl   $0x80484d4,(%esp)
   0x08048428 :    call   0x80482d0
   0x0804842d :    mov    $0x0,%eax
   0x08048432 :    leave
   0x08048433 :    ret

A instrução movswl é a responsável pela extensão do bit de sinal.

Se não usada corretamente, a extensão de sinal pode desencadear um bug grave. Imagine um short armazenando o tamanho de um vetor e que seu valor vem do usuário. Um usuário malicioso pode passar um valor negativo para esse short int que, em seguida, pode ser repassado para uma função que recebe um unsigned como parâmetro, como strncpy() ou memcpy(). O que acontecerá é que esse short se estenderá e o valor resultante será promovido para unsigned, resultando em um valor enorme para a movimentação de bytes. Isso provavelmente travará o programa e se ele for um daemon, o usuário malicioso terá sucesso em um ataque DoS.

void funcao(char *dados, short int tamanho) {
     char buffer[256] ;
     /* etc */
     memcpy(buffer, dados, tamanho) ;
     /* etc */

Observe que tamanho é do tipo short int. Se um usuário passar o valor -1, por exemplo, o compilador promoverá esse short para unsigned int no último parâmetro de memcpy(), e também estenderá o sinal desse número. A função de cópia movimentará 0xffffffff bytes, que nada mais é que 4GB de dados. Isso, provavelmente, travará o programa porque várias páginas de memória serão sobrescritas com valores aleatórios.

Para evitar-se a extensão de sinal, o short teria que ser declarado ou promovido para unsigned. Nesse caso os bits em excesso seriam setados para zero pela instrução movzwl.

Overflow em tipos

Sabemos que cada tipo tem um tamanho máximo, mas o que acontece se passarmos um valor superior a esse máximo? O que acontecerá é chamado de overflow. O truncamento, como vimos, também é um tipo de overflow, pois uma variável receberá um tipo maior que o que ela suporta.

Nem sempre um overflow é um erro. Operações em números negativos necessitam que um overflow aconteça para o resultado ser correto [4]. O overflow se torna um perigo quando ele não é previsto pelo programador, principalmente em se tratando de variáveis com sinal. Por exemplo, se uma variável positiva com sinal receber um valor bem grande, ela passará de positiva para negativa. Essa mudança de sinal seria usada por um atacante para burlar condições de teste e também para passar valores negativos pra funções como memcpy(). Veja um exemplo:

#define TAMANHO_MAXIMO 256

void copiar_dados(char *dados, int size1, int size2) {
     char buffer[TAMANHO_MAXIMO] ;

     if(size1 + size2 > TAMANHO_MAXIMO) { return; }

     memcpy(buffer, dados, size1) ;

     /* etc */
}

Suponha que as variáveis dados, size1 e size2 sejam controladas pelo usuário. O if da função testa se a soma dos valores (o tamanho total) é maior que o tamanho máximo permitido. Se sim, a função retorna sem copiar os dados.

Como as variáveis são do tipo signed, a comparação do if será realizada considerando o bit de sinal. Se de algum modo o usuário fizer um overflow na soma dos tamanhos, ele conseguirá burlar a condição do if. Em seguida, a variável size1 é utilizada no parâmetro de memcpy(), podendo ocasionar numa cópia dados além do esperado e possibilitar um ataque de buffer overflow.

Por exemplo, considere os seguintes valores:

size1 = 264 ;
size2 = 0x80000000 ; /* obs: menor número negativo possível */

A soma size1+size2 resultará em um número negativo:

if ( 264 + 0x80000000 > 4069 ) { ... }

ou melhor:

if ( -2147483384 > 4096 ) { ... }

Consequentemente a condição do if falhará e a instrução memcpy() será executada com o parâmetro size1=264, que é maior que o tamanho limite do buffer local. Nessa situação temos o clássico buffer overflow e o atacante utilizará disso para obter controle total do fluxo do programa.

Para corrigir esse problema, basta exigir que a soma dos tamanhos seja tratada como unsigned. Podemos fazer isso trocando o tipo de uma das variáveis para unsigned, ou usar um cast:

if ( (unsigned int)size1 + size2 > TAMANHO_MAXIMO ) { return ; }

Para saber mais sobre Integer Overflow não deixe de conferir a referência [4].

Desafio: um código vulnerável

Esse código é do level07 do wargame SmashTheStack [6].

[bla.c]
//written by bla
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char **argv) {
        int count = atoi(argv[1]);
        int buf[10];

        if(count >= 10)
                return 1;

        memcpy(buf, argv[2], count * sizeof(int));

        if(count == 0x574f4c46) {
                printf("WIN!\n");
                execl("/bin/sh", "sh" ,NULL);
        } else
                printf("Not today son\n");

        return 0;
}

Compile:

$ sudo cc -o bla bla.c
$ sudo chmod +s bla       # ativando suid bit

Seu objetivo é alterar a variável count para o valor 0x574f4c46 para executar o root shell /bin/sh.

Na parte 2 postarei a solução desse problema e também mais códigos vulneráveis. Boa sorte. :)

Conclusão

As conversões de tipos em C geralmente não são levadas a sério nos cursos dessa linguagem. Vários detalhes estão envolvidos nesse assunto e alguns deles podem levar o programa a bugs sérios. Nesse post vimos a teoria por trás da conversão de tipos (coerção e cast), extensão de sinal e overflow nos tipos. Na segunda parte desse post, veremos mais códigos vulneráveis e também alguns exploits.

Referências

[1] Type Conversions by Steve Summit (Acessado em: Setembro/2012)
http://www.eskimo.com/~scs/cclass/krnotes/sx5g.html

[2] Complemento para dois by wikipedia (Acessado em: Setembro/2012)
http://pt.wikipedia.org/wiki/Complementoparadois

[3] about assembly CF(Carry) and OF(Overflow) flag by stackoverflow (Acessado em: Setembro/2012)
http://stackoverflow.com/questions/791991/about-assembly-cfcarry-and-ofoverflow-flag

[4] Basic Integer Overflows by blexim (Acessado em: Setembro/2012)
http://www.phrack.org/issues.html?issue=60&id=10

[5] What is the difference between casting and coercing? by stackoverflow (Acessado em: Setembro/2012)
http://stackoverflow.com/questions/8857763/what-is-the-difference-between-casting-and-coercing

[6] IO wargame by smashthestack (Acessado em: Setembro/2012)
http://io.smashthestack.org:84/

2 pensamentos sobre “Conversão de Tipos Em C (Parte I)

  1. Pingback: Criando Funções Com Número Variável De Argumentos Em C | Daemonio Labs

Deixe um comentário