D2
Администратор
- Регистрация
- 19 Фев 2025
- Сообщения
- 4,380
- Реакции
- 0
Когда‑то, еще в начале погружения в тему ядерных руткитов в Linux, мне попалась заметка из Phrack об их обнаружении с реализацией для i386. Статья была не новая, и речь в ней шла о ядре Linux образца 2003 года. Что‑то в этой заметке меня зацепило, хотя многое оставалось непонятным. Мне захотелось воплотить ту идею антируткита, но уже на современных системах.
Код того, что получилось в итоге, доступен на моем GitHub.
Но и после удаления из списка модулей руткиты все еще возможно обнаружить — на этот раз в sysfs, конкретно в /sys/modules. Этот псевдофайл был даже упомянут в документации Volatility — фреймворка для анализа разнообразных дампов памяти. Исследование этого файла — тоже один из вариантов обнаружения неаккуратных руткитов. И хотя в той документации заявлено, что разработчикам не встречался руткит, который бы удалял себя из обоих мест, уже известный нам KoviD LKM и тут преуспел. Что еще забавнее: первый закоммиченный вариант Diamorphine тоже удалял себя не только лишь из списка модулей.
KoviD же использует sysfs_remove_file(), а свой статус устанавливает при этом в MODULE_STATE_UNFORMED. Эта константа используется для обозначения «подвешенного» состояния, когда модуль еще находится в процессе инициализации и загрузки ядром, а значит, выгружать его ну никак нельзя без неизвестных необратимых последствий для ядра. Такой финт помогает обхитрить антируткиты, использующие __module_address() в ходе перебора содержимого виртуальной памяти, как, например, делает rkspotter (о чем поговорим чуть ниже).
В общем, это было давно, и внутри ядра за 20 лет было удалено, либо появилось очень многое. Кроме того, ядро славится нестабильным внутренним API, и оригинальный сорец из Phrack ожидаемо откажется собираться по множеству причин. Но если разобраться в самой сути предложенной идеи, ее таки можно успешно воплотить в нынешних реалиях.
В той заметке многое было вынесено за скобки, и без должной подготовки авторская логика решения понятна далеко не сразу. В целом, предлагаемый там метод чем‑то похож на блуждание в потемках содержимого оперативки наощупь: пройтись по региону памяти, в котором аллоцируются дескрипторы модулей, и, как только обнаруживается нечто, имеющее сходство с валидным struct module, вывести содержимое из потенциальных полей согласно известной структуре дескриптора.
Например, известно, что по какому‑то смещению должен быть указатель на init-функцию, а по другим — размеры различных секций загруженного модуля, код его текущего статуса и тому подобное. Это значит, что диапазон нужных нам значений памяти по таким смещениям ограничен, и можно прикинуть, насколько текущий адрес похож на начало struct module. То есть можно выработать проверки, чтобы не выводить откровенный мусор из памяти, и детектить нужное по максимуму.
Конечно, как ты понимаешь, за прошедшее с момента написания той статьи время изменились не только внутренние функции ядра, но и куча структур. В первоначальной реализации madsys проверялось только, чтобы поле с именем модуля содержало нормальный текст. В случае x86-64 мы не можем себе этого позволить: виртуальное адресное пространство сильно больше, так как больше стало различных возможных структур, и в итоге такому скромному условию удовлетворит огромная куча данных в памяти.
Другая проблема, которая решается в module_hunter — проверка того факта, что текущий исследуемый виртуальный адрес имеет отображение в физической памяти. Это значит, что обращаясь по этому адресу, модуль не свалится в панику, таща за собой всю систему. Проверку тоже придется переработать, поскольку она привязана к архитектуре.
Идея rkspotter — в том, чтобы пройтись по региону памяти под названием module mapping space (где оказываются LKM после загрузки) и с помощью этой самой функции проверять, какому модулю принадлежит очередной адрес. Для заданного адреса __module_address() возвращает сразу указатель на дескриптор соответствующего модуля, что позволяло удобно по одному‑единственному адресу получить информацию об LKM. Вся грязная работа по проверке валидности трансляции виртуального адреса выполнялась под капотом.
Конечно, можно было бы просто попытаться скопипастить __module_address(), но мой спортивный интерес был в том, чтобы перевоплотить изначальную идею madsys. Какие еще есть подводные камни интересные задачи на пути к новой реализации?
То есть, задачи примерно такие:
Код: Скопировать в буфер обмена
Изначальный module_hunter ходил по области vmalloc, где раньше выделялась память под модули и их дескрипторы. Ее размер на x86-32 составлял всего 128 Мбайт, и это сильно снижало число адресов (и время) для перебора в сравнении с полным адресным пространством в 4 Гбайт (даже если взять чисто ядерную его часть в 1-2 Гбайт).
В адресном пространстве на x86-64 для модулей ядра отведена отдельная область виртуальной памяти размером 1520 Мбайт (одинаковая для 48- и 57-битных адресов), которую мы уже упоминали — module mapping space. Ее виртуальные адреса ограничены макросами MODULES_VADDR и MODULES_END. После очередных экспериментов выяснилось, что в этой области оказываются и дескрипторы модулей, и сам их код с данными. Замечательно! Перебирать огромное виртуальное адресное пространство объемом в терабайты не придется.
Если такие словосочетания как «многоуровневые таблицы страниц» или paging levels для тебя ничего не значат, но у тебя есть невероятное желание углубиться в понимание архитектурных штук, можешь заглянуть, например, в OSDev или документацию Linux по сабжу. Задание со звездочкой — изучить реализации для разных типов процессоров.
Сейчас перед нами та же проблема, о которой писал в свое время madsys:
В общем, есть смысл проверять доступность страницы памяти самостоятельно. Для этого нужно разобраться с тем, как происходит проверка, когда сам процессор обращается по виртуальному адресу, и как устроены участвующие в этом процессе структуры.
На деле данные, к которым обращается программа, должны физически где‑то находиться. А при обращении по виртуальному адресу этот адрес должен преобразоваться в физический адрес в оперативной паяти, чтобы оборудование поняло, какие данные читать. Сейчас на твоей машине запросто может работать несколько программ, в которых код начинается где‑то в районе 0x400000, но в физической памяти это будут совершенно разные данные по разным физическим адресам.
Это все за кулисами разруливает MMU (Memory management unit, модуль управления памятью). Примечательно, что его в принципе может и не быть (например, во встраиваемых системах, где он и не нужен), как когда‑то и не было. С ним же появляется возможность реализовать виртуальные адресные пространства, то есть защиту памяти одних программ от других — на устройстве. Без этого добра можно было легко из приложения пользователя переписать исполняемую память операционной системы, и либо крашнуть все, либо делать другие интересные вещи. Скажем, абсолютно безнаказанно перехватывать системные вызовы или прерывания.
Когда в системе есть MMU, каждая пользовательская программа считает, что все адресное пространство принадлежит только ей, она не затрет ничью память и никто не затрет ее. В этом, в общем‑то, и заключается суть виртуального адресного пространства процесса. Для разных платформ оно делится в разных соотношениях между пользовательским приложением и ядром. Ядерная часть при этом во всех процессах отображается в одни и те же страницы физической памяти, поскольку ядро в системе одно. Это же касается разделяемых библиотек, ведь незачем плодить экземпляры одного и того же в конечной физической памяти.
Например, верхний валиндный юзерспейсный адрес для x86-64 при четырехуровневых таблицах трансляции — 0x00007fffffffffff, а начальный ядерный — 0xffff800000000000. Между ними лежит огромный промежуток (16 млн Тбайт), адреса которого именуются неканоническими, то есть они невалидны для определенной конфигурации оборудования, хотя в теории и могли бы использоваться. Неиспользуемые биты в виртуальных адресах — как раз то, что позволило внедрить такую штуку, как Pointer Authentication Code (Linear Address Masking, PDF).
Физический адрес искомой страницы будет получен только на последнем этапе хождения по таблицам. Также, как видишь на схеме ниже, нескольким записям в таблицах страниц (Page Table Entry, PTE) могут соответствовать одинаковые страницы в оперативной памяти. Это, например, используется для выделения чистеньких зануленных страниц в процесс (смотри ZERO_PAGE).
Некоторые страницы виртуального адресного пространства могут и вовсе не иметь физического отображения, то есть таким PTE не соответствует никакая физическая страница. Это те самые области памяти, при обращении к которым произойдет Segmentation fault. В других случаях маппинг может быть, но на текущий момент отсутствовать в физической памяти (например, окжается выгружен на диск при нехватке памяти).
Пример ложноположительных срабатываний антируктита на мусор в памяти в процессе поиска нужного сочетания проверок выглядит примерно так:
Иногда и нулевая страница (та, что занимает виртуальные адреса 0..PAGE_SIZE-1) может оказаться валидной. Это приводит к тому, что нулевой указатель трактуется как самый обкновенный, с возможностью разыменования без каких‑либо сегфолтов и паник. Когда‑то эта особенность поспособствовала эксплуатации ядерной уязвимости.
Записи в таблицах (то есть PTE/PDE) содержат разную информацию о страничке: присутствует ли она сейчас в памяти, каковы права доступа к ней, принадлежит ли она пространству пользователя или ядру, и прочее. Определения разных битов можно найти в исходниках ядра, но не забывай, что от архитектуры к архитектуре они будут различаться.
Код: Скопировать в буфер обмена
Для человека, не имеющего представления о работе памяти на 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):
Нужно также помнить, что каждый следующий уровень трансляции при обращении к нему нужно проверять на присутствие в памяти. Это можно сделать с помощью следующих макросов:
Код: Скопировать в буфер обмена
Что‑то очень похожее можно отыскать и в коде ядра:
Как и ожидалось, в выводе lsmod не обнаруживается ничего подозрительного, однако наша старо‑новомодная разработка выявляет, что враг не дремлет.
Что касается совместимости с различными версиями ядра, код успешно отработал на нескольких ядрах (4.4, 5.14, 5.15 и 6.5 на x86-64). Это, конечно, не значит, что он не упадет на каких‑нибудь промежуточных версиях или когда‑нибудь в будущем. Если вдруг столкнешься с чем‑то подобным, не стесняйся сообщить мне через GitHub.
Источник xakep.ru
Автор @kclo3
ksen-lin.github.io
Код того, что получилось в итоге, доступен на моем GitHub.
ПАРА СЛОВ ОБ 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-битной архитектуре по умолчанию.
Код: Скопировать в буфер обмена
Код:
[ 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. После очередных экспериментов выяснилось, что в этой области оказываются и дескрипторы модулей, и сам их код с данными. Замечательно! Перебирать огромное виртуальное адресное пространство объемом в терабайты не придется.
Отображение страниц памяти и уровни трансляции
Вот мы подошли к камню преткновения, который в первую очередь не давал мне сходу понять, как идею хождения по ядерной памяти заставить заработать на современных машинах.Если такие словосочетания как «многоуровневые таблицы страниц» или paging levels для тебя ничего не значат, но у тебя есть невероятное желание углубиться в понимание архитектурных штук, можешь заглянуть, например, в OSDev или документацию Linux по сабжу. Задание со звездочкой — изучить реализации для разных типов процессоров.
Сейчас перед нами та же проблема, о которой писал в свое время madsys:
Эту задачу можно было бы решить, не вдаваясь в подробности магии трансляции виртуальных адресов в физические, с помощью __module_address(), как это делает rkspotter. Но мы уже знаем, что не можем использовать эту функцию: она не позволит красиво реализовать решение на свежайших ядрах, да и не особо соответствует изначальной задаче. К тому же наш код будет более самодостаточным и с чуть меньшей вероятностью сломается на следующих ядрах.На этом этапе ты, возможно, думаешь: «так ведь очень просто взять и пробрутить список вредоносных модулей». Но это не так по одной важной причине: адрес, к которому мы пытаемся обратиться, может не иметь отображения в физической памяти, что приведет к ошибке страницы (page fault), и ядро отрапортует: «Unable to handle kernel paging request at virtual address».
Нажмите, чтобы раскрыть...
В общем, есть смысл проверять доступность страницы памяти самостоятельно. Для этого нужно разобраться с тем, как происходит проверка, когда сам процессор обращается по виртуальному адресу, и как устроены участвующие в этом процессе структуры.
На деле данные, к которым обращается программа, должны физически где‑то находиться. А при обращении по виртуальному адресу этот адрес должен преобразоваться в физический адрес в оперативной паяти, чтобы оборудование поняло, какие данные читать. Сейчас на твоей машине запросто может работать несколько программ, в которых код начинается где‑то в районе 0x400000, но в физической памяти это будут совершенно разные данные по разным физическим адресам.
Это все за кулисами разруливает MMU (Memory management unit, модуль управления памятью). Примечательно, что его в принципе может и не быть (например, во встраиваемых системах, где он и не нужен), как когда‑то и не было. С ним же появляется возможность реализовать виртуальные адресные пространства, то есть защиту памяти одних программ от других — на устройстве. Без этого добра можно было легко из приложения пользователя переписать исполняемую память операционной системы, и либо крашнуть все, либо делать другие интересные вещи. Скажем, абсолютно безнаказанно перехватывать системные вызовы или прерывания.
Когда в системе есть MMU, каждая пользовательская программа считает, что все адресное пространство принадлежит только ей, она не затрет ничью память и никто не затрет ее. В этом, в общем‑то, и заключается суть виртуального адресного пространства процесса. Для разных платформ оно делится в разных соотношениях между пользовательским приложением и ядром. Ядерная часть при этом во всех процессах отображается в одни и те же страницы физической памяти, поскольку ядро в системе одно. Это же касается разделяемых библиотек, ведь незачем плодить экземпляры одного и того же в конечной физической памяти.
Например, верхний валиндный юзерспейсный адрес для x86-64 при четырехуровневых таблицах трансляции — 0x00007fffffffffff, а начальный ядерный — 0xffff800000000000. Между ними лежит огромный промежуток (16 млн Тбайт), адреса которого именуются неканоническими, то есть они невалидны для определенной конфигурации оборудования, хотя в теории и могли бы использоваться. Неиспользуемые биты в виртуальных адресах — как раз то, что позволило внедрить такую штуку, как Pointer Authentication Code (Linear Address Masking, PDF).
Об уровнях трансляции (paging levels)
Начнем с более простой темы, и сперва рассмотрим, как транслируется 32-битный адрес при двухуровневом пейджинге, который дает привычные 4 Гбайт на одно виртуальное адресное пространство. Адрес делится на три части, которые представляют индекс в таблице Page Directory (каталог страниц), индекс в таблице страниц Page Table и смещение внутри самой страницы.Физический адрес искомой страницы будет получен только на последнем этапе хождения по таблицам. Также, как видишь на схеме ниже, нескольким записям в таблицах страниц (Page Table Entry, PTE) могут соответствовать одинаковые страницы в оперативной памяти. Это, например, используется для выделения чистеньких зануленных страниц в процесс (смотри ZERO_PAGE).

Некоторые страницы виртуального адресного пространства могут и вовсе не иметь физического отображения, то есть таким PTE не соответствует никакая физическая страница. Это те самые области памяти, при обращении к которым произойдет Segmentation fault. В других случаях маппинг может быть, но на текущий момент отсутствовать в физической памяти (например, окжается выгружен на диск при нехватке памяти).
РЕИНКАРНАЦИЯ 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.
Пример ложноположительных срабатываний антируктита на мусор в памяти в процессе поиска нужного сочетания проверок выглядит примерно так:
Иногда и нулевая страница (та, что занимает виртуальные адреса 0..PAGE_SIZE-1) может оказаться валидной. Это приводит к тому, что нулевой указатель трактуется как самый обкновенный, с возможностью разыменования без каких‑либо сегфолтов и паник. Когда‑то эта особенность поспособствовала эксплуатации ядерной уязвимости.
Записи в таблицах (то есть 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;
}
Глядя на схему выше, ты можешь догадаться, что антируткит по адресу 0xc0101000 ожидает увидеть страничный каталог, то есть таблицу с PDE — Page Directory Entry (KASLR для слабаков! до появления рандомизации адресов в ядре было еще 10 лет). Далее программа обращается по индексу, который получится после битового сдвига целевого виртуального адреса. Также на схеме снизу ты увидишь, что старшая часть адреса (биты 22-31) — это индекс в каталоге страниц.
Если полученная запись PDE ненулевая, то она в свою очередь указывает на таблицу страниц. Точнее, в if (page & 1) проверяется бит присутствия страницы (PAGE_PRESENT).
За индекс в таблице страниц берутся уже биты 12-21 искомого адреса, что вычисляется в коде выше при помощи побитового И с 0x003ff000. При обращении по этому индексу можно получить соответствующую запись PTE, либо ноль, если этот виртуальный адрес в контексте нашего процесса не отображается ни в какой физический.
Для поддержки бо́льших адресных пространств может потребоваться больше уровней трансляции. Для интересующихся схему работы с адресом при трех уровнях можно найти в документации ядра. В зависимости от оборудования будет задействовано несколько Page Directory вместо одной из примера выше. Соответственно, нужно будет обойти больше таблиц, прежде чем получится добраться до искомой физической странички. Трансляция будет происходить, например, в таком порядке (в терминологии Linux):
- Page global directory (PGD);
- P4D — если пейджинг пятиуровневый;
- Page upper directory (PUD);
- Page middle directory (PMD);
- Page table.
Нужно также помнить, что каждый следующий уровень трансляции при обращении к нему нужно проверять на присутствие в памяти. Это можно сделать с помощью следующих макросов:
- pgd_present()
- p4d_present()
- pud_present()
- pmd_present()
- pte_present()
Код: Скопировать в буфер обмена
Код:
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);
. . .
- в kern_addr_valid(), которая была убрана около года назад;
- в dump_pagetable(), spurious_kernel_fault(), mm_find_pmd()...
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.
Источник xakep.ru
Автор @kclo3
ksen-lin.github.io