March 15

По следам Phrack. Ищем LKM-руткиты в оперативке и изучаем устройство памяти x64

  1. Пара слов об LKM-руткитах
  2. Подходы к поиску дескрипторов LKM-руткитов в оперативной памяти
  3. Реинкарнация module_hunter
  4. Outro: о портируемости

Ког­да‑то, еще в начале пог­ружения в тему ядер­ных рут­китов в Linux, мне попалась замет­ка из Phrack об их обна­руже­нии с реали­заци­ей для i386. Статья была не новая, и речь в ней шла о ядре Linux образца 2003 года. Что‑то в этой замет­ке меня зацепи­ло, хотя мно­гое оста­валось непонят­ным. Мне захоте­лось воп­лотить ту идею анти­рут­кита, но уже на сов­ремен­ных сис­темах.

ПАРА СЛОВ ОБ LKM-РУТКИТАХ

С древ­ней­ших вре­мен рут­киты уров­ня ядра для Linux (они же LKM-рут­киты) исполь­зуют из все­го мно­жес­тва механиз­мов сок­рытия всё один и тот же: уда­ление сво­его дес­крип­тора модуля (struct_module) из связ­ного спис­ка заг­ружен­ных модулей ядра modules. Это дей­ствие скры­вает их из вывода в procfs (/proc/modules) и вывода коман­ды lsmod, а так­же защища­ет от выг­рузки через rmmod. Ведь ядро теперь счи­тает, что такой модуль не заг­ружен, вот и выг­ружать нечего.

Как скры­вал­ся Adore LKM 0.13 (1999-2000). Тог­да спи­сок модулей был односвяз­ным, а струк­туры `list` в ядре еще не было
Как скры­вает­ся сей­час Diamorphine

Не­кото­рые рут­киты пос­ле уда­ления себя из спис­ка модулей могут затирать некото­рые арте­фак­ты в памяти, что­бы най­ти их сле­ды было слож­нее. Нап­ример, начиная с вер­сии 2.5.71 Linux уста­нав­лива­ет зна­чения ука­зате­лей next и prev связ­ного спис­ка в LIST_POISON1 и LIST_POISON2 (0x00100100 и 0x00200200) в струк­туре при исклю­чении ее из это­го спис­ка. Это полез­но для детек­та оши­бок, и этот же факт мож­но исполь­зовать для обна­руже­ния «висящих» в памяти дес­крип­торов LKM-рут­китов, отвя­зан­ных ранее от спис­ка модулей. Конеч­но, дос­таточ­но умный рут­кит переза­пишет столь явно выделя­ющиеся в памяти зна­чения на что‑то менее замет­ное, обой­дя таким обра­зом про­вер­ку. Так дела­ет, к при­меру, появив­ший­ся в 2022 году KoviD LKM.

Но и пос­ле уда­ления из спис­ка модулей рут­киты все еще воз­можно обна­ружить — на этот раз в sysfs, кон­крет­но в /sys/modules. Этот псев­дофайл был даже упо­мянут в до­кумен­тации Volatility — фрей­мвор­ка для ана­лиза раз­нооб­разных дам­пов памяти. Иссле­дова­ние это­го фай­ла — тоже один из вари­антов обна­руже­ния неак­курат­ных рут­китов. И хотя в той докумен­тации заяв­лено, что раз­работ­чикам не встре­чал­ся рут­кит, который бы уда­лял себя из обо­их мест, уже извес­тный нам KoviD LKM и тут пре­успел. Что еще забав­нее: пер­вый заком­мичен­ный вари­ант Diamorphine тоже уда­лял себя не толь­ко лишь из спис­ка модулей.

Как скры­вал­ся Diamorphine образца нояб­ря 2013

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 в dmesg руга­ется на реп­тилию (Ubuntu 18.10)

Идея 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).

Схе­ма тран­сля­ции 32-бит­ного вир­туаль­ного адре­са

Не­кото­рые стра­ницы вир­туаль­ного адресно­го прос­транс­тва могут и вов­се не иметь физичес­кого отоб­ражения, то есть таким 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):

  • Page global directory (PGD);
  • P4D — если пей­джинг пяти­уров­невый;
  • Page upper directory (PUD);
  • Page middle directory (PMD);
  • Page table.

Сог­ласно докумен­тации, даже если ядро соб­рано с под­дер­жкой пяти­уров­невых таб­лиц, оно все еще будет работать на обо­рудо­вании с четырь­мя уров­нями тран­сля­ции, прос­то в этом слу­чае уро­вень P4D будет «вырож­ден» в ран­тай­ме.

Нуж­но так­же пом­нить, что каж­дый сле­дующий уро­вень тран­сля­ции при обра­щении к нему нуж­но про­верять на при­сутс­твие в памяти. Это мож­но сде­лать с помощью сле­дующих мак­росов:

  • 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()...

Что каса­ется раз­лично­го количес­тва уров­ней тран­сля­ции, то исполь­зуемое текущим ядром зна­чение мож­но най­ти в опции CONFIG_PGTABLE_LEVELS. Под­дер­жка пяти­уров­невого пей­джин­га в ядре появи­лась в 2017 году в вер­сиях 4.11-4.12 (смот­ри ком­мит и статью на LWN).

Proof-of-Concept

Да­вай на нес­коль­ких сис­темах про­верим, что получи­лось. Если интерфейс вза­имо­дей­ствия с PoC тебе кажет­ся стран­ным, то не удив­ляй­ся — он такой и есть. Таков он был в ори­гина­ле, так что не суди стро­го.

Как и ожи­далось, в выводе lsmod не обна­ружи­вает­ся ничего подоз­ритель­ного, одна­ко наша ста­ро‑новомод­ная раз­работ­ка выяв­ляет, что враг не дрем­лет.

nitara2 и Diamorphine на ubuntu 16.04. На этом ядре еще не было пяти­уров­невого пей­джин­га
nitara2 и Diamorphine на ubuntu 22.04
nitara2 и KoviD LMK на ubuntu 22.10
nitara2, Diamorphine и Reptile на Linux 6.5

OUTRO: О ПОРТИРУЕМОСТИ

Ес­ли взять текущий код, убрать про­вер­ку на CONFIG_X86_64 и ском­пилиро­вать его, нап­ример, на Raspberry Pi с Linux 6.1, то он соберет­ся и даже запус­тится. Но ничего не най­дет, что и неуди­витель­но: пол­ноцен­но зарабо­тать на дру­гих архи­тек­турах ему помеша­ют раз­личия в орга­низа­ции их вир­туаль­ных адресных прос­транств. В слу­чае AArch64 дру­гой не толь­ко лей­аут памяти (нап­ример, область для модулей ядра занима­ет 2 Гбайт вмес­то 1520 Мбайт), но и воз­можное чис­ло уров­ней пей­джин­га. Оно варь­иру­ется от двух до четырех в зависи­мос­ти от раз­мера стра­ницы и чис­ла исполь­зуемых бит адре­са. Соот­ветс­твен­но, это меня­ет и области некано­ничес­ких адре­сов, на что в анти­рут­ките есть про­вер­ки.

Что каса­ется сов­мести­мос­ти с раз­личны­ми вер­сиями ядра, код успешно отра­ботал на нес­коль­ких ядрах (4.4, 5.14, 5.15 и 6.5 на x86-64). Это, конеч­но, не зна­чит, что он не упа­дет на каких‑нибудь про­межу­точ­ных вер­сиях или ког­да‑нибудь в будущем. Если вдруг стол­кнешь­ся с чем‑то подоб­ным, не стес­няй­ся сооб­щить мне через GitHub.