Раздел 32 · Системное программирование: Zig, ассемблер, Verilog

Динамическая компоновка, PIC, GOT и PLT

lead~190 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Динамическая компоновка, PIC, GOT и PLT

В прошлом уроке твой zt ld разместил секции по адресам, подставил числа в каждую запись перемещения и выдал файл, который ядро запустило. Все адреса в нём были известны до запуска. Сегодня это перестаёт быть правдой: функция addvec живёт в отдельном файле libvector.so, и где он окажется в памяти, не знает никто, пока программа не стартует. Мы соберём такую библиотеку на Zig, вызовем её из C и из Zig, заглянем в ячейку памяти до первого вызова и после него и увидим, как она меняется прямо под работающей программой. А zt научится отвечать на вопрос, от каких библиотек зависит файл, и подписывать в дизассемблере <puts@plt> и <puts@got>.

Цели урока

  • Объяснить, чем плоха статическая библиотека для кода, который нужен всем, и что меняет разделяемая.
  • Собрать .so на Zig через zig build-lib -dynamic и export fn, вызвать её из C и из Zig.
  • Читать PT_INTERP и секцию .dynamic: DT_NEEDED, DT_RUNPATH, $ORIGIN, и понимать, в каком порядке загрузчик ищет библиотеки.
  • Понимать, почему позиционно-независимому коду хватает адресации через %rip и одной таблицы GOT, и отличать R_X86_64_GLOB_DAT от R_X86_64_JUMP_SLOT.
  • Пройти руками первый и второй вызов через PLT и показать заплатку в GOT на живой программе.
  • Объяснить, что дают -z now и RELRO и от какой атаки они закрывают.
  • Загружать библиотеку во время выполнения: dlopen и dlsym из C, std.DynLib из Zig.
  • Дописать в zt подкоманды ldd и plt и подписи заглушек в дизассемблере.

Чем плоха статика

В уроке про библиотеки ты собирал libvector.a и видел, что компоновщик вынимает из архива только нужные объектники и копирует их в исполняемый файл. Для своей библиотеки на две функции это прекрасно. Для библиотеки, которая нужна всем, у копирования три цены.

Первая цена это место. Каждая программа на C зовёт printf, и при статической компоновке код printf лежит в каждом исполняемом файле на диске и в памяти каждого процесса. На машине, где одновременно живёт сотня процессов, это сотня копий одного и того же кода.

Вторая цена это обновления. В libc нашли уязвимость, вышла исправленная версия. При статической компоновке исправление попадёт в программу только тогда, когда её кто-то пересоберёт. Всё, что собрано статически и забыто, остаётся дырявым навсегда.

Третья цена это жёсткость. Набор кода в программе зафиксирован в момент компоновки. Подключить модуль, про который автор программы не знал (плагин редактора, драйвер базы данных, кодек), нельзя.

Разделяемая библиотека снимает все три. Её код лежит на диске один раз, в файле .so. При запуске программы его отображают в адресное пространство процесса, и страницы с кодом у всех процессов общие: физическая память одна, потому что код только читается. Заменил файл библиотеки, и все программы при следующем запуске получат новую версию. А загрузить библиотеку можно и посреди работы, по имени из строки.

Посмотрим на числа. Одна и та же программа из одного puts("hello"), собранная двумя способами, без отладочной информации:

$ zig cc -target x86_64-linux-gnu -O1 -s -o hello-dyn hello.c
$ zig cc -target x86_64-linux-musl -static -O1 -s -o hello-static hello.c
$ ls -l hello-dyn hello-static
3376 hello-dyn
5192 hello-static
$ ls -lL /lib/x86_64-linux-gnu/libc.so.6
1926232 /lib/x86_64-linux-gnu/libc.so.6

Статический файл на musl всего на два килобайта больше: musl маленькая, и компоновщик взял из неё только puts с окружением. Но посмотри на третью строку. Библиотека glibc весит почти два мегабайта, и динамическая программа не носит с собой ни байта из них. Чем больше библиотека и чем больше программ ею пользуются, тем сильнее перевес. У статики остаётся своё законное место, мы вернёмся к нему в следующем уроке: один файл без зависимостей удобно класть в контейнер.

За удобство приходится платить механизмом. Компоновщик больше не может подставить адрес addvec в инструкцию call: адреса ещё нет. Весь остаток урока про то, как эту проблему решили, потратив одну таблицу и одну косвенность.

Собираем libvector.so на Zig

Библиотека та же, что в главе 7 книги: сложение и умножение векторов плюс счётчик вызовов. Только написана на Zig. Файл vector.zig:

//! libvector.so: две функции над векторами и счётчик вызовов.

/// Сколько раз звали addvec. Глобальная переменная библиотеки, видна снаружи.
pub export var addcnt: c_int = 0;

/// z[i] = x[i] + y[i]
pub export fn addvec(x: [*]const c_int, y: [*]const c_int, z: [*]c_int, n: c_int) void {
    addcnt += 1;
    var i: usize = 0;
    while (i < @as(usize, @intCast(n))) : (i += 1) z[i] = x[i] + y[i];
}

/// z[i] = x[i] * y[i]
pub export fn multvec(x: [*]const c_int, y: [*]const c_int, z: [*]c_int, n: c_int) void {
    var i: usize = 0;
    while (i < @as(usize, @intCast(n))) : (i += 1) z[i] = x[i] * y[i];
}

Слово export делает две вещи: даёт имени глобальное связывание без искажений (в таблице символов будет ровно addvec) и фиксирует соглашение о вызовах C. Типы c_int и указатели [*] нужны затем, чтобы сигнатуру можно было дословно повторить в заголовке на C.

$ zig build-lib -dynamic -target x86_64-linux-gnu -O ReleaseSmall vector.zig
$ readelf -h libvector.so | grep -E 'Type|Entry'
  Type:                              DYN (Shared object file)
  Entry point address:               0x0
$ readelf --dyn-syms libvector.so

Symbol table '.dynsym' contains 4 entries:
   Num:    Value          Size Type    Bind   Vis       Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT   UND
     1: 0000000000001384    33 FUNC    GLOBAL DEFAULT     7 multvec
     2: 00000000000013a5    38 FUNC    GLOBAL DEFAULT     7 addvec
     3: 0000000000003470     4 OBJECT  GLOBAL DEFAULT    10 addcnt

Три отличия от того, что ты видел раньше. Тип файла не REL и не EXEC, а DYN. Точки входа нет: библиотеку никто не запускает. И таблица символов называется .dynsym: это урезанная копия .symtab, в которой остались только имена, нужные при загрузке. .symtab можно срезать командой strip, а .dynsym нельзя: без неё загрузчик не найдёт addvec.

Обрати внимание на адреса: 0x13a5, 0x3470. Библиотека скомпонована так, будто её загрузят по адресу ноль. Это не адреса, а смещения от начала образа в памяти. Настоящий адрес получится, когда загрузчик выберет, куда библиотеку положить, и прибавит базу.

Теперь программа на C. Их будет две. Первая короткая, на ней удобно разглядывать механизм: min.c зовёт addvec дважды, между вызовами сама трогает счётчик библиотеки и возвращает сумму как код выхода.

extern int addcnt;
void addvec(const int *x, const int *y, int *z, int n);

int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];

int main(void) {
    addvec(x, y, z, 2);
    addcnt += 1;
    addvec(x, y, z, 2);
    return z[0] + z[1] + addcnt;
}

Вторая, main.c, печатает в stderr, где что происходит. Именно в stderr: он не буферизуется, и строки программы встанут между строками отладочного вывода загрузчика ровно в том порядке, в каком всё случилось.

#include <stdio.h>

extern int addcnt;
void addvec(const int *x, const int *y, int *z, int n);

int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];

int main(void) {
    fputs("main: start\n", stderr);
    addvec(x, y, z, 2);
    fputs("main: between calls\n", stderr);
    addvec(x, y, z, 2);
    fprintf(stderr, "z = [%d %d], addcnt = %d\n", z[0], z[1], addcnt);
    return 0;
}

Собираем обе. Ключ -L. говорит компоновщику, где искать libvector.so, ключ -lvector называет библиотеку. Про -rpath и -z lazy разговор впереди. Запускаем на x86-64 Linux; у меня это контейнер, как и в прошлых уроках блока.

$ zig cc -target x86_64-linux-gnu -O1 -o min  min.c  -L. -lvector -Wl,-rpath,'$ORIGIN' -Wl,-z,lazy
$ zig cc -target x86_64-linux-gnu -O1 -o prog main.c -L. -lvector -Wl,-rpath,'$ORIGIN' -Wl,-z,lazy
$ ./min; echo "exit $?"
exit 13
$ ./prog
main: start
main: between calls
z = [4 6], addcnt = 2

Тринадцать это 4 плюс 6 плюс 3: два вызова addvec и одно прибавление из main попали в один и тот же счётчик. Программа и библиотека собраны разными компиляторами из разных языков, лежат в разных файлах, а переменная у них общая.

Что при этом сделал компоновщик? Почти ничего. Он проверил, что addvec и addcnt в libvector.so есть, и записал в исполняемый файл имя библиотеки. Ни одного байта кода библиотеки в prog нет. Остальное доделывается при запуске.

Кто доделывает: ld-linux.so

В прошлом уроке загрузка выглядела просто: ядро читает заголовки программы, отображает сегменты PT_LOAD и прыгает на точку входа. У prog в заголовках программы есть сегмент, которого у статического файла не было:

$ readelf -l prog
Elf file type is EXEC (Executable file)
Entry point 0x10015d0
There are 11 program headers, starting at offset 64

Program Headers:
  Type           Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  PHDR           0x000040 0x0000000001000040 0x0000000001000040 0x000268 0x000268 R   0x8
  INTERP         0x0002a8 0x00000000010002a8 0x00000000010002a8 0x00001c 0x00001c R   0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x000000 0x0000000001000000 0x0000000001000000 0x0005c4 0x0005c4 R   0x1000
  ...

PT_INTERP это просто строка с путём. Увидев её, ядро отображает в память не только программу, но и файл по этому пути, и управление отдаёт ему, а не твоей точке входа. Этот файл и есть динамический компоновщик, он же загрузчик ld-linux.so. Он делает при каждом запуске ту самую работу, которую zt ld делал один раз при сборке: находит модули, разрешает имена, выполняет перемещения. Только модули теперь .so, а перемещений осталось совсем мало, и ниже ты увидишь почему.

Откуда загрузчик знает, что грузить? Из секции .dynamic. Это список пар: метка и значение.

$ readelf -d prog
Dynamic section at offset 0x770 contains 21 entries:
  Tag                Type         Name/Value
  0x000000000000001d (RUNPATH)    Library runpath: [$ORIGIN]
  0x0000000000000001 (NEEDED)     Shared library: [libvector.so]
  0x0000000000000001 (NEEDED)     Shared library: [libc.so.6]
  0x0000000000000015 (DEBUG)      0x0
  0x0000000000000007 (RELA)       0x1000480
  0x0000000000000008 (RELASZ)     72 (bytes)
  0x0000000000000009 (RELAENT)    24 (bytes)
  0x0000000000000017 (JMPREL)     0x10004c8
  0x0000000000000002 (PLTRELSZ)   72 (bytes)
  0x0000000000000003 (PLTGOT)     0x10038e8
  0x0000000000000014 (PLTREL)     RELA
  0x0000000000000006 (SYMTAB)     0x10002e8
  0x000000000000000b (SYMENT)     24 (bytes)
  0x0000000000000005 (STRTAB)     0x100041c
  ...
  0x0000000000000000 (NULL)       0x0

Загрузчик не читает таблицу секций вовсе: её может и не быть. Он находит .dynamic через сегмент PT_DYNAMIC, а всё остальное через неё: SYMTAB и STRTAB это адреса .dynsym и .dynstr, RELA и JMPREL это две таблицы перемещений, PLTGOT адрес таблицы, о которой пойдёт речь дальше. Нам сегодня важнее всего первые три строки.

DT_NEEDED это имя библиотеки. Только имя, без пути: libvector.so. Компоновщик взял его из записи SONAME самой библиотеки. Где файл с таким именем лежит, загрузчик выясняет сам, и порядок поиска у него жёсткий:

  1. каталоги из переменной окружения LD_LIBRARY_PATH;
  2. каталоги из DT_RUNPATH того файла, который библиотеку просит;
  3. кэш /etc/ld.so.cache, который строит ldconfig по системным каталогам;
  4. каталоги по умолчанию: /lib, /usr/lib и их варианты для архитектуры.

DT_RUNPATH мы записали сами ключом -rpath. А $ORIGIN в нём это не переменная окружения, а слово, которое загрузчик заменяет каталогом, где лежит сам исполняемый файл. Так программа возит библиотеку рядом с собой и находит её, куда бы каталог ни переложили. Загрузчик умеет рассказывать, что он делает: переменная LD_DEBUG включает отладочный вывод, значение libs показывает поиск.

$ LD_DEBUG=libs ./prog
         6:	find library=libvector.so [0]; searching
         6:	 search path=/w/glibc-hwcaps/x86-64-v2:/w/tls/x86_64/x86_64:/w/tls/x86_64:/w/tls/x86_64:/w/tls:/w/x86_64/x86_64:/w/x86_64:/w/x86_64:/w		(RUNPATH from file ./prog)
         6:	  trying file=/w/glibc-hwcaps/x86-64-v2/libvector.so
         6:	  trying file=/w/tls/x86_64/x86_64/libvector.so
         6:	  trying file=/w/tls/x86_64/libvector.so
         6:	  trying file=/w/tls/x86_64/libvector.so
         6:	  trying file=/w/tls/libvector.so
         6:	  trying file=/w/x86_64/x86_64/libvector.so
         6:	  trying file=/w/x86_64/libvector.so
         6:	  trying file=/w/x86_64/libvector.so
         6:	  trying file=/w/libvector.so
         6:
         6:	find library=libc.so.6 [0]; searching
         6:	 search path=/w		(RUNPATH from file ./prog)
         6:	  trying file=/w/libc.so.6
         6:	 search cache=/etc/ld.so.cache
         6:	  trying file=/lib/x86_64-linux-gnu/libc.so.6
         6:
         6:	calling init: /lib64/ld-linux-x86-64.so.2
         6:	calling init: /lib/x86_64-linux-gnu/libc.so.6
         6:	calling init: /w/libvector.so
         6:	initialize program: ./prog
         6:	transferring control: ./prog
main: start
main: between calls
z = [4 6], addcnt = 2

Шестёрка слева это номер процесса; пустые строки и хвост с вызовами деструкторов (calling fini) я убрал. Программа лежит в /w, и $ORIGIN превратился в /w. Сначала загрузчик пробует подкаталоги с вариантами под конкретный процессор, потом сам каталог, и находит libvector.so с девятой попытки. Для libc.so.6 в /w ничего нет, и поиск уходит в кэш. Дальше идут конструкторы библиотек, и только строка transferring control означает прыжок на точку входа prog. Всё, что выше неё, произошло до первой инструкции твоей программы.

Проверим, что $ORIGIN правда работает, и что без него всё ломается. Уносим программу без библиотеки, потом вместе с ней:

$ cp prog /tmp/prog && /tmp/prog
/tmp/prog: error while loading shared libraries: libvector.so: cannot open shared object file: No such file or directory
$ echo $?
127
$ LD_LIBRARY_PATH=/w /tmp/prog
main: start
main: between calls
z = [4 6], addcnt = 2
$ mkdir /tmp/app && cp prog libvector.so /tmp/app && cd / && /tmp/app/prog
main: start
main: between calls
z = [4 6], addcnt = 2

Сообщение об ошибке печатает не программа и не ядро, а загрузчик: main даже не начинался. Это первая ошибка динамической компоновки, которую встречает каждый, и теперь ты знаешь три способа её лечить: положить библиотеку в системный каталог, задать LD_LIBRARY_PATH или записать RUNPATH при сборке.

Весь список зависимостей вместе с адресами загрузки показывает ldd:

$ ldd ./prog
	libvector.so => /w/./libvector.so (0x00007fffff7c4000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fffff5e0000)
	/lib64/ld-linux-x86-64.so.2 (0x00007ffffffcb000)

Настоящий ldd это сценарий оболочки, который запускает загрузчик с переменной LD_TRACE_LOADED_OBJECTS: тот всё загружает, печатает список и выходит, не передавая управление программе. Отсюда правило: не запускай ldd на файле, которому не доверяешь. В шаге проекта мы напишем zt ldd, который ничего не запускает, а честно читает .dynamic.

Позиционно-независимый код

Библиотеку могут загрузить по любому адресу: у каждого процесса свой набор библиотек, и свободное место у каждого своё. Как сделать, чтобы один и тот же код работал по любому адресу?

Первый способ ты уже знаешь: перемещения. Загрузчик мог бы пройти по коду библиотеки и вписать в каждую инструкцию правильный адрес, как это делал zt ld. Так когда-то и поступали, и у этого способа роковой недостаток: страницы кода, в которые что-то вписали, перестают быть общими. Ядро делит между процессами только страницы, совпадающие с файлом на диске. Запиши в страницу хоть байт, и процесс получит личную копию. Главная выгода разделяемых библиотек пропала бы.

Второй способ: скомпилировать код так, чтобы править его было не нужно. Позиционно-независимый код (PIC, position-independent code) держится на одном наблюдении: куда бы ни загрузили модуль, расстояния внутри него не меняются. Секция .data лежит от секции .text на том же расстоянии, что и в файле, потому что загрузчик отображает весь модуль одним куском с общей базой.

А на x86-64 есть адресация относительно %rip, с которой ты работал в уроке про перемещения: R_X86_64_PC32 это и есть расстояние от инструкции до цели. Посмотри, как addvec в нашей библиотеке увеличивает счётчик:

$ objdump -d --no-show-raw-insn libvector.so
00000000000013a5 <addvec>:
    13a5:      	pushq	%rbp
    13a6:      	movq	%rsp, %rbp
    13a9:      	incl	0x20c1(%rip)            # 0x3470 <addcnt>
    13af:      	movl	%ecx, %eax
    ...
$ readelf -r libvector.so

There are no relocations in this file.

Инструкция по смещению 0x13a9 обращается к addcnt как к ячейке на расстоянии 0x20c1 от следующей инструкции: 0x13af + 0x20c1 = 0x3470. Загрузят библиотеку по базе 0x7fffff7c4000, инструкция окажется по адресу 0x7fffff7c53a9, счётчик по адресу 0x7fffff7c7470, а расстояние между ними останется 0x20c1. Компоновщик посчитал его один раз при сборке библиотеки, и в файле не осталось ни одного перемещения: загрузчику здесь нечего делать.

Так выглядит всё, что модуль делает внутри себя: вызовы своих функций, обращения к своим данным. Трудность только с чужим. Когда main обращается к addcnt или зовёт addvec, расстояние до них неизвестно при сборке, потому что они в другом модуле, а модули загружаются независимо. Здесь и нужна таблица.

GOT: одна таблица адресов на модуль

Решение опирается на то же наблюдение про расстояния. До чужой переменной расстояние неизвестно. Зато до своей собственной секции данных оно известно всегда. Значит, можно завести в своих данных ячейку, в которую кто-то при загрузке положит адрес чужой переменной, и обращаться к переменной в два шага: взять адрес из ячейки, потом пойти по нему.

Набор таких ячеек и есть GOT (global offset table). У каждого модуля она своя: у min одна, у libvector.so была бы своя, если бы библиотека обращалась к чему-то снаружи. Посмотри, как main из min.c прибавляет единицу к addcnt:

$ objdump -d --no-show-raw-insn min
0000000001001500 <main>:
    ...
 100152e:      	callq	0x1001600 <addvec@plt>
 1001533:      	movq	0x122e(%rip), %r12      # 0x1002768
 100153a:      	addl	$0x1, (%r12)
    ...
 1001559:      	addl	(%r12), %eax

Первая инструкция не трогает addcnt. Она читает восемь байт из ячейки 0x1002768, которая лежит в самой программе на расстоянии 0x122e от %rip, и кладёт их в %r12. Вторая прибавляет единицу уже по адресу из %r12. Компилятор запомнил адрес в регистре и в конце main пользуется им ещё раз, не заглядывая в таблицу повторно.

Кто заполнит ячейку 0x1002768? Об этом написано в перемещениях. У libvector.so их не было вовсе, а у программы они есть:

$ readelf -r min

Relocation section '.rela.dyn' at offset 0x400 contains 2 entries:
    Offset             Info             Type               Symbol's Value  Symbol's Name + Addend
0000000001002760  0000000100000006 R_X86_64_GLOB_DAT      0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0
0000000001002768  0000000300000006 R_X86_64_GLOB_DAT      0000000000000000 addcnt + 0

Relocation section '.rela.plt' at offset 0x430 contains 1 entries:
    Offset             Info             Type               Symbol's Value  Symbol's Name + Addend
0000000001003798  0000000200000007 R_X86_64_JUMP_SLOT     0000000000000000 addvec + 0

Это перемещения нового сорта. В уроке 44 запись перемещения была просьбой к zt ld: впиши сюда число, когда узнаешь адрес. Здесь просьба та же, только адресована она загрузчику и исполняется при каждом запуске. R_X86_64_GLOB_DAT устроен проще любого типа из прошлого урока: найди имя addcnt среди загруженных модулей и запиши его адрес в восемь байт по адресу 0x1002768. Никакой арифметики с %rip, никаких добавок.

И посмотри, куда эти записи показывают: 0x1002760, 0x1002768, 0x1003798. Это всё секции данных. В секцию .text не показывает ни одна. Загрузчик правит только таблицу, страницы с кодом остаются нетронутыми и общими для всех процессов. Ради этого всё и затевалось: одна косвенность в обмен на неизменяемый код.

Обрати внимание и на первую запись: __libc_start_main. Это функция, а не переменная, но стартовый код _start зовёт её не через call с адресом, а через ячейку:

 10014ed:      	leaq	0xc(%rip), %rdi         # 0x1001500 <main>
 10014f4:      	callq	*0x1266(%rip)           # 0x1002760
 10014fa:      	hlt

Звёздочка перед операндом означает косвенный вызов: процессор читает восемь байт по адресу 0x1002760 и прыгает туда, куда они показывают. Так вызывают функцию, когда её адрес нужен сразу и без фокусов. Но для обычных вызовов компоновщик по умолчанию строит конструкцию хитрее, и у неё есть причина.

Переключи виджет ниже на переменную addcnt и пройди три шага: загрузка заполняет ячейку, main читает из неё адрес, потом идёт по адресу.

PLT: вызов функции в два прыжка

Вернись к листингу main. Вызов addvec выглядит как самый обычный call с относительным смещением, только цель у него странная: 0x1001600 <addvec@plt>. Это адрес внутри самой программы, в секции .plt. Вот она целиком:

$ objdump -d --no-show-raw-insn -j .plt min
00000000010015f0 <.plt>:
 10015f0:      	pushq	0x2192(%rip)            # 0x1003788
 10015f6:      	jmpq	*0x2194(%rip)           # 0x1003790
 10015fc:      	nopl	(%rax)

0000000001001600 <addvec@plt>:
 1001600:      	jmpq	*0x2192(%rip)           # 0x1003798
 1001606:      	pushq	$0x0
 100160b:      	jmp	0x10015f0 <.plt>

PLT (procedure linkage table) это массив заглушек по шестнадцать байт. Нулевая заглушка общая, дальше по одной на каждую внешнюю функцию. У min внешняя функция одна, поэтому заглушек две. Рядом с ней живёт вторая половина механизма, секция .got.plt: продолжение GOT, отведённое под функции.

$ objdump -s -j .got.plt min
Contents of section .got.plt:
 1003780 10260001 00000000 00000000 00000000  .&..............
 1003790 00000000 00000000 06160001 00000000  ................

Четыре ячейки по восемь байт. Читаем с учётом порядка байт (младший первым):

ЯчейкаАдресВ файлеЧто это
GOT[0]0x10037800x1002610адрес секции .dynamic этого модуля
GOT[1]0x10037880загрузчик впишет сюда указатель на свою запись о модуле
GOT[2]0x10037900загрузчик впишет сюда адрес своей функции разрешения имён
GOT[3]0x10037980x1001606ячейка addvec

Самое интересное в последней строке. В ячейке addvec лежит не ноль и не адрес addvec (его пока никто не знает), а 0x1001606. Сверься с листингом PLT: это адрес второй инструкции заглушки addvec@plt, той самой pushq $0x0. Ячейка показывает обратно в заглушку, которая через неё прыгает.

Теперь пройдём первый вызов по шагам.

  1. main выполняет callq 0x1001600. На стек ложится адрес возврата 0x1001533, управление переходит в заглушку.
  2. Заглушка делает jmpq *0x2192(%rip): читает GOT[3] и прыгает по прочитанному. Там 0x1001606, следующая инструкция той же заглушки. Прыжок получился на месте.
  3. pushq $0x0 кладёт на стек ноль. Это номер записи в .rela.plt: нулевая запись, и в ней сказано “имя addvec, ячейка 0x1003798”.
  4. jmp 0x10015f0 уводит в общую нулевую заглушку. Она кладёт на стек содержимое GOT[1], то есть указатель, по которому загрузчик опознает, какой модуль спрашивает, и прыгает по адресу из GOT[2] внутрь ld-linux.so.
  5. Загрузчик снимает со стека два своих аргумента (какой модуль, какая запись), находит addvec в libvector.so, записывает её настоящий адрес в GOT[3] и прыгает на addvec. Именно прыгает, а не вызывает: на вершине стека снова адрес возврата 0x1001533.
  6. addvec отрабатывает, ret возвращает управление прямо в main. Про PLT и загрузчик функция ничего не знает.

Второй вызов короче. main снова попадает в заглушку, заглушка снова прыгает по содержимому GOT[3], но теперь там настоящий адрес, и мы сразу в addvec. Цена второго и всех следующих вызовов: один лишний косвенный прыжок.

Этот приём называется ленивое связывание. Зачем такие сложности, если для addcnt хватило одной записи GLOB_DAT? Поиск имени стоит дорого: загрузчик считает хеш строки и обходит таблицы всех загруженных модулей по очереди. Большая программа импортирует тысячи функций, а за один запуск зовёт малую часть. Программа, которую запустили ради --help, не должна платить за связывание функций печати, сети и шифрования. С переменными так не выйдет: обращение к данным это обычная mov, перехватить её нечем. А вызов функции перехватить легко, он и так передаёт управление.

Пройди оба вызова в виджете. Следи за ячейкой GOT[3]: она меняется ровно один раз, когда при первом вызове управление доходит до загрузчика. Потом переключи режим на -z now и посмотри, что останется от механизма.

Одна деталь для внимательных. В уроке 44 ты видел в объектниках перемещение R_X86_64_PLT32 и считал его так же, как PC32. Теперь понятно, откуда имя: компилятор помечает вызов внешней функции словами “если цель окажется в другом модуле, веди этот call в её заглушку PLT”. При статической компоновке цель рядом, заглушка не нужна, и zt ld честно подставлял расстояние до самой функции. При динамической компоновщик создаёт заглушку и подставляет расстояние до неё. Инструкция call в обоих случаях одна и та же, пять байт, и править её при запуске не нужно.

Смотрим на заплатку вживую

Всё сказанное выше пока рассказ по дизассемблеру. Давай поймаем момент записи в ячейку. Способов два: спросить у загрузчика и подсмотреть самим.

LD_DEBUG=bindings

Значение bindings заставляет загрузчик печатать строку на каждое связанное имя. Запускаем prog, тот, что пишет в stderr. Загрузчик связывает ещё и десятки имён внутри самой libc, эти строки я отфильтровал:

$ LD_DEBUG=bindings ./prog 2>&1 | grep -v 'libc.so.6 \[0\] to /lib'
         6:	binding file ./prog [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `__libc_start_main' [GLIBC_2.2.5]
         6:	binding file ./prog [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `stderr' [GLIBC_2.2.5]
         6:	binding file ./prog [0] to /w/libvector.so [0]: normal symbol `addcnt'
         ...
         6:	calling init: /w/libvector.so
         6:	initialize program: ./prog
         6:	transferring control: ./prog
         6:	binding file ./prog [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `fwrite' [GLIBC_2.2.5]
main: start
         6:	binding file ./prog [0] to /w/libvector.so [0]: normal symbol `addvec'
main: between calls
         6:	binding file ./prog [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `fprintf' [GLIBC_2.2.5]
z = [4 6], addcnt = 2

Читай сверху вниз. До строки transferring control связаны три имени, и все три это записи GLOB_DAT: __libc_start_main, stderr и наш addcnt. Данные связываются при загрузке, без лени. (На месте многоточия ещё четыре строки про malloc, calloc, realloc и free: это загрузчик переключает на libc свой собственный распределитель памяти, к prog они отношения не имеют.)

Дальше пошла программа, и функции связываются по одной, каждая перед своим первым вызовом. fwrite появился потому, что компилятор заменил fputs с известной строкой на fwrite. Строка про addvec стоит ровно между main: start и main: between calls, и она одна: второй вызов загрузчика не побеспокоил. fprintf связался последним, прямо перед печатью итога.

Счёт можно проверить и цифрами. Значение statistics печатает, сколько перемещений загрузчик выполнил до передачи управления и сколько всего к моменту выхода:

$ LD_DEBUG=statistics ./prog 2>&1 | grep 'number of relocations:'
         6:	                 number of relocations: 76
         6:	           final number of relocations: 80

Подсматриваем сами

Загрузчику можно и не верить на слово. min и prog собраны как обычные исполняемые файлы с фиксированными адресами (тип EXEC, не DYN), так что адрес ячейки известен до запуска, и программа может прочитать собственную GOT. Заведём для этого ещё одну библиотеку на Zig, peek.zig:

//! libpeek.so: прочитать и записать восемь байтов по произвольному адресу.
//! Отдельная библиотека нужна затем, чтобы оптимизатор C не видел, что мы
//! читаем собственную таблицу GOT, и ничего не додумал.

pub export fn peek(address: usize) usize {
    const cell: *const volatile usize = @ptrFromInt(address);
    return cell.*;
}

pub export fn poke(address: usize, value: usize) void {
    const cell: *volatile usize = @ptrFromInt(address);
    cell.* = value;
}

И программу spy.c, которая получает адрес ячейки в аргументе и печатает её содержимое до первого вызова addvec и после него. Режим poke пригодится в следующем разделе.

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

extern int addcnt;
void addvec(const int *x, const int *y, int *z, int n);
unsigned long peek(unsigned long address);
void poke(unsigned long address, unsigned long value);

int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];

/* spy <адрес ячейки GOT> [poke] */
int main(int argc, char **argv) {
    unsigned long slot = strtoul(argv[1], NULL, 16);

    fprintf(stderr, "addcnt lives at  %p\n", (void *)&addcnt);
    fprintf(stderr, "slot before call %#lx\n", peek(slot));
    addvec(x, y, z, 2);
    fprintf(stderr, "slot after call  %#lx, z = [%d %d]\n", peek(slot), z[0], z[1]);

    if (argc > 2 && strcmp(argv[2], "poke") == 0) {
        /* multvec лежит в библиотеке на 0x21 байт раньше addvec */
        poke(slot, peek(slot) - 0x21);
        addvec(x, y, z, 2);
        fprintf(stderr, "slot after poke  %#lx, z = [%d %d]\n", peek(slot), z[0], z[1]);
    }
    return 0;
}

Собираем. Адрес ячейки addvec берём из .rela.plt: у этой программы внешних функций больше, и ячейка уже не четвёртая.

$ zig build-lib -dynamic -target x86_64-linux-gnu -O ReleaseSmall peek.zig
$ zig cc -target x86_64-linux-gnu -O1 -o spy-lazy spy.c -L. -lvector -lpeek -Wl,-rpath,'$ORIGIN' -Wl,-z,lazy
$ readelf -r spy-lazy | grep addvec
0000000001003af8  0000000500000007 R_X86_64_JUMP_SLOT     0000000000000000 addvec + 0
$ ./spy-lazy 1003af8
addcnt lives at  0x7fffff7c7470
slot before call 0x1001916
slot after call  0x7fffff7c53a5, z = [4 6]

До вызова в ячейке 0x1001916: адрес внутри самой программы, вторая инструкция заглушки addvec@plt (сама заглушка в этом файле стоит по адресу 0x1001910). После вызова там 0x7fffff7c53a5. Сверим с тем, что мы знаем о библиотеке. addcnt оказался по адресу 0x7fffff7c7470, а его смещение в файле 0x3470, значит, база библиотеки 0x7fffff7c4000. Смещение addvec в файле 0x13a5. База плюс смещение даёт 0x7fffff7c53a5. Сошлось до байта: загрузчик вписал в ячейку настоящий адрес функции, пока программа работала.

Тем же способом можно заглянуть в служебные ячейки. Они стоят в начале .got.plt, у spy-lazy это адреса 0x1003ad0 и 0x1003ad8:

$ ./spy-lazy 1003ad0 | grep before
slot before call 0x7ffffffff2e0
$ ./spy-lazy 1003ad8 | grep before
slot before call 0x7ffffffdd1c0

Сравни с выводом ldd выше: загрузчик отображён с адреса 0x7ffffffcb000, и второе число попадает внутрь него. Это вход в функцию разрешения имён, у glibc она называется _dl_runtime_resolve. Первое число это указатель на структуру link_map, в которой загрузчик держит всё, что знает о модуле spy-lazy.

Адреса у меня одинаковые от запуска к запуску, потому что контейнер работает под эмуляцией и раскладка памяти в нём не рандомизируется. На обычном Linux база библиотеки при каждом запуске новая, и младшие три цифры (3a5, 470) единственное, что останется неизменным.

-z now и RELRO: запираем таблицу

Посмотри на ленивое связывание глазами атакующего. В процессе есть область памяти, в которую разрешена запись и в которой лежат адреса функций, и программа прыгает по ним при каждом вызове. Если в программе есть ошибка, позволяющая записать восемь байт по выбранному адресу (переполнение буфера, запись по висячему указателю), то лучшей цели не найти. Подмени ячейку free на адрес system, дождись, пока программа вызовет free со строкой, которую ты контролируешь, и у тебя оболочка. Приём называется перезаписью GOT, и он годами был рабочей лошадкой эксплойтов.

Режим poke нашей программы делает ровно это, только безобидно. Он вычитает из ячейки addvec число 0x21: по таблице .dynsym из начала урока multvec лежит по смещению 0x1384, на 0x21 байт раньше addvec.

$ ./spy-lazy 1003af8 poke
addcnt lives at  0x7fffff7c7470
slot before call 0x1001916
slot after call  0x7fffff7c53a5, z = [4 6]
slot after poke  0x7fffff7c5384, z = [3 8]

В исходнике оба раза написано addvec. Во второй раз выполнилась multvec: три это один на три, восемь это два на четыре. Ни одна инструкция программы не изменилась, изменились восемь байт в таблице.

Защита состоит из двух частей. Первая: отказаться от лени. Ключ компоновщика -z now ставит в .dynamic флаг BIND_NOW, и загрузчик связывает все функции сразу, до передачи управления. Вторая: RELRO, relocation read-only. Раз после загрузки в таблицу больше никто не пишет, её можно закрыть от записи. В файле появляется сегмент PT_GNU_RELRO, и загрузчик, закончив перемещения, зовёт для этого диапазона mprotect с правами только на чтение.

Соберём min с -z now и сравним раскладку секций с ленивым вариантом:

$ readelf -lW min | grep -E 'GNU_RELRO|^   0[45] '
  GNU_RELRO      0x000610 0x0000000001002610 0x0000000001002610 0x000160 0x0009f0 R   0x1
   04     .dynamic .got .relro_padding
   05     .data .got.plt .bss

$ zig cc -target x86_64-linux-gnu -O1 -o min-now min.c -L. -lvector -Wl,-rpath,'$ORIGIN' -Wl,-z,now
$ readelf -lW min-now | grep -E 'GNU_RELRO|^   0[45] '
  GNU_RELRO      0x000610 0x0000000001002610 0x0000000001002610 0x0001a0 0x0009f0 R   0x1
   04     .dynamic .got .got.plt .relro_padding
   05     .data .bss
$ readelf -d min-now | grep -E 'FLAGS'
  0x000000000000001e (FLAGS)      BIND_NOW
  0x000000006ffffffb (FLAGS_1)    NOW

Сегмент 04 это то, что накрывает GNU_RELRO, сегмент 05 остаётся записываемым навсегда. В ленивом файле .got с ячейками данных уже защищена (это называют частичным RELRO), а .got.plt живёт рядом с .data: в неё загрузчику ещё писать при первых вызовах. С -z now компоновщик переносит .got.plt в защищённый сегмент. Это полный RELRO. Секция .relro_padding добивает сегмент до границы страницы, потому что mprotect работает только целыми страницами.

Проверяем на spy. У варианта с -z now ячейка addvec переехала, её адрес снова берём из readelf -r:

$ zig cc -target x86_64-linux-gnu -O1 -o spy-now spy.c -L. -lvector -lpeek -Wl,-rpath,'$ORIGIN' -Wl,-z,now
$ ./spy-now 1002b08 poke
addcnt lives at  0x7fffff7c7470
slot before call 0x7fffff7c53a5
slot after call  0x7fffff7c53a5, z = [4 6]
Segmentation fault
$ echo $?
139

Две перемены. Настоящий адрес лежит в ячейке уже до первого вызова: лени больше нет. А попытка записи убила процесс сигналом SIGSEGV: страница закрыта. Атакующему с записью восьми байт эта цель больше недоступна.

Заглушки PLT при этом никуда не делись, main по-прежнему зовёт addvec@plt, и та прыгает через ячейку. Просто ветка с pushq и общей заглушкой теперь мёртвый код. LD_DEBUG=bindings на prog, собранном с -z now, показывает, что все шесть имён программы связаны до строки transferring control, а между строками самой программы пусто.

Того же эффекта по времени можно добиться без пересборки, переменной окружения LD_BIND_NOW=1. Но защиты она не даёт:

$ LD_BIND_NOW=1 ./spy-lazy 1003af8 poke
addcnt lives at  0x7fffff7c7470
slot before call 0x7fffff7c53a5
slot after call  0x7fffff7c53a5, z = [4 6]
slot after poke  0x7fffff7c5384, z = [3 8]

Связано всё заранее, а ячейка осталась в записываемом сегменте: раскладку секций выбирает компоновщик при сборке, переменная окружения её не изменит.

Что выбрано по умолчанию? Зависит от того, кто собирает. GNU ld сам по себе оставляет ленивое связывание и частичный RELRO. Ubuntu настраивает свой gcc на -z now, Fedora собирает с этим ключом все пакеты, Debian включает его по желанию сопровождающего. Компоновщик, встроенный в Zig, ставит -z now всегда. Это легко проверить: собери prog вообще без ключа -z и сравни с вариантом -z now.

$ zig cc -target x86_64-linux-gnu -O1 -o prog-default main.c -L. -lvector -Wl,-rpath,'$ORIGIN'
$ cmp prog-default prog-now && echo same
same

Файлы совпали побайтно. Вот почему во всех командах урока стоит -Wl,-z,lazy: без этого ключа ленивого связывания ты сегодня не увидишь. Цена полного RELRO это время запуска: все имена ищутся сразу, нужны они или нет. Для программы с сотней импортов это незаметно, для программы с десятками тысяч может быть ощутимо, и поэтому выбор оставлен за тем, кто собирает.

Есть и третий вариант: убрать PLT совсем. Ключ компилятора -fno-plt заставляет звать внешние функции так, как _start зовёт __libc_start_main: косвенным call прямо через ячейку .got. Заглушек нет, секции .got.plt нет, перемещение для функции становится обычным GLOB_DAT, и лень невозможна по построению. Вызов получается на один прыжок короче.

На macOS. Идея та же, имена другие. Разделяемая библиотека это .dylib в формате Mach-O, динамический компоновщик это /usr/lib/dyld, и путь к нему записан в команде загрузки LC_LOAD_DYLINKER, родственнике PT_INTERP. Наша vector.zig собирается без единой правки: zig build-lib -dynamic vector.zig даёт libvector.dylib, а zig cc -o prog main.c -L. -lvector -Wl,-rpath,@loader_path программу. Слово @loader_path играет роль $ORIGIN. Вместо ldd и readelf -d здесь otool -L prog, вместо LD_DEBUG=libs переменная DYLD_PRINT_LIBRARIES=1, вместо LD_LIBRARY_PATH переменная DYLD_LIBRARY_PATH (для системных программ её вырезает защита целостности системы). Аналог PLT называется __stubs, аналог .got.plt это __la_symbol_ptr, аналог .got это __got: всё это видно в otool -l prog. Ленивое связывание на macOS постепенно уходит: компоновщик Apple с macOS 12 записывает все привязки единой цепочкой (chained fixups), и dyld разрешает их при загрузке, после чего страница с указателями закрывается от записи, как при полном RELRO. Системные библиотеки при этом вообще не лежат отдельными файлами: libSystem.B.dylib из вывода otool -L на диске не найти, она живёт в общем кэше dyld, который отображается во все процессы сразу. Всё, что в уроке снято через LD_DEBUG, readelf и zt, относится к ELF, поэтому собирай с -target x86_64-linux-gnu и запускай в контейнере linux/amd64; читать ELF-файлы zt и llvm-readelf могут и на macOS.

Та же библиотека из Zig

До сих пор библиотека была на Zig, а звали её из C. Теперь оба конца на Zig. Чтобы сослаться на функцию из чужого модуля, хватает объявления extern. Файл caller.zig:

const std = @import("std");

extern var addcnt: c_int;
extern fn addvec(x: [*]const c_int, y: [*]const c_int, z: [*]c_int, n: c_int) void;

pub fn main(init: std.process.Init) !void {
    var buf: [4096]u8 = undefined;
    var w = std.Io.File.stdout().writer(init.io, &buf);
    const out = &w.interface;

    const x = [_]c_int{ 1, 2 };
    const y = [_]c_int{ 3, 4 };
    var z: [2]c_int = undefined;

    addvec(&x, &y, &z, 2);
    addvec(&x, &y, &z, 2);
    try out.print("z = [{d} {d}], addcnt = {d}\n", .{ z[0], z[1], addcnt });
    try out.flush();
}

extern fn для Zig то же, что прототип без тела для C: имя есть, определения нет, разберётся компоновщик. Соглашение о вызовах у extern fn по умолчанию сишное, поэтому с export fn из vector.zig оно стыкуется без лишних слов.

$ zig build-exe -target x86_64-linux-gnu -O ReleaseSmall caller.zig -L. -lvector -rpath '$ORIGIN'
$ ./caller
z = [4 6], addcnt = 2
$ readelf -d caller | grep -E 'NEEDED|RUNPATH|FLAGS'
  0x000000000000001d (RUNPATH)  Library runpath: [$ORIGIN]
  0x0000000000000001 (NEEDED)   Shared library: [libvector.so]
  0x000000000000001e (FLAGS)    BIND_NOW
  0x000000006ffffffb (FLAGS_1)  NOW
$ readelf -r caller

Relocation section '.rela.dyn' at offset 0x3a0 contains 1 entries:
    Offset             Info             Type               Symbol's Value  Symbol's Name + Addend
0000000001020238  0000000200000006 R_X86_64_GLOB_DAT      0000000000000000 addcnt + 0

Relocation section '.rela.plt' at offset 0x3b8 contains 1 entries:
    Offset             Info             Type               Symbol's Value  Symbol's Name + Addend
0000000001020258  0000000100000007 R_X86_64_JUMP_SLOT     0000000000000000 addvec + 0

Механизм тот же до последней записи: GLOB_DAT для переменной, JUMP_SLOT для функции, заглушка в .plt. Отличие одно, и оно в списке NEEDED: там нет libc.so.6. Программе на Zig libc не нужна, стандартная библиотека Zig сама делает системные вызовы, и единственная зависимость это наша libvector.so. При этом PT_INTERP в файле есть: кто-то же должен libvector.so загрузить. Получился динамически скомпонованный файл без libc, зверь, которого на C встретишь редко.

Если таких внешних объявлений набирается много, Zig умеет взять их прямо из заголовка на C, ты делал это в уроке про сборку и C. А в проекте с build.zig флаги -L и -l превращаются в exe.root_module.addLibraryPath(...) и exe.root_module.linkSystemLibrary("vector", .{}).

dlopen: загрузка по ходу работы

Во всех примерах выше имя библиотеки было записано в DT_NEEDED, и загрузчик делал всю работу до main. Третье обещание разделяемых библиотек из начала урока пока не выполнено: подключить код, про который при сборке программы никто не знал. Для этого у загрузчика есть программный интерфейс из четырёх функций.

ФункцияЧто делает
dlopen(path, flags)загружает библиотеку и её зависимости, выполняет перемещения, зовёт конструкторы, возвращает описатель
dlsym(handle, name)ищет имя в .dynsym библиотеки, возвращает адрес или NULL
dlclose(handle)уменьшает счётчик ссылок; когда он дошёл до нуля, библиотека выгружается
dlerror()текст последней ошибки

Флаг RTLD_LAZY или RTLD_NOW выбирает для библиотеки уже знакомое тебе: связывать её собственные вызовы наружу лениво или сразу. Программа dl.c:

#include <dlfcn.h>
#include <stdio.h>

int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];

int main(void) {
    fputs("main: start\n", stderr);

    void *handle = dlopen("./libvector.so", RTLD_LAZY);
    if (handle == NULL) {
        fprintf(stderr, "dlopen: %s\n", dlerror());
        return 1;
    }

    void (*addvec)(const int *, const int *, int *, int) = dlsym(handle, "addvec");
    if (addvec == NULL) {
        fprintf(stderr, "dlsym: %s\n", dlerror());
        return 1;
    }

    addvec(x, y, z, 2);
    int *addcnt = dlsym(handle, "addcnt");
    fprintf(stderr, "z = [%d %d], addcnt = %d\n", z[0], z[1], *addcnt);

    if (dlsym(handle, "subvec") == NULL) fprintf(stderr, "dlsym: %s\n", dlerror());

    dlclose(handle);
    return 0;
}

Обрати внимание на команду сборки: в ней нет ни -L., ни -lvector. Компоновщик про libvector.so не узнает вообще.

$ zig cc -target x86_64-linux-gnu -O1 -o dl dl.c
$ readelf -d dl | grep NEEDED
  0x0000000000000001 (NEEDED)     Shared library: [libc.so.6]
  0x0000000000000001 (NEEDED)     Shared library: [libdl.so.2]
$ LD_DEBUG=files ./dl 2>&1 | grep -E 'main|libvector|z =|dlsym'
main: start
         6:	file=./libvector.so [0];  dynamically loaded by ./dl [0]
         6:	file=./libvector.so [0];  generating link map
         6:	calling init: ./libvector.so
         6:	opening file=./libvector.so [0]; direct_opencount=1
z = [4 6], addcnt = 1
         6:	./libvector.so: error: symbol lookup error: undefined symbol: subvec (fatal)
dlsym: ./libvector.so: undefined symbol: subvec
         6:	calling fini: ./libvector.so [0]
         6:	file=./libvector.so [0];  destroying link map

Библиотека появилась в процессе после строки main: start, отработала и исчезла до выхода из main. Типов у dlsym нет: он возвращает void *, и приводить его к правильному указателю на функцию приходится на честное слово. Ошибёшься в сигнатуре, и узнаешь об этом по испорченному стеку.

На dlopen держится всё, что называют плагинами: модули веб-сервера, драйверы баз данных, кодеки, расширения интерпретаторов. Когда Python выполняет import numpy, внутри происходит dlopen файла .so и dlsym функции инициализации. Так же Java подключает код на C через JNI.

std.DynLib

В стандартной библиотеке Zig 0.16 эту роль играет std.DynLib: open, lookup, close. В отличие от dlsym, lookup типизирован: тип указателя ты называешь первым аргументом, а результат это ?T, и забыть проверку на отсутствие имени компилятор не даст. Файл plugin.zig:

const std = @import("std");

const AddVec = *const fn (x: [*]const c_int, y: [*]const c_int, z: [*]c_int, n: c_int) callconv(.c) void;

pub fn main(init: std.process.Init) !void {
    var buf: [4096]u8 = undefined;
    var w = std.Io.File.stdout().writer(init.io, &buf);
    const out = &w.interface;

    var lib = try std.DynLib.open("./libvector.so");
    defer lib.close();

    const addvec = lib.lookup(AddVec, "addvec") orelse return error.SymbolNotFound;
    const addcnt = lib.lookup(*c_int, "addcnt") orelse return error.SymbolNotFound;

    const x = [_]c_int{ 1, 2 };
    const y = [_]c_int{ 3, 4 };
    var z: [2]c_int = undefined;
    addvec(&x, &y, &z, 2);

    try out.print("z = [{d} {d}], addcnt = {d}\n", .{ z[0], z[1], addcnt.* });
    try out.print("subvec: {any}\n", .{lib.lookup(AddVec, "subvec") != null});
    try out.flush();
}

Здесь callconv(.c) уже нужен явно: это не extern fn, а обычный тип указателя на функцию, и по умолчанию у него соглашение Zig.

$ zig build-exe -target x86_64-linux-gnu -O ReleaseSafe plugin.zig
$ ./plugin
z = [4 6], addcnt = 1
subvec: false
$ readelf -lW plugin | grep -cE 'INTERP|DYNAMIC'
0

А теперь самое любопытное. В plugin нет ни PT_INTERP, ни .dynamic: это статический файл, загрузчик ld-linux.so в его процессе не появлялся вообще. Кто же загрузил библиотеку? Сама стандартная библиотека Zig. Когда программа собрана без libc, std.DynLib это std.ElfDynLib, загрузчик в миниатюре, написанный на Zig: он отображает файл через mmap, проходит по заголовкам программы, отображает сегменты PT_LOAD с нужными правами, находит PT_DYNAMIC, берёт из неё DT_SYMTAB, DT_STRTAB и DT_GNU_HASH и ищет имя по хеш-таблице. Открой lib/std/dynamic_library.zig в каталоге, который печатает zig env: там несколько сотен строк, и после сегодняшнего урока ты поймёшь каждую.

У миниатюры есть граница, и о ней надо знать: перемещений она не выполняет и зависимости из DT_NEEDED не грузит. Нашей libvector.so это не мешает, у неё, как ты видел, перемещений нет. Возьмём библиотеку, которой нужна libc, greet.c с одним вызовом printf:

$ zig cc -target x86_64-linux-gnu -shared -fPIC -O1 -o libgreet.so greet.c
$ readelf -r libgreet.so | grep printf
00000000000024b8  0000000100000007 R_X86_64_JUMP_SLOT     0000000000000000 printf@GLIBC_2.2.5 + 0
$ zig build-exe -target x86_64-linux-gnu -O ReleaseSafe greeter.zig
$ ./greeter
Segmentation fault at address 0x0
...
$ zig build-exe -target x86_64-linux-gnu -O ReleaseSafe greeter.zig -lc --name greeter-libc
$ ./greeter-libc
hello, zig

Программа greeter.zig устроена как plugin.zig: открывает ./libgreet.so, ищет greet и зовёт её со строкой. Без libc ячейку printf в GOT библиотеки никто не заполнил, и прыжок через неё увёл на нулевой адрес. С ключом -lc тот же исходник работает, потому что std.DynLib во время компиляции выбирает другую реализацию: при наличии libc это тонкая обёртка над настоящими dlopen и dlsym, и всю работу делает ld-linux.so. Правило простое: грузишь через std.DynLib что-то сложнее самодостаточной библиотеки, собирай программу с -lc.

Шаг проекта: zt ldd, zt plt и подписи заглушек

Пора научить zt всему, что мы сегодня смотрели чужими инструментами. В шаге три части:

  • zt ldd <файл> печатает загрузчик из PT_INTERP и библиотеки из DT_NEEDED, ищет каждую по каталогам из DT_RUNPATH и по системным, а потом повторяет то же для найденных библиотек. Ничего не запускает.
  • zt plt <файл> сводит три таблицы в одну: для каждой записи GLOB_DAT и JUMP_SLOT печатает адрес ячейки GOT, что лежит в ней в файле, адрес заглушки PLT и имя.
  • zt disasm понимает косвенные jmpq *disp(%rip) и callq *disp(%rip), подписывает заглушки как <puts@plt>, а ячейки как <puts@got>. Помнишь ерунду вроде <addvec+0x1002768>, которую objdump пишет рядом с адресом ячейки и которую я стирал из листингов? Наш дизассемблер будет писать там правду.

Фикстуры

Тестам нужен динамический файл, в котором больше одной внешней функции. Положи в fixtures/dyn/ маленькую библиотеку greet.c:

#include <stdio.h>

/* Маленькая разделяемая библиотека для фикстуры: одна функция, которая
   сама зовёт libc. Так у бинарника появляется две зависимости, а не одна. */
void greet(const char *name) {
    printf("hello, %s\n", name);
}

И программу hello.c, которая зовёт и её, и libc:

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

void greet(const char *name);

/* Фикстура динамической компоновки: зовёт свою библиотеку libgreet.so
   и libc. malloc и free здесь затем, чтобы их увидел zt ltrace. */
int main(void) {
    char *name = malloc(32);
    strcpy(name, "zt");
    greet(name);
    free(name);
    puts("bye");
    return 0;
}

Про zt ltrace из комментария речь пойдёт в следующем уроке. Собираем один раз и кладём готовые файлы в репозиторий, как и остальные фикстуры: тесты не должны зависеть от того, какой компилятор стоит на машине.

$ cd fixtures/dyn
$ zig cc -target x86_64-linux-gnu -O1 -shared -fPIC -Wl,-soname,libgreet.so -o libgreet.so greet.c
$ zig cc -target x86_64-linux-gnu -O1 -fno-builtin -o hello_dyn hello.c -L. -lgreet -Wl,-rpath,'$ORIGIN' -Wl,-z,lazy
$ objdump -d hello_dyn > hello.objdump.txt
$ objdump -d libgreet.so > libgreet.objdump.txt

Ключ -fno-builtin не даёт компилятору заменить strcpy и puts своими встроенными версиями, а -z lazy, как ты уже знаешь, оставляет в ячейках .got.plt адреса заглушек: на них удобно проверять связку. В fixtures/root.zig добавь группу:

pub const dyn = struct {
    pub const hello: ElfFixture = .{
        .bytes = @embedFile("dyn/hello_dyn"),
        .readelf = @embedFile("dyn/hello.readelf.txt"),
        .nm = @embedFile("dyn/hello.nm.txt"),
        .objdump = @embedFile("dyn/hello.objdump.txt"),
    };
    pub const libgreet = @embedFile("dyn/libgreet.so");
    pub const libgreet_objdump = @embedFile("dyn/libgreet.objdump.txt");
};

Эталоны hello.readelf.txt и hello.nm.txt снимаются так же, как в уроке 42: readelf -h -S -l -d -r и nm.

elf/dynamic.zig: то, что читает загрузчик

Новый модуль читателя ELF. В нём две вещи: строка из сегмента PT_INTERP и секция .dynamic как массив пар Elf64_Dyn. Значения DT_NEEDED и DT_RUNPATH это не строки, а смещения в таблице строк .dynstr; на неё указывает поле link заголовка секции .dynamic, точно так же, как .symtab указывала на .strtab.

//! То, что читает динамический загрузчик: сегмент PT_INTERP с путём к нему
//! самому и секция `.dynamic` (`Elf64_Dyn`), список пар «метка, значение»:
//! какие библиотеки нужны, где таблица символов, где перемещения для PLT.

const std = @import("std");

const file_mod = @import("file.zig");
const format = @import("format.zig");

const program = @import("program.zig");

const File = file_mod.File;

/// Запись секции `.dynamic`, `Elf64_Dyn`.
pub const Entry = extern struct {
    tag: i64,
    value: u64,
};

comptime {
    std.debug.assert(@sizeOf(Entry) == 16);
}

/// Метки записей `.dynamic`, которые нужны `zt ldd` и дампу PLT.
pub const dt_null: i64 = 0;
pub const dt_needed: i64 = 1;
pub const dt_soname: i64 = 14;
pub const dt_rpath: i64 = 15;
pub const dt_runpath: i64 = 29;

/// Путь к динамическому загрузчику из сегмента PT_INTERP. У статического
/// бинарника такого сегмента нет, и ядро запускает программу само.
pub fn interpreter(file: File) file_mod.Error!?[]const u8 {
    var index: u16 = 0;
    while (index < file.header.phnum) : (index += 1) {
        const header = try program.programHeader(file, index);
        if (header.kind() != .interp) continue;
        if (header.offset > file.bytes.len or file.bytes.len - header.offset < header.filesz)
            return error.Truncated;
        return std.mem.sliceTo(file.bytes[@intCast(header.offset)..][0..@intCast(header.filesz)], 0);
    }
    return null;
}

pub const Dynamic = struct {
    entries: []const u8,
    /// Строки `.dynstr`: на них ссылаются значения DT_NEEDED и DT_RUNPATH.
    strings: []const u8,

    /// `null` значит, что файл скомпонован статически.
    pub fn find(file: File) file_mod.Error!?Dynamic {
        const index = file.findSectionByType(.dynamic) catch |err| switch (err) {
            error.SectionNotFound => return null,
            else => return err,
        };
        const header = try file.section(index);
        return .{
            .entries = try file.sectionData(header),
            .strings = try file.sectionData(try file.section(header.link)),
        };
    }

    pub fn count(table: Dynamic) u32 {
        return @intCast(table.entries.len / @sizeOf(Entry));
    }

    pub fn get(table: Dynamic, index: u32) file_mod.Error!Entry {
        return file_mod.readStruct(Entry, table.entries, @as(u64, index) * @sizeOf(Entry));
    }

    /// Строка, на которую ссылается значение записи.
    pub fn string(table: Dynamic, entry: Entry) []const u8 {
        return file_mod.stringAt(table.strings, @truncate(entry.value));
    }
};

Подключи модуль в src/elf.zig строкой pub const dynamic = @import("elf/dynamic.zig");. В src/elf/format.zig понадобятся значения, которых до сих пор могло не быть: тип секции dynamic = 6 и dynsym = 11, типы сегмента dynamic = 2 и interp = 3, типы перемещений glob_dat = 6 и jump_slot = 7 с именами R_X86_64_GLOB_DAT и R_X86_64_JUMP_SLOT. Настоящий загрузчик, напомню, таблицу секций не читает и находит .dynamic через PT_DYNAMIC. Мы идём через секции, потому что читатель секций у нас уже есть, а файлы без таблицы секций в учебном проекте не встречаются.

ldd.zig: обход зависимостей

Логика делится на две функции. read отвечает на вопрос “что файл сам говорит о своих зависимостях” и не трогает диск, поэтому её легко проверить тестом на фикстуре. print делает обход в ширину: очередь файлов, множество уже встреченных имён, для каждого имени поиск по каталогам. Порядок поиска упрощён по сравнению с настоящим: LD_LIBRARY_PATH мы не читаем (это поведение процесса, а не свойство файла), вместо кэша ld.so.cache стоит список каталогов Debian и Ubuntu.

//! `zt ldd`: от каких разделяемых библиотек зависит файл.
//!
//! Настоящий `ldd` запускает динамический загрузчик и спрашивает у него.
//! Мы идём честным путём и читаем то же, что читает загрузчик: сегмент
//! PT_INTERP с путём к нему самому и записи DT_NEEDED секции `.dynamic`.
//! Потом ищем каждую библиотеку по тем же каталогам и повторяем для неё:
//! зависимости зависимостей тоже попадут в процесс.

const std = @import("std");

const elf = @import("elf.zig");

const dynamic = elf.dynamic;

/// Где загрузчик ищет библиотеки, если ему не подсказали. Список для
/// Debian и Ubuntu на x86-64; настоящий загрузчик читает `/etc/ld.so.cache`.
pub const default_directories = [_][]const u8{
    "/lib/x86_64-linux-gnu",
    "/usr/lib/x86_64-linux-gnu",
    "/lib64",
    "/usr/lib64",
    "/lib",
    "/usr/lib",
};

/// Что файл сам говорит о своих зависимостях.
pub const Needs = struct {
    interpreter: ?[]const u8,
    /// DT_RUNPATH или DT_RPATH: каталоги поиска через двоеточие.
    runpath: ?[]const u8,
    libraries: []const []const u8,
};

pub fn read(gpa: std.mem.Allocator, file: elf.File) !Needs {
    var libraries: std.ArrayList([]const u8) = .empty;
    errdefer libraries.deinit(gpa);
    var runpath: ?[]const u8 = null;

    if (try dynamic.Dynamic.find(file)) |table| {
        var index: u32 = 0;
        while (index < table.count()) : (index += 1) {
            const entry = try table.get(index);
            switch (entry.tag) {
                dynamic.dt_null => break,
                dynamic.dt_needed => try libraries.append(gpa, table.string(entry)),
                dynamic.dt_runpath, dynamic.dt_rpath => runpath = table.string(entry),
                else => {},
            }
        }
    }

    return .{
        .interpreter = try dynamic.interpreter(file),
        .runpath = runpath,
        .libraries = try libraries.toOwnedSlice(gpa),
    };
}

/// `$ORIGIN` в RUNPATH значит «каталог, где лежит сам файл»: так программа
/// возит библиотеки рядом с собой и не зависит от того, куда её положили.
pub fn expandOrigin(gpa: std.mem.Allocator, directory: []const u8, origin: []const u8) ![]u8 {
    return std.mem.replaceOwned(u8, gpa, directory, "$ORIGIN", origin);
}

/// Печатает дерево зависимостей, развёрнутое в список, как это делает ldd:
/// каждая библиотека один раз, в порядке обхода в ширину.
pub fn print(arena: std.mem.Allocator, io: std.Io, out: *std.Io.Writer, path: []const u8, bytes: []const u8) !void {
    const Pending = struct { path: []const u8, bytes: []const u8 };

    var queue: std.ArrayList(Pending) = .empty;
    try queue.append(arena, .{ .path = path, .bytes = bytes });
    var seen: std.StringArrayHashMapUnmanaged(void) = .empty;

    var position: usize = 0;
    while (position < queue.items.len) : (position += 1) {
        const current = queue.items[position];
        const needs = try read(arena, try elf.File.parse(current.bytes));

        if (position == 0) {
            if (needs.interpreter) |interpreter| {
                try out.print("\tзагрузчик {s}\n", .{interpreter});
            } else if (needs.libraries.len == 0) {
                try out.writeAll("\tскомпонован статически\n");
            }
        }

        for (needs.libraries) |library| {
            if ((try seen.getOrPut(arena, library)).found_existing) continue;

            const found = try find(arena, io, library, needs.runpath, std.fs.path.dirname(current.path) orelse ".");
            if (found) |hit| {
                try out.print("\t{s} => {s}\n", .{ library, hit.path });
                try queue.append(arena, .{ .path = hit.path, .bytes = hit.bytes });
            } else {
                try out.print("\t{s} => не найдена\n", .{library});
            }
        }
    }
}

const Found = struct { path: []const u8, bytes: []const u8 };

/// Порядок поиска как у загрузчика: сначала RUNPATH файла, который
/// библиотеку просит, потом каталоги по умолчанию.
fn find(
    arena: std.mem.Allocator,
    io: std.Io,
    library: []const u8,
    runpath: ?[]const u8,
    origin: []const u8,
) !?Found {
    if (runpath) |list| {
        var directories = std.mem.tokenizeScalar(u8, list, ':');
        while (directories.next()) |directory| {
            const expanded = try expandOrigin(arena, directory, origin);
            if (try tryOpen(arena, io, expanded, library)) |found| return found;
        }
    }
    for (default_directories) |directory| {
        if (try tryOpen(arena, io, directory, library)) |found| return found;
    }
    return null;
}

fn tryOpen(arena: std.mem.Allocator, io: std.Io, directory: []const u8, library: []const u8) !?Found {
    const path = try std.fs.path.join(arena, &.{ directory, library });
    const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(64 * 1024 * 1024)) catch return null;
    // Файл с таким именем может оказаться и не ELF: например, сценарий компоновщика.
    _ = elf.File.parse(bytes) catch return null;
    return .{ .path = path, .bytes = bytes };
}

test "$ORIGIN заменяется каталогом файла" {
    const gpa = std.testing.allocator;
    const expanded = try expandOrigin(gpa, "$ORIGIN/../lib", "/opt/app/bin");
    defer gpa.free(expanded);
    try std.testing.expectEqualStrings("/opt/app/bin/../lib", expanded);
}

Обрати внимание на tryOpen: файл с подходящим именем может оказаться не ELF. В /usr/lib/x86_64-linux-gnu/libc.so (без цифры на конце) лежит текстовый сценарий компоновщика, и настоящий загрузчик на нём тоже не споткнётся: он ищет libc.so.6.

plt.zig: три таблицы в одной

Связать имя, ячейку и заглушку можно, не зная устройства заглушек заранее. Имя и ячейка записаны в перемещениях: идём по всем секциям типа RELA, берём записи JUMP_SLOT и GLOB_DAT, имя достаём из .dynsym по номеру символа. Заглушку находим дизассемблером: идём по секциям, чьё имя начинается с .plt, ищем jmpq *disp(%rip) и смотрим, в какую ячейку он целится. Адрес инструкции, округлённый вниз до шестнадцати, это начало заглушки. Такой поиск переживает и классический PLT, и варианты вроде .plt.sec, которые компоновщики строят для процессоров с защитой потока управления.

//! Связка PLT и GOT у динамически скомпонованного файла.
//!
//! Вызов библиотечной функции идёт в два прыжка. `call puts@plt` попадает в
//! заглушку в секции `.plt`, а заглушка делает `jmpq *слот(%rip)`: прыгает по
//! адресу, который лежит в ячейке GOT. Кто заполнит ячейку, написано в
//! `.rela.plt`: запись R_X86_64_JUMP_SLOT называет ячейку и имя функции.
//!
//! Здесь эти три таблицы сводятся в одну: имя, ячейка, заглушка.

const std = @import("std");

const decoder = @import("x86/decoder.zig");
const elf = @import("elf.zig");

pub const Entry = struct {
    name: []const u8,
    kind: elf.format.RelocationType,
    /// Адрес ячейки GOT, которую заполнит загрузчик.
    slot: u64,
    /// Что лежит в ячейке до загрузки. При ленивом связывании это адрес
    /// второй инструкции заглушки, при немедленном может быть и ноль.
    slot_value: u64,
    /// Адрес заглушки PLT, которая прыгает через эту ячейку. Для данных
    /// (R_X86_64_GLOB_DAT) заглушки нет.
    stub: ?u64 = null,
};

/// Заглушка PLT занимает шестнадцать байтов и выровнена на шестнадцать.
const stub_size = 16;

/// Все ячейки GOT, которые заполняет загрузчик, вместе с их заглушками.
/// У статического файла список пустой.
pub fn entries(gpa: std.mem.Allocator, file: elf.File) ![]Entry {
    var list: std.ArrayList(Entry) = .empty;
    errdefer list.deinit(gpa);

    const dynsym = elf.SymbolTable.find(file, .dynsym) catch |err| switch (err) {
        error.SectionNotFound => return list.toOwnedSlice(gpa),
        else => return err,
    };

    var index: u32 = 0;
    while (index < file.sectionCount()) : (index += 1) {
        const header = try file.section(index);
        if (header.kind() != .rela) continue;
        const relas = try file.sectionData(header);

        var number: u32 = 0;
        while (number < elf.relocs.count(relas)) : (number += 1) {
            const rela = try elf.relocs.get(relas, number);
            if (rela.kind() != .jump_slot and rela.kind() != .glob_dat) continue;
            try list.append(gpa, .{
                .name = dynsym.name(try dynsym.get(rela.symbolIndex())),
                .kind = rela.kind(),
                .slot = rela.offset,
                .slot_value = try readAddress(file, rela.offset) orelse 0,
            });
        }
    }

    try findStubs(file, list.items);
    return list.toOwnedSlice(gpa);
}

/// Идёт по секциям `.plt*` и ищет `jmpq *disp(%rip)`. Куда показывает
/// смещение, той ячейке заглушка и принадлежит.
fn findStubs(file: elf.File, list: []Entry) !void {
    var index: u32 = 0;
    while (index < file.sectionCount()) : (index += 1) {
        const header = try file.section(index);
        if (!std.mem.startsWith(u8, try file.sectionName(header), ".plt")) continue;
        const code = try file.sectionData(header);

        var offset: usize = 0;
        while (offset < code.len) {
            const instruction = decoder.decode(code[offset..], header.addr + offset);
            offset += instruction.length();
            if (!instruction.indirect or !std.mem.eql(u8, instruction.mnemonic, "jmp")) continue;

            const target = switch (instruction.operands[0]) {
                .memory => |memory| memory.rip_target orelse continue,
                else => continue,
            };
            for (list) |*entry| {
                if (entry.slot == target) entry.stub = instruction.address & ~@as(u64, stub_size - 1);
            }
        }
    }
}

/// Восемь байтов по виртуальному адресу: находим секцию, в которую адрес
/// попадает, и читаем из её байтов в файле.
fn readAddress(file: elf.File, address: u64) !?u64 {
    var index: u32 = 0;
    while (index < file.sectionCount()) : (index += 1) {
        const header = try file.section(index);
        if (header.flags & elf.format.shf_alloc == 0) continue;
        if (address < header.addr or address + 8 > header.addr + header.size) continue;
        const data = try file.sectionData(header);
        const offset: usize = @intCast(address - header.addr);
        if (offset + 8 > data.len) return null;
        return std.mem.readInt(u64, data[offset..][0..8], .little);
    }
    return null;
}

/// `zt plt`: таблица ячеек GOT и их заглушек.
pub fn print(gpa: std.mem.Allocator, out: *std.Io.Writer, file: elf.File) !void {
    const list = try entries(gpa, file);
    defer gpa.free(list);

    if (list.len == 0) {
        try out.writeAll("динамических перемещений нет: файл скомпонован статически\n");
        return;
    }

    try out.writeAll("ячейка GOT        в файле лежит     заглушка PLT      тип                 имя\n");
    for (list) |entry| {
        try out.print("{x:0>16}  {x:0>16}  ", .{ entry.slot, entry.slot_value });
        if (entry.stub) |stub| {
            try out.print("{x:0>16}", .{stub});
        } else {
            try out.splatByteAll('-', 16);
        }
        try out.print("  {s: <18}  {s}\n", .{ entry.kind.name(), entry.name });
    }
}

labels.zig: третий источник подписей

Модуль подписей из урока 42 брал имена из таблицы символов и имён секций, а с урока 14 умеет принять и готовый список из nm. У заглушек PLT символов нет, компоновщик их не создаёт. Поэтому добавляем третий источник: записи из plt.entries. Каждая заглушка получает подпись имя@plt с высшим рангом, а список ячеек сохраняется в поле slots, чтобы по адресу ячейки можно было спросить имя. Файл целиком:

//! Подписи для дизассемблера: какому адресу какое имя соответствует.
//!
//! Источников три. Таблица символов даёт имена функций. Секция без символа
//! в начале подписывается своим именем, как `<.plt>`. Заглушки PLT символов
//! не имеют вовсе, их имена собираются из `.rela.plt`: так `call 0x1001530`
//! превращается в `call 0x1001530 <puts@plt>`.
//!
//! Таблицу символов можно передать и снаружи, готовым выводом `nm`: так
//! дизассемблер подписывал вызовы, пока не умел читать ELF сам.

const std = @import("std");

const elf = @import("elf.zig");
const plt = @import("plt.zig");

pub const Label = struct {
    section: u32,
    address: u64,
    name: []const u8,
    /// Чем больше, тем охотнее берём имя, когда на одном адресе их несколько:
    /// глобальный символ важнее локального, а тот важнее имени секции.
    rank: u8,

    fn lessThan(_: void, a: Label, b: Label) bool {
        if (a.section != b.section) return a.section < b.section;
        if (a.address != b.address) return a.address < b.address;
        return a.rank > b.rank;
    }
};

pub const Found = struct { name: []const u8, offset: u64 };

/// Имя функции и её адрес, как их печатает `nm`: `000000000000001f T ztFib`.
pub const Symbol = struct {
    name: []const u8,
    address: u64,
    /// Большая буква у `nm`: имя видно из других файлов.
    global: bool,
};

/// Разбирает вывод `nm`. Берёт только код, буквы `T` и `t`: у неопределённых
/// имён адреса нет, а данные дизассемблеру не нужны. Строки указывают в `text`.
pub fn parseNm(gpa: std.mem.Allocator, text: []const u8) ![]Symbol {
    var list: std.ArrayList(Symbol) = .empty;
    errdefer list.deinit(gpa);

    var lines = std.mem.splitScalar(u8, text, '\n');
    while (lines.next()) |line| {
        // У неопределённого имени колонка адреса пустая, и полей будет два.
        var fields = std.mem.tokenizeScalar(u8, line, ' ');
        const address = fields.next() orelse continue;
        const letter = fields.next() orelse continue;
        const name = fields.next() orelse continue;
        if (letter.len != 1 or std.ascii.toLower(letter[0]) != 't') continue;
        try list.append(gpa, .{
            .name = name,
            .address = try std.fmt.parseInt(u64, address, 16),
            .global = letter[0] == 'T',
        });
    }
    return list.toOwnedSlice(gpa);
}

/// Секция с машинным кодом: у неё есть байты в файле и флаг исполнения.
pub fn isCode(header: elf.SectionHeader) bool {
    return header.kind() == .progbits and header.flags & elf.format.shf_execinstr != 0;
}

pub const Labels = struct {
    arena: std.heap.ArenaAllocator,
    /// Файл, из которого собраны подписи. Нет его, когда список пришёл снаружи.
    file: ?elf.File,
    /// Отсортированы по секции, адресу и убыванию ранга.
    code: []const Label,
    /// Ячейки GOT, которые заполнит загрузчик.
    slots: []const plt.Entry,

    pub fn collect(gpa: std.mem.Allocator, file: elf.File) !Labels {
        var arena: std.heap.ArenaAllocator = .init(gpa);
        errdefer arena.deinit();
        const allocator = arena.allocator();

        var list: std.ArrayList(Label) = .empty;

        var index: u32 = 0;
        while (index < file.sectionCount()) : (index += 1) {
            const header = try file.section(index);
            if (!isCode(header)) continue;
            try list.append(allocator, .{
                .section = index,
                .address = header.addr,
                .name = try file.sectionName(header),
                .rank = 0,
            });
        }

        // Таблицы символов может и не быть: её срезает `strip`.
        if (elf.SymbolTable.find(file, .symtab)) |table| {
            var number: u32 = 1;
            while (number < table.count()) : (number += 1) {
                const symbol = try table.get(number);
                if (symbol.kind() != .func and symbol.kind() != .notype) continue;
                if (symbol.shndx >= file.sectionCount() or table.name(symbol).len == 0) continue;
                if (!isCode(try file.section(symbol.shndx))) continue;
                try list.append(allocator, .{
                    .section = symbol.shndx,
                    .address = symbol.value,
                    .name = table.name(symbol),
                    .rank = if (symbol.binding() == .local) 1 else 2,
                });
            }
        } else |err| if (err != error.SectionNotFound) return err;

        const slots = try plt.entries(allocator, file);
        for (slots) |entry| {
            const stub = entry.stub orelse continue;
            try list.append(allocator, .{
                .section = try sectionOf(file, stub) orelse continue,
                .address = stub,
                .name = try std.fmt.allocPrint(allocator, "{s}@plt", .{entry.name}),
                .rank = 3,
            });
        }

        std.mem.sort(Label, list.items, {}, Label.lessThan);
        return .{ .arena = arena, .file = file, .code = list.items, .slots = slots };
    }

    /// Подписи из готового списка, без файла. Весь код считается одной
    /// секцией с номером 0: у куска байтов других секций и нет.
    pub fn fromSymbols(gpa: std.mem.Allocator, symbols: []const Symbol) !Labels {
        var arena: std.heap.ArenaAllocator = .init(gpa);
        errdefer arena.deinit();

        const list = try arena.allocator().alloc(Label, symbols.len);
        for (symbols, list) |symbol, *label| label.* = .{
            .section = 0,
            .address = symbol.address,
            .name = symbol.name,
            .rank = if (symbol.global) 2 else 1,
        };
        std.mem.sort(Label, list, {}, Label.lessThan);
        return .{ .arena = arena, .file = null, .code = list, .slots = &.{} };
    }

    pub fn deinit(labels: *Labels) void {
        labels.arena.deinit();
    }

    /// Имя, стоящее ровно на этом адресе: с него начинается функция.
    pub fn exact(labels: Labels, section: u32, address: u64) ?[]const u8 {
        for (labels.code) |label| {
            if (label.section == section and label.address == address) return label.name;
        }
        return null;
    }

    /// Адрес следующей подписи в секции после данного адреса.
    pub fn nextAfter(labels: Labels, section: u32, address: u64) ?u64 {
        for (labels.code) |label| {
            if (label.section == section and label.address > address) return label.address;
        }
        return null;
    }

    /// Ближайшее имя не правее адреса и расстояние до него.
    /// `from_section` это секция, из которой на адрес ссылаются.
    pub fn nearest(labels: Labels, from_section: u32, address: u64) ?Found {
        // У объектника все секции начинаются с нуля, и адрес понятен только
        // внутри своей секции. У готового файла секцию находим по адресу.
        // Список снаружи это одна секция, её номер уже передан.
        const executable = if (labels.file) |file| file.header.type != @intFromEnum(elf.format.FileType.relocatable) else false;
        const section = if (executable)
            (sectionOf(labels.file.?, address) catch null) orelse return null
        else
            from_section;

        var best: ?Found = null;
        for (labels.code) |label| {
            if (label.section != section or label.address > address) continue;
            // При равных адресах первым в списке стоит имя с большим рангом.
            if (best != null and best.?.offset == address - label.address) continue;
            best = .{ .name = label.name, .offset = address - label.address };
        }
        return best;
    }

    pub fn slot(labels: Labels, address: u64) ?[]const u8 {
        for (labels.slots) |entry| {
            if (entry.slot == address) return entry.name;
        }
        return null;
    }
};

/// Номер секции с кодом, в которую попадает адрес.
fn sectionOf(file: elf.File, address: u64) !?u32 {
    var index: u32 = 0;
    while (index < file.sectionCount()) : (index += 1) {
        const header = try file.section(index);
        if (!isCode(header)) continue;
        if (address >= header.addr and address < header.addr + header.size) return index;
    }
    return null;
}

test "из вывода nm берутся только функции" {
    const gpa = std.testing.allocator;
    const symbols = try parseNm(gpa,
        \\000000000000001f T ztFib
        \\                 U ztLog
        \\0000000000000000 t helper
        \\0000000000000010 D table
        \\
    );
    defer gpa.free(symbols);

    try std.testing.expectEqual(@as(usize, 2), symbols.len);
    try std.testing.expectEqualStrings("ztFib", symbols[0].name);
    try std.testing.expectEqual(@as(u64, 0x1f), symbols[0].address);
    try std.testing.expect(symbols[0].global);
    try std.testing.expect(!symbols[1].global);
}

test "цель без своего имени подписывается от ближайшего слева" {
    var labels = try Labels.fromSymbols(std.testing.allocator, &.{
        .{ .name = "b", .address = 0x40, .global = true },
        .{ .name = "a", .address = 0x0, .global = true },
    });
    defer labels.deinit();

    const inside = labels.nearest(0, 0x5a).?;
    try std.testing.expectEqualStrings("b", inside.name);
    try std.testing.expectEqual(@as(u64, 0x1a), inside.offset);
    try std.testing.expectEqualStrings("a", labels.exact(0, 0).?);
}

Изменённые места в дизассемблере

Опкод 0xFF это группа: что за инструкция, решает поле reg байта ModRM. Номера 0 и 1 (inc и dec) у тебя есть с урока 11, 2 и 4 (косвенные call и jmp, флаг indirect и звёздочка в форматтере) с урока 13. В заглушке PLT встречается ещё номер 6, push из памяти: им нулевая заглушка кладёт на стек адрес из GOT. В src/x86/ops_branch.zig функция indirect получает третью ветку, а заодно тест на форму, из которой состоит каждая заглушка:

--- a/src/x86/ops_branch.zig
+++ b/src/x86/ops_branch.zig
@@ -61,17 +61,19 @@
     return cursor.one("call", 'q', .{ .target = cursor.relativeTarget(displacement) });
 }
 
-/// Хвост группы 0xFF: косвенный вызов (`/2`) и косвенный переход (`/4`).
-/// Косвенный значит, что адрес цели не записан в инструкции, а лежит в
-/// регистре или в памяти. Так выглядит переход по таблице: `jmpq *(,%rdi,8)`.
+/// Хвост группы 0xFF: косвенный вызов (`/2`), косвенный переход (`/4`) и
+/// `push` из памяти (`/6`). Косвенный значит, что адрес цели не записан в
+/// инструкции, а лежит в регистре или в памяти. Именно так устроен PLT:
+/// `jmpq *слот(%rip)` прыгает туда, куда показывает ячейка GOT.
 pub fn indirect(cursor: *Cursor, digit: u3, rm: instruction.Operand) ?Instruction {
     const mnemonic = switch (digit) {
         2 => "call",
         4 => "jmp",
+        6 => "push",
         else => return null,
     };
     var result = cursor.one(mnemonic, 'q', rm);
-    result.indirect = true;
+    result.indirect = digit != 6;
     return result;
 }
 
@@ -94,6 +96,16 @@
     );
 }
 
+test "косвенный переход через ячейку памяти, как в PLT" {
+    // ff 25 7a 11 00 00: jmpq *0x117a(%rip)
+    var cursor: Cursor = .{ .bytes = &.{ 0xff, 0x25, 0x7a, 0x11, 0x00, 0x00 }, .address = 0x1001530 };
+    _ = cursor.next();
+    const modrm = cursor.readModRm(.qword).?;
+    const result = indirect(&cursor, modrm.digit, modrm.rm).?;
+    try std.testing.expect(result.indirect);
+    try std.testing.expectEqualStrings("jmp", result.mnemonic);
+}
+
 test "имена условий склеиваются на этапе компиляции" {
     try std.testing.expectEqualStrings("je", jump_names[4]);
     try std.testing.expectEqualStrings("jne", jump_names[5]);

У push звёздочки нет: он не прыгает, а кладёт на стек значение из памяти, поэтому indirect для него ложь. Ширина та же, восемь байтов без REX.W, и group5 из урока 13 уже читает операнд с шириной qword для всех номеров от второго, так что в ней меняется только комментарий:

--- a/src/x86/decoder.zig
+++ b/src/x86/decoder.zig
@@ -212,8 +212,8 @@
 }
 
 /// Группа 0xFE и 0xFF: инкремент и декремент, а у 0xFF ещё косвенные
-/// вызов и переход. Те работают с восемью байтами без REX.W, поэтому
-/// ModRM для них читаем с шириной qword.
+/// вызов и переход и `push` из памяти. Те работают с восемью байтами
+/// без REX.W, поэтому ModRM для них читаем с шириной qword.
 fn group5(cursor: *Cursor, size: Size, comptime has_branches: bool) ?Instruction {
     const digit: u3 = @truncate((cursor.peek() orelse return null) >> 3);
     if (has_branches and digit >= 2) {

И в src/disasm.zig после комментария с адресом, вычисленным от %rip, дописывается имя ячейки. В printLine это одна строка внутри ветки с комментарием, плюс новая функция:

    if (formatter.hasComment(instruction)) {
        try out.writeAll("  ");
        try formatter.formatComment(out, instruction);
        if (labels) |known| try printSlotLabel(out, instruction, known);
    }
/// Подпись ячейки GOT в комментарии: `# 0x10026b0 <puts@got>`.
fn printSlotLabel(out: *std.Io.Writer, instruction: Instruction, labels: *const Labels) !void {
    for (instruction.operandSlice()) |operand| {
        const target = switch (operand) {
            .memory => |memory| memory.rip_target orelse continue,
            else => continue,
        };
        if (labels.slot(target)) |name| try out.print(" <{s}@got>", .{name});
    }
}

Осталось подключить подкоманды. В src/root.zig добавь pub const plt = @import("plt.zig"); и pub const ldd = @import("ldd.zig");, в текст usage строки zt plt <файл> и zt ldd <файл>, а в цепочку if функции run в src/main.zig две ветки:

    } else if (std.mem.eql(u8, command, "plt")) {
        if (rest.len != 1) return fail(out, "zt plt: нужен ровно один файл\n");
        const bytes = try std.Io.Dir.cwd().readFileAlloc(init.io, rest[0], gpa, file_limit);
        try zt.plt.print(gpa, out, try parseElf(out, "plt", rest[0], bytes));
    } else if (std.mem.eql(u8, command, "ldd")) {
        if (rest.len != 1) return fail(out, "zt ldd: нужен ровно один файл\n");
        const bytes = try std.Io.Dir.cwd().readFileAlloc(init.io, rest[0], gpa, file_limit);
        _ = try parseElf(out, "ldd", rest[0], bytes);
        try zt.ldd.print(gpa, init.io, out, rest[0], bytes);
    } else {

gpa в main это арена на всю программу, поэтому ldd.print может не освобождать прочитанные библиотеки поштучно.

Прогон

Собираем zt под Linux (zig build -Dtarget=x86_64-linux-gnu) и запускаем в контейнере на программах урока:

$ zt ldd prog
	загрузчик /lib64/ld-linux-x86-64.so.2
	libvector.so => ./libvector.so
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
	ld-linux-x86-64.so.2 => /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
$ cp prog /tmp && zt ldd /tmp/prog
	загрузчик /lib64/ld-linux-x86-64.so.2
	libvector.so => не найдена
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
	ld-linux-x86-64.so.2 => /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
$ zt ldd caller
	загрузчик /lib64/ld-linux-x86-64.so.2
	libvector.so => ./libvector.so
$ zt ldd plugin
	скомпонован статически

Четвёртая строка первого вывода это находка обхода: prog про ld-linux-x86-64.so.2 в DT_NEEDED не говорит, её просит libc.so.6. Вторая команда воспроизводит ошибку из начала урока, ничего не запуская: $ORIGIN превратился в /tmp, а библиотеки там нет. У caller зависимость одна, у plugin ни одной.

$ zt plt min
ячейка GOT        в файле лежит     заглушка PLT      тип                 имя
0000000001002760  0000000000000000  ----------------  R_X86_64_GLOB_DAT   __libc_start_main
0000000001002768  0000000000000000  ----------------  R_X86_64_GLOB_DAT   addcnt
0000000001003798  0000000001001606  0000000001001600  R_X86_64_JUMP_SLOT  addvec

Вся механика урока в трёх строках: две ячейки данных без заглушек и с нулями в файле, одна ячейка функции, которая в файле показывает на заглушка + 6. А вот ради чего правили дизассемблер:

$ zt disasm min
    ...
 10014f4: ff 15 66 12 00 00            	callq	*0x1266(%rip)  # 0x1002760 <__libc_start_main@got>
    ...
 100152e: e8 cd 00 00 00               	callq	0x1001600 <addvec@plt>
 1001533: 4c 8b 25 2e 12 00 00         	movq	0x122e(%rip), %r12  # 0x1002768 <addcnt@got>
 100153a: 41 83 04 24 01               	addl	$0x1, (%r12)
    ...
Disassembly of section .plt:

00000000010015f0 <.plt>:
 10015f0: ff 35 92 21 00 00            	pushq	0x2192(%rip)  # 0x1003788
 10015f6: ff 25 94 21 00 00            	jmpq	*0x2194(%rip)  # 0x1003790
 10015fc: 0f 1f 40 00                  	nopl	(%rax)

0000000001001600 <addvec@plt>:
 1001600: ff 25 92 21 00 00            	jmpq	*0x2192(%rip)  # 0x1003798 <addvec@got>
 1001606: 68 00 00 00 00               	pushq	$0x0
 100160b: e9 e0 ff ff ff               	jmp	0x10015f0 <.plt>

Посмотри на байты. ff 15 это косвенный call (ModRM 0x15: поле reg равно 2, адресация от %rip), ff 25 косвенный jmp (поле reg равно 4), ff 35 это push из памяти (поле reg равно 6). Одна группа, три инструкции, и все три встретились в одном маленьком файле.

Тесты шага

Файл tests/step_45.zig. Не забудь дописать число 45 в массив project_steps в build.zig.

//! Шаг 45: `zt ldd`, связка PLT и GOT, подписи `<puts@plt>` в дизассемблере.
//!
//! Фикстуры это маленькая библиотека `libgreet.so` и программа `hello_dyn`,
//! которая зовёт её и libc. Обе собраны под x86-64 Linux с glibc заранее.

const std = @import("std");

const fixtures = @import("fixtures");
const zt = @import("zt");

const support = @import("elf_support.zig");

const elf = zt.elf;

test "PT_INTERP называет динамический загрузчик" {
    const file = try elf.File.parse(fixtures.dyn.hello.bytes);
    try std.testing.expectEqualStrings(
        "/lib64/ld-linux-x86-64.so.2",
        (try elf.dynamic.interpreter(file)).?,
    );
    // Библиотеку никто не запускает, загрузчик ей не нужен.
    const library = try elf.File.parse(fixtures.dyn.libgreet);
    try std.testing.expectEqual(@as(?[]const u8, null), try elf.dynamic.interpreter(library));
}

test "DT_NEEDED и DT_RUNPATH программы" {
    const gpa = std.testing.allocator;
    const needs = try zt.ldd.read(gpa, try elf.File.parse(fixtures.dyn.hello.bytes));
    defer gpa.free(needs.libraries);

    try std.testing.expectEqual(@as(usize, 2), needs.libraries.len);
    try std.testing.expectEqualStrings("libgreet.so", needs.libraries[0]);
    try std.testing.expectEqualStrings("libc.so.6", needs.libraries[1]);
    try std.testing.expectEqualStrings("$ORIGIN", needs.runpath.?);
}

test "у библиотеки свои зависимости" {
    const gpa = std.testing.allocator;
    const file = try elf.File.parse(fixtures.dyn.libgreet);
    try std.testing.expectEqual(@intFromEnum(elf.format.FileType.shared), file.header.type);

    const needs = try zt.ldd.read(gpa, file);
    defer gpa.free(needs.libraries);
    try std.testing.expectEqual(@as(usize, 1), needs.libraries.len);
    try std.testing.expectEqualStrings("libc.so.6", needs.libraries[0]);
}

test "статический бинарник от zt ld ни от кого не зависит" {
    const gpa = std.testing.allocator;
    var arena: std.heap.ArenaAllocator = .init(gpa);
    defer arena.deinit();
    const result = try zt.ld.link(arena.allocator(), &.{
        .{ .name = "main.o", .bytes = fixtures.link.main.bytes },
        .{ .name = "addvec.o", .bytes = fixtures.link.addvec.bytes },
    });

    const needs = try zt.ldd.read(gpa, try elf.File.parse(result.image.?));
    defer gpa.free(needs.libraries);
    try std.testing.expectEqual(@as(?[]const u8, null), needs.interpreter);
    try std.testing.expectEqual(@as(usize, 0), needs.libraries.len);
}

test "у каждой функции своя ячейка GOT и своя заглушка PLT" {
    const gpa = std.testing.allocator;
    const entries = try zt.plt.entries(gpa, try elf.File.parse(fixtures.dyn.hello.bytes));
    defer gpa.free(entries);

    // Одна запись GLOB_DAT и пять JUMP_SLOT.
    try std.testing.expectEqual(@as(usize, 6), entries.len);
    try std.testing.expectEqualStrings("__libc_start_main", entries[0].name);
    try std.testing.expectEqual(@as(?u64, null), entries[0].stub);

    const names = [_][]const u8{ "malloc", "strcpy", "greet", "free", "puts" };
    for (names, entries[1..], 0..) |name, entry, number| {
        try std.testing.expectEqualStrings(name, entry.name);
        try std.testing.expectEqual(elf.format.RelocationType.jump_slot, entry.kind);
        // Ячейки идут подряд по восемь байтов, заглушки подряд по шестнадцать.
        try std.testing.expectEqual(entries[1].slot + 8 * number, entry.slot);
        try std.testing.expectEqual(entries[1].stub.? + 16 * number, entry.stub.?);
        // Ленивое связывание: до первого вызова ячейка показывает обратно
        // в заглушку, на инструкцию сразу за `jmpq *слот(%rip)`.
        try std.testing.expectEqual(entry.stub.? + 6, entry.slot_value);
    }
}

test "дамп PLT и GOT" {
    const gpa = std.testing.allocator;
    var out: std.Io.Writer.Allocating = .init(gpa);
    defer out.deinit();
    try zt.plt.print(gpa, &out.writer, try elf.File.parse(fixtures.dyn.hello.bytes));
    try std.testing.expectEqualStrings(
        \\ячейка GOT        в файле лежит     заглушка PLT      тип                 имя
        \\0000000001002840  0000000000000000  ----------------  R_X86_64_GLOB_DAT   __libc_start_main
        \\0000000001003860  00000000010016a6  00000000010016a0  R_X86_64_JUMP_SLOT  malloc
        \\0000000001003868  00000000010016b6  00000000010016b0  R_X86_64_JUMP_SLOT  strcpy
        \\0000000001003870  00000000010016c6  00000000010016c0  R_X86_64_JUMP_SLOT  greet
        \\0000000001003878  00000000010016d6  00000000010016d0  R_X86_64_JUMP_SLOT  free
        \\0000000001003880  00000000010016e6  00000000010016e0  R_X86_64_JUMP_SLOT  puts
        \\
    , out.written());
}

test "косвенные jmp и call через память" {
    const decode = zt.x86.decoder.decode;
    var buffer: [64]u8 = undefined;

    // Заглушка PLT: ff 25 disp32.
    const jump = decode(&.{ 0xff, 0x25, 0xba, 0x21, 0x00, 0x00 }, 0x10016a0);
    try std.testing.expect(jump.indirect);
    try std.testing.expectEqual(@as(?u64, 0x1003860), jump.operandSlice()[0].memory.rip_target);
    var out: std.Io.Writer = .fixed(&buffer);
    try zt.x86.formatter.format(&out, jump);
    try std.testing.expectEqualStrings("jmpq\t*0x21ba(%rip)", out.buffered());

    // Вызов через GOT без PLT, как его порождает -fno-plt: ff 15 disp32.
    const call = decode(&.{ 0xff, 0x15, 0x10, 0x00, 0x00, 0x00 }, 0x1000);
    out = .fixed(&buffer);
    try zt.x86.formatter.format(&out, call);
    try std.testing.expectEqualStrings("callq\t*0x10(%rip)", out.buffered());

    // push из памяти живёт в той же группе, но звёздочки у него нет.
    const push = decode(&.{ 0xff, 0x35, 0xba, 0x21, 0x00, 0x00 }, 0x1001690);
    try std.testing.expect(!push.indirect);
    try std.testing.expectEqualStrings("push", push.mnemonic);
}

test "disasm динамического файла совпадает с objdump вместе с <puts@plt>" {
    try support.expectDisasm(fixtures.dyn.hello.bytes, fixtures.dyn.hello.objdump);
    try support.expectDisasm(fixtures.dyn.libgreet, fixtures.dyn.libgreet_objdump);
}

test "вызовы подписаны именами заглушек, а заглушки именами ячеек" {
    const gpa = std.testing.allocator;
    var out: std.Io.Writer.Allocating = .init(gpa);
    defer out.deinit();
    try zt.disasm.disassembleFile(gpa, &out.writer, try elf.File.parse(fixtures.dyn.hello.bytes));
    const text = out.written();

    try std.testing.expect(std.mem.indexOf(u8, text, "callq\t0x10016e0 <puts@plt>") != null);
    try std.testing.expect(std.mem.indexOf(u8, text, "00000000010016e0 <puts@plt>:") != null);
    try std.testing.expect(std.mem.indexOf(u8, text, "jmpq\t*0x219a(%rip)  # 0x1003880 <puts@got>") != null);
    try std.testing.expect(std.mem.indexOf(u8, text, "jmp\t0x1001690 <.plt>") != null);
}

Тест про статический файл опирается на твой zt ld из прошлого урока: то, что он собирает, по определению ни от кого не зависит. А тест disasm динамического файла совпадает с objdump самый строгий: он сверяет с эталоном весь вывод дизассемблера по обоим файлам, включая подписи заглушек в каждом callq. Комментарии после решётки при сверке отбрасываются, потому что именно там мы с objdump расходимся, и расходимся намеренно.

$ zig build test --summary all
...
Build Summary: 43/43 steps succeeded; 235/242 tests passed (7 skipped)

Числа у тебя будут меньше: в моём эталоне уже лежат шаги следующих уроков. Число снято на macOS, и семь пропусков это тесты, которым нужен Linux: они запускают собранный бинарник или трассируют процесс.

Упражнения

Итоги

  • Статическая библиотека копируется в каждую программу: место на диске и в памяти, обновления только пересборкой, набор кода зафиксирован при компоновке. Разделяемая библиотека лежит на диске один раз, её код отображается во все процессы с общими физическими страницами, и подключается она при запуске или по ходу работы.
  • .so на Zig это zig build-lib -dynamic плюс export fn и export var: глобальное имя без искажений и соглашение о вызовах C. Тип файла DYN, точки входа нет, имена для загрузчика лежат в .dynsym, адреса в файле это смещения от базы.
  • Ядро видит в файле PT_INTERP и отдаёт управление ld-linux.so. Тот читает .dynamic: DT_NEEDED называет библиотеки по имени, без пути. Порядок поиска: LD_LIBRARY_PATH, DT_RUNPATH (в нём $ORIGIN это каталог самого файла), кэш ld.so.cache, системные каталоги. LD_DEBUG=libs показывает поиск, ldd показывает итог, но запускает загрузчик.
  • Позиционно-независимый код не содержит ни одного адреса, требующего правки: к своему обращается через смещение от %rip, к чужому через таблицу. Страницы кода остаются одинаковыми во всех процессах, загрузчик пишет только в данные.
  • GOT это массив ячеек с адресами внешних имён. Ячейку переменной заполняет перемещение R_X86_64_GLOB_DAT при загрузке, код читает адрес из ячейки и идёт по нему.
  • PLT это заглушки по шестнадцать байт: jmpq *GOT[n], pushq $номер, jmp PLT[0]. При ленивом связывании ячейка сначала показывает на вторую инструкцию своей заглушки, первый вызов уходит через PLT[0] и GOT[2] в загрузчик, тот вписывает в ячейку адрес функции (R_X86_64_JUMP_SLOT) и прыгает на неё. Второй вызов это один косвенный прыжок. LD_DEBUG=bindings печатает связывание в момент первого вызова.
  • Записываемая таблица адресов функций это цель атаки: восемь байт в ячейке меняют то, что зовёт программа. -z now связывает всё до main, полный RELRO переносит .got.plt в сегмент PT_GNU_RELRO, который загрузчик закрывает через mprotect. LD_BIND_NOW=1 убирает лень, но не защищает. Компоновщик Zig по умолчанию собирает с -z now.
  • Из Zig библиотеку зовут через extern fn и extern var с ключами -L и -l; программе на Zig libc для этого не нужна. dlopen, dlsym, dlclose, dlerror загружают библиотеку по ходу работы. std.DynLib делает то же с типизированным lookup; без libc это собственный мини-загрузчик ElfDynLib, который не выполняет перемещений, с -lc это обёртка над dlopen.
  • zt ldd читает PT_INTERP, DT_NEEDED и DT_RUNPATH и обходит зависимости, ничего не запуская. zt plt сводит .rela.dyn, .rela.plt и заглушки в одну таблицу. zt disasm понимает ff /2, ff /4, ff /6 и подписывает <addvec@plt> и <addcnt@got>.

Дальше

Сегодня загрузчик искал имя addvec по модулям в строгом порядке и брал первое найденное. Из этого порядка растёт мощный приём: если подсунуть загрузчику свою библиотеку раньше остальных, то твоя функция malloc заменит библиотечную в чужой программе, которую ты не пересобирал и исходников которой у тебя нет. В следующем уроке мы разберём подмену функций на трёх уровнях (при компиляции, при компоновке и при загрузке через LD_PRELOAD), напишем трассировщик malloc и free, а zt получит подкоманду ltrace. Там же закроем блок практикой: статическая сборка на musl против glibc, срезание лишнего, кросс-компоновка под чужую платформу.

домашка

Домашка