Quando um programa em Linux chama printf, sleep ou qualquer outra função de uma biblioteca dinâmica, seu endereço real é desconhecido antecipadamente devido ao ASLR (Address Space Layout Randomization). No entanto, a chamada é executada com sucesso. Neste artigo, vamos detalhar passo a passo como os mecanismos de PLT (Procedure Linkage Table), GOT (Global Offset Table) e relocações funcionam. Acompanharemos o processo de resolução de endereços usando objdump, readelf e GDB, e observaremos em tempo real o que acontece nas primeiras e subsequentes chamadas de uma função de biblioteca.
No momento do lançamento de um programa em Linux, o kernel o carrega na memória e o linker dinâmico carrega todas as bibliotecas necessárias. O mecanismo que vamos analisar funciona de forma idêntica para qualquer biblioteca dinâmica, mas como exemplo, utilizaremos a biblioteca padrão da linguagem C, libc.so.6. Ela é carregada praticamente sempre e contém funções bem conhecidas como printf, puts ou sleep.
No entanto, há um obstáculo. Devido ao ASLR, um mecanismo de segurança que posiciona bibliotecas aleatoriamente na memória, o endereço de libc.so.6 e todas as funções dentro dela muda a cada execução. O compilador não pode saber esses endereços antecipadamente. Como, então, o programa encontra a função desejada se seu endereço só se torna conhecido durante a execução?
A resposta reside no mecanismo de relocações: PLT, GOT e lazy binding. Vamos entender como isso funciona com o exemplo de um programa simples, empty_sleep, que chama a função sleep(30) e termina. Não apenas estudaremos a teoria, mas também veremos as relocações em ação com o depurador GDB.
Programa Vazio para Experimento
Para o nosso exemplo, escreveremos um programa simples chamado empty_sleep. Ele não faz nada além de chamar sleep(30):
c#include <unistd.h> int main() { sleep(30); return 0; }
Vamos compilá-lo em um arquivo objeto:
bashgcc -c empty_sleep.c -o empty_sleep.o
Em seguida, examinaremos o código desmontado. É importante notar que a saída dos comandos pode variar dependendo das flags usadas para compilar o GCC e o glibc. Apresentaremos duas variantes. As diferenças residem nos offsets do código, na presença da instrução endbr64 e na estrutura dos stubs do PLT. No entanto, a lógica de relocações não é afetada.
Agora, vejamos o código desmontado:
bashobjdump -d -M intel empty_sleep.o
Primeiro Exemplo (com endbr64):
assembly0000000000000000 <main>: 0: f3 0f 1e fa endbr64 4: 55 push rbp 5: 48 89 e5 mov rbp,rsp 8: bf 1e 00 00 00 mov edi,0x1e d: e8 00 00 00 00 call 12 <main+0x12> 12: b8 00 00 00 00 mov eax,0x0 17: 5d pop rbp 18: c3 ret
Segundo Exemplo (sem endbr64):
assembly0000000000000000 <main>: 0: 55 push rbp 1: 48 89 e5 mov rbp,rsp 4: bf 1e 00 00 00 mov edi,0x1e 9: e8 00 00 00 00 call e <main+0xe> e: b8 00 00 00 00 mov eax,0x0 13: 5d pop rbp 14: c3 ret
O primeiro exemplo inclui a instrução endbr64, enquanto o segundo não. No primeiro caso, o stub está no offset 0xd; no segundo, está no offset 0x9. Fora isso, não há diferença.
O ponto crucial aqui é a instrução call com o operando 0x00000000 – o stub. O compilador não sabe onde sleep será localizado, então ele deixa um espaço em branco. Os quatro bytes nulos são um placeholder temporário que será substituído pelo endereço real posteriormente.
A instrução endbr64 faz parte da tecnologia Intel CET (Control-Flow Enforcement Technology), uma proteção de hardware contra ataques de fluxo de controle indireto, como JOP (Jump-Oriented Programming) e similares. Ela não está relacionada ao mecanismo de relocações. Se o seu GCC foi compilado sem suporte a CET, essa instrução não estará presente, e o offset do stub será quatro bytes menor. Isso também não afeta a lógica de relocações.
Repetindo, os quatro bytes nulos são uma marca temporária. O endereço real será gravado mais tarde, e por enquanto, o compilador deixou uma entrada em uma tabela especial.
Como o Compilador Informa sobre Edições Futuras
O compilador grava informações em uma tabela especial: no offset especificado (imediatamente após o código da operação e8), o endereço da função sleep precisará ser inserido. Vamos verificar:
bashreadelf -r empty_sleep.o
Saída:
Relocation section '.rela.text' at offset 0x178 contains 1 entries:
Offset Info Type Sym. Value Sym. Name + Addend
00000000000e 000400000004 R_X86_64_PLT32 0000000000000000 sleep-4
O valor específico do offset depende do tamanho do código da função main e pode variar no seu caso. O importante é que ele aponta exatamente para os quatro bytes que vimos no desassembler.
Vamos analisar a entrada por campos. O offset 0xe corresponde ao exemplo com endbr64. Se o seu output não tiver endbr64, o offset será 0xa.
| Campo | Valor (com endbr64) | Valor (sem endbr64) | Descrição |
|---|---|---|---|
| Offset | 0xe | 0xa | Onde na seção .text o stub está localizado. |
| Info (bits altos) | 0x0004 | 0x0004 | Índice do símbolo na tabela de símbolos = 4. |
| Info (bits baixos) | 0x00000004 | 0x00000004 | Tipo de relocalização = R_X86_64_PLT32. |
| Nome do Símbolo | sleep | sleep | É necessário inserir o endereço do símbolo sleep. |
De onde veio o índice 4? Vamos consultar a tabela de símbolos:
bashreadelf -s empty_sleep.o
Saída:
Symbol table '.symtab' contains 5 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS empty_sleep.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 25 FUNC GLOBAL DEFAULT 1 main
4: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sleep
O símbolo número 4 é sleep. Seu valor ainda é 0x0 porque o endereço real é desconhecido. As letras UND (undefined) significam que o símbolo é definido em outro lugar, neste caso, na biblioteca libc.so.6.
Portanto, no arquivo objeto, temos:
- Um stub
00000000no código. - Uma entrada na tabela de relocações: "neste offset, insira o endereço de
sleep".
Em seguida, entra em ação o linker estático.
Como o Linker Estático Cria o Mecanismo de Substituição
Continuaremos com o primeiro exemplo (com endbr64). Para a segunda variante, as diferenças são apenas nos offsets. Vamos linkar o programa:
bashgcc empty_sleep.o -o empty_sleep
Agora, vamos examinar a função main no arquivo executável final:
bashobjdump -d -M intel empty_sleep | grep -A10 '<main>:'
Saída:
assembly0000000000001149 <main>: 1149: f3 0f 1e fa endbr64 114d: 55 push rbp 114e: 48 89 e5 mov rbp,rsp 1151: bf 1e 00 00 00 mov edi,0x1e 1156: e8 f5 fe ff ff call 1050 <sleep@plt> 115b: b8 00 00 00 00 mov eax,0x0 1160: 5d pop rbp 1161: c3 ret
O endereço de main mudou de 0x0 para 0x1149. O linker estático posicionou o código no arquivo final, adicionando cabeçalhos e outras seções. Os endereços específicos serão diferentes para você, e isso é normal.
Em vez de um call com zeros, agora temos call 1050 <sleep@plt>. O linker estático não inseriu o endereço real de sleep – ele não pode, pois a biblioteca é carregada dinamicamente e seu endereço só será conhecido em tempo de execução. Em vez disso, ele criou dois mecanismos cruciais:
- PLT (Procedure Linkage Table): Uma tabela de stubs para funções de bibliotecas dinâmicas.
- GOT (Global Offset Table): Uma tabela onde o linker dinâmico posteriormente gravará os endereços reais.
Agora, a instrução call em main aponta não diretamente para sleep, mas para uma entrada na PLT. O que há dentro do stub da PLT? Vamos investigar.
O que há dentro do Stub da PLT
Apresentaremos ambos os tipos de stub da PLT; eles parecem diferentes, mas fazem a mesma coisa.
Primeiro, alguns termos:
- Stub da PLT: Um pequeno fragmento de código que
mainchama em vez da função real da biblioteca. - Resolver: O código do linker dinâmico que, na primeira chamada, encontra o endereço real da função na biblioteca e o grava na GOT.
Vamos examinar o stub de sleep@plt:
bashobjdump -d -M intel --no-show-raw-insn empty_sleep | grep -A6 '<sleep@plt>:'
A flag --no-show-raw-insn oculta a coluna com os bytes brutos, tornando a saída mais limpa e fácil de ler.
A saída pode variar. No primeiro exemplo (com endbr64), o stub e o resolver estarão em seções diferentes. No segundo (sem endbr64), eles estarão combinados no mesmo local.
Exemplo com Stub e Resolver Combinados (Segundo exemplo da seção "Programa Vazio para Experimento", doravante chamado de exemplo com três instruções):
assembly0000000000001030 <sleep@plt>: 1030: jmp QWORD PTR [rip+0x2fca] # 4000 <sleep@GLIBC_2.2.5> 1036: push 0x0 103b: jmp 1020 <_init+0x20>
Exemplo com Stub e Resolver Separados (Primeiro exemplo da seção "Programa Vazio para Experimento", doravante chamado de exemplo com instruções separadas):
assembly# Seção .plt.sec - stub chamado por main: 0000000000001050 <sleep@plt>: # Nota: Este rótulo pode variar dependendo da versão do objdump 1050: endbr64 1054: jmp QWORD PTR [rip+0x2f76] # 3fd0 <sleep@GLIBC_2.2.5> 105a: nop WORD PTR [rax+rax*1+0x0]
No caso separado, o stub não possui push nem jmp para o resolver – apenas jmp para a GOT e nop. No entanto, o comentário # 3fd0 <sleep@GLIBC_2.2.5> sugere que o jmp acessa a GOT em um offset de 0x3fd0. Vamos verificar lá:
bashobjdump -D -M intel empty_sleep | grep '3fd0:'
Resultado:
assembly3fd0: 30 10 xor BYTE PTR [rax], dl
Os bytes 30 10 não são um comando xor, mas os bytes inferiores do endereço 0x1030, gravados em ordem reversa (little-endian). Assim, a GOT aponta para o endereço 0x1030 dentro da seção .plt. Vamos ver o que está lá:
bashobjdump -d -M intel --no-show-raw-insn empty_sleep | grep -A4 '1030:'
Veremos o seguinte resultado:
assembly1030: endbr64 1034: push 0x0 1039: jmp 1020 <_init+0x20> 103e: xchg ax, ax
Aqui estão o push e o jmp para o resolver, no endereço 0x1030 na seção .plt. Nós os encontramos não pelo nome <sleep@plt>, mas pelo endereço que estava na GOT. A GOT, por sua vez, aponta não para o stub em .plt.sec, mas para o código do resolver em .plt.
Ambos os casos implementam o mesmo mecanismo. No primeiro, o stub e o resolver são combinados em um só lugar; no segundo, são divididos em seções diferentes: .plt.sec contém apenas o stub (para onde o call de main vai), e .plt contém o código do resolver com push e jmp (para onde se vai apenas na primeira chamada através da GOT).
Agora, vamos analisar cada instrução em detalhe.
Instrução 1: jmp para a GOT
Começaremos a análise com o exemplo de três instruções:
assembly0000000000001030 <sleep@plt>: 1030: jmp QWORD PTR [rip+0x2fca] # 4000 <sleep@GLIBC_2.2.5> 1036: push 0x0 103b: jmp 1020 <_init+0x20>
A instrução jmp QWORD PTR [rip+0x2fca] é um salto para o endereço armazenado na GOT. No exemplo acima, o comentário # 4000 <sleep@GLIBC_2.2.5> indica que o endereço está no offset 0x4000. É para lá que o linker dinâmico gravará o endereço real de sleep.
No entanto, na primeira chamada, não há o endereço de sleep lá, mas um endereço que aponta de volta para o código dentro da PLT. Vamos verificar a GOT:
bashobjdump -D -M intel empty_sleep | grep -E '\.got(\.plt)?:'
Dependendo da versão do objdump, o nome da seção pode ser .got ou .got.plt – é a mesma área de memória. A saída também pode variar.
Para o exemplo, vamos usar a variante com três instruções:
Desmontagem da seção .got.plt:
assembly0000000000003fe8 <_GLOBAL_OFFSET_TABLE_>: 3fe8: e0 3d loopne 4027 <_end+0x7> ... 3ffe: 00 00 add BYTE PTR [rax], al 4000: 36 10 00 adc BYTE PTR [rax], al
Desmontagem para o exemplo com instruções separadas (como vimos na seção "O que há dentro do Stub da PLT"):
Desmontagem da seção .got:
assembly0000000000003fb8 <_GLOBAL_OFFSET_TABLE_>: 3fb8: c8 3d 00 00 enter 0x3d,0x0 ... 3fd0: 30 10 xor BYTE PTR [rax], dl
O objdump interpreta o conteúdo da GOT como código de máquina. Os bytes 36 10 00 no offset 0x4000 (exemplo com três instruções) ou 30 10 no offset 0x3fd0 (exemplo com instruções separadas) não são comandos ss adc ou xor, mas os bytes inferiores do endereço em ordem reversa (little-endian). No primeiro caso, é 0x1036; no segundo, 0x1030. Ambos os endereços apontam para o código dentro da PLT: ou para a próxima instrução após o jmp (exemplo com três instruções), ou para a entrada da seção .plt, onde estão o push e o jmp para o resolver (exemplo com instruções separadas).
Continuação disponível apenas para membros
Materiais das últimas edições tornam-se disponíveis individualmente apenas dois meses após a publicação. Para continuar a leitura, é necessário se tornar um membro da comunidade "Xakep.ru".
Junte-se à comunidade "Xakep.ru"!
A associação à comunidade durante o período especificado lhe dará acesso a TODO o material "Xakep", permitirá baixar edições em PDF, desativará a publicidade no site e aumentará seu desconto cumulativo pessoal!
Saiba mais
Já sou membro "Xakep.ru"
← Anterior O custo de acesso à infraestrutura de empresas começa em US$ 100





