для файлов типа разделяемая библиотека не установлено каких либо приложений

Администрирование систем Linux. Работа с разделяемыми библиотеками

Глава 29. Работа с разделяемыми библиотеками

29.1. Краткая информация о разделяемых библиотеках

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

29.2. Директории /lib и /usr/lib

При просмотре содержимого директорий /lib и /usr/lib можно обнаружить большое количество символьных ссылок. Имена большинства файлов разделяемых библиотек содержат подробные описания номеров версий, причем для этих файлов также создаются символьные ссылки с именами, содержащими лишь указания на номера основных версий.

29.3. Утилита ldd

29.4. Утилита ltrace

Имя пакета программного обеспечения дистрибутива Debian/Ubuntu, который содержит указанную разделяемую библиотеку, может быть установлено следующим образом.

Имя пакета программного обеспечения дистрибутива RedHat/Fedora, который содержит указанную разделяемую библиотеку, может быть установлено следующим образом.

Теперь при проверке целостности файлов из пакета программного обеспечения не выводится информации о каких-либо проблемах с файлом разделяемой библиотеки.

29.7. Трассировка вызовов библиотечных функций с помощью утилиты strace

Созданный нами файл может быть открыт только для чтения, но мы все равно попытаемся изменить его содержимое и использовать директиву :w! для принудительной записи данных в него. После этого мы закроем текстовый редактор vi и перейдем к рассмотрению содержимого файла журнала трассировки вызовов функций разделяемых библиотек.

Обратите внимание на то, что текстовый редактор vi осуществлял изменение прав доступа к файлу дважды. Файл журнала трассировки вызовов функций разделяемых библиотек содержит большой объем информации, поэтому его содержимое не приводится в полном объеме в данной книге.

Источник

Выполнение разделяемых библиотек в Linux

В данной статье мы поговорим о создании библиотек с возможностью их запуска и передачи им аргументов из командной строки. В ответ библиотека выдаст нам информацию об авторе и документацию своих функций. В качестве примера создадим небольшую библиотеку myexec:

int my_main(int argc,char *argv[]) <
printf(«Main started. Argc: %d\n»,argc);
for (;*argv;*argv++) printf(» %s\n»,*argv);
exit(0);
>
int f1(void) < printf("function %s\n",__FUNCTION__); >
int f2(void)

Функции f1() и f2() являются функциями библиотеки, из-за которых она собственно и пишется. Нас они не интересуют, поэтому они будут просто выводить свое имя сразу после вызова. Именно эти функции мы будем документировать. Функция my_main() будет точкой входа в библиотеку. Она показывает все входные аргументы, переданные из командной строки. Вызов exit() в конце обязателен, так как после выполнения функции my_main() управление никуда не возвращается, как в случае обычных выполняемых файлов. Поэтому нам нужно самостоятельно завершить выполнение.

Собираем, запускаем и видим то, что ожидали:

const char interp[] __attribute__((section(«.interp»))) = «/lib/ld-linux.so.2»;

Файл /lib/ld-linux.so.2 является загрузчиком для ОС Linux.

Другой способ не требует вмешательства в исходный код библиотеки. Требуемая секция добавляется в объектный файл с помощью утилиты objcopy:

Функция my_main() запустилась, но параметры передались не в том порядке_ в котором мы ожидали. За передачу параметров от ядра к функции main() отвечает специальный код компилятора, запакованный в объектный файл сrt1.o. При передаче управления от ядра к выполняемому файлу в стеке процесса содержатся:

Однако функция my_main() справедливо полагает, что первое значение в стеке есть адрес возврата вызвавшей ее функции. Ядро, как мы знаем, этот адрес не кладет в стек. Таким образом, мы теряем аргумент argc, остальные аргументы можно достать средствами языка C. Решение, кажется, лежит на поверхности. Достаточно «обернуть» вызов функции my_main() в какую-нибудь другую функцию, например:

В п. 1,2,3 стек засоряется, а в п. 4,5,6 вычищается. Как видно, п. 4,5,6 происходят после вызова my_main(), поэтому нам придется очистить стек самостоятельно до вызова нашей входной функции. По адресу 667-66с делается операция получения текущего адреса выполнения программы в регистр ebp: инструкция call положит адрес возврата в стек, а инструкция pop тут же его оттуда вытащит, поэтому эта часть на положение стека не влияет. Регистр ebp необходим для доступа к таблице PLT нашей библиотеки.

Также нам нужно создать указатель на массив указателей на аргументы командной строки, так как наша my_main(), как и обычная main(), принимает его в качестве второго аргумента. Итак, нам нужно очистить три двойных слова из стека и создать указатель. Вариантов сделать это предостаточно, вот один из вариантов функции pre():

void pre(void) <
asm volatile (
«xorl %ebp,%ebp\n\t» //

В п.1 очищаем ebp, далее очищаем стек на четыре двойных слова (вместо трех), обращаемся к памяти за стеком и достаем argc. В п. 4 создаем нужный указатель и в п.5 складываем готовые аргументы в стек для передачи my_main(). И наконец, в п.6 вызываем ее. Вставляем этот код в начало библиотеки, собираем c новой точкой входа pre и запускаем:

что даст нам ожидаемый вывод:

Источник

Position-independent code (PIC) в разделяемых библиотеках

для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть фото для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть картинку для файлов типа разделяемая библиотека не установлено каких либо приложений. Картинка про для файлов типа разделяемая библиотека не установлено каких либо приложений. Фото для файлов типа разделяемая библиотека не установлено каких либо приложений

Привет. Меня зовут Марко, и я системный программист в Badoo. Я очень люблю досконально разбираться в том, как работают те или иные вещи, и тонкости работы разделяемых библиотек в Linux не исключение. Я представляю вам перевод именно такого разбора. Приятного чтения.

Я уже описывал необходимость специальной обработки разделяемых библиотек во время загрузки их в адресное пространство процесса. Если кратко, то, когда линкер создает разделяемую библиотеку, он заранее не знает, в каком месте в памяти она будет загружена. Из-за этого делать ссылки на данные и код внутри библиотеки проблематично: непонятно, как создавать ссылку, чтобы она указывала в правильное место после того, как библиотека будет загружена.

В Linux и в ELF существует два главных способа решить эту проблему:

Релокацию во время загрузки мы уже рассмотрели. А сейчас рассмотрим второй подход – PIC.

Изначально я планировал рассказывать и о x86, и о x64 (также известной как x86-64), но статья всё росла и росла, и я решил, что нужно быть более практичным. Так что в этой статье я расскажу только о x86, а о x64 речь пойдёт в другой (я надеюсь, гораздо более короткой). Я взял более старую архитектуру x86, так как в отличие от x64 она разрабатывалась без учета PIC, и реализация PIC в ней чуть более сложная.

Проблемы релокации во время загрузки

Как мы увидели в предыдущей статье, релокация во время загрузки – очень простой и прямолинейный метод. И он работает. Но PIC гораздо более популярен на данный момент и является рекомендуемым способом создания разделяемых библиотек. Почему, спросите вы?

У релокации есть несколько проблем: она занимает время и секция text (содержащая машинный код) уже не подходит для разделения между процессами.

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

Ну, и несколько слов про проблему невозможности расшарить text-секцию. Она несколько серьёзнее. Одна из главных задач существования разделяемых библиотек – сэкономить на памяти. Некоторые библиотеки используются несколькими приложениями одновременно. Если text-секция (где находится машинный код) может быть загружена в память только один раз (и затем добавлена в другие процессы с помощью mmap), то можно сэкономить довольно большое количество оперативной памяти. Но это невозможно при использовании релокации, так как text-секция должна быть изменена во время загрузки, чтобы подставить правильные указатели для конкретного процесса. Получается, для каждого процесса, использующего библиотеку, приходится держать полную копию этой библиотеки в памяти [1]. Никакого разделения не происходит.

Более того, держать text-секцию с правами на запись (а она должна быть с правами на запись, чтобы загрузчик мог подкорректировать ссылки) – плохо с точки зрения безопасности. Сделать эксплоит в этом случае гораздо легче.

Как мы увидим далее, PIC практически полностью решает эти проблемы.

Введение

Идея, которая стоит за PIC, очень проста – добавление в код промежуточного слоя для всех ссылок на глобальные объекты и функции. Если по-умному использовать некоторые артефакты процессов линковки и загрузки, можно сделать раздел text действительно не зависящим от адреса, куда его положат; мы сможем отобразить сегмент с помощью mmap на самые разные адреса в адресном пространстве процесса, и нам не понадобится изменять в нём ни один бит. В следующих нескольких разделах я покажу, как можно этого достичь.

Ключевая идея №1. Смещение между секциями text и data

Одна из ключевых идей, на которых основывается PIC, – смещение между секциями text и data, размер которого известен линкеру во время линковки. Когда линкер объединяет несколько объектных файлов, он собирает их секции вместе (к примеру, все секции text объединяются в одну большую секцию text). Таким образом, линкеру известны и размеры секций, и их относительное расположение.

Например, сразу за секцией text может следовать секция data, и в этом случае смещение от любой инструкции из секции text до начала секции data будет равняться размеру секции text минус смещение до данной инструкции от начала секции text. И все эти размеры и смещения известны линкеру.

для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть фото для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть картинку для файлов типа разделяемая библиотека не установлено каких либо приложений. Картинка про для файлов типа разделяемая библиотека не установлено каких либо приложений. Фото для файлов типа разделяемая библиотека не установлено каких либо приложений

На диаграмме выше секция code была загружена по некоторому адресу (неизвестному нам на момент линковки) 0xXXXX0000 (иксы буквально означают «всё равно, что там»), а секция data – сразу после нее по адресу 0xXXXXF000. В этом случае, если какая-то инструкция по смещению 0x80 в секции code захочет указать на что-то в секции data, линкер знает относительное смещение (0xEF80 в данном случае) и может добавить его в инструкцию.

Заметьте, что ничего не изменится, если другая секция будет замаплена между секциями code и data или если секция data будет расположена до секции code. Поскольку линкер знает размеры всех секций и решает, куда их положить, идея остаётся неизменной.

Ключевая идея №2. Делаем так, чтобы смещение относительно IP работало на x86

Всё, о чём было рассказано выше, работает, если мы вообще можем воспользоваться относительными смещениями. Ведь ссылки на данные (например, как в инструкции MOV) на x86 требуют абсолютные адреса. Так что же нам делать?

Если у нас есть относительный адрес, а нужен абсолютный, нам не хватает значения указателя команд, или счётчика команд (instruction pointer – IP). Ведь по определению относительный адрес относителен по отношению к IP. На x86 не существует инструкции для получения IP, но мы можем воспользоваться простой хитростью. Вот небольшой ассемблерный псевдокод, который её демонстрирует:

Что здесь происходит:

Глобальная таблица смещений (GOT)

Теперь у нас есть всё, чтобы, наконец, рассказать о том, как реализована не зависящая от позиции адресация на x86. А реализована она с помощью глобальной таблицы смещений (global offset table или GOT).

GOT – это просто таблица с адресами, которая находится в секции data. Предположим, что какая-то инструкция в секции code хочет обратиться к переменной. Вместо того, чтобы обратится к ней через абсолютный адрес (который потребует релокации), она обращается к записи в GOT. Поскольку GOT имеет строго определённое место в секции data, и линкер знает о нём, это обращение тоже является относительным. А запись в GOT уже содержит абсолютный адрес переменной:

для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть фото для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть картинку для файлов типа разделяемая библиотека не установлено каких либо приложений. Картинка про для файлов типа разделяемая библиотека не установлено каких либо приложений. Фото для файлов типа разделяемая библиотека не установлено каких либо приложений

В псевдоассемблере это будет выглядеть как замена абсолютной адресации.

на адресацию через регистр и небольшую прокладку:

Каким-то образом найдём адрес GOT и положим его в ebx:

lea ebx, ADDR_OF_GOT

Предположим, адрес переменной (ADDR_OF_VAR) находится по смещению 0x10 в GOT. В этом случае следующая инструкция положит ADDR_OF_VAR в edx:

mov edx, DWORD PTR [ebx + 0x10]

Наконец, обратимся к переменной и положим её значение в edx:

mov edx, DWORD PTR [edx]

Таким образом мы избавились от релокации в секции code путём перенаправления обращений через GOT. Но мы также создали релокацию в секции data. Почему? Потому что GOT в любом случае должна содержать абсолютный адрес переменной, чтобы вышеописанная схема работала. Так где же профит?

А профита, оказывается, много. Релокация в data-секции сопряжена с гораздо меньшим количеством проблем, чем релокация в секции code. Этому есть две причины, соответствующие двум проблемам, возникающим при релокации во время загрузки.

PIC с обращениями через GOT (пример)

Сейчас я покажу полноценный пример, который демонстрирует механику PIC:

Давайте посмотрим, что сгенерировал компилятор, фокусируясь на функции ml_func:

Я буду указывать на адрес инструкций (самое левое число в выводе). Этот адрес – это смещение от того адреса, на который была замаплена библиотека.

Давайте достанем калькулятор и проверим компилятор. Ищем myglob. Как я уже упоминал выше, вызов __i686.get_pc_thunk.cx кладёт адрес следующей инструкции в ecx. Это 0x444 [2]. Следующая инструкция прибавляет к нему 0x1bb0 – и в результате в ecx мы получим 0x1ff4. Наконец, чтобы получить элемент GOT, который содержит адрес myglob, делаем [ecx — 0x10]. Элемент, таким образом, имеет адрес 0x1fe4, и это первый элемент в GOT, согласно заголовку секции.

Но есть одна вещь, которой нам пока не хватает. Как именно адрес myglob оказывается в элементе GOT по адресу 0x1fe4? Вспомните, что я упоминал релокацию, так что давайте её найдём:

Вот она, релокация для myglob, указывающая на адрес 0x1fe4, как мы и ожидали. Релокация имеет тип R_386_GLOB_DAT, который просто говорит загрузчику: «Положи реальное значение симпола (то есть его адрес) по данному смещению». Теперь всё понятно. Осталось только посмотреть как, это всё выглядит при загрузке библиотеки. Мы можем это сделать, создав простой бинарник (driver), который линкуется к libmlpic_dataonly.so и вызывает ml_func, и запустив его через gdb.

Дебаггер вошёл в ml_func и остановился на IP 0x0013144a [4]. Мы видим, что ecx имеет значение 0x132ff4 (адрес инструкции плюс 0x1bb0). Заметьте, что в данный момент, во время работы, это всё абсолютные адреса – библиотека уже загружена в адресное пространство процесса.

Так, элемент GOT с myglob должен быть на [ecx — 0x10]. Давайте проверим:

То есть мы ожидаем что 0x0013300c – это адрес myglob. Проверяем:

Вызов функций в PIC

Итак, мы увидели, как работает PIC для адресов на данные. Но что насчёт функций? Теоретически тот же самый способ будет работать и для функций. Вместо того, чтобы call содержал адрес функции, пусть он содержит адрес элемента из GOT, а элемент уже будет заполнен при загрузке.

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

Оптимизация: «ленивый» байндинг

Когда разделяемая библиотека использует какую-либо функцию, реальный адрес этой функции ещё не известен. Определение реального адреса называется байндинг (binding), и это то, что загрузчик делает, когда загружает разделяемую библиотеку в адресное пространство процесса. Байндинг не тривиален, так как загрузчику нужно искать символы функций в специальных таблицах [5].

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

Чтобы ускорить этот процесс, и была придумана хитрая схема «ленивого» байндинга. «Ленивая» — это общий термин оптимизаций в IT, когда какая-либо работа откладывается до самого последнего момента. Смысл этой оптимизации в том, чтобы не делать лишнюю работу, которая может быть и не нужна. Примерами такой «ленивой» оптимизации являются механизм copy-on-write и «ленивые» вычисления.

«Ленивая» схема реализована путём добавления ещё одного уровня адресации – PLT.

Procedure Linkage Table (PLT)

PLT – это часть секции text в бинарнике, состоящая из набора элементов (один элемент на одну внешнюю функцию, которую вызывает библиотека). Каждый элемент в PLT – это небольшой кусок выполняемого машинного кода. Вместо вызова функции напрямую вызывается кусок кода из PLT, который уже сам вызывает функцию. Такой подход часто называют «трамплином». Каждый элемент из PLT имеет собственный элемент в GOT, который содержит реальное смещение для функции. После того как загрузчик определит её, конечно.

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

Как я уже упоминал, PLT позволяет делать «ленивое» определение адресов функций. В тот момент, когда разделяемая библиотека впервые загружена, реальные адреса функций ещё не определены:

для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть фото для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть картинку для файлов типа разделяемая библиотека не установлено каких либо приложений. Картинка про для файлов типа разделяемая библиотека не установлено каких либо приложений. Фото для файлов типа разделяемая библиотека не установлено каких либо приложений

Что происходит после того, как func вызвана первый раз:

После первого раза диаграмма выглядит немного по-другому:

для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть фото для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть картинку для файлов типа разделяемая библиотека не установлено каких либо приложений. Картинка про для файлов типа разделяемая библиотека не установлено каких либо приложений. Фото для файлов типа разделяемая библиотека не установлено каких либо приложений

Заметьте, что GOT[n] теперь указывает на реальную func [7] вместо того чтобы указывать обратно в PLT. Так что когда функция вызывается повторно, происходит следующее:

Другими словами, func теперь попросту вызывается без использования метода «определения» и без лишнего прыжка. Этот механизм позволяет делать «ленивое» определение адресов функций и не делать никакого определения для тех функций, которые не вызываются.

Обратите внимание, библиотека при этом абсолютно не зависит от адреса, по которому она будет загружена, ведь единственное место, где используется абсолютный адрес, – это GOT, а она находится в секции data и будет релоцирована во время загрузки загрузчиком. Даже PLT не зависит от адреса загрузки, так что она может находиться в секции text, доступной только для чтения.

Я не углубляюсь в детали работы метода «определения», но это и не так важно. Метод – это просто кусок низкоуровневого кода в загрузчике, который делает своё дело. Аргументы, которые готовятся перед вызовом метода, дают ему знать, адрес какой функции необходимо определить и куда следует поместить результат.

PIC с вызовом функции через PLT и GOT (пример)

Ну, и для того чтобы подкрепить теорию практикой, рассмотрим пример, который демонстрирует вызов функции с помощью вышеописанного метода.

Вот код разделяемой библиотеки:

Этот код будет скомпилирован в libmlpic.so, и мы сфокусируемся на вызове ml_util_func из ml_func. Дизассемблируем ml_func:

Вспомните, что каждый элемент PLT состоит из трёх частей:

Метод «определения» (элемент 0 в PLT) находится по адресу 0x370, но он нас сейчас не интересует. Гораздо интересно посмотреть, что содержит GOT. Для этого нам снова понадобится калькулятор.

Трюк для получения текущего IP в ml_func был сделан по адресу 0x483, и к нему мы прибавили 0x1b71. Так что GOT находится по адресу 0x1ff4. Мы можем увидеть, что там, с помощью readelf [8]:

Запись в GOT для ml_util_func@plt, похоже, находится по смещению +0x14, или 0x2008. Судя по выводу выше, слово по этому адресу имеет значение 0x3a6, а это адрес push-инструкции в ml_util_func@plt.

Чтобы помочь загрузчику сделать своё дело, в GOT добавлена запись с адресом места в GOT, куда нужно записать адрес ml_util_func:

Последняя строчка означает, что загрузчику нужно положить адрес символа ml_util_func в 0x2008 (а это, в свою очередь, элемент GOT для данной функции).

Было бы классно увидеть, как происходит эта модификация в GOT. Для этого воспользуемся GDB ещё раз.

Мы сейчас находимся перед первым вызовом ml_util_func. Вспомните, что адрес GOT находится в ebx. Посмотрим, что там:

Смещение для нужного нам элемента находится по адресу [ebx+0x14]:

Да, заканчивается на 0x3a6. Выглядит правильно. Теперь давайте шагнём до вызова ml_util_func и посмотрим ещё раз:

Значение по адресу 0x133008 поменялось. Получается, что 0x0013146c – реальный адрес ml_util_func, который был положен туда загрузчиком:

Управляем определением адреса загрузчиком

Сейчас самое время упомянуть о том, что процесс «ленивого» определения адреса, который осуществляется загрузчиком, может быть настроен несколькими переменными окружения (а также соответствующими аргументами для линкера ld). Иногда эти настройки могут быть полезны для дебаггинга или каких-то специальных требований к производительности.

Переменная LD_BIND_NOW, когда она определена, говорит загрузчику определять все адреса при старте, а не «лениво». Её работу можно проверить, посмотрев вывод gdb для примера выше в том случае, когда она задана. Мы увидим, что элемент из GOT для ml_util_func содержит реальный адрес функции ещё до первого вызова функции.

Напротив, LD_BIND_NOT говорит загрузчику не обновлять GOT никогда. То есть каждый вызов функции в этом случае будет идти через метод «определения».

Загрузчик настраивается и некоторыми другими флагами. Я рекомендую изучить man ld.so. Там много интересной информации.

Стоимость PIC

Мы начали разговор с проблемы релокации во время работы и решения этой проблемы PIC. Но сам PIC, увы, тоже не без проблем. Одна из них – стоимость лишней косвенной адресации. Это лишнее обращение к памяти при каждом обращении к глобальной переменной или функции. «Масштаб бедствия» зависит от компилятора, процессорной архитектуры и собственно приложения.

Другая, менее очевидная, проблема – использование дополнительных регистров для реализации PIC. Чтобы не определять адрес GOT слишком часто, компилятору имеет смысл сгенерировать код, который будет хранить адрес в регистре (например, ebx). Но это значит, что целый регистр уходит только на GOT. Для RISC-архитектур, у которых обычно много регистров общего пользования, это не такая уж большая проблема, чего не скажешь об архитектурах типа x86, у которых мало доступных регистров. Использование PIC означает на один регистр меньше, а значит, нужно будет делать больше обращений к памяти.

Заключение

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

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

Однако, несмотря на недостатки, PIC становится всё более популярным подходом. Некоторые неIntel-архитектуры, такие как SPARC64, требуют обязательного использования PIC для разделяемых библиотек, а многие другие (например, ARM) – имеют IP-зависимую адресацию, чтобы сделать PIC более эффективным. И то, и другое верно для наследницы x86 – x64.

Мы не фокусировались на проблемах производительности и архитектурах процессора. Моя задача была в том, чтобы рассказать, как работает PIC. Если объяснение было недостаточно «прозрачным», дайте мне знать в комментариях – и я постараюсь дать больше информации.

Источник

Перенаправление функций в разделяемых ELF-библиотеках

для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть фото для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть картинку для файлов типа разделяемая библиотека не установлено каких либо приложений. Картинка про для файлов типа разделяемая библиотека не установлено каких либо приложений. Фото для файлов типа разделяемая библиотека не установлено каких либо приложенийВсе мы пользуемся динамически-компонуемыми билиотеками. Их возможности поистине великолепны. Во-первых, такая библиотека загружается в физическое адресное пространство только один раз для всех процессов. Во-вторых, можно расширять функционал своей программы, подгружая дополнительную библиотеку, которая и будет этот функционал обеспечивать. И все это без перезапуска самой программы. А еще решается проблема обновлений. Для динамически компонуемой библиотеки можно определить стандартный интерфейс и влиять на функционал и качество своей основной программы, просто меняя версию библиотеки. Такие методы повторного использования кода даже получили название «архитектура plug-in’ов». Но топик не об этом.

Кстати, нетерпеливые могут все скачать и попробовать прямо сейчас.

Конечно, редко какая динамически компонуемая библиотека в своей реализации опирается исключительно на себя, то есть вычислительные возможности процессора и память. Библиотеки используют библиотеки. Или, хотя бы, стандартные библиотеки. Как, например, программы на С\С++ используют стандартные библиотеки С\С++. Последнии, кстати, для удобства тоже организуются в динамически компонуемом виде (libc.so и libstdc++.so). Сами они хранятся в файлах особого формата. Мое исследование проводилось для ОС Linux, в которой основным форматом динамически компонуемых библиотек является ELF (Executable and Linkable Format).
Некоторое время назад я столкнулся с необходимостью перехватывать вызовы функций из одной библиотеки в другую. Просто для того, чтобы обрабатывать их особым образом. Это называется перенаправлением вызова.

Поподробней о перенаправлении

для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть фото для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть картинку для файлов типа разделяемая библиотека не установлено каких либо приложений. Картинка про для файлов типа разделяемая библиотека не установлено каких либо приложений. Фото для файлов типа разделяемая библиотека не установлено каких либо приложений

для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть фото для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть картинку для файлов типа разделяемая библиотека не установлено каких либо приложений. Картинка про для файлов типа разделяемая библиотека не установлено каких либо приложений. Фото для файлов типа разделяемая библиотека не установлено каких либо приложений

для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть фото для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть картинку для файлов типа разделяемая библиотека не установлено каких либо приложений. Картинка про для файлов типа разделяемая библиотека не установлено каких либо приложений. Фото для файлов типа разделяемая библиотека не установлено каких либо приложений

При этом менять код или перекомпилировать сами библиотеки не разрешается, только главную программу.

Зачем это нужно?

Кратко об ELF

Лучший способ понять ELF – это набраться терпения и пару раз внимательно прочитать его спецификацию, затем написать простую программу, откомпилировать ее и детально исследовать с помощью шестнадцатеричного редактора, сравнивая увиденное со спецификацией. Такой метод исследования сразу натолкнет на мысль написать какой-нибудь простой парсер для ELF, так как появится много рутинной работы. Но, не стоит спешить. Таких утилит создано уже несколько. Для исследования возьмем файлы из предыдущего раздела:

File test.c
File libtest1.c
File libtest2.c

Из каких частей состоит ELF?

Любой ELF файл начинается со специального заголовка. Его структуру, как и описание многих других элементов ELF, можно найти в файле /usr/include/linux/elf.h. У заголовка есть специальное поле, в котором записано смещение от начала файла таблицы заголовков секций. Каждый элемент этой таблицы описывает некоторую секцию в ELF. Секция – это наименьший неделимый структурный элемент в ELF файле. При загрузке в память, секции объединяются в сегменты. Сегменты – это наименьшие неделимые части ELF файла, которые могут быть отображены в память загрузчиком (ld-linux.so.2). Сегменты описывает таблица сегментов, смещение которой так же есть в заголовке ELF файла.

для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть фото для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть картинку для файлов типа разделяемая библиотека не установлено каких либо приложений. Картинка про для файлов типа разделяемая библиотека не установлено каких либо приложений. Фото для файлов типа разделяемая библиотека не установлено каких либо приложений

Первая команда создает динамически компонуемую библиотеку libtest1.so. Вторая – libtest2.so. Обратите внимание на ключ –fPIC. Он заставляет компилятор генерировать так называемый Position Independent Code. Подробности в следующем разделе. Третья команда создает исполняемый модуль c именем test путем компиляции файла test.c и компоновки его с уже созданными библиотеками libtest1.so и libtest2.so. Последние находятся в текущем каталоге, что отражено использованием ключа –L$PWD. Компоновка с libdl.so необходима для использования функицй dlopen() и dlclose().

Для запуска программы необходимо выполнить следующие команды:

То есть, добавить динамическому компоновщику\загрузчику путь к текущему каталогу как путь для поиска библиотек. Вывод программы получим такой:

libtest1: 1st call to the original puts()
libtest1: 2nd call to the original puts()
libtest2: 1st call to the original puts()
libtest2: 2nd call to the original puts()
——————————

Теперь посмотрим на секции модуля test. Для этого запустим readelf с ключом –a. Ниже преведены наиболее интересные из них:

Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x08048034 0x08048034 0x00100 0x00100 R E 0x4
INTERP 0x000134 0x08048134 0x08048134 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x08048000 0x08048000 0x007f8 0x007f8 R E 0x1000
LOAD 0x000ef4 0x08049ef4 0x08049ef4 0x00134 0x00140 RW 0x1000
DYNAMIC 0x000f08 0x08049f08 0x08049f08 0x000e8 0x000e8 RW 0x4
NOTE 0x000148 0x08048148 0x08048148 0x00020 0x00020 R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
GNU_RELRO 0x000ef4 0x08049ef4 0x08049ef4 0x0010c 0x0010c R 0x1

Это список сегментов – своеобразных контейнеров для секций в памяти. Также указан путь к специальному модулю – динамическому компоновщику\загрузчику. Именно ему предстоит расположить содержимое этого ELF файла в памяти.

Symbol table ‘.dynsym’ contains 17 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000 0 FUNC GLOBAL DEFAULT UND libtest2
2: 00000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
3: 00000000 0 NOTYPE WEAK DEFAULT UND _Jv_RegisterClasses
4: 00000000 0 FUNC GLOBAL DEFAULT UND dlclose@GLIBC_2.0 (2)
5: 00000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.0 (3)
6: 00000000 0 FUNC GLOBAL DEFAULT UND libtest1
7: 00000000 0 FUNC GLOBAL DEFAULT UND dlopen@GLIBC_2.1 (4)
8: 00000000 0 FUNC GLOBAL DEFAULT UND fprintf@GLIBC_2.0 (3)
9: 00000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.0 (3)
10: 0804a034 0 NOTYPE GLOBAL DEFAULT ABS _end
11: 0804a028 0 NOTYPE GLOBAL DEFAULT ABS _edata
12: 0804879c 4 OBJECT GLOBAL DEFAULT 15 _IO_stdin_used
13: 0804a028 4 OBJECT GLOBAL DEFAULT 24 stderr@GLIBC_2.0 (3)
14: 0804a028 0 NOTYPE GLOBAL DEFAULT ABS __bss_start
15: 080484b4 0 FUNC GLOBAL DEFAULT 11 _init
16: 0804877c 0 FUNC GLOBAL DEFAULT 14 _fini

Кроме прочих функций, необходимых для правильной загрузки\выгрузки программы, можно отыскать знакомые имена: libtest1, libtest2, dlopen, fprintf, puts, dlclose. Для всех них значится тип FUNC и тот факт, что они в этом модуле не определены – индекс секции помечен как UND.

Секции “.rel.dyn” и “.rel.plt” являются таблицами переразмещений для тех символов из “.dynsym”, для которых вообще необходимо переразмещение при компоновке.

Relocation section ‘.rel.dyn’ at offset 0x464 contains 2 entries:
Offset Info Type Sym.Value Sym. Name
08049ff0 00000206 R_386_GLOB_DAT 00000000 __gmon_start__
0804a028 00000d05 R_386_COPY 0804a028 stderr

Relocation section ‘.rel.plt’ at offset 0x474 contains 8 entries:
Offset Info Type Sym.Value Sym. Name
0804a000 00000107 R_386_JUMP_SLOT 00000000 libtest2
0804a004 00000207 R_386_JUMP_SLOT 00000000 __gmon_start__
0804a008 00000407 R_386_JUMP_SLOT 00000000 dlclose
0804a00c 00000507 R_386_JUMP_SLOT 00000000 __libc_start_main
0804a010 00000607 R_386_JUMP_SLOT 00000000 libtest1
0804a014 00000707 R_386_JUMP_SLOT 00000000 dlopen
0804a018 00000807 R_386_JUMP_SLOT 00000000 fprintf
0804a01c 00000907 R_386_JUMP_SLOT 00000000 puts

В чем разница между этими таблицами с точки зрения динамической компоновки функций? Это тема следующего раздела.

Как компонуются разделяемые ELF-библиотеки?

Компиляция библиотек libtest1.so и libtest2.so несколько отличалась. libtest2.so компилировалась с ключом –fPIC (генерировать Position Independent Code). Посмотрим, как это отразилось на таблицах динамических символов для этих двух модулей (используем readelf):

Symbol table ‘.dynsym’ contains 11 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
2: 00000000 0 NOTYPE WEAK DEFAULT UND _Jv_RegisterClasses
3: 00000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.0 (2)
4: 00000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@GLIBC_2.1.3 (3)
5: 00002014 0 NOTYPE GLOBAL DEFAULT ABS _end
6: 0000200c 0 NOTYPE GLOBAL DEFAULT ABS _edata
7: 0000043c 32 FUNC GLOBAL DEFAULT 11 libtest1
8: 0000200c 0 NOTYPE GLOBAL DEFAULT ABS __bss_start
9: 0000031c 0 FUNC GLOBAL DEFAULT 9 _init
10: 00000498 0 FUNC GLOBAL DEFAULT 12 _fini

Symbol table ‘.dynsym’ contains 11 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
2: 00000000 0 NOTYPE WEAK DEFAULT UND _Jv_RegisterClasses
3: 00000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.0 (2)
4: 00000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@GLIBC_2.1.3 (3)
5: 00002018 0 NOTYPE GLOBAL DEFAULT ABS _end
6: 00002010 0 NOTYPE GLOBAL DEFAULT ABS _edata
7: 00002010 0 NOTYPE GLOBAL DEFAULT ABS __bss_start
8: 00000304 0 FUNC GLOBAL DEFAULT 9 _init
9: 0000043c 52 FUNC GLOBAL DEFAULT 11 libtest2
10: 000004a8 0 FUNC GLOBAL DEFAULT 12 _fini

Итак, таблицы динамических символов для обеих библиотек отличаются только порядком следования самих символов. Видно, что обе они используют неопределенную функцию puts(), а предоставляют libtest1() либо libtest2(). Как изменились таблицы переразмещений?

Relocation section ‘.rel.dyn’ at offset 0x2cc contains 8 entries:
Offset Info Type Sym.Value Sym. Name
00000445 00000008 R_386_RELATIVE
00000451 00000008 R_386_RELATIVE
00002008 00000008 R_386_RELATIVE
0000044a 00000302 R_386_PC32 00000000 puts
00000456 00000302 R_386_PC32 00000000 puts
00001fe8 00000106 R_386_GLOB_DAT 00000000 __gmon_start__
00001fec 00000206 R_386_GLOB_DAT 00000000 _Jv_RegisterClasses
00001ff0 00000406 R_386_GLOB_DAT 00000000 __cxa_finalize

Relocation section ‘.rel.plt’ at offset 0x30c contains 2 entries:
Offset Info Type Sym.Value Sym. Name
00002000 00000107 R_386_JUMP_SLOT 00000000 __gmon_start__
00002004 00000407 R_386_JUMP_SLOT 00000000 __cxa_finalize

Для libtest1.so переразмещение для функции puts() встречается два раза в секции “.rel.dyn”. Посмотрим на эти места непосредственно в модуле при помощи дизассемблера. Необходимо отыскать функцию libtest1() в которой и происходит двойной вызов puts(). Используем objdump –D:

Имеем две относительные инструкции CALL (код E8) с операндами 0xFFFFFFFC. Относительный CALL c таким операндом лишен смысла, так как, по сути, передает управление на один байт вперед относительно адреса инструкции CALL. Если посмотреть на смещение переразмещений для puts() в секции “.rel.dyn”, то можно обнаружить, что они применяются как раз к операнду инструкции CALL. Таким образом, в обоих случаях обращения к puts(), загрузчик просто перезапишет 0xFFFFFFFC так, что CALL будет переходить на корректный адрес функции puts().
Так работает переразмещение типа R_386_PC32.

Теперь обратим внимание на libtest2.so:

Relocation section ‘.rel.dyn’ at offset 0x2cc contains 4 entries:
Offset Info Type Sym.Value Sym. Name
0000200c 00000008 R_386_RELATIVE
00001fe8 00000106 R_386_GLOB_DAT 00000000 __gmon_start__
00001fec 00000206 R_386_GLOB_DAT 00000000 _Jv_RegisterClasses
00001ff0 00000406 R_386_GLOB_DAT 00000000 __cxa_finalize

Relocation section ‘.rel.plt’ at offset 0x2ec contains 3 entries:
Offset Info Type Sym.Value Sym. Name
00002000 00000107 R_386_JUMP_SLOT 00000000 __gmon_start__
00002004 00000307 R_386_JUMP_SLOT 00000000 puts
00002008 00000407 R_386_JUMP_SLOT 00000000 __cxa_finalize

Обращение к puts() упоминается только единожды, и, притом, в секции “.rel.plt”. Посмотрим на ассемблер и займемся отладкой:

Операнды инструкций CALL уже разные и осмысленные, а значит, что они на что-то указывают. Это уже не просто набивка (padding). Также полезно отметить, что перед вызовом самой puts() происходят запись 0x1FF4 (0x1BAC + 0x448) в регистр EBX. Отладчик помогает узнавать изначальное значение EBX, равное 0x448. Значит, это где-то дальше пригодится. Адрес 0x354 ведет нас к очень интересной секции “.plt”, которая, как и “.text”, помечена как исполняемая. Вот она:

По интересующему нас адресу 0x354 обнаруживаем три инструкции. В первой из них происходит безусловный переход по адресу, на который указывает EBX (0x1FF4) плюс 0x10. Произведя простые вычисления, получим значение указателя 0x2004. Эти адреса попадают в секцию “.got.plt”.

Самое интересное обнаруживается тогда, когда мы этот указатель разыменовываем и, наконец-то, получаем адрес безусловного перехода, равный 0x35A. Но, это же, по сути, следующая инструкция! Зачем было производить такие сложные манипуляции и ссылаться на секцию “.got.plt”, чтобы просто перейти на следующую инструкцию? Что вообще такое PLT и GOT?

PLT (Procedure Linkage Table) — это таблица компоновки процедур. Она присутствует в исполняемых и разделяемых модулях. Это массив заглушек, по одной на каждую импортируемую функцию.

Вызов функции по адресу PLT[n+1] приведет к косвенному переходу управления по адресу GOT[n+3]. При первом вызове GOT[n+3] указывает назад, на PLT[n+1] + 6, что представляет собой последовательность PUSH\JMP на PLT[0]. Проходя через PLT[0], компоновщик использует сохраненный стековый аргумент, чтобы определить ‘n’ и затем разрешает символ ‘n’. Потом компоновщик исправляет значение GOT[n+3] так, чтобы оно указывало прямо на целевую подпрограмму, и, в конце концов, вызывает ее. Каждый следующий вызов PLT[n+1] будет направлен на целевую подпрограмму без подобного разрешения ее адреса через инструкцию JMP.

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

Управление передается на код компоновщика. ‘n’ уже в стеке и туда же добавляется адрес GOT[1]. Таким образом компоновщик (находится в /lib/ld-linux.so.2) может определить, какая библиотека требует его услуг.

GOT (Global Offset Table) — это глобальная таблица смещений. Ее первые три элемента зарезервированы. При первой инициализации GOT все ее элементы, которые относятся к разрешению адресов в PLT, указывают обратно на PLT[0].

для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть фото для файлов типа разделяемая библиотека не установлено каких либо приложений. Смотреть картинку для файлов типа разделяемая библиотека не установлено каких либо приложений. Картинка про для файлов типа разделяемая библиотека не установлено каких либо приложений. Фото для файлов типа разделяемая библиотека не установлено каких либо приложений

Так работает переразмещение типа R_386_JUMP_SLOT, которое использовалось в библиотеке libtest2.so. Остальные типы переразмещений относятся к статической компоновке, поэтому нам не пригодятся.

В методах разрешения вызова импортируемых функций и заключается разница между кодом, зависящим от позиции загрузки в память и не зависящим от нее (PIC).

Важные выводы

Долгожданное решение

Прототип функции для перенаправления на языке С получается следующий:

Алгоритм перенаправления

Полная реализация этой функции с тестовыми примерами доступна для скачивания.

Перепишем нашу тестовую программу:

Вывод получим следующий:

libtest1: 1st call to the original puts()
libtest1: 2nd call to the original puts()
libtest2: 1st call to the original puts()
libtest2: 2nd call to the original puts()
——————————
libtest1: 1st call to the original puts()
is HOOKED!
libtest1: 2nd call to the original puts()
is HOOKED!
libtest2: 1st call to the original puts()
is HOOKED!
libtest2: 2nd call to the original puts()
is HOOKED!
——————————
libtest1: 1st call to the original puts()
libtest1: 2nd call to the original puts()
libtest2: 1st call to the original puts()
libtest2: 2nd call to the original puts()

Что свидетельствует о полном выполнении задачи, поставленной в самом начале. Ура!

Как узнать адрес по которому загрузилась разделяемая библиотека?

Этот очень интересный вопрос возникает при внимательном рассмотрении прототипа функции для перенаправления. После некоторого исследования мне удалось обнаружить способ определения адреса загрузки библиотеки по ее описателю, который возвращает функция dlopen(). Делается это таким макросом:

Как записать и восстановить адрес новой функции?

С переписыванием адресов, на которые указывают переразмещения из секции “.rel.plt” проблем не возникает. По сути, переписывается операнд инструкции JMP соответствующего элемента из секции “.plt”. А операнды такой инструкции – это просто адреса.

Интересней дела обстоят с применением переразмещений к операндам относительных инструкций CALL (код E8). Адреса перехода в них вычисляются по формуле:

address_of_a_function = CALL_argument + address_of_the_next_instruction

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

page_address = (size_t)relocation_address & (0xFFFFFFFF ^ 0xFFF) = (size_t)relocation_address & 0xFFFFF000;

Размер одной страницы, можно узнать, вызвав sysconf(_SC_PAGESIZE).

Источник

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *