Цель следующего потока символов ASCII - пролить свет на область, которая, вероятно, не является общеизвестной даже для опытных разработчиков системы: загрузчики, библиотеки и исполняемые файлы в экосистеме ELF Linux.

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

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

Вот предметы, которые мы попытаемся рассказать:

  • За кадром «Hello World»
  • Исполняемый и связываемый формат (ELF)
  • Кому, черт возьми, нужны библиотеки ?!
  • Библиотеки, необходимые для исполняемого файла
  • Статическое перечисление необходимых библиотек
  • Динамическое перечисление необходимых библиотек
  • Загрузчик кто?
  • Используйте свой собственный загрузчик
  • Что загружает загрузчик?
  • R [UN] ПУТЬ
  • NODEFLIB

За кадром «Hello World»

Хочется верить, что каждый хотя бы раз в жизни видел следующий фрагмент кода C:

#include <stdio.h>
void main() {
  printf("Hello World!\n");
}

А потом, наверное, мы его скомпилировали и выполнили:

$ gcc main.c -o myapp         # <--- Compile
$ ./myapp                     # <--- Execute
Hello World!

Но действительно ли мы понимаем, что там произошло во время этих двух тривиальных шагов, которые мы прошли? Что это за myapp файл, который был создан? Как наша программа могла использовать метод printf без его реализации? А что случилось, когда мы его выполнили?

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

Исполняемый и связываемый формат (ELF)

Люди на самом деле этого не осознают, но формат файла ELF является краеугольным камнем для всех их магических приложений! ELF - это стандартный формат файла для исполняемых файлов, библиотек и многого другого.

Прямо как в нашем приложении «Hello World»; myapp - это файл ELF (даже не подозревая об этом), для его выполнения использовались другие модули, такие как динамический загрузчик (о котором мы поговорим позже).

По своей конструкции формат ELF является гибким, расширяемым и кроссплатформенным. Он поддерживает различный порядок байтов и размеры адресов, поэтому его можно использовать на любом ЦП или архитектуре набора команд. Это способствовало его внедрению во многих различных операционных системах и сделало его стандартом де-факто для Linux и других Unix-подобных систем.

Поскольку формат ELF - это отдельная тема, мы не будем останавливаться на этом, но если вам нужна дополнительная информация, взгляните на этот всеобъемлющий стандарт ELF.

Кому, черт возьми, нужны библиотеки?

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

Это похоже на наше приложение «Hello World», где мы использовали метод printf, но не реализовали его. Поскольку печать на экран требуется для очень многих приложений, она была реализована однажды и теперь доступна для всех, кто пишет приложения на C или C ++.

Для этого были изобретены библиотеки. Библиотеки позволяют программистам упаковывать код в модули многократного использования. Затем каждая программа, требующая такой же функциональности, может просто использовать уже существующую библиотеку. На самом деле существует два типа библиотек:

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

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

  1. Исполняемый файл связывается с библиотекой во время компиляции. Исполняемый файл может вызывать методы, определенные в библиотеке, как если бы оба использовали общий код. Нет необходимости явно загружать библиотеку, и загрузчик обрабатывает ее автоматически во время выполнения. Классическим примером является любая программа на C, которая связывает и использует glibc.
  2. Исполняемый файл НЕ связывается с библиотекой во время компиляции. Вместо этого он загружает и (возможно) выгружает библиотеку только во время выполнения. Исполняемый файл распутывает и использует API библиотеки во время выполнения, используя функции, предоставляемые динамическим загрузчиком. Для этого используется dlopen (дополнительную информацию см. На странице man (3)). Классический пример - приложение, которое загружает плагины во время выполнения.

Библиотеки, необходимые для исполняемого файла

Теперь, когда мы знаем, что такое библиотеки, мы определим два новых термина:

  • Статический исполняемый файл: исполняемый файл, не зависящий от каких-либо библиотек.
  • Динамический исполняемый файл: исполняемый файл, который зависит от других библиотек.

Итак, учитывая исполняемый файл, как мы можем определить, динамический он или статический? Требуются дополнительные библиотеки или нет? Мы можем использовать простую команду под названием file.

Например, когда мы используем его в нашем приложении «Hello World», мы видим, что он динамически связан (выделение добавлено), поэтому требуются динамические библиотеки:

$ file myapp
myapp: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=f385b7c6c03b6b5c8c416e3c4a2267030ca095aa, not stripped

›Примечание. Статические исполняемые файлы« статически связаны »

Но как это возможно? Мы никому ничего не рассказывали о дополнительных библиотеках! Почему он динамически связан?

Что ж, оказывается, что по умолчанию, когда мы компилируем приложение C / C ++, компилятор запрограммирован на автоматическое использование стандартных библиотек: glibc.so для C и дополнительно libstdc++.so для C ++.

Кроме того, в реальной жизни, когда мы компилируем приложение, мы обычно говорим компоновщику добавить дополнительные библиотеки, используя флаг -l. Например, если мы используем общедоступную библиотеку, такую ​​как boost, мы можем указать компилятору выполнить компоновку с этой библиотекой (т.е. потребовать ее во время выполнения), передав параметр -lboost во время компиляции / компоновки.

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

Теперь, когда мы знаем, что наше приложение является динамическим исполняемым файлом, как мы можем определить, какие библиотеки ему требуются?

Статическое перечисление необходимых библиотек

Есть несколько способов проверить библиотеки, необходимые для конкретного исполняемого файла. Мы будем использовать замечательный инструмент под названием patchelf. Хотя он в основном предназначен для выполнения того, что предлагает его название, исправления ELF, у него есть полезная опция под названием --print-needed, которая печатает список библиотек (базовых имен), необходимых для этого исполняемого файла?

Например, если мы используем его в нашем приложении «Hello World», мы получим следующий результат:

$ patchelf --print-needed myapp
libc.so.6

Мы можем выполнить ту же задачу, непосредственно исследуя динамические разделы ELF. Каждая необходимая библиотека представлена ​​записью в разделе .dynamic, поэтому мы можем просто распечатать весь раздел .dynamic с помощью команды readelf -d <path-to-elf> и найти ключевое слово NEEDED:

$ readelf -d myapp | grep NEEDED
0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]

Динамическое перечисление необходимых библиотек

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

Для сбора дополнительной информации мы можем использовать отличный скрипт под названием ldd (List Dynamic Dependencies). Этот сценарий перечисляет все динамические библиотеки, необходимые для конкретного исполняемого файла. И, в отличие от методов статического перечисления, этот инструмент описывает полный путь (!) Библиотеки, которую мы собираемся использовать. Например, вывод для нашего приложения «Hello World»:

$ ldd myapp
linux-vdso.so.1 (0x00007fffed5b3000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fa412e60000)
/lib64/ld-linux-x86-64.so.2 (0x00007fa413600000)

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

<lib-basename> => <lib-full-path> (lib-load-addr)

На этот раз для каждой из необходимых библиотек мы можем увидеть фактический загружаемый экземпляр и адрес загрузки. Например, libc.so.6 будет загружен из /lib/x86_64-linux-gnu/libc.so.6.

Как ldd собирает эту информацию? Ну, он в основном загружает приложение (не выполняя его) и отслеживает действия загрузчика. Если мы запустим его с включенным режимом трассировки Bash (bash -x $(which ldd) myapp), мы увидим, что весь сценарий оценивается как одна строка (я удалил все пустые операторы экспорта вокруг него, чтобы улучшить читаемость):

LD_TRACE_LOADED_OBJECTS=1 /lib64/ld-linux-x86-64.so.2 myapp

Здесь можно спросить себя: «Черт возьми ?! Ну да, это определенно странно! Что мы здесь пытаемся сделать? Что это за странный ld-linux-x86-64.so.2 файл? Разве это не библиотека (согласно ее расширению .so.2)?

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

Загрузчик кто?

Как вы уже могли догадаться, динамический загрузчик - это единственный статический исполняемый файл ELF, имя которого обычно зарезервировано библиотеками.

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

›Примечание. Загрузчик по умолчанию (на 64-разрядных машинах) можно найти по адресу /lib64/ld-linux.so.2 и фактически является символической ссылкой на настоящий файл загрузчика /lib/x86_64-linux-gnu/ld-<version>.so.

Используйте свой собственный загрузчик

Ну, а загрузчик системы использовать? Вы можете BYOL? С одной стороны, этого хочет избежать каждый системный программист. Различные менеджеры пакетов заботятся о многих проблемах совместимости, и как только вы сойдете с этого поезда и выйдете на свободу, многие вещи могут пойти не так на каждом шагу.

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

Чтобы узнать, какой загрузчик «ожидает» ELF, мы можем использовать приложение file (выделено мной):

$ file myapp
myapp: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=f385b7c6c03b6b5c8c416e3c4a2267030ca095aa, not stripped

Принимая во внимание, что для изменения загрузчика для определенного ELF мы можем использовать patchelf и указать интерпретатор и эльфа для исправления:

$ patchelf --set-interpreter <path-to-interpreter> <path-to-elf>
# For example:
$ patchelf --set-interpreter "/opt/pkg/lib/ld.so.2" /opt/pkg/app

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

Что загружает загрузчик?

Когда мы загружаем статический исполняемый файл, нет необходимости в динамическом загрузчике, потому что все необходимые библиотеки были интегрированы в исполняемый файл во время времени компоновки.

Однако, когда мы загружаем динамический исполняемый файл, система зависит от загрузчика, который делает тяжелую работу.

Загрузчик всегда запускается с загрузки:

  • Все библиотеки, указанные в переменной среды LD_PRELOAD
  • Все библиотеки, перечисленные в /etc/ld.so.preload

Затем он пытается удовлетворить каждую строку прямой зависимости (библиотеки), указанную как NEEDED в разделе .dynamic ELF.

Если для одной из этих библиотек требуется дополнительная библиотека, не требуемая исполняемым файлом, эта библиотека называется «косвенной зависимостью». И как только мы закончим загрузку всех прямых зависимостей, загрузчик загрузит косвенные.

Для каждой (прямой / косвенной) зависимости загрузчик следует этому процессу принятия решений:

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

В противном случае загрузчик выполняет следующие шаги (каждый шаг содержит условие. Если это условие не выполняется, загрузчик пропускает его):

Шаг 1. Использование каталогов в DT_RPATH

  • Условие: DT_RUNPATH атрибут не существует
  • Примечание. DT_RPATH не рекомендуется

Шаг 2: Использование переменной среды LD_LIBRARY_PATH

  • Условие: исполняемый файл не запускается в режиме безопасного выполнения (формально, когда AT_SECURE имеет ненулевое значение. Неформально это происходит, например, когда мы устанавливаем бит SUID файла)
  • Пример: если установлен бит SETUID, а реальный и эффективный UID процесса различаются, загрузчик его проигнорирует.
  • Примечание. LD_LIBRARY_PATH можно переопределить, запустив динамический компоновщик напрямую с параметром --library-path, например: /lib64/ld-linux-x86–64.so.2 --library-path "/my/libs:/my/other/libs" myapp

Шаг 3. Использование каталогов в DT_RUNPATH

  • Условие: загрузчик загружает прямую зависимость.
  • Пример: если нашему двоичному файлу myapp нужна одна библиотека a.so, а a.so требуется b.so, когда загрузчик будет искать b.so (который не является прямой зависимостью от myapp), он пропустит этот шаг!
  • Примечание. Он отличается от DT_RPATH, который применяется всегда.

Шаг 4: из файла кеша /etc/ld.so.cache

  • Условие: ELF не содержит флаг NODEFLIB.

Шаг 5: путь по умолчанию /lib[64], а затем /usr/lib[64]

  • Условие: ELF не содержит флага NODEFLIB.

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

/bin/myapp: error while loading shared libraries: libncursesw.so.5: cannot open shared object file

Теперь, когда мы знаем, что направляет загрузчик при выполнении своей работы, давайте перейдем к следующему шагу и посмотрим, что мы можем посоветовать. Для этого мы кратко рассмотрим некоторые из UPPERCASE ключевых слов, которые мы только что видели.

R [UN] ПУТЬ

И RPATH, и RUNPATH являются необязательными записями в разделе .dynamic исполняемых файлов ELF или разделяемых библиотек.

Как мы уже понимаем, их цель - расширить возможности разработчика и позволить ему изменять поведение динамического загрузчика.

Если вы не читали последнюю главу или просто чтобы лучше понять эти ценности, давайте подведем итоги:

  • Если RUNPATH не задан, загрузчик ищет каждую библиотеку в каталогах, указанных RPATH.
  • Если установлено RUNPATH, загрузчик ищет библиотеки прямой зависимости в каталогах, указанных RUNPATH.
  • RPATH не рекомендуется, вместо этого рекомендуется использовать RUNPATH

Имея хорошее представление о последствиях, мы можем теперь начать говорить о следующей фазе, как мы можем ею управлять?

Во-первых, прежде чем мы начнем что-то ломать, мы должны знать, с чем имеем дело. Имея файл ELF, мы можем проверить R[UN]PATH значений, используя:

readelf -d <path-to-elf> | egrep "RPATH|RUNPATH"

Если RPATH и RUNPATH не заданы, вывода не будет, в противном случае мы увидим запись динамического раздела:

$ readelf -d ./example | egrep "RPATH|RUNPATH"
0x000000000000001d (RUNPATH) Library runpath: [/my/patched/libs]

А теперь самое интересное. Мы можем управлять ELF двумя разными способами:

  1. Устанавливается во время компиляции. Компоновщик GNU ld поддерживает параметр -rpath. Все параметры -rpath объединяются и добавляются в окончательный исполняемый файл (см. Ld (1) для получения дополнительной информации).
  2. Управляйте существующим файлом ELF с помощью любимого patchelf:
# Clearing RPATH & RUNPATH
patchelf --remove-rpath <path-to-elf>
# Setting RPATH
patchelf --force-rpath --set-rpath <desired-rpath> <path-to-elf>
# Setting RUNPATH
patchelf --set-rpath <desired-rpath> <path-to-elf>

NODEFLIB

Следующий флаг - это флаг, который мы можем найти в необязательном разделе .dynamic, который называется FLAGS_1. Вкратце, он говорит загрузчику избегать загрузки библиотек из:

  • Кеш-файл загрузчика
  • Расположение системы по умолчанию

›Примечание. Этот запрос остается в силе, даже если это означает, что операция загрузки завершится неудачно.

Как узнать, установлен ли этот флаг? Итак, еще раз распечатываем динамические разделы ELF и ищем выражение NODEFLIB:

readelf -d <path-to-elf> | grep NODEFLIB

И как мы можем этим манипулировать? Аналогично R[UN]PATH:

  • Вариант 1: во время компиляции путем передачи флага -z nodefaultlib компоновщику GNU.
  • Вариант 2: манипулируйте существующим ELF, используя, конечно же, наш уважаемый инструмент: patchelf --no-default-lib <path-to-elf>

Это конец, и я надеюсь, вам понравилось прокручивать статью до конца :)

А теперь серьезно, я надеюсь, что, прочитав это, вы почувствуете себя лучше подготовленным для решения подобных проблем в вашей системе. Если вы считаете, что в этой статье можно было бы использовать дополнительную главу, дайте мне знать!

Если вы чувствуете, что узнали что-то новое, прочитав это, я буду очень признателен, если вы хлопнете в ладоши!

Ресурсы: