По следам Phrack. Ищем LKM-руткиты в оперативке и изучаем устройство памяти x64
Когда‑то, еще в начале погружения в тему ядерных руткитов в Linux, мне попалась заметка из Phrack об их обнаружении с реализацией для i386. Статья была не новая, и речь в ней шла о ядре Linux образца 2003 года. Что‑то в этой заметке меня зацепило, хотя многое оставалось непонятным. Мне захотелось воплотить ту идею антируткита, но уже на современных системах.
ПАРА СЛОВ ОБ LKM-РУТКИТАХ
С древнейших времен руткиты уровня ядра для Linux (они же LKM-руткиты) используют из всего множества механизмов сокрытия всё один и тот же: удаление своего дескриптора модуля (struct_module
) из связного списка загруженных модулей ядра modules
. Это действие скрывает их из вывода в procfs
(/proc/modules
) и вывода команды lsmod
, а также защищает от выгрузки через rmmod
. Ведь ядро теперь считает, что такой модуль не загружен, вот и выгружать нечего.
Некоторые руткиты после удаления себя из списка модулей могут затирать некоторые артефакты в памяти, чтобы найти их следы было сложнее. Например, начиная с версии 2.5.71 Linux устанавливает значения указателей next
и prev
связного списка в LIST_POISON1
и LIST_POISON2
(0x00100100
и 0x00200200
) в структуре при исключении ее из этого списка. Это полезно для детекта ошибок, и этот же факт можно использовать для обнаружения «висящих» в памяти дескрипторов LKM-руткитов, отвязанных ранее от списка модулей. Конечно, достаточно умный руткит перезапишет столь явно выделяющиеся в памяти значения на что‑то менее заметное, обойдя таким образом проверку. Так делает, к примеру, появившийся в 2022 году KoviD LKM.
Но и после удаления из списка модулей руткиты все еще возможно обнаружить — на этот раз в sysfs
, конкретно в /sys/modules
. Этот псевдофайл был даже упомянут в документации Volatility — фреймворка для анализа разнообразных дампов памяти. Исследование этого файла — тоже один из вариантов обнаружения неаккуратных руткитов. И хотя в той документации заявлено, что разработчикам не встречался руткит, который бы удалял себя из обоих мест, уже известный нам KoviD LKM и тут преуспел. Что еще забавнее: первый закоммиченный вариант Diamorphine тоже удалял себя не только лишь из списка модулей.
KoviD же использует sysfs_remove_file()
, а свой статус устанавливает при этом в MODULE_STATE_UNFORMED
. Эта константа используется для обозначения «подвешенного» состояния, когда модуль еще находится в процессе инициализации и загрузки ядром, а значит, выгружать его ну никак нельзя без неизвестных необратимых последствий для ядра. Такой финт помогает обхитрить антируткиты, использующие __module_address()
в ходе перебора содержимого виртуальной памяти, как, например, делает rkspotter (о чем поговорим чуть ниже).
ПОДХОДЫ К ПОИСКУ ДЕСКРИПТОРОВ LKM-РУТКИТОВ В ОПЕРАТИВНОЙ ПАМЯТИ
В этой статье мы обсуждаем способы поиска руткитов в оперативной памяти живой системы и в виртуальном адресном пространстве ядра. В теории, такой поиск может осуществлять не только модуль ядра, но и гипервизор (что вообще‑то правильнее с точки зрения колец защиты). Но мы рассмотрим только первый вариант как более простой для реализации PoC и наиболее близкий к оригиналу. Также я не затрагиваю детект вредоносов в памяти по хешам, но стараюсь рассмотреть что‑то применимое конкретно к LKM-руткитам, а не к малвари в целом. В основном это про исследовательские PoC.
Об исходном module_hunter
В 2003 году во Phrack #61 в рубрике Linenoise была опубликована заметка автора madsys о способе поиска LKM-руткитов в памяти: Finding hidden kernel modules (the extrem way). То были времена ядер 2.2—2.4 и 32-битных машин; сейчас же на горизонте Linux 6.8, а найти железку x86-32 весьма и весьма непросто (да и незачем, кроме как на опыты).
В общем, это было давно, и внутри ядра за 20 лет было удалено, либо появилось очень многое. Кроме того, ядро славится нестабильным внутренним API, и оригинальный сорец из Phrack ожидаемо откажется собираться по множеству причин. Но если разобраться в самой сути предложенной идеи, ее таки можно успешно воплотить в нынешних реалиях.
В той заметке многое было вынесено за скобки, и без должной подготовки авторская логика решения понятна далеко не сразу. В целом, предлагаемый там метод чем‑то похож на блуждание в потемках содержимого оперативки наощупь: пройтись по региону памяти, в котором аллоцируются дескрипторы модулей, и, как только обнаруживается нечто, имеющее сходство с валидным struct module
, вывести содержимое из потенциальных полей согласно известной структуре дескриптора.
Например, известно, что по какому‑то смещению должен быть указатель на init-функцию, а по другим — размеры различных секций загруженного модуля, код его текущего статуса и тому подобное. Это значит, что диапазон нужных нам значений памяти по таким смещениям ограничен, и можно прикинуть, насколько текущий адрес похож на начало struct module
. То есть можно выработать проверки, чтобы не выводить откровенный мусор из памяти, и детектить нужное по максимуму.
Конечно, как ты понимаешь, за прошедшее с момента написания той статьи время изменились не только внутренние функции ядра, но и куча структур. В первоначальной реализации madsys проверялось только, чтобы поле с именем модуля содержало нормальный текст. В случае x86-64 мы не можем себе этого позволить: виртуальное адресное пространство сильно больше, так как больше стало различных возможных структур, и в итоге такому скромному условию удовлетворит огромная куча данных в памяти.
Другая проблема, которая решается в module_hunter
— проверка того факта, что текущий исследуемый виртуальный адрес имеет отображение в физической памяти. Это значит, что обращаясь по этому адресу, модуль не свалится в панику, таща за собой всю систему. Проверку тоже придется переработать, поскольку она привязана к архитектуре.
rkspotter и проблема с __module_address()
Нужны были способы пройтись по памяти так, чтобы не уронить систему. И тут мне попался уже знакомый нам rkspotter. Он обнаруживает применение нескольких техник сокрытия, которые в ходу у LKM-руткитов. Это позволяет ему преуспеть в своих задачах даже в том случае, когда один из методов не отрабатывает. Проблема, однако, в том, что этот антируткит полагается на функцию __module_address()
, которую в 2020-м убирали из числа экспортируемых, и с версии Linux 5.4.118 она недоступна для модулей.
Идея rkspotter — в том, чтобы пройтись по региону памяти под названием module mapping space
(где оказываются LKM после загрузки) и с помощью этой самой функции проверять, какому модулю принадлежит очередной адрес. Для заданного адреса __module_address()
возвращает сразу указатель на дескриптор соответствующего модуля, что позволяло удобно по одному‑единственному адресу получить информацию об LKM. Вся грязная работа по проверке валидности трансляции виртуального адреса выполнялась под капотом.
Конечно, можно было бы просто попытаться скопипастить __module_address()
, но мой спортивный интерес был в том, чтобы перевоплотить изначальную идею madsys. Какие еще есть подводные камни интересные задачи на пути к новой реализации?
Что нужно фиксить
Чтобы написать новую рабочую тулзу, нужно изучить все, что менялось в ядре за последние 20 лет и связано с «висящими» дескрипторами LKM. Точнее, придется исправить все ошибки компиляции, с которыми мы столкнемся по ходу дела.
То есть, задачи примерно такие:
- пофиксить вызовы изменившихся ядерных API. Код оригинала, на самом деле, очень мал, и единственный используемый ядерный API касается
procfs
, так что этот пункт не потребует много времени; - выделить поля
struct module
, наиболее подходящие для детекта отвязанной от общего списка модулей структуры; - изучить и учесть изменения управления памятью на x86-64 в сравнении с i386;
- а также учесть, что на 64-битной архитектуре совершенно иначе распределено виртуальное адресное пространство, и оно несоизмеримо больше: 128 Тбайт на ядерную часть и столько же на юзерспейс — в противовес 1 Гбайт и 3 Гбайт соответствено на 32-битной архитектуре по умолчанию.
Что ж, пора переходить к самому интересному!
РЕИНКАРНАЦИЯ MODULE_HUNTER
По полям, по полям...
При работе с x64 простой проверки на валидность имени модуля недостаточно. Эксперименты показали, что без дополнительных проверок выводится слишком много лишнего. Ложноположительные результаты — не круто. После изучения типичного содержимого различных полей можно попробовать остановиться на следующих проверках:
- память соответствует полю
state
и имеет одно из валидных для этого поля значений:MODULE_STATE_LIVE
,MODULE_STATE_COMING
,MODULE_STATE_GOING
,MODULE_STATE_UNFORMED
; - значения в полях
init
иexit
указывают вmodule mapping space
(на x86-64) либо равныNULL
; - хотя бы одно из полей
init
,exit
,list.next
иlist.prev
не равноNULL
; list.next
иlist.prev
являются каноническими:NULL
либоLIST_POISON1
/LIST_POISON2
(но это не точно, и можно будет опустить впоследствии);- размер модуля (
core_layout.size
для версии менее 6.4) ненулевой и кратенPAGE_SIZE
.
Важно понимать, что этот список не высечен в камне и его можно дополнять и исправлять в будущем при появлении более изощренных руткитов, либо для систем, где его по каким‑то причинам будет недостаточно.
Пример ложноположительных срабатываний антируктита на мусор в памяти в процессе поиска нужного сочетания проверок выглядит примерно так:
[ 5944.082676] nitara2: address module
[ 5944.085392] nitara2: 0xffffffffc011c300 "serio_raw" [ 5944.085435] nitara2: 0xffffffffc0120ff0 "{" [ 5944.085512] nitara2: 0xffffffffc0129680 "i2c_piix4"
[ 5944.087444] nitara2: 0xffffffffc01fd040 "fb_sys_fops" [ 5944.089131] nitara2: 0xffffffffc02affb0 "`", [ 5944.089342] nitara2: 0xffffffffc02c30c0 "cfg80211" [ 5944.091874] nitara2: 0xffffffffc03cb700 "kvm"
[ 5944.094188] nitara2: 0xffffffffc04be0c0 "nitara2" [ 5944.670378] nitara2: end check (total gone 66060288 steps)
Виртуальная память x86
После того как мы определились с нужными для детекта отвязанной структуры полями, пора понять, в какой части виртуального адресного пространства ядра вообще искать.
Изначальный module_hunter
ходил по области vmalloc
, где раньше выделялась память под модули и их дескрипторы. Ее размер на x86-32 составлял всего 128 Мбайт, и это сильно снижало число адресов (и время) для перебора в сравнении с полным адресным пространством в 4 Гбайт (даже если взять чисто ядерную его часть в 1-2 Гбайт).
В адресном пространстве на x86-64 для модулей ядра отведена отдельная область виртуальной памяти размером 1520 Мбайт (одинаковая для 48- и 57-битных адресов), которую мы уже упоминали — module mapping space
. Ее виртуальные адреса ограничены макросами MODULES_VADDR
и MODULES_END
. После очередных экспериментов выяснилось, что в этой области оказываются и дескрипторы модулей, и сам их код с данными. Замечательно! Перебирать огромное виртуальное адресное пространство объемом в терабайты не придется.
Отображение страниц памяти и уровни трансляции
Вот мы подошли к камню преткновения, который в первую очередь не давал мне сходу понять, как идею хождения по ядерной памяти заставить заработать на современных машинах.
Сейчас перед нами та же проблема, о которой писал в свое время madsys:
На этом этапе ты, возможно, думаешь: «так ведь очень просто взять и пробрутить список вредоносных модулей». Но это не так по одной важной причине: адрес, к которому мы пытаемся обратиться, может не иметь отображения в физической памяти, что приведет к ошибке страницы (page fault), и ядро отрапортует: «Unable to handle kernel paging request at virtual address».
Эту задачу можно было бы решить, не вдаваясь в подробности магии трансляции виртуальных адресов в физические, с помощью __module_address()
, как это делает rkspotter. Но мы уже знаем, что не можем использовать эту функцию: она не позволит красиво реализовать решение на свежайших ядрах, да и не особо соответствует изначальной задаче. К тому же наш код будет более самодостаточным и с чуть меньшей вероятностью сломается на следующих ядрах.
В общем, есть смысл проверять доступность страницы памяти самостоятельно. Для этого нужно разобраться с тем, как происходит проверка, когда сам процессор обращается по виртуальному адресу, и как устроены участвующие в этом процессе структуры.
На деле данные, к которым обращается программа, должны физически где‑то находиться. А при обращении по виртуальному адресу этот адрес должен преобразоваться в физический адрес в оперативной паяти, чтобы оборудование поняло, какие данные читать. Сейчас на твоей машине запросто может работать несколько программ, в которых код начинается где‑то в районе 0x400000
, но в физической памяти это будут совершенно разные данные по разным физическим адресам.
Это все за кулисами разруливает MMU (Memory management unit, модуль управления памятью). Примечательно, что его в принципе может и не быть (например, во встраиваемых системах, где он и не нужен), как когда‑то и не было. С ним же появляется возможность реализовать виртуальные адресные пространства, то есть защиту памяти одних программ от других — на устройстве. Без этого добра можно было легко из приложения пользователя переписать исполняемую память операционной системы, и либо крашнуть все, либо делать другие интересные вещи. Скажем, абсолютно безнаказанно перехватывать системные вызовы или прерывания.
Когда в системе есть MMU, каждая пользовательская программа считает, что все адресное пространство принадлежит только ей, она не затрет ничью память и никто не затрет ее. В этом, в общем‑то, и заключается суть виртуального адресного пространства процесса. Для разных платформ оно делится в разных соотношениях между пользовательским приложением и ядром. Ядерная часть при этом во всех процессах отображается в одни и те же страницы физической памяти, поскольку ядро в системе одно. Это же касается разделяемых библиотек, ведь незачем плодить экземпляры одного и того же в конечной физической памяти.
Об уровнях трансляции (paging levels)
Начнем с более простой темы, и сперва рассмотрим, как транслируется 32-битный адрес при двухуровневом пейджинге, который дает привычные 4 Гбайт на одно виртуальное адресное пространство. Адрес делится на три части, которые представляют индекс в таблице Page Directory (каталог страниц), индекс в таблице страниц Page Table и смещение внутри самой страницы.
Физический адрес искомой страницы будет получен только на последнем этапе хождения по таблицам. Также, как видишь на схеме ниже, нескольким записям в таблицах страниц (Page Table Entry, PTE) могут соответствовать одинаковые страницы в оперативной памяти. Это, например, используется для выделения чистеньких зануленных страниц в процесс (смотри ZERO_PAGE).
Некоторые страницы виртуального адресного пространства могут и вовсе не иметь физического отображения, то есть таким PTE не соответствует никакая физическая страница. Это те самые области памяти, при обращении к которым произойдет Segmentation fault. В других случаях маппинг может быть, но на текущий момент отсутствовать в физической памяти (например, окжается выгружен на диск при нехватке памяти).
Записи в таблицах (то есть PTE/PDE) содержат разную информацию о страничке: присутствует ли она сейчас в памяти, каковы права доступа к ней, принадлежит ли она пространству пользователя или ядру, и прочее. Определения разных битов можно найти в исходниках ядра, но не забывай, что от архитектуры к архитектуре они будут различаться.
Учимся приходить по адресу и не падать
В module_hunter
за проверку существования маппинга отвечал следующий кусок кода:
int valid_addr(unsigned long address){
unsigned long page;
if (!address) return 0;
page = ((unsigned long *)0xc0101000)[address >> 22];
//pde
if (page & 1) {
page &= PAGE_MASK;
address &= 0x003ff000;
page = ((unsigned long *) __va(page))[address >> PAGE_SHIFT];
//pte
if (page)
return 1; }
return 0;}
Для человека, не имеющего представления о работе памяти на i386, константы вроде 0xc0101000
и 0x003ff000
только усиливают ощущение какой‑то темной магии. Первое число поддается гуглению, но без понимания самой сути процесса лучше не становится.
Глядя на схему выше, ты можешь догадаться, что антируткит по адресу 0xc0101000
ожидает увидеть страничный каталог, то есть таблицу с PDE — Page Directory Entry (KASLR для слабаков! до появления рандомизации адресов в ядре было еще 10 лет). Далее программа обращается по индексу, который получится после битового сдвига целевого виртуального адреса. Также на схеме снизу ты увидишь, что старшая часть адреса (биты 22-31) — это индекс в каталоге страниц.
Если полученная запись PDE ненулевая, то она в свою очередь указывает на таблицу страниц. Точнее, в if (page & 1)
проверяется бит присутствия страницы (PAGE_PRESENT
).
За индекс в таблице страниц берутся уже биты 12-21 искомого адреса, что вычисляется в коде выше при помощи побитового И с 0x003ff000
. При обращении по этому индексу можно получить соответствующую запись PTE, либо ноль, если этот виртуальный адрес в контексте нашего процесса не отображается ни в какой физический.
Для поддержки бо́льших адресных пространств может потребоваться больше уровней трансляции. Для интересующихся схему работы с адресом при трех уровнях можно найти в документации ядра. В зависимости от оборудования будет задействовано несколько Page Directory вместо одной из примера выше. Соответственно, нужно будет обойти больше таблиц, прежде чем получится добраться до искомой физической странички. Трансляция будет происходить, например, в таком порядке (в терминологии Linux):
Согласно документации, даже если ядро собрано с поддержкой пятиуровневых таблиц, оно все еще будет работать на оборудовании с четырьмя уровнями трансляции, просто в этом случае уровень P4D будет «вырожден» в рантайме.
Нужно также помнить, что каждый следующий уровень трансляции при обращении к нему нужно проверять на присутствие в памяти. Это можно сделать с помощью следующих макросов:
Они проверяют, установлен ли бит присутствия страницы и, в зависимости от текущего уровня, некоторые другие биты тоже. А полную проверку на каждом из уровней можно сделать примерно по такой схеме:
struct mm_struct *mm = current->mm;pgd = pgd_offset(mm, addr);if (!pgd || pgd_none(*pgd) || !pgd_present(*pgd) ) return false;p4d = p4d_offset(pgd, addr); . . .
Что‑то очень похожее можно отыскать и в коде ядра:
Что касается различного количества уровней трансляции, то используемое текущим ядром значение можно найти в опции CONFIG_PGTABLE_LEVELS
. Поддержка пятиуровневого пейджинга в ядре появилась в 2017 году в версиях 4.11-4.12 (смотри коммит и статью на LWN).
Proof-of-Concept
Давай на нескольких системах проверим, что получилось. Если интерфейс взаимодействия с PoC тебе кажется странным, то не удивляйся — он такой и есть. Таков он был в оригинале, так что не суди строго.
Как и ожидалось, в выводе lsmod
не обнаруживается ничего подозрительного, однако наша старо‑новомодная разработка выявляет, что враг не дремлет.
OUTRO: О ПОРТИРУЕМОСТИ
Если взять текущий код, убрать проверку на CONFIG_X86_64
и скомпилировать его, например, на Raspberry Pi с Linux 6.1, то он соберется и даже запустится. Но ничего не найдет, что и неудивительно: полноценно заработать на других архитектурах ему помешают различия в организации их виртуальных адресных пространств. В случае AArch64 другой не только лейаут памяти (например, область для модулей ядра занимает 2 Гбайт вместо 1520 Мбайт), но и возможное число уровней пейджинга. Оно варьируется от двух до четырех в зависимости от размера страницы и числа используемых бит адреса. Соответственно, это меняет и области неканонических адресов, на что в антирутките есть проверки.
Что касается совместимости с различными версиями ядра, код успешно отработал на нескольких ядрах (4.4, 5.14, 5.15 и 6.5 на x86-64). Это, конечно, не значит, что он не упадет на каких‑нибудь промежуточных версиях или когда‑нибудь в будущем. Если вдруг столкнешься с чем‑то подобным, не стесняйся сообщить мне через GitHub.