Загрузка кода в чужой процесс на Linux

Оказывается грузить свой код в чужой процесс на Linux ничуть не менее увлекательно, чем на Windows. Я раньше не наведывался в этой темный угол, а вот на днях заглянул. Содержимое угла привычно обнадежило: залатанный древний загрузчик, дохлые мухи, пара растяжек, и грабли кругом. Все как мы любим. Задача была такая. Есть несколько загружаемых с помощью LD_PRELOAD модулей. Модули переопределяют поведение небольшого числа POSIX функций. Требовалось перенести сборку модулей на более свежую версию GCC. Казалось что может быть проще?.. Да, давно уже не ощущал себя насколько наивным, аж ностальгия пробивает. Ниже следует список обнаруженных граблей.

Переменная окружения LD_PRELOAD указывает загрузчику список модулей, которые дополнительно загружаются в процесс и используются для разрешения символов в первую очередь. Например, если определить в foobar.so функцию read() и запустить LD_PRELOAD=foobar.so /bin/sh, то вместо системной функции read(), /bin/sh будет звать read() из foobar.so. Дополнительно, read() из foobar.so может позвать dlsym(RTLD_NEXT, “read”), чтобы найти адрес “настоящей” read(). Очень часто этот механизм используется, чтобы изменить поведение функции в некоторых только в определенных случаях.

Грабли №1. Не получается смешивать в одном процессе модули собранные для разных реализаций libc, например не получается загрузить foobar.so собранный под MUSL в процесс /bin/sh, собранный под glibc. Разные версии glibc работают, но только потому, что glibc серьезно относится к обратной совместимости. Я не разбираюсь в вопросе в достаточной степени, чтобы судить о причинах такого ограничения. Возможно даже, что есть способ создать загружаемый модуль, который зависит только от системных вызовов ядра. У меня пока не получилось.

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

Модули в формате ELF позволяют определять конструкторы и деструкторы - функции которые будут вызваны при загрузке и выгрузке модуля. Через них, например, реализуется вызов конструкторов и деструкторов глобальных объектов в С++. Очень удобный и расширяемый механизм.

Грабли №3. LD_PRELOAD разрешает указывать несколько модулей, но порядок вызова их конструкторов и деструкторов зависит от реализации libc. glibc вызывает их в обратном порядке. MUSL, начиная с некоторой версии, - в прямом.

Грабли №4. Функции, переопределенные в загружаемом модуле могут быть вызваны до того как будут вызваны конструкторы из этого модуля. Это может произойти, если конструкторы других модулей, загруженных в процесс, вызывают одну из переопределенных функций. Как результат следует избегать глобальных объектов, требующих вызова конструктора для инициализации.

Так как загружаемый модуль должен уметь жить в адресном пространстве произвольного процесса (см. грабли №2), все используемые библиотеки следует линковать статически. К сожалению, ящик Пандоры открывается от легкого сквозняка. Компилятор умеет генерировать две версии ассемблерного кода под x86_64: код который быть загружен по произвольному адресу и код, который должен быть загружен по заранее определенному адресу. Первая версия генерируется компилятором если указан ключ -fPIC (position-independent code). Вторая позволяет получать более компактный код.

Грабли №5. Загружаемые модули код библиотек в поставке компилятора собраны с -fPIC, как и следовало бы ожидать. А вот статические версии тех же библиотек собраны без -fPIC. Это тоже можно понять, ведь при статической сборке получается один бинарный файл, который всегда можно загрузить по выбранному адресу - он-то загружается первым. Это не работает при статической сборке загружаемого модуля (.so). Мы хотим линковать все зависимости статически, но мы хотим получить позиционно-независимый код, так как модуль будет загружаться по заранее неизвестному адресу.

Грабли №6. Не все библиотеки из поставки компилятора можно линковать статически. В моем конкретном случае не получилось это сделать с libpthread, но как я понимаю это все зависит от конкретной сборки компилятора.

Грабли №7. По умолчанию компилятор экспортирует все символы из загружаемого модуля. Здесь вам не Windows, так сказать. Если статически линковать foobar.so с libstdc++, то все std:: символы будут экспортированы из foobar.so. При загрузке через LD_PRELOAD это приводит к переопределению всех std:: символов в процессе. Это работает до тех пор, пока все собрано одним компилятором, но совершенно феерически ломается, стоит только поменять версию компилятора в одном месте. Одним из симптомов было бросание std::bad_cast при компиляции безобидного (и корректного) регулярного выражения.

Грабли №8. Попытки спрятать лишние символы с помощью -fvisibility=hidden и --exclude-libs,ALL не работают, так как libstdc++ принудительно меняет видимость символов на default через #pragma, что имеет приоритет выше, чем параметры командной строки.

В конце концов, загнать Гидру обратно в банку все же получилось, но пришлось ей в процессе отрубить несколько голов.

comments powered by Disqus