Как стать автором
Обновить
1005.48
OTUS
Цифровые навыки от ведущих экспертов

Трюк с LD_PRELOAD

Время на прочтение7 мин
Количество просмотров4.4K
Автор оригинала: Peter Goldsborough

Недавно на кафедре баз данных 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.

В заключение делюсь дополнительными ресурсами, которые могут оказаться вам полезными:

А также приглашаем всех желающих на открытый урок, посвященный нововведениям стандарта С23. На нем рассмотрим устаревшие и удалённые возможности языка, новые языковые конструкции и изменения в стандартной библиотеке. Записаться можно здесь.

Теги:
Хабы:
Всего голосов 48: ↑32 и ↓16+16
Комментарии15

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS