Baseado no curso da USNA e NSA (Stack Based Binary Exploits and Defenses)

Home

Aula 02: Tipos de dados e layout do programa de memória

Conteúdo:

1 Tipos de dados numéricos e sinalização

1.1 Tipos numéricos básicos

Vamos relembrar os tipos de dados básicos para programas C. Uma coisa a considerar é que vamos trabalhar com arquiteturas de 32 bits em oposição aos de 64 bits. Isso é principalmente para manter as coisas simples para os exploits, mas isso significa que algumas coisas podem ser um pouco diferentes as vezes.

Por exemplo, considere este programa e sua saída com o tipos numéricos.

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

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

  char c = 0xef;
  short s = 0xbeef;
  int i = 0xdeadbeef;
  long l = 0xcafebabe;

  printf("char c=%d size=%u\n",c,sizeof(char));
  printf("short s=%d size=%u\n",s,sizeof(short));
  printf("int i=%d size=%u\n",i,sizeof(int));
  printf("long l=%ld size=%u\n",l,sizeof(long));

  return 0;
}
felipe@pc:~/binaryanalysis$ ./datatypes
char c=-17 size=1
short s=-16657 size=2
int i=-559038737 size=4
long l=-889275714 size=4

Observe que o número longo é do mesmo tamanho que um número inteiro, 4 bytes, em oposição a 8 bytes em máquinas de 64 bits. Isso ocorre porque registradores em máquinas de 32 bits têm 32 bits de largura, e armazenar valores de 8-byte é irritante, na melhor das hipóteses. Você ainda pode ter valores de 8 bytes, mas você tem que usar long long.

1.2 Valores numéricos assinados

Os tipos numéricos básicos também são assinados, ou seja, seu intervalo de valores vai de [ -2(w-1) : 2(w-1)-1 ] orque um dos bits é usado para indicar o sinal. O bit com sinal é o principal, se for 1, então o número é negativo, mas se for 0, o número é positivo. Os números positivos são representados como você pode esperar, com em relação à contagem binária; no entanto, números negativos são representados com 2 complementos. Isso significa que você conta de trás para frente … mais ou menos.

Veja o exemplo abaixo para os maiores e menores valores negativos:

felipe@pc:~/binaryanalysis$ cat signess.c
#include <stdio.h>
#include <stdlib.h>

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

  //largest positive signed int: 
  //  binary: 0111 1111 1111 1111 1111 1111 1111 1111
  //     hex:   7    f     f    f    f    f   f    f
  int l_pos = 0x7fffffff;

  //smallest postivie signed int: 
  //  binary: 0000 0000  0000 0000 0000 0000 0000 0000
  //     hex:   0   0     0    0    0    0    0    0
  int s_pos = 0x00000000;

  //smallest negative signed int:
  //  binary: 1000 0000 0000 0000 0000 0000 0000 0000
  //     hex:   8    0    0     0    0    0    0   0
  int s_neg = 0x80000000;


  //largest negative signed int:
  //  binary: 1111 1111 1111 1111 1111 1111 1111 1111
  //     hex:   8    f   f     f    f    f    f   f
  int l_neg = 0xffffffff;



  printf(" largest positive=%d \t (0x%08x)\n",l_pos,l_pos);
  printf("smallest positive=%d \t\t (0x%08x)\n",s_pos,s_pos);
  printf("\n");

  printf(" largest negative=%d \t\t (0x%08x)\n",l_neg,l_neg);
  printf("smallest negative=%d \t (0x%08x)\n",s_neg,s_neg);
  printf("\n");

}
felipe@pc:~/binaryanalysis$ ./signess 
 largest positive=2147483647 	 (0x7fffffff)
smallest positive=0 		 (0x00000000)

 largest negative=-1 		 (0xffffffff)
smallest negative=-2147483648 	 (0x80000000)

Se fôssemos contar em dois-complemento com números negativos, começamos em 0xffffffff (-1) e vamos para 0x80000000 (-2147483648). É aqui que o contagem regressiva vem, exceto que estamos contando para trás nos 31 bits inferiores. O programa abaixo mostra isso claramente:

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

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

  int s_neg = 0x80000000;
  int l_neg = 0xffffffff;

  printf("largest negative  =%d \t\t(0x%08x)\n",l_neg,l_neg);
  printf("largest negative-1=%d \t\t(0x%08x)\n",l_neg-1,l_neg-1);
  printf("largest negative-2=%d \t\t(0x%08x)\n",l_neg-2,l_neg-2);
  printf("largest negative-3=%d \t\t(0x%08x)\n",l_neg-3,l_neg-3);
  printf("...\n");
  printf("smallest negative+3=%d (0x%08x)\n",s_neg+3,s_neg+3);
  printf("smallest negative+2=%d (0x%08x)\n",s_neg+2,s_neg+2);
  printf("smallest negative+1=%d (0x%08x)\n",s_neg+1,s_neg+1);
  printf("smallest negative  =%d (0x%08x)\n",s_neg,s_neg);

}
felipe@pc:~/binaryanalysis$ ./twos-comp
largest negative  =-1 		(0xffffffff)
largest negative-1=-2 		(0xfffffffe)
largest negative-2=-3 		(0xfffffffd)
largest negative-3=-4 		(0xfffffffc)
...
smallest negative+3=-2147483645 (0x80000003)
smallest negative+2=-2147483646 (0x80000002)
smallest negative+1=-2147483647 (0x80000001)
smallest negative  =-2147483648 (0x80000000)

É claro que, como acontece com todos os dados em C, a forma como interpretamos uns e zeros é realmente nos olhos de quem vê. Se, em vez disso, interpretássemos esses valores como não assinados, então obtemos uma saída muito diferente:

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

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

  int s_neg = 0x80000000;
  int l_neg = 0xffffffff;

  printf("(unsigned) largest negative  =%u \t\t(0x%08x)\n",l_neg,l_neg);
  printf("(unsigned)  largest negative-1=%u \t\t(0x%08x)\n",l_neg-1,l_neg-1);
  printf("(unsigned) largest negative-2=%u \t\t(0x%08x)\n",l_neg-2,l_neg-2);
  printf("(unsigned) largest negative-3=%u \t\t(0x%08x)\n",l_neg-3,l_neg-3);
  printf("...\n");
  printf("(unsigned) smallest negative+3=%u (0x%08x)\n",s_neg+3,s_neg+3);
  printf("(unsigned) smallest negative+2=%u (0x%08x)\n",s_neg+2,s_neg+2);
  printf("(unsigned) smallest negative+1=%u (0x%08x)\n",s_neg+1,s_neg+1);
  printf("(unsigned) smallest negative  =%u (0x%08x)\n",s_neg,s_neg);

}
felipe@pc:~/binaryanalysis$ ./unsigned 
(unsigned) largest negative  =4294967295  (0xffffffff)
(unsigned) largest negative-1=4294967294  (0xfffffffe)
(unsigned) largest negative-2=4294967293  (0xfffffffd)
(unsigned) largest negative-3=4294967292  (0xfffffffc)
...
(unsigned) smallest negative+3=2147483651 (0x80000003)
(unsigned) smallest negative+2=2147483650 (0x80000002)
(unsigned) smallest negative+1=2147483649 (0x80000001)
(unsigned) smallest negative  =2147483648 (0x80000000)

2 Ponteiros e referências de memória

2.1 Ponteiros básicos

Ponteiros (ou referências de memória) são cruciais para programas C. Algumas terminologias para a sintaxe dos ponteiros:

  • int * p : declarando um ponteiro inteiro p.
  • O valor ou valor do ponteiro de p é uma referência de memória, um endereço de um valor de memória.
  • *p : desreferenciar significa seguir o ponteiro para a memória referenciado por p e alterar o valor da memória lá.
  • &a : endereço da variável a, que é um pvalor de ponteiro, pois é uma referência de memória.

A melhor maneira de ter uma ideia disso é vê-lo em ação:

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

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

  int a = 10;
  int b = 20;
  int * p = &a; //MARK 1

  *p = b; //MARK 2

  p = &b;
  b = 50; //MARK 3

  printf("a=%d &a=%p\n",a,&a);
  printf("b=%d &b=%p\n",b,&b);
  printf("p=%p &p=%p *p=%d\n",p,&p,*p);

  return 0;
}

Podemos analisar isso usando um diagrama de memória para cada uma das marcas. Nós usamos setas para indicar uma referência de memória.

 MARK 1            Mark 2           Mark 3

.----.----.       .----.----.      .----.----.
| a  | 10 |<-.    | a  | 20 |<-.   | a  | 20 |
|----+----|  |    |----+----|  |   |----+----|
| b  | 20 |  |    | b  | 20 |  |   | b  | 50 |<-.
|----+----|  |    |----+----|  |   |----+----|  |
| p  |  --+--'    | p  |  --+--'   | p  |  --+--'
'----'----'       '----'----'      '----'----'

E podemos ver que a última marca é o caso quando executamos o programa.

felipe@pc:~/binaryanalysis$ ./reference 
a=20 &a=0xbfd00ebc
b=50 &b=0xbfd00eb8
p=0xbfd00eb8 &p=0xbfd00eb4 *p=50

Observe, porém, que os valores de ponteiro ou referências de memória são realmente apenas números. Realmente, a maneira como devemos modelar este diagrama é com as referências e valores completos, para o estado final:

     address     value
  .------------.------------.
a | 0xbfaebd9c |  20        |
  |------------+------------|
b | 0xbfd00eb8 |  50        | <-.
  |------------+------------|   |
p | 0xbfd00eb4 | 0xbfd00eb8 | --'
  '------------'------------'

2.2 Randomização da memória

Uma coisa que vai te enganar é que na maioria dos linux modernos, cada execução do programa randomizará o espaço de endereço. A razão para isso ficará claro mais tarde, mas as implicações são que, quando você executar o programa novamente, você obterá valores diferentes.

felipe@pc:~/binaryanalysis$ ./reference 
a=20 &a=0xbfaebd9c
b=20 &b=0xbfaebd98
p=0xbfaebd98 &p=0xbfaebd94 *p=50

Para garantir que esse não seja o caso, você terá que desativar esse modo. Veja como fazer isso.

felipe@pc:~/binaryanalysis$ echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

Agora, ao executar o programa, você obterá a mesma saída todas as vezes.

felipe@pc:~/binaryanalysis$ ./reference 
a=20 &a=0xbffff6bc
b=20 &b=0xbffff6b8
p=0xbffff6b8 &p=0xbffff6b4 *p=50
user@si485H-base:demo$ ./reference 
a=20 &a=0xbffff6bc
b=20 &b=0xbffff6b8
p=0xbffff6b8 &p=0xbffff6b4 *p=50

3 Matrizes(arrays) e strings

3.1 Valores de matriz(array) e ponteiros são a mesma coisa!

Aqui está um fato: ponteiros e valores de array são a mesma coisa. Segure esta verdade auto-evidente, e você nunca se perderá na escura floresta da memória …

Por que essa verdade é verdadeira? Bem, tem a ver com a definição de um array. Um array é um bloco de memória contínuo de uma sequência de tipos de dados digitados de forma semelhante. Uma matriz é uma referência aos primeiros dados item no bloco de memória contíguo e, portanto, o valor de uma matriz faz referência à memória. Isso significa que um valor de matriz é um ponteiro. Bum!

Se isso não estiver tão claro, vejamos um exemplo.

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

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

  int a[] = {10,11,12,13,14};

  int * p = a;

  p[2] = 5;

  printf("a=%p p=%p\n",a,p);

  int i;
  for(i=0;i<5;i++){
    printf("a[%d] = %d\n",i,a[i]);
  }

  return 0;
}

Aqui, uma matriz a é declarada com 5 valores e permitimos que o ponteiro p mantenha o mesmo valor que a. Espere! Observe que não usamos & para definir o valor de p. Esta é uma atribuição direta, então o valor em a já é uma referência de memória a um inteiro porque esse é o tipo de dados que p armazena.

Em seguida, observe que esta linha:

p[2] = 5;

Estamos usando o operador de índice de array [ ] com p, portanto, em um sentido real, estamos tratando p como uma matriz(array). E a operação faz como seria de se esperar na saída.

felipe@pc:~/binaryanalysis$ ./arrays 
a=0xbffff6b4 p=0xbffff6b4
a[0] = 10
a[1] = 11
a[2] = 5
a[3] = 13
a[4] = 14

Isso levanta a questão: afinal, o que é o operador de índice de matriz? É uma deferência especial que adiciona o índice à base. Por exemplo:

p[i]  <-- same as --->   *(p+i)

Você pode ver que isso é verdade no exemplo a seguir:

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

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

  int a[] = {10,11,12,13,14};

  int * p = a;

  *(p+2) = 5;

  printf("a=%p p=%p\n",a,p);

  int i;
  for(i=0;i<5;i++){
    printf("a+%d=%p *(a+%d) = %d\n",i,a+i,i,a[i]);
  }

  return 0;
}
felipe@pc:~/binaryanalysis$ ./p_arrays 
a=0xbffff6b4 p=0xbffff6b4
a+0=0xbffff6b4 *(a+0) = 10
a+1=0xbffff6b8 *(a+1) = 11
a+2=0xbffff6bc *(a+2) = 5
a+3=0xbffff6c0 *(a+3) = 13
a+4=0xbffff6c4 *(a+4) = 14

Ok, então se os valores de array e ponteiros são iguais, por que temos array Valores? Bem, eu menti, só um pouco. Os valores e ponteiros da matriz são os mesmo no sentido de que ambos são referências de memória, mas o declaração de uma matriz e a declaração de um ponteiro não são a mesma. Ao declarar um array, você está declarando uma região de memória tudo de uma vez, e você deve ser capaz de sempre fazer referência a essa memória, caso contrário, você terá um vazamento de memória. Ou seja, um valor de matriz é imutável ou constante — não pode mudar.

Quando você declara um ponteiro, por outro lado, você está criando um valor que faz referência à memória, mas a memória a que ele faz referência pode mudar.

3.2 Aritmética de ponteiro

Vamos dar uma olhada mais de perto na última saída:

felipe@pc:~/binaryanalysis$ ./p_arrays 
a=0xbffff6b4 p=0xbffff6b4
a+0=0xbffff6b4 *(a+0) = 10
a+1=0xbffff6b8 *(a+1) = 11
a+2=0xbffff6bc *(a+2) = 5
a+3=0xbffff6c0 *(a+3) = 13
a+4=0xbffff6c4 *(a+4) = 14

Observe a referência de memória em cada incrementação. Enquanto estamos adicionando apenas 1 elevado ao valor a, a referência de memória resultante está mudando a cada 4. Isso ocorre porque cada número inteiro tem quatro bytes de largura, então o computador tem que deslocar cada referência de índice de matriz pelo tamanho dos tipos de dados armazenado dentro da matriz(array).

Considere o mesmo estilo de programa para diferentes tipos, e este fato torna-se aparente:

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

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

  int a[] = {10,11,12,13,14};
  short s[] = {10,11,12,13,14};
  char c[] =  {10,11,12,13,14};

  int i;
  for(i=0;i<5;i++){
    printf("a+%d=%p *(a+%d) = %d\n",i,a+i,i,a[i]);
  }

  printf("\n");

  for(i=0;i<5;i++){
    printf("s+%d=%p *(s+%d) = %d\n",i,s+i,i,s[i]);
  }

  printf("\n");

  for(i=0;i<5;i++){
    printf("c+%d=%p *(c+%d) = %d\n",i,c+i,i,c[i]);
  }

  return 0;
}
felipe@pc:~/binaryanalysis$ ./pointer_arithemtic 
a+0=0xbffff6a8 *(a+0) = 10
a+1=0xbffff6ac *(a+1) = 11
a+2=0xbffff6b0 *(a+2) = 12
a+3=0xbffff6b4 *(a+3) = 13
a+4=0xbffff6b8 *(a+4) = 14

s+0=0xbffff69e *(s+0) = 10
s+1=0xbffff6a0 *(s+1) = 11
s+2=0xbffff6a2 *(s+2) = 12
s+3=0xbffff6a4 *(s+3) = 13
s+4=0xbffff6a6 *(s+4) = 14

c+0=0xbffff699 *(c+0) = 10
c+1=0xbffff69a *(c+1) = 11
c+2=0xbffff69b *(c+2) = 12
c+3=0xbffff69c *(c+3) = 13
c+4=0xbffff69d *(c+4) = 14

3.3 Strings

C-strings(!!!!) a ruína dos estudantes programadores em todo o mundo, mas eles não são tão ruins se você se lembrar que a string é simplesmente uma matriz de caracteres terminados em nulo. Em seu uso mais pedante, nós pode declarar uma string como uma matriz no sentido padrão.

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

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

  char str[] = {'H','e','l','l','o',' ','W','o','r','l','d','!','\n','\0'};

  printf("%s",str);

  return 0;
}

No entanto, este é um fardo enorme, então em C, podemos usar as aspas duplas para declarar strings o que cria automaticamente a matriz e null a termina. Como abaixo:

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

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

  char str[] = "Hello World!\n";

  printf("%s",str);

  return 0;
}

Observe que, quando declaramos uma string com aspas duplas, ainda tem que declarar a variável que faz referência à string como uma matriz tipo (ou um tipo de ponteiro), porque ainda é uma matriz(array).

E, como matrizes(arrays) de inteiro, ainda podemos fazer referência a itens individuais na matriz usando a iteração do ponteiro. Também é típico com strings para aproveitar o fato de que é terminado em NULL. NULL é apenas um pseudônimo para 0, que é um valor falso.

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

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

  char str[] = "Hello World!\n";
  char * c;

  for( c = str;*c; c++){
    putchar(*c);
  }

  return 0;
}

4 Layout da memória do programa

4.1 Cowboys and Endian-s

Se você pensar sobre isso, existem duas maneiras fundamentais muito diferentes para organizar bytes com significado. Como pensadores da linguagem ocidental e aprendizagem, atribuímos significado da esquerda para a direita. Por exemplo, se eu anoto o número:

210500

Esse é o número duzentos e dez mil e quinhentos. É não o número cinco mil e doze (lendo o número à direita para esquerda).

Onde o byte mais significativo é representado é referido como endian-ness em ciência da computação. De longe, o mais prevalente representação é little endian, pelo qual os menos significativos bytes vêm primeiro. Isso está em oposição ao big endian, que é como pensamos nas coisas, aonde o byte mais significativo vem primeiro.

O impacto disso fica claro com um programa simples abaixo.

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

int main(int argc, char * argv){

  int a = 0xdeadbeef;

  //treat the integer like a array of one-byte values
  char * p = (char *) &a;

  int i;
  for(i=0;i<4;i++){
    printf("p[%d] = 0x%hhx\n",i,p[i]);
  }

  return 0;

}

Aqui, tratamos o inteiro a como um buffer de 4 bytes. Se escrevermos esses bytes indexados de 0 a 3, vemos que o número 0xdeadbeef está escrito ao contrário, 0xef, 0xbe, 0xad, 0xde.

felipe@pc:~/binaryanalysis$ ./endian 
p[0] = 0xef
p[1] = 0xbe
p[2] = 0xad
p[3] = 0xde

4.2 Stack, Heap, Data, BSS

Agora que temos uma noção decente de como interagimos com a memória, vamos passar algum tempo examinando o layout do endereço de memória. A grande questão é, quando declaramos uma variável/valor/dados, onde esses dados estão localizados?

Para começar, precisamos nos lembrar do espaço de endereço virtual, que permite que um único programa trate toda o espaço de memória disponível de forma ordenada, de endereços altos a baixos. Na realidade, o memória é armazenada na RAM física em locais aleatórios, mas a partir do perspectiva do programa, está tudo alinhado.

Quando um programa é carregado na memória, há diferentes regiões para diferentes tipos de dados. Um conceito-chave, porém, é que os dados do programa e seu código residem todos na memória juntos, que é o que vamos alavancar quando exploramos programas mais tarde.

Aqui estão as áreas gerais do layout de memória do programa, de maior endereço para endereço inferior. Observe que este é um endereço de memória de 32 bits, portanto, há um total de cerca de 4 GB no espaço de memória.

higher address
0xffffffff --> .----------------.
               |    reserved    |  <-- command line args
               +----------------+      envirment variables
               |                |
               |     stack      |  <-- user stack, function frames
               |       |        |
               :       |        :
               '       v        '
                                   <-- mapped data
               .       ^        .
               :       |        :
               |       |        |
               |     heap       |  <-- user heap, dynamic memory
               +----------------+
               |      bss       |  <-- global memory 
               +----------------+
               |     text       |  <-- code segments
0x00000000 --> '----------------'
lower address

Aqui está para que cada uma dessas seções é usada:

  • reserved: o espaço reservado é usado para passar pelo ambiente variáveis e argumentos de linha de comando para o programa.
  • stack:a pilha é para organizar a execução do programa em quadros de pilha para rastreamento de funções e variáveis locais. Cada chamada de função envia(push) uma frame da pilha, e cada returno aparece(pops)em um quadro de pilha. A pilha cresce em direção aos endereços baixos, em espaço de endereço de memória vazio.
  • heap : o heap é para alocações de memória dinâmicas e globais, como a chamada de malloc()
  • bss : O BSS é usado para armazenar valores globais ou declarados estaticamente.
  • text : é onde o código do programa, ou seja, as instruções x86, é armazenado.

Podemos ver cada um desses locais de memória no seguinte programa:

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

void foo(void) { return; }

int main(int argc, char *argp[], char *envp[]){

  int a = 10;

  char stack_str[] = "Hello";

  char * heap_str = malloc(strlen(stack_str)+1);
  strcpy(heap_str,stack_str);

  char * bss_str = "World";



  printf("(reserved)   evnp = 0x%08x \n", envp);
  printf("(stack)        &a = 0x%08x \n", &a);
  printf("(stack) stack_str = 0x%08x \n", stack_str);
  printf("(heap)   heap_str = 0x%08x \n", heap_str);
  printf("(bss)     bss_str = 0x%08x \n", bss_str);
  printf("(text)       main = 0x%08x \n", main);
  printf("(text)        foo = 0x%08x \n", foo);
}
felipe@pc:~/binaryanalysis$ ./mem_layout 
(reserved)   evnp = 0xbffff77c 
(stack)        &a = 0xbffff6c4 
(stack) stack_str = 0xbffff6be 
(heap)   heap_str = 0x0804b008 
(bss)     bss_str = 0x08048630 
(text)       main = 0x080484b3 
(text)        foo = 0x080484ad