Недавно на кафедре баз данных TUM я работал над интересной низкоуровневой библиотекой на языке С — tssx, заменяющей в любом приложении взаимодействие через сокеты на быструю передачу данных через разделяемую память. С нашей библиотекой Postgres работает более чем в два раза быстрее, а некоторые программы даже на порядок быстрее. В основе библиотеки лежит трюк с LD_PRELOAD
, о котором я и расскажу в этой статье.
Введение
Трюк с LD_PRELOAD
полагается на функциональность динамического компоновщика в Unix-системах, позволяющую запросить связывание символов, предоставляемых некоторой разделяемой библиотекой, раньше других библиотек. Важно помнить, что при запуске программы динамический загрузчик операционной системы сначала загружает в память (адресное пространство) процесса динамические библиотеки, на которые вы ссылаетесь — чтобы затем динамический компоновщик смог найти символы во время загрузки или во время выполнения и связать их с реальными определениями. Более подробно об этом можно прочитать здесь. Также поясню терминологию: под символом я понимаю любую функцию, структуру или объявление переменной, на которые программа может ссылаться в коде. В этой статье мы будем рассматривать в основном символы функций. В следующих частях статьи мы рассмотрим трюк с LD_PRELOAD
подробнее и разберем несколько практических примеров его использования в Linux и OS X.
Инъекция кода
Как упоминалось выше, компоновщик отвечает за нахождение реальных определений символов. Станет веселее, если мы учтём, что для некоторого символа можно дать более одного определения. Здесь придется вести себя осторожнее, чтобы не столкнуться с дубликатами символов, но с помощью хитрых трюков и правильного использования системных библиотек это вполне возможно. Чтобы понять, зачем это может быть нужно, представьте, что у вас есть какой-либо исполняемый файл, например ls
или make
. Естественно, эти исполняемые файлы ссылаются на структуры и вызывают функции, которые они либо определяют сами, либо ссылаются на них из статических или разделяемых библиотек, например, libc
. А теперь представьте, что можно дать собственные определения для символов, от которых зависит исполняемый файл, и заставить программу ссылаться на ваши символы, а не на оригинальные, то есть, по сути, внедрить свои определения. Именно это и позволяет сделать трюк с LD_PRELOAD
.
Давайте посмотрим, как это сделать. Для начала напишем небольшой фрагмент кода на языке С в качестве объекта наших экспериментов с инъекциями. Он просто считывает строку из stdin
и выводит ее на экран:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, const char *argv[]) {
char buffer[1000];
int amount_read;
int fd;
fd = fileno(stdin);
if ((amount_read = read(fd, buffer, sizeof buffer)) == -1) {
perror("ошибка чтения");
return EXIT_FAILURE;
}
if (fwrite(buffer, sizeof(char), amount_read, stdout) == -1) {
perror("ошибка записи");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
Затем скомпилируем его в обычный исполняемый файл:
$ gcc main.c -o out
Если его запустить и передать что-то на вход, он должен вести себя так, как ожидается:
$ ./out
>>> foo
>>> foo
Теперь представим себя безумным ученым и напишем новое определение для системного вызова read
, которое затем загрузим перед определением, предоставляемым стандартной библиотекой C. Для этого мы просто переопределим read с точно такой же сигнатурой, как и у оригинального системного вызова, которую можно найти на его странице руководства. И поскольку у нас нет ни стыда, ни совести, мы не будем читать ввод пользователя, а просто вернем строку "I love cats" (почему бы и нет?):
#include <string.h>
ssize_t read(int fd, void *data, size_t size) {
strcpy(data, "I love cats");
return 12;
}
Заметим, что я не слишком забочусь о проверке границ, хотя для ваших целей она, очевидно, понадобится. Самое замечательное в трюке с LD_PRELOAD
то, что нам не придется делать много работы. Самое главное, нам не придется трогать ни одной строчки кода в исходном исполняемом файле и не придется его перекомпилировать. Все, что нам нужно сделать, это скомпилировать нашу инъекцию в разделяемую библиотеку:
$ gcc -shared -fPIC -o inject.so inject.c
И использовать трюк с LD_PRELOAD
, выставив соответствующую переменную окружения LD_PRELOAD
в путь к нашей разделяемой библиотеке перед выполнением целевой программы, штатным образом:
$ LD_PRELOAD=$PWD/inject.so ./out
Вместо того, чтобы читать пользовательский ввод, эта программа просто выведет I love cats
. Обратите внимание, как мы используем $PWD
при указании пути к библиотеке. Это важно в том случае, если рабочий каталог исполняемого файла отличается от текущего каталога. Заметим также, что если просто сделать export LD_PRELOAD=$PWD/inject.so
, а не добавлять переменную окружения перед именем исполняемого файла, то это приведет к перезаписи системного вызова read для каждого исполняемого файла в системе, что я, конечно же, рекомендую сделать.
OS X
Трюк с LD_PRELOAD
работает и в OS X (она же macOS). Как бы то ни было, вам хотите скомпилировать вашу библиотеку в файл .dylib
:
$ gcc -shared -fPIC -o inject.dylib inject.c
и затем использовать следующую строку для инъекции кода:
$ DYLD_INSERT_LIBRARIES=$PWD/inject.dylib DYLD_FORCE_FLAT_NAMESPACE=1 ./out
чего должно быть достаточно.
Фишинг символов
Еще одной потребностью, с которой мы можем столкнуться при выполнении наших вредоносных инъекций кода, является получение исходного символа — фишинг символов, как я это называю. Допустим, вы успешно заменили системный вызов write
своим собственным определением из разделяемой библиотеки, так что все вызовы write
в итоге обращаются к вашей функции. Часто цель состоит не в том, чтобы полностью заменить системный вызов, а в том, чтобы обернуть его. Например, мы хотим лишь записать в логи, что пользователь выполнил вызов, или отобразить некоторые параметры, но в конечном итоге вызвать исходное определение, чтобы сделать инъекцию прозрачной для программы. К счастью, это тоже возможно! Чтобы это сделать, можно получить исходный символ с помощью системной библиотеки <dlfcn.h>
, которая предоставляет функцию dlsym для получения символов от динамического компоновщика:
#define _GNU_SOURCE
#include <string.h>
#include <dlfcn.h>
#include <stdio.h>
typedef ssize_t (*real_read_t)(int, void *, size_t);
ssize_t real_read(int fd, void *data, size_t size) {
return ((real_read_t)dlsym(RTLD_NEXT, "read"))(fd, data, size);
}
ssize_t read(int fd, void *data, size_t size) {
strcpy(data, "I love cats");
return 12;
}
Как видите, мы сообщаем функции dlsym
имя символа, который хотим загрузить, в виде обычной строки. Затем она получит структуру, переменную или, что актуально для нашего случая, функцию и вернет ее в виде void*
. Этот указатель мы можем смело привести к типу указателя на функцию, объявленного через typedef
. Обратите внимание, что мы добавляем в вызов макрос RTLD_NEXT
, который является единственным допустимым значением для этого параметра кроме RTLD_DEFAULT
. RTLD_DEFAULT
просто загружает символ по умолчанию, находящийся в глобальной области видимости, то есть тот, который доступен по прямому вызову или ссылке в программном коде (наше определение). С другой стороны, RTLD_NEXT
применит алгоритм поиска символов, чтобы найти любое определение для запрашиваемого символа, отличное от символа по умолчанию, т.е. следующее в порядке загрузки компоновщика. В нашем случае этим следующим символом будет исходное определение read
в libc
. И, наконец, отметим, что макрос _GNU_SOURCE
необходимо определить для того, чтобы включить в код функции динамического компоновщика, необходимые для доступа к некоторым расширениям GNU.
Получив с помощью dlsym
исходный системный вызов, мы можем просто вызвать его с теми аргументами, которые он обычно принимает. В результате можно инициировать исходный системный вызов изнутри нашего «злого» варианта, чтобы, например, вывести все, что прочитал (read) пользователь, в stdout
перед возвратом исходных данных:
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
typedef ssize_t (*real_read_t)(int, void *, size_t);
ssize_t real_read(int fd, void *data, size_t size) {
return ((real_read_t)dlsym(RTLD_NEXT, "read"))(fd, data, size);
}
ssize_t read(int fd, void *data, size_t size) {
ssize_t amount_read;
// Выполнить исходный системный вызов
amount_read = real_read(fd, data, size);
// Наш вредоносный код
fwrite(data, sizeof(char), amount_read, stdout);
// Ведём себя, будто настоящий системный вызов
return amount_read;
}
Кроме того, нам придется перекомпилировать нашу разделяемую библиотеку с флагом -ldl
для компоновки библиотеки dl
, которая необходима для нашей магии динамического компоновщика:
gcc -shared -fPIC -ldl -o inject.so inject.c
Теперь мы можем делать довольно интересные вещи с нашей инъекцией. Просто добавьте её перед произвольными исполняемыми файлами в вашей системе, чтобы стать свидетелем забавным эффектам. Например, мы можем шпионить за gcc
, компилирующим нашу библиотеку, которая шпионит за gcc
, компилирующим нашу библиотеку, которая шпионит за gcc
, компилирующим нашу библиотеку, которая ...
LD_PRELOAD=$PWD/inject.so gcc -shared -fPIC -ldl -o inject.so inject.c
Заключение
Надеюсь, в этой статье вы нашли для себя полезные советы по использованию трюка с LD_PRELOAD
. Код, методы и команды, которые я здесь привел — это практически все, что вам нужно для написания собственных определений системных вызовов и внедрения кода в другие исполняемые файлы. Однако обратите внимание, что с помощью этого трюка трудно сделать по-настоящему злодейские вещи, поскольку динамический загрузчик подгрузит библиотеку только в том случае, если эффективный идентификатор пользователя равен реальному идентификатору пользователя, то есть если вы являетесь владельцем исполняемого файла, в который пытаетесь внедрить код. Тем не менее, есть много интересных вещей, которые вы можете сделать, чтобы изменить мир к лучшему. Если вы хотите посмотреть, как я использовал описанный в статье трюк для замены сокетов домена UNIX на каналы в общей памяти, переходите на страницу tssx.
В заключение делюсь дополнительными ресурсами, которые могут оказаться вам полезными:
Dynamic linker tricks: Using LD_PRELOAD to cheat, inject features and investigate programs
http://pubs.opengroup.org/onlinepubs/009695399/functions/dlsym.html
А также приглашаем всех желающих на открытый урок, посвященный нововведениям стандарта С23. На нем рассмотрим устаревшие и удалённые возможности языка, новые языковые конструкции и изменения в стандартной библиотеке. Записаться можно здесь.
Комментарии (15)
Scratch
17.07.2023 18:46+1Так и что, стал постгрес в 2 раза быстрее? Или 7 лет назад всё забросили, а сегодня откопали из могилы ради пары халявных просмотров?
Фу такими бытьHardcoin
17.07.2023 18:46+9Не стал. Бросили библиотеку, что неудивительно. Если бы такой простой "трюк", как использование разделяемой памяти позволил бы постгре стать вдвое быстрее, сами разработчики его конечно использовали бы.
vk6677
17.07.2023 18:46Как я понимаю, обмен через общую память между приложениями доступен только при запуске на одном устройстве. Нет сетевой "прозрачности".
Ядро операционной системы тоже модернизируется, возможно, за эти 7 лет обмен через стандартные сокеты на localhost тоже стал более производительным. Нужно тестировать.
prefrontalCortex
17.07.2023 18:46+3Обмен через сокеты домена UNIX тоже доступен только в рамках одного хоста.
SolidSn9ke
17.07.2023 18:46+1Вроде такой подход используется в любительских портах Android игр на PS Vita)
Как понял загружается родной андрюшный .so, далее перехватываются проблемные функции и просто патчатся при запуске
Уже неделю пытаюсь разобраться как бы это попробовать провернуть, но видать знаний чёт маловато
dlinyj
17.07.2023 18:46Статья, хоть и старая, но нашёл весьма интересной. Но вот так:
ssize_t read(int fd, void *data, size_t size) { strcpy(data, "I love cats"); return 12;
Делать нельзя, никогда, ни при каких обстоятельствах. Мы не знаем размер выделенного массива, по указателюdata
. И нам передаётся объём данных, которые запрашивает пользовательsize
. Надо хотя бы проверять, что 12 символов будет точно больше, чемsize
. Иначе возможны всякие неприятности, проверка мелкая, но спасает много сил.
Пишу, потому что встречал такие косяки в китайских ядрах Android и они приводили к очень тяжёлым трудно уловимым последствиям. Ядро может даже не кернел паникнёт, но будет работать уже страннее.brain_tyrin
17.07.2023 18:46+4Ну там же прям следующим предложением говорится "Заметим, что я не слишком забочусь о проверке границ, хотя для ваших целей она, очевидно, понадобится."
dlinyj
17.07.2023 18:46Я бы даже не стал так писать, потому что предложение не заметят, а проблем потом будет много.
outlingo
17.07.2023 18:46+3Допустим, вы успешно заменили системный вызов write
Вы заменили не "системный вызов", вы подменили библиотечный символ (функцию read в данном случае).
Это вообще ни в коем случае не одно и то же.
Системный вызов никуда не делся, он реализован в ядре.
baldr
Вот вроде бы и тема должна быть интересная - сейчас расскажут как ускорить PostgreSQL в джва раза, и какой-то неизвестный трюк покажут!
Но это ж OTUS. Чемпионы по копированию банальщины. Ну ребята, ну опять...
Если какой-то автор в своем блоге пишет заметку с названием "интересный трюк", то не стоит тупо копировать это. Для заголовка на Хабр хотя бы надо раскрыть: "подмена системного вызова через LD_PRELOAD". Ясно и лаконично, снимает большинство вопросов, те кто знает - просто не идут читать.
Статья 2016 года. 7 лет - это довольно большой срок. Я не говорю что все поменялось, но стоит такое указывать в комментарии в начале.
По содержанию статьи - претензии уже не к переводчику, а к автору. Но для качественного оформления стоит все-таки добавить комментарии в начале. Автор увлеченно аннотирует что сейчас расскажет трюк с ускорением программ на порядок, но внезапно, просто рассказывает про подмену системного вызова. Как он ускорил postgres - ну понятно что через этот "трюк", но скорее всего, там было переписана довольно существенная часть с вызовом системных функций - может быть кэширование или еще что..
HlebyShek
Первый обзац, последнее предложение.
Похоже написать критикующий комментарий гораздно проще, чем прочесть преамбулу.
К тому же если вы достаточно опытен в работе с linux, то по названию итак было очевидно о чем статья.
baldr
И-Интрига? Простите, не настолько я опытен, видимо. "Вчера я установил новую библиотеку и с помощью неё PostgreSQL заработал в два раза быстрее! Скорее садитесь, я расскажу вам как устанавливать библиотеки".
Да, много кликбейта в статьях, которые оказываются заурядными. Но, простите, вы все статьи в блоге этой компании читали? Вот прямо каждый раз - вижу OTUS, морщусь, но покупаюсь на громкий заголовок. В результате - действительно полная вода, но в конце "кстати, у нас снова новый курс по какой-то фигне".
dlinyj
При этом они гонят контент низкого качества и обвиняют, что есть какие-то специальные чаты, которые сливают им рейтинг. Хотя они делают это сами своими руками.