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 inteirop
.- 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 porp
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