Содержание статьи
- Пустая программа для эксперимента
- Как компилятор сообщает о будущих правках
- Как статический компоновщик создает механизм подстановки
- Что внутри PLT-заглушки
- Инструкция 1: jmp в GOT
- Инструкция 2: push 0x0
- Инструкция 3: jmp на резолвер
- Что происходит, когда заглушка и резолвер разнесены
- Как увидеть релокации вживую
- Точка 1: компоновщик еще не начинал
- Точка 2: программа загружена, но
sleepеще не вызывали - Что изменится с флагом BIND_NOW
- Точка 3: после первого вызова sleep
- Первый и второй вызов: в чем разница
- Убедимся на практике
- Что узнали и чему научились
printf, sleep или любую другую функцию из динамической библиотеки, ее реальный адрес заранее неизвестен из‑за ASLR. Тем не менее вызов каким‑то образом срабатывает. В этой статье мы шаг за шагом разберем, как работают механизмы PLT, GOT и релокации, проследим процесс разрешения адресов с помощью objdump, readelf и GDB, а также увидим вживую, что происходит при первом и последующих вызовах библиотечной функции.В момент запуска программы в Linux ядро загружает ее в память, а динамический компоновщик подгружает все необходимые библиотеки. Механизм, который мы будем разбирать, работает одинаково для любой динамической библиотеки, но в качестве примера мы возьмем стандартную библиотеку языка C libc.: она загружается практически всегда. Именно в ней лежат хорошо знакомые нам функции вроде printf, puts или sleep.
Но тут есть загвоздка. Из‑за ASLR (address space layout randomization) — защитного механизма, который случайным образом размещает библиотеки в памяти, — адрес libc. и всех функций внутри нее меняется при каждом запуске. Компилятор не может знать эти адреса заранее. Как тогда программа находит нужную функцию, если ее адрес становится известен только во время выполнения?
Ответ кроется в механизме релокаций: PLT, GOT и ленивой привязке. Давай разберемся, как это работает, на примере простой программы empty_sleep, которая вызывает функцию sleep( и завершается. И не только изучим теорию, но и увидим релокации вживую с помощью отладчика GDB.
Пустая программа для эксперимента
Для примера напишем простую программу empty_sleep. Она ничего не делает, только вызывает sleep(:
#include <unistd.h>int main() { sleep(30); return 0;}Скомпилируем ее в объектный файл:
gcc -c empty_sleep.c -o empty_sleep.o
Затем посмотрим на дизассемблированный код, но сначала — небольшое пояснение.
В зависимости от того, с какими флагами был собран GCC и glibc, вывод команд может выглядеть по‑разному. Я покажу два варианта. Различаются смещения в коде, наличие инструкции endbr64 и структура PLT-заглушек. На логику релокаций это не влияет.
Теперь смотрим дизассемблированный код:
objdump -d -M intel empty_sleep.o
Вот первый пример:
0000000000000000 <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
А вот второй пример:
0000000000000000 <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
Первый пример — с инструкцией endbr64, второй — без нее. В первом случае заглушка находится по смещению 0xd, во втором — по смещению 0x9. В остальном никакой разницы нет.
Что здесь важно? Инструкция call имеет операнд 0x00 — заглушку. Компилятор не знает, где будет находиться sleep, поэтому оставляет пустое место. Четыре нулевых байта — это временный заполнитель, который позже будет заменен настоящим адресом.
Инструкция endbr64 — это часть технологии Intel CET (control-flow enforcement technology) — аппаратной защиты от атак через косвенные переходы, таких как JOP (jump-oriented programming) и подобных. Она не имеет отношения к механизму релокаций. Если твой GCC собран без поддержки CET, этой инструкции не будет, а смещение заглушки окажется на четыре байта меньше. На логику релокаций это тоже не влияет.
Еще раз повторюсь, четыре нулевых байта — это временная метка. Настоящий адрес будет записан позже, а пока компилятор оставил запись в специальной таблице.
Как компилятор сообщает о будущих правках
Компилятор записывает в специальную таблицу следующие сведения: по определенному смещению (сразу после кода операции e8) нужно будет вписать адрес функции sleep. Посмотрим:
readelf -r empty_sleep.o
Вывод:
Раздел перемещения .rela.text at offset 0x178 contains 1 entry:
Смещение Инфо Тип Знач.симв. Имя симв. + Addend
00000000000e 000400000004 R_X86_64_PLT32 0000000000000000 sleep - 4
Конкретное значение смещения зависит от размера кода функции main и может отличаться в твоем случае. Главное — оно указывает ровно на те четыре байта, которые мы видели в дизассемблере.
Разберем запись по полям. Смещение 0xe соответствует примеру с endbr64. Если в твоем выводе endbr64 нет, смещение будет 0xa.
| Поле | Значение | Расшифровка |
|---|---|---|
| Смещение |
0xe (или 0xa без endbr64) |
Где в секции . находится заглушка |
| Инфо (старшие биты) | 0x0004 |
Индекс символа в таблице символов = 4 |
| Инфо (младшие биты) | 0x00000004 |
Тип релокации = R_X86_64_PLT32
|
| Имя | sleep |
Нужно подставить адрес символа sleep
|
Откуда взялся индекс 4? Заглянем в таблицу символов:
readelf -s empty_sleep.o
Symbol table '.symtab' contains 5 entries:
Чис: Знач Разм Тип Связ Vis Индекс имени
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
Символ номер 4 — это sleep. Его значение пока 0x0, потому что реальный адрес неизвестен. Буквы UND (undefined) означают, что символ определен где‑то вовне, в нашем случае — в библиотеке libc..
Итак, в объектном файле у нас есть:
- заглушка
00в коде;00 00 00 - запись в таблице релокаций: «по этому смещению вписать адрес
sleep».
Дальше в дело вступает статический компоновщик.
Как статический компоновщик создает механизм подстановки
Продолжу с первым примером (с endbr64), для второго варианта отличия только в смещениях. Скомпонуем программу:
gcc empty_sleep.o -o empty_sleep
Теперь посмотрим на main в готовом исполняемом файле:
objdump -d -M intel empty_sleep | grep -A10 '<main>:'0000000000001149 <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
Адрес main изменился с 0x0 на 0x1149 — статический компоновщик разместил код в финальном файле, добавив заголовки и другие секции. Конкретные адреса у тебя будут другими, это нормально.
Вместо call с нулями теперь call . Статический компоновщик не подставил реальный адрес sleep — он и не может, ведь библиотека загружается динамически и ее адрес станет известен только при запуске. Вместо этого он создал два ключевых механизма:
- PLT (procedure linkage table) — таблица заглушек для функций из динамических библиотек;
- GOT (global offset table) — таблица, куда динамический компоновщик позже запишет реальные адреса.
Теперь инструкция call в main указывает не на sleep напрямую, а на запись в PLT. Что внутри PLT-заглушки? Разбираемся дальше.
Что внутри PLT-заглушки
Сейчас покажу оба варианта PLT-заглушки, они выглядят по‑разному, но делают одно и то же.
Начнем с терминов. PLT-заглушка — это небольшой фрагмент кода, который main вызывает вместо настоящей библиотечной функции. Резолвер — это код динамического компоновщика, который при первом вызове находит настоящий адрес функции в библиотеке и записывает его в GOT.
Посмотрим на заглушку sleep@plt:
objdump -d -M intel --no-show-raw-insn empty_sleep | grep -A6 '<sleep@plt>:'Флаг --no-show-raw-insn скрывает столбец с «сырыми» байтами — с ним вывод чище и легче читается.
Вывод может выглядеть по‑разному. В первом примере (с endbr64) заглушка и резолвер окажутся в разных секциях. Во втором (без endbr64) — объединены в одном месте.
Вот пример, где заглушка и резолвер объединены (второй пример из раздела «Пустая программа для эксперимента», дальше буду называть его примером с тремя инструкциями):
0000000000001030 <sleep@plt>:
1030: jmp QWORD PTR \[rip+0x2fca] # 4000 <sleep@GLIBC_2.2.5>
1036: push 0x0
103b: jmp 1020 <_init+0x20>
А вот пример, где заглушка и резолвер разнесены (первый пример из раздела «Пустая программа для эксперимента», дальше буду называть примером с разнесенными инструкциями):
# Секция .plt.sec — заглушка, которую вызывает main:
0000000000001050 <sleep@plt>:
1050: endbr64
1054: jmp QWORD PTR \[rip+0x2f76] # 3fd0 <sleep@GLIBC_2.2.5>
105a: nop WORD PTR \[rax+rax\*1+0x0]
В разнесенном варианте в заглушке нет ни push, ни jmp на резолвер — только jmp в GOT и nop. Но комментарий # подсказывает, что jmp обращается к GOT по смещению 0x3fd0. Заглянем туда:
objdump -D -M intel empty_sleep | grep -A4 '3fd0:'Результат:
3fd0: 30 10 xor BYTE PTR \[rax],dl
Байты 30 — это не команда xor, а младшие байты адреса 0x1030, записанные в обратном порядке (little-endian). Получается, GOT указывает на адрес 0x1030 внутри секции .. Давай посмотрим, что там:
objdump -d -M intel --no-show-raw-insn empty_sleep | grep -A4 '1030:'Мы увидим такой результат:
1030: endbr64
1034: push 0x0
1039: jmp 1020 <_init+0x20>
103e: xchg ax,ax
Вот они, push и jmp на резолвер, по адресу 0x1030 в секции .. Мы нашли их не по имени <, а по адресу, который лежал в GOT. GOT, в свою очередь, указывает не на заглушку в ., а на код резолвера в ..
Оба варианта реализуют один и тот же механизм. В первом случае заглушка и резолвер объединены в одном месте, во втором — разнесены по разным секциям: . содержит только заглушку (сюда приходит call из main), а . — код резолвера с push и jmp (сюда попадают только при первом вызове через GOT).
Теперь разберем каждую инструкцию подробнее.
Инструкция 1: jmp в GOT
Начнем разбираться на примере с тремя инструкциями:
0000000000001030 <sleep@plt>:
1030: jmp QWORD PTR \[rip+0x2fca] # 4000 <sleep@GLIBC_2.2.5> 1036: push 0x0
103b: jmp 1020 <_init+0x20>
Инструкция jmp — это переход по адресу, который хранится в GOT. В примере выше комментарий # подсказывает, что адрес находится по смещению 0x4000. Именно туда динамический компоновщик позже запишет реальный адрес sleep.
Но при первом вызове там лежит не адрес sleep, а адрес, указывающий обратно на код внутри PLT. Заглянем в GOT:
objdump -D -M intel empty_sleep | grep -A6 -E '\\.got(\\.plt)?:'В зависимости от версии objdump название секции может быть . или . — это одна и та же область памяти. Вывод тоже может отличаться.
Для примера возьмем вариант с тремя инструкциями:
Дизассемблирование раздела .got.plt:
0000000000003fe8 <_GLOBAL_OFFSET_TABLE_>:
3fe8: e0 3d loopne 4027 <_end+0x7>
...
3ffe: 00 00 add BYTE PTR \[rax],al
4000: 36 10 00 ss adc BYTE PTR \[rax],al
А вот дизассемблирование для примера с разнесенными инструкциями (как мы уже видели в разделе «Что внутри PLT-заглушки»):
Дизассемблирование раздела .got:
0000000000003fb8 <_GLOBAL_OFFSET_TABLE_>:
3fb8: c8 3d 00 00 enter 0x3d,0x0
...
3fd0: 30 10 xor BYTE PTR \[rax],dl
Инструмент objdump интерпретирует содержимое GOT как машинный код. Байты 36 по смещению 0x4000 (пример с тремя инструкциями) или 30 по смещению 0x3fd0 (пример с разнесенными инструкциями) — это не команды ss или xor, а младшие байты адреса в обратном порядке (little-endian). В первом случае это 0x1036, во втором — 0x1030. Оба адреса указывают на код внутри PLT: либо на следующую инструкцию после jmp (пример с тремя инструкциями), либо на вход в секцию ., где лежат push и jmp на резолвер (пример с разнесенными инструкциями).
Продолжение доступно только участникам
Материалы из последних выпусков становятся доступны по отдельности только через два месяца после публикации. Чтобы продолжить чтение, необходимо стать участником сообщества «Xakep.ru».
Присоединяйся к сообществу «Xakep.ru»!
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
