Недавно на кафедре баз данных 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. На нем рассмотрим устаревшие и удалённые возможности языка, новые языковые конструкции и изменения в стандартной библиотеке. Записаться можно здесь.

Комментарии (15)


  1. baldr
    17.07.2023 18:46
    +16

    Вот вроде бы и тема должна быть интересная - сейчас расскажут как ускорить PostgreSQL в джва раза, и какой-то неизвестный трюк покажут!

    Но это ж OTUS. Чемпионы по копированию банальщины. Ну ребята, ну опять...

    • Если какой-то автор в своем блоге пишет заметку с названием "интересный трюк", то не стоит тупо копировать это. Для заголовка на Хабр хотя бы надо раскрыть: "подмена системного вызова через LD_PRELOAD". Ясно и лаконично, снимает большинство вопросов, те кто знает - просто не идут читать.

    • Статья 2016 года. 7 лет - это довольно большой срок. Я не говорю что все поменялось, но стоит такое указывать в комментарии в начале.

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


    1. HlebyShek
      17.07.2023 18:46
      +2

      Недавно на кафедре баз данных TUM я работал над интересной низкоуровневой библиотекой на языке С — tssx, заменяющей в любом приложении взаимодействие через сокеты на быструю передачу данных через разделяемую память. С нашей библиотекой Postgres работает более чем в два раза быстрее, а некоторые программы даже на порядок быстрее. В основе библиотеки лежит трюк с LD_PRELOAD, о котором я и расскажу в этой статье.

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


      1. baldr
        17.07.2023 18:46

        И-Интрига? Простите, не настолько я опытен, видимо. "Вчера я установил новую библиотеку и с помощью неё PostgreSQL заработал в два раза быстрее! Скорее садитесь, я расскажу вам как устанавливать библиотеки".

        Да, много кликбейта в статьях, которые оказываются заурядными. Но, простите, вы все статьи в блоге этой компании читали? Вот прямо каждый раз - вижу OTUS, морщусь, но покупаюсь на громкий заголовок. В результате - действительно полная вода, но в конце "кстати, у нас снова новый курс по какой-то фигне".


        1. dlinyj
          17.07.2023 18:46
          +1

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


  1. Scratch
    17.07.2023 18:46
    +1

    Так и что, стал постгрес в 2 раза быстрее? Или 7 лет назад всё забросили, а сегодня откопали из могилы ради пары халявных просмотров?
    Фу такими быть


    1. Hardcoin
      17.07.2023 18:46
      +9

      Не стал. Бросили библиотеку, что неудивительно. Если бы такой простой "трюк", как использование разделяемой памяти позволил бы постгре стать вдвое быстрее, сами разработчики его конечно использовали бы.


  1. vk6677
    17.07.2023 18:46

    Как я понимаю, обмен через общую память между приложениями доступен только при запуске на одном устройстве. Нет сетевой "прозрачности".

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


    1. prefrontalCortex
      17.07.2023 18:46
      +3

      Обмен через сокеты домена UNIX тоже доступен только в рамках одного хоста.


  1. SolidSn9ke
    17.07.2023 18:46
    +1

    Вроде такой подход используется в любительских портах Android игр на PS Vita)
    Как понял загружается родной андрюшный .so, далее перехватываются проблемные функции и просто патчатся при запуске
    Уже неделю пытаюсь разобраться как бы это попробовать провернуть, но видать знаний чёт маловато


  1. 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 и они приводили к очень тяжёлым трудно уловимым последствиям. Ядро может даже не кернел паникнёт, но будет работать уже страннее.


    1. brain_tyrin
      17.07.2023 18:46
      +4

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


      1. dlinyj
        17.07.2023 18:46

        Я бы даже не стал так писать, потому что предложение не заметят, а проблем потом будет много.


  1. outlingo
    17.07.2023 18:46
    +3

    Допустим, вы успешно заменили системный вызов write

    Вы заменили не "системный вызов", вы подменили библиотечный символ (функцию read в данном случае).

    Это вообще ни в коем случае не одно и то же.

    Системный вызов никуда не делся, он реализован в ядре.


    1. dlinyj
      17.07.2023 18:46

      Подменить системный вызов тоже можно, но это совсем другая магия.


  1. event1
    17.07.2023 18:46
    +1

    В проекте mptcpd если приложение mptcpwrap, которое с помощью аналогичного трюка превращает обычные сокеты в многопутные.