При работе с разделяемыми библиотеками основная заключается в том, что загрузчик может загружать их в любое место вида памяти. В действительности, это одна из мер безопасности, которые обычно принимают дистрибутивы Linux, — рандомизация адресных пространств, чтобы библиотеки и части приложения можно было загружать в любое место в памяти.
Но это означает, что библиотеки и приложение должны быть написаны специально, чтобы загрузка их в память не приводила к ошибкам. Данный тип кода приложения известен как позиционно-независимый код или PIC (position-independent code).
Так, в прошлой теме при компиляции программы мы использовали опцию -no-pie
gcc hello.s -no-pie -lten -L . -o hello
Эта опция сообщает gcc не создавать исполняемый файл, независимый от позиции (PIE). Фактически при компиляции в заголовке исполняемого файла устанавливается один бит, который сигнализирует загрузчику о том, что код зависит от позиции. Затем загрузчик всегда будет загружать код в одно и то же фиксированное место виртуальной памяти, указанное в заголовке исполняемого файла.
С одной стороны, опция -no-pie
удобна, так как нам не надо думать о том, как сделать наш код, независимым от позиции. В то же время это повышает уязвимость приложения:
если библиотеки и части приложения будут загружаться в одно и то же место, то у злоумышленников будет больше возможностей как-то повлиять и изменить работу программы.
Теперь рассмотрим, как сделать наш код независимым от позиции. Есть три основные области, где код необходимо изменить, чтобы код был независимым от позиции:
Ссылки на внешние функции
Ссылки на раздел .data библиотеки
Ссылки на внешние объекты
Ссылки на внешние функции обрабатываются компоновщиком/загрузчиком автоматически с помощью таблиц PLT/GOT. Так, вызов call printf
заменяется на вызов
call printf@plt
, который обращается по адресу функции printf, указанному в таблице GOT.
При вызове функций мы можем пропустить применение таблицы PLT и напрямую вызвать запись из GOT. Это заставит загрузчик загружать значение непосредственно перед запуском программы, а не отложенном режиме, как это происходит при вызове через PLT. Для этого достаточно заменить вызовы функций обращениями к записи в GOT. Например, вместо
call printf
или
call printf@plt
можно использовать следующую инструкцию
call *printf@GOTPCREL(%rip)
Это может немного ускорить выполнение, но также немного увеличит время загрузки приложения.
Ссылки на адреса в разделе .data библиотеки обрабатываются в режиме адресации, известном как PC-relative addressing или адресация относительно регистра PC (Program Counter) он же регистр %rip (Instruction Pointer). Этот режим адресации записывает адреса данных как смещение относительно указателя текущей команды.
Например, пусть у нас есть некоторые данные в секции .data:
.data message: .asciz "Hello METANIT.COM\n" arg: .quad 10
Для загрузки этих данных в коде применяется адресация относительно %rip
:
movq arg(%rip), %rax
В данном случае мы применяем адресацию относительно регистра PC (RIP). Сама инструкция указывает, что нам нужен адрес переменной arg, но закодировать его нужно как смещение относительно указателя инструкции в этом месте. Следовательно, независимо от того, где в памяти загружается код, программа все равно будет знать, где находится переменная arg, поскольку это будет фиксированное смещение от текущего места в коде.
Если нам нужен адрес переменной, можно использовать инструкцию leaq:
leaq message(%rip), %rsi
То есть опять же мы получаем адрес переменной message относительно регистра %rip.
Для ссылки на внешние данные (например, объект стандартного вывода stdout
) применяется двухэтапный поиск с помощью таблицы GOT.
Для этого нужно использовать специальный символ GOTPCREL.
Допустим, мы хотим загрузить объект стандартного вывода stdout
в регистр %rdi. В общем случае мы могли бы это сделать так:
movq stdout, %rdi
Но в действительности мы не знаем, где будет располагаться код объекта stdout
. Поэтому нам нужно найти адрес этого объекта в таблице GOT, а затем использовать этот
адрес для загрузки фактического значения. Для этого применяются две инструкции:
movq stdout@GOTPCREL(%rip), %rdi movq (%rdi), %rdi
Первая инструкция находит местоположение переменной в глобальной таблице смещений, используя адресацию относительно PC, а затем загружает ее в %rdi.
Специальный символ GOTPCREL
обозначает местоположение GOT относительно PC. Затем вторая инструкция использует это место для поиска значения самого объекта stdout
.
Теперь определим независимое от позиции приложение (Position Independent Executable) или PIE (которое используют независимый от позиции код (PIC)). Например, определим файл hello.s со следующим кодом:
.globl main .data message: .asciz "Hello METANIT.COM\n" .text main: subq $8, %rsp # выравнивание должно быть по 16 байтам movq stdout@GOTPCREL(%rip), %rdi # stdout@GOTPCREL(%rip) - загрузка относительно RIP movq (%rdi), %rdi leaq message(%rip), %rsi # message(%rip) - загрузка относительно RIP call fprintf addq $8, %rsp ret
Для демонстрации применения глобального объекта stdout
здесь используется стандартная библиотечная функция fprintf, а не printf
.
Функция fprintf получает в качестве первого параметра глобальный объект stdout
через адрес из таблицы GOT. В качестве второго параметра в регистр %rsi
загружается адрес переменной message. И опять же, поскольку идет обращение к данным в секции .data, применяется адрес относительно регистра %rip:
leaq message(%rip), %rsi
Для компиляции приложения используем команду:
gcc -pie hello.s -o hello
Флаг -pie
как раз и указывает, что будет компилироваться приложение, независимое от позиции. Нередко эта опция уже применяется по умолчанию, поэтому можно сократить команду компиляции:
gcc hello.s -o hello
Подобно тому, как мы можем определять независимые от позиции приложения, мы также может определять незываисимые от позиции библиотеки. Так, пусть для примера у нас будет файл ten.s, который содержит простенькую функцию умножения некоторого числа, которое передается через регистр %rdi, на 10:
.globl ten .data arg: .quad 10 .text # Функция возвращает число умноженное на 10 # Параметр: # %rdi - число, для умножения на 10 # Результат функции возвращается через регистр %rax ten: movq arg, %rax # результат в %rax mulq %rdi # %rax = %rax *%rdi ret
Попробуем скомпилировать этот файл в разделяемую библиотеку libten.so:
root@Eugene:~/asm# gcc -shared ten.s -o libten.so
В этом случае мы можем столкнуться с ошибкой:
root@Eugene:~/asm# gcc -shared ten.s -o libten.so /usr/bin/ld: /tmp/ccBOOTZZ.o: relocation R_X86_64_32S against `.data' can not be used when making a shared object; recompile with -fPIC /usr/bin/ld: failed to set dynamic section sizes: bad value collect2: error: ld returned 1 exit status root@Eugene:~/asm#
Теперь исправим код файла:
.globl ten .data arg: .quad 10 .text ten: movq arg(%rip), %rax mulq %rdi # %rax = %rax *%rdi ret
По сравнению с предыдущим вариантом здесь исправлена одна строка кода
movq arg(%rip), %rax
И если мы повторим компиляцию библиотеки, то у нас не возникнет никаких проблем:
gcc -shared ten.s -o libten.so
Используем эту библиотеку, например, в файле hello.s:
.globl main .data message: .asciz "%d * 10 = %d\n" # строка форматирования num: .quad 5 .text main: subq $8, %rsp # выравнивание должно быть по 16 байтам movq num(%rip), %rdi # параметр функции ten - загрузка относительно регистра %rip call ten # вызываем функцию ten leaq message(%rip), %rdi # первый параметр функции printf - строка форматирования movq num(%rip), %rsi # второй параметр функции printf - первый аргумент строки форматирования movq %rax, %rdx # третий параметр функции printf - второй аргумент строки форматирования call printf addq $8, %rsp ret
Скомпилируем программу:
gcc -rdynamic hello.s -lten -L . -o hello
Затем мы можем ее выполнить. Полный вывод компиляции и выполнения программы:
root@Eugene:~/asm# gcc -rdynamic hello.s -lten -L . -o hello root@Eugene:~/asm# export LD_LIBRARY_PATH=. root@Eugene:~/asm# ./hello 5 * 10 = 50 root@Eugene:~/asm#