Você já se perguntou o que acontece dentro do Linux depois que você digita ./programa no terminal e pressiona Enter?
O que exatamente acontece em seguida? Como o kernel encontra o arquivo? Como ele o carrega na memória? Quem chama main ? E como você pode ver tudo isso ao vivo?
Vamos analisar usando o exemplo do programa vazio empty_sleep . Ele não faz nada, apenas inicia e termina após 30 segundos. Não há código extra nele, então toda a atenção será focada no processo de carregamento. Tudo o que veremos se aplica à maioria dos programas compilados dinamicamente no Linux.
Neste artigo, mostrarei como, usando strace , rastrear o caminho do programa em tempo real de execve até o ponto de entrada do programa e explicarei o que tudo isso significa.
O que são chamadas de sistema e o que strace tem a ver com isso
No Linux, existem dois modos de operação: modo usuário e modo kernel. Programas regulares (incluindo instâncias suspeitas) operam no modo usuário. Eles podem pedir ao kernel para fazer algo apenas através de chamadas de sistema . Por exemplo: abrir um arquivo, alocar memória ou terminar.
strace é uma ferramenta que intercepta e mostra todas as chamadas de sistema de um programa. Vamos usá-lo para ver cada etapa do carregamento.
Um programa vazio para o experimento (quase vazio)
Código-fonte do programa empty_sleep :
c#include <unistd.h> int main() { sleep(30); return 0; }
Ele não faz nada. Apenas espera 30 segundos. Isso é suficiente para olhar em sua memória, mas falaremos sobre isso mais tarde. Agora, vamos rastrear o processo de carregamento do programa na memória.
Executamos strace e vemos a primeira chamada de sistema
Executamos strace :
bashstrace ./empty_sleep
A primeira chamada de sistema que vemos é execve :
execve("./empty_sleep", ["./empty_sleep"], 0x7fffffffe220 /* 35 vars */) = 0
O que aconteceu aqui?
O shell (bash) criou sua própria cópia e chamou a chamada de sistema execve() , que substitui o processo atual por um novo programa. O kernel começa a carregar o arquivo ELF. A chamada execve retornará o controle apenas em caso de erro. Se tudo estiver normal, o controle será transferido para o carregador dinâmico.
Dentro de execve() , o kernel:
- Lê o cabeçalho ELF,
- Lê os Program Headers (segmentos do programa),
- Para cada segmento LOAD, chama mmap ,
- Carrega o interpretador (também conhecido como carregador dinâmico) da seção .interp ,
- Passa o controle para o carregador dinâmico.
Todas essas chamadas mmap ocorrem dentro de execve , então não as vemos separadamente. Vemos apenas o fato da chamada bem-sucedida.
O que é um carregador dinâmico
O carregador dinâmico ( ld-linux.so ) é um programa especial que o kernel carrega junto com seu programa compilado dinamicamente. Ele prepara o ambiente: carrega as bibliotecas necessárias, configura a memória, vincula chamadas de função (executa realocações).
Você pode ver o caminho para o carregador dinâmico no arquivo ELF usando readelf :
bashreadelf -l empty_sleep | grep INTERP
INTERP 0x000350 0x0000000000000350 0x0000000000000350 0x00001c 0x00001c R 0x1
[Solicitado interpretador do programa: /lib64/ld-linux-x86-64.so.2]
O arquivo /lib64/ld-linux-x86-64.so.2 é o carregador dinâmico (doravante, no texto - apenas o carregador).
O que o carregador dinâmico faz
Imediatamente após execve , o controle passa para o carregador. Vamos ver o que ele faz, usando strace .
Configuração da memória
Primeiro, o carregador configura a memória para si mesmo. Ele obtém o endereço atual do final da heap e aloca memória anônima (8 KB) para suas necessidades:
brk(NULL) = 0x555555559000
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7ffff7fbf000
O que é uma heap?
Esta é uma área para alocação dinâmica de memória durante a execução do programa. A heap cresce para cima (para endereços maiores). Quando o programa precisa de mais memória, ele usa a chamada de sistema brk() . Na saída acima, a chamada brk(NULL) não aloca memória, mas apenas solicita o endereço atual do final da heap. É assim que o carregador descobre onde a heap termina, para que possa expandi-la, se necessário.
Pesquisando bibliotecas
O carregador verifica se há bibliotecas para pré-carregamento (usadas para depuração):
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (Nenhum arquivo ou diretório desse tipo)
Normalmente, esse arquivo não existe.
Em seguida, o carregador abre o cache de bibliotecas do sistema /etc/ld.so.cache , olha as informações do arquivo, mapeia-o na memória e fecha o descritor:
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=94483, ...}) = 0
mmap(NULL, 94483, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7ffff7fa7000
close(3) = 0
O que o carregador faz:
- Abre o arquivo de cache.
- Obtém seu tamanho ( st_size=94483 ).
- Mapeia-o na memória via mmap .
- Fecha o descritor de arquivo (a memória permanece).
Agora, o carregador pode encontrar rapidamente na memória onde a biblioteca necessária está localizada.
Quais bibliotecas o programa precisa
Vamos ver quais bibliotecas o programa empty_sleep precisa:
bashreadelf -d empty_sleep | grep NEEDED
0x0000000000000001 (NEEDED) Biblioteca compartilhada: [libc.so.6]
Apenas uma biblioteca é necessária - o padrão libc.so.6 . É isso que o carregador vai carregar agora.
Carregando a biblioteca libc.so.6 na memória
Primeiro, o carregador abre o arquivo da biblioteca e lê seu cabeçalho ELF - os primeiros 832 bytes:
openat(AT_FDCWD, "/usr/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0000\241\2\0\0\0\0\0"..., 832) = 832
Do cabeçalho, o carregador aprende:
- Número mágico ( \177ELF ),
- Tipo de arquivo,
- Arquitetura,
- Número de cabeçalhos de programa (Program Headers).
Em seguida, o carregador lê os cabeçalhos do programa. Para libc.so.6 há 15 deles, cada um com 56 bytes. No total, 840 bytes a partir do deslocamento 64:
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 840, 64) = 840
Esses 840 bytes armazenam informações sobre segmentos do tipo LOAD , que devem ser carregados na memória.
Segmentos VS Seções
Não confunda segmentos e seções - são coisas diferentes.
Seções
- organização lógica do arquivo para o linker estático e o depurador: .text (código), .data (variáveis), .rodata (constantes).
Segmentos
- organização física para o carregador (partes do kernel). Não importa para o carregador onde o código está no programa e onde estão as constantes. Ele está interessado em quais dados precisam ser carregados na memória e quais direitos de acesso esses dados têm (leitura, gravação, execução). Portanto, os segmentos combinam várias seções com os mesmos direitos.
Mapeando a biblioteca na memória
O carregador agora sabe tudo sobre libc.so.6 . Ele obtém informações sobre o arquivo (tamanho, direitos de acesso) e inicia uma série de chamadas mmap :
mmap(NULL, 2055760, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7ffff7db1000
mmap(0x7ffff7dd9000, 1474560, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x28000) = 0x7ffff7dd9000
mmap(0x7ffff7f41000, 339968, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x190000) = 0x7ffff7f41000
mmap(0x7ffff7f94000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1e3000) = 0x7ffff7f94000
mmap(0x7ffff7f9a000, 52816, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7ffff7f9a000
Vamos analisar a primeira chamada:
mmap(NULL, 2055760, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7ffff7db1000
Ele aloca memória para toda a biblioteca com direitos apenas de leitura. O tamanho da memória solicitada é maior que o tamanho da biblioteca ( 2055760 > 2014472 ), porque o tamanho é alinhado ao limite da página (geralmente 4096 bytes = 0x1000 ).
As outras quatro chamadas mmap cobrem segmentos individuais com os direitos corretos: o segmento executável recebe PROT_EXEC , o segmento de dados - PROT_WRITE .
Depois que a biblioteca é mapeada na memória, o carregador fecha o descritor de arquivo:
close(3) = 0
Configuração final antes de transferir o controle
Após carregar as bibliotecas, o carregador executa os toques finais.
Configurando o armazenamento local de threads (TLS)
O carregador configura um mecanismo que permite que cada thread tenha sua própria cópia da variável global. Por exemplo, cada thread deve ter seu próprio errno :
mmap(NULL, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7ffff7dae000
arch_prctl(ARCH_SET_FS, 0x7ffff7dae740) = 0
Proteção de memória (GNU_RELRO)
O mecanismo GNU_RELRO faz com que algumas áreas de memória sejam somente leitura depois que o carregador executa as realocações. Isso protege a tabela GOT de ser sobrescrita:
mprotect(0x7ffff7f94000, 16384, PROT_READ) = 0
mprotect(0x555555557000, 4096, PROT_READ) = 0
mprotect(0x7ffff7ffb000, 8192, PROT_READ) = 0
Aqui, pode-se notar que as tabelas do carregador, a biblioteca libc.so.6 e, por enquanto, apenas presumivelmente, o programa empty_sleep são protegidos contra sobrescrita. Em quais endereços o programa empty_sleep foi carregado, descobriremos um pouco mais tarde.
Limite de pilha e ASLR
O carregador define o limite da pilha - um mecanismo de proteção contra estouro:
prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=8192*1024, rlim_max=RLIM64_INFINITY}) = 0
E também obtém bytes aleatórios para ASLR (randomização do espaço de endereço) - um mecanismo de proteção que dificulta a previsão dos endereços das funções:
getrandom("\xbf\x72\x35\x75\xe1\xd0\xd0\x63", 8, GRND_NONBLOCK) = 8
Esses bytes aleatórios são usados pelo kernel e pelo carregador dinâmico para selecionar endereços aleatórios ao carregar o programa, bibliotecas, pilha e heap. Este é o ASLR.
Liberando memória temporária
O cache de bibliotecas do sistema /etc/ld.so.cache não é mais necessário, a memória é liberada:
munmap(0x7ffff7fa7000, 94483) = 0
Transferindo o controle para o programa
Após todos os preparativos, o carregador transfere o controle para _start
- o ponto de entrada do programa.
_start não é uma função main , mas um ponto de entrada de serviço que o compilador adiciona. Ele prepara a pilha, chama os construtores de objetos globais e só então transfere o controle para main .
Vamos olhar para o ponto de entrada empty_sleep usando readelf :
bashreadelf -h empty_sleep
Lá encontraremos a linha:
Endereço do ponto de entrada: 0x1040
No código descompilado, você pode ver como _start prepara os argumentos e chama _libc_start_main e, em seguida, nossa função principal main .
Como garantir que tudo foi carregado corretamente
No Linux, existe um sistema de arquivos virtual /proc . Para cada processo em execução, existe uma pasta /proc/PID/ e o arquivo maps mostra como a memória virtual desse processo é distribuída.
Vamos executar empty_sleep em segundo plano e ver:
bash./empty_sleep & PID=$! cat /proc/$PID/maps
Saída (abreviada):
555555554000-555555555000 r--p 00000000 00:3a 6 /mnt/.../empty_sleep
555555555000-555555556000 r-xp 00001000 00:3a 6 /mnt/.../empty_sleep
555555556000-555555557000 r--p 00002000 00:3a 6 /mnt/.../empty_sleep
555555557000-555555558000 r--p 00002000 00:3a 6 /mnt/.../empty_sleep
555555558000-555555559000 rw-p 00003000 00:3a 6 /mnt/.../empty_sleep
7ffff7db1000-7ffff7dd9000 r--p 00000000 08:01 263133 /usr/lib/x86_64-linux-gnu/libc.so.6
7ffff7dd9000-7ffff7f41000 r-xp 00028000 08:01 263133 /usr/lib/x86_64-linux-gnu/libc.so.6
7ffff7f41000-7ffff7f94000 r--p 00190000 08:01 263133 /usr/lib/x86_64-linux-gnu/libc.so.6
7ffff7f94000-7ffff7f98000 r--p 001e3000 08:01 263133 /usr/lib/x86_64-linux-gnu/libc.so.6
7ffff7f98000-7ffff7f9a000 rw-p 001e7000 08:01 263133 /usr/lib/x86_64-linux-gnu/libc.so.6
7ffff7f9a000-7ffff7fa7000 rw-p 00000000 00:00 0
7ffff7fc7000-7ffff7fc8000 r--p 00000000 08:01 263130 /usr/.../ld-linux-x86-64.so.2
7ffff7fc8000-7ffff7ff0000 r-xp 00001000 08:01 263130 /usr/.../ld-linux-x86-64.so.2
7ffff7ff0000-7ffff7ffb000 r--p 00029000 08:01 263130 /usr/.../ld-linux-x86-64.so.2
7ffff7ffb000-7ffff7ffd000 r--p 00034000 08:01 263130 /usr/.../ld-linux-x86-64.so.2
7ffff7ffd000-7ffff7ffe000 rw-p 00036000 08:01 263130 /usr/.../ld-linux-x86-64.so.2
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0 [stack]
Cada linha descreve uma área de memória. A partir do dump, podemos ver:
o próprio programa empty_sleep (endereços 555555554000-555555559000 ), a biblioteca libc.so.6 (endereços 7ffff7db1000-7ffff7fa7000 ), o carregador dinâmico ld-linux-x86-64.so.2 (endereços 7ffff7fc7000-7ffff7ffe000 ), pilha ( 7ffffffde000-7ffffffff000 ), tabelas GOT protegidas contra sobrescrita do programa, carregador e biblioteca libc.so.6 (endereços 555555557000-555555558000 , 7ffff7ffb000-7ffff7ffd000 , 7ffff7f94000-7ffff7f98000 ).
Agora você pode ver com seus próprios olhos tudo o que analisamos nas chamadas de sistema mmap .
O que aprendemos e o que aprendemos
Juntos, rastreamos o caminho da chamada de sistema execve até o ponto de entrada do programa empty_sleep (função _start ):
- O kernel carrega o programa e o carregador dinâmico.
- O carregador configura a memória, procura bibliotecas via /etc/ld.so.cache .
- O carregador abre, lê e mapeia libc.so.6 na memória por meio de uma série de chamadas mmap .
- O carregador configura TLS, protege a memória via GNU_RELRO , define o limite da pilha e obtém bytes aleatórios para ASLR.
- O carregador libera memória temporária e transfere o controle para _start .
- A função _start chama _libc_start_main , que chama main .
E o mais importante - aprendemos a observar tudo isso em tempo real usando strace e olhar para o mapa de memória final via /proc/pid/maps .
O que vem a seguir
A ferramenta strace mostra chamadas de sistema - chamadas para o kernel. Mas não mostra chamadas de funções de biblioteca regulares, como strcmp , printf , memcpy . Para isso, existe outra ferramenta - ltrace . Ele intercepta chamadas de função de bibliotecas dinâmicas e pode mostrar, por exemplo, qual senha o programa espera.
Mas este já é o tema de um artigo separado.
P.S.
Tenho um pedido para você. Escrevi este material com base no meu curso gratuito "Hacker Branco: Análise de Arquivos no Linux" para iniciantes em segurança da informação e estudantes de especialidades técnicas. Ele fornece habilidades básicas de análise: determinar o tipo de arquivo, pesquisar arquivos embutidos e artefatos óbvios, familiarizar-se com a estrutura do formato ELF e carregar o programa na memória. Mas eu não tenho uma visão de fora - daqueles que já entendem bem o assunto.
Olhe para o texto criticamente:
- Onde eu simplifiquei até perder o sentido?
- O que importante eu perdi?
- Como você explicaria este tópico para um iniciante?
O curso é gratuito, estou constantemente trabalhando nele. Qualquer crítica é bem-vinda. Obrigado por ler.
