Наложение заплаток на ядро обычно требует перезагрузки системы, что не всегда приемлемо (особенно в отношении серверов), однако ядро можно залатать и вживую. Аналогичным образом поступают и защитные системы, руткиты и прочие программы, модифицирующие ядро на лету, но! Практически все они делают это неправильно! Ядро нужно хачить совсем не так! Мыщъх укажет верный путь, пролегающий сквозь извилистый серпантин технических проблем и подводных камней, особенно характерных для многопроцессорных систем.

Самомодифицирующийся код долгое время считался дурным тоном программирования и уделом хакеров-извращенцев. Теоретики от программирования уходили от практических потребностей, порождая сферических коней в вакууме, совершенно не заботясь о проблемах тех, кому на них приходится ездить.

Аналогичным образом обстоят дела и с модификацией ядер операционных систем, разработчики которых предоставляют программисту набор API-функций для управления памятью, процессами и прочими системными ресурсами, но только не самим ядром!

Вмешательство во внутреннюю жизнь ядра — это грязный хак, всегда таящий в себе потенциальную опасность развалить все и вся. Большинство программ, модифицирующих ядро, делают это настолько небрежно, что при знакомстве с ними остается только удивляться, как же они ухитряются работать и не падать? На самом деле они падают, причем на многопроцессорных машинах частота падений существенно увеличивается.

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

Расплатой за корректность становится резко возросшая сложность техники модификации, а также некоторое замедление работы системы, поэтому к hot-patch'у на серверах следует прибегать лишь в тех случаях, когда перезагрузка невозможна или крайне нежелательна.

 

Техника поиска различий

Для создания «горячей» заплатки необходимо иметь diff-файл, показывающий, каким образом была заткнута дыра, после чего нам остается только перевести исправления на язык ассемблера, модифицируя ядро непосредственно в оперативной памяти.

К сожалению, раздобыть diff-файл удается далеко не всегда. Зачастую разработчики распространяют кумулятивные обновления, включающие в себя множество исправлений, не имеющих к дыре никакого отношения и модифицирующих внутренние структуры ядра, в результате чего «горячая» модификация кода влечет за собой необходимость перестройки данных, с которыми работает ядро. А это уже нереально, особенно с учетом того, что обработка данных не атомарна и в момент наложения заплатки старые данные могут находиться на различных стадиях обработки, будучи загруженными в локальные переменные и регистры.

А что делать, если исходные тексты недоступны (как, например, в случае Windows) или в них не удается разобраться?! Тогда необходимо прошвырнуться по security-сайтам, раскурить имеющиеся эксплойты, в общем, разобраться, где прячется уязвимость и как ее устранить.

Достаточно часто первооткрыватели дыры не только сообщают параметры вектора атаки, но и приводят дизассемблерные листинги двоичных модулей (или реконструированный псевдокод) с указанием ошибок, либо описывают обстоятельства атаки, позволяющие найти дыру самостоятельно.

 

Техника горячей модификации ядра

В операционных системах семейства Linux и NT ядро проецируется на единое 4-гигабайтное адресное пространство. В Linux ядро занимает 1 Гб, располагаясь по адресам C000000h – FFFFFFFFh. В NT/W2K/XP ядро по умолчанию отъедает 2 Гб, занимая старшую половину адресного пространства (8000000h – FFFFFFFFh), но если указать ключ /3GB в файле boot.ini (поддерживаемый начиная с Windows 2000 Advanced Server/Datacenter Server), то ядро ужмется до 1 Гб. Ядро FreeBSD вплоть до версии 3.х занимало всего 256 Мб, но, начиная с версии 4.x, разрослось до 1 Гб, оккупируя регион C000000h – FFFFFFFFh.


Упрощенная архитектура NT/W2K/XP/

Память ядра доступна с прикладного уровня через псевдоустройство \Device\PhysicalMemory (NT/W2K/XP) и /dev/kmem (Linux/BSD). В ранних версиях NT псевдоустройство PhysicalMemory было открыто для чтения/записи любому пользователю из группы «Администраторы», однако, начиная с Windows 2003 Server SP1, к нему не может получить доступ даже System.

UNIX-подобные системы тоже закрывают доступ к kmem, и недалек тот день, когда из большинства дистрибутивов оно будет полностью изъято. И хотя псевдоустройство /dev/mem (физическая память до линейной трансляции) по-прежнему в строю и отказаться от него никак не получается (поскольку его используют многие приложения, те же X'ы, например), для модификации ядра оно не годится, поскольку не обеспечивает атомарности, а значит, наложение заплатки может привести к краху системы.

Из драйвера (или, используя терминологию UNIX-подобных систем, «загружаемого модуля»), работающего на нулевом кольце, память ядра защищена от непреднамеренной модификации, однако эту защиту легко отключить. Исключение составляют 64-разрядные версии XP и Висты, в которые встроена неотключаемая защита от умышленной модификации под названием PatchGuard, техника обхода которой описана мыщъх'ом в статье «Взлом patch-guard».

В NT/W2K/XP/Виста-x86 существуют два способа отключения защиты от непреднамеренной модификации из нулевого кольца: статический и динамический. Статический сводится к созданию параметра EnforceWriteProtection типа REG_DWORD со значением 0x0 в HKLM\SYSTEM\CurrentControlSet\Control\SessionManager\MemoryManagement, а динамический осуществляется сбросом WP-бита в управляющем регистре CR0, который расшифровывается как Write Protection. Повторная установка бита включает защиту.

Аналогичным способом можно отключить и защиту ядра в UNIX-подобных системах. Сброс WP-бита действует на аппаратном уровне, открывая все accessibly-станицы для модификации независимо от того, разрешена в них запись или нет. Естественно, текущий уровень привилегий (CPL) не должен превышать CPL модифицируемой страницы, иначе процессор сгенерирует исключения типа «ошибка доступа» (то есть с прикладного уровня ядро все равно остается недоступно).

Сброс WP-бита имеет глобальное воздействие, затрагивающее не только ядро, но и прикладные процессы, поэтому отключать защиту на долгое время крайне нежелательно. Некоторые программы (особенно протекторы исполняемых файлов и некоторые защиты) явно закладываются на генерацию исключения, возникающего при попытке записи в ReadOnly-страницу, и после сброса WP-бита перестают работать.


Адресное пространство NT/W2K/XP в конфигурации по умолчанию

Как вариант - можно поиграться низкоуровневыми функциями семейства pte_x (например, pte_mkwrite), работающими с каталогом страниц. Это более красивый и надежный, однако, увы, системно-зависимый путь, поэтому на практике приходится идти на компромисс, жертвуя надежностью в пользу переносимости.

 

Проблема когерентности и пути ее решения

Итак, теперь мы можем модифицировать ядро, накладывая «горячие» заплатки или перехватывая системные функции, внедряя в их начало команду перехода на свое тело. Большинство руткитов именно так и поступает, забыв о том, что подопытный код может исполняться одновременно с его модификацией, приводя к краху системы. Причем эта «одновременность» довольно относительная. Как известно, на однопроцессорных машинах потоки выполняются последовательно, а не параллельно, и иллюзия «одновременности» создается лишь за счет быстрого переключения между ними.

Допустим, поток А был прерван при исполнении функции foo, после чего планировщик передал управление потоку B, выполняющему функцию bar. Вопрос: что произойдет, если мы модифицируем содержимое foo? Очевидно, когда поток А вновь получит управление, он окажется в совершенно другом окружении, возможно, даже пытаясь продолжить выполнение с середины новой машинной команды!

Причем нет никакой возможности узнать, находится этот участок кода под выполнением или нет! То есть как это нет?! Очень даже есть — просто просматриваем контексты всех потоков (процессов, отложенных функций), при необходимости дожидаясь момента, когда обозначенный код выйдет из-под управления, после чего правим его. Вот и все! Просто, элегантно, но, увы, неработоспособно.

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

Существует несколько универсальных решений этой проблемы. Вот, например, одно из них: внедряем в начало модифицируемой функции команду INT 03h, соответствующую однобайтовому опкоду CCh, и тогда при ее вызове процессор будет генерировать отладочное исключение, перехватываемое нашим обработчиком, передающим управление на пропатченную версию обозначенной функции, расположенную совсем в другом месте. Оригинальная функция (за исключением первого байта) остается неизменной, и потому мы можем не волноваться за то, что какой-то неожиданно проснувшийся поток продолжит ее выполнение.
Поскольку выполнение машинных команд — атомарная операция, то записывать INT 03h можно поверх любой команды, и это гарантированно не приведет к развалу системы, даже если модифицируемая команда исполняется в этот момент на другом процессоре! Процессор выполнит либо оригинальную команду, либо INT 03h. Промежуточное состояние у него попросту отсутствует.
Достоинство этого решения в том, что оно не требует анализа ассемблерного кода исходной функции. Мы просто пишем INT 03h - и все! Недостатки: а) при модификации более чем одной функции обработчик должен анализировать адрес исключения, чтобы определить, куда передать управление; б) это плохо работает с отладчиками (де-факто INT 03h представляет собой программную точку останова); в) часто вызываемые функции при такой методике перехвата будут заметно тормозить, снижая общую производительность.

Более сложное, но вместе с тем и более «технологическое» решение заключается в записи команды jmp near target поверх машинной команды равной или большей длины, где target – адрес модифицируемой функции, которой передается управление.

 

Проблема атомарности и пути ее решения

Запись команды jmp near target должна представлять атомарную операцию, выполняемую целиком за один раз. В противном случае может сложиться ситуация, при которой процессор попытается выполнить «недописанную» команду со всеми вытекающими отсюда последствиями, но инструкция вида mov [mem], reg8/16/32 не позволяет записывать более четырех байт, а потому совершенно непригодна для решения поставленной задачи.

Некоторые хакеры используют SSE-инструкции, позволяющие записывать более четырех байт, и на однопроцессорных машинах такой трюк работает вполне нормально. Но на многопроцессорных системах существует вероятность (пускай и ничтожная) модификации кода в процессе его выполнения, а префикс блокировки шины (LOCK) перед SSE-командами вставлять нельзя.

К счастью, начиная с первопней, в лексиконе процессоров существует замечательная команда CMPXCHG8B, поддерживающая префикс «LOCK» и записывающая одним махом целых 8 байт! Для внедрения 5-байтовой инструкции jmp near target этого более чем достаточно. Естественно, чтобы не затереть оставшиеся 3 байта, сначала мы должны прочитать 8 байт из памяти, наложить на них jmp near target и записать полученную смесь обратно. Вот тут некоторые спрашивают: зачем это делать, ведь jmp – это безусловный переход, и находящиеся за ним команды никогда не получат управления. А затем, что находящиеся за ним команды могли получить управление еще до модификации. Примечание: некоторые трансляторы не поддерживают
инструкцию CMPXCHG8B, и в этом случае ее можно задать через директиву DB или _emit в байтовом виде 0Fh C7h 0Eh.

Готовый пример реализации внедрения jmp near target посредством CMPXCHG8B приведен ниже:

Внедрение jmp near target посредством команды CMPXCHG8B; в регистре EAX передается адрес записи jmp, а в регистре EBX – target

; сохраняем адрес модифицируемой команды
PUSH EAX
; sizeof(jmp near target)
ADD EAX, 5
; вычисление операнда команды jmp near target
SUB EBX, EAX
; ESI - адрес модифицируемой команды
POP ESI
; обнуляем EDX:EAX
XOR EAX, EAX
XOR EDX, EDX
; читаем 8 байт из [ESI]
CMPXCHG8B [ESI]
; заносим в стек 4 старших прочитанных байта
PUSH EDX
; оставляем из них три
INC ESP
; накладываем операнд команды jmp near target
PUSH EBX
; накладываем опкод команды jmp near target
PUSH 0E9000000h
; удаляем 3 нуля
ADD ESP, 3
; подготавливаем регистры к выполнению CMPXCHG8B
POP EBX
POP ECX
; записываем 8 байт в [ESI], блокируя шину
LOCK CMPXCHG8B [ESI]
 

Советы и рецепты по наложению заплатки

Чтобы не связываться с ассемблером, достаточно скопировать исправленный вариант функции в свой модуль — пусть транслятор компилирует, тогда нам останется всего лишь передать на нее управление командой jmp near target (естественно, вместе с функцией необходимо скопировать и все макросы, заданные директивой define, а также подключить необходимые заголовочные файлы).

При этом мы наталкивается на следующие проблемы: а) если функция обращается к глобальным переменным, то мы должны подставить адреса переменных оригинальной функции, иначе поведение системы станет непредсказуемым; б) адреса «внутренних» функций ядра, вызываемые этой функцией, также необходимо подставлять вручную; в) мы не можем приказать компилятору исключить уже выполненные команды, поэтому прежде чем передавать управление откомпилированной функции, следует выполнить откат, повесив на jmp near target промежуточный обработчик, который в данном случае будет выглядеть так:

POP EBP
POP ESI
POP EDI
POP EBP

Как видно, мы выполняем обратную последовательность команд, восстанавливая стек и содержимое регистров, а при необходимости освобождая выделенную функцией память и прочие системные ресурсы.

С Windows в этом плане сложнее. Исходных текстов нет, и вставить исправленную функцию в драйвер не получится. Здесь есть два пути: дизассемблировать ядро и переписать код на Си (трудоемко, зато надежно) или же скопировать функцию прямо в двоичном виде, корректируя ссылки на функции, вызываемые по относительным адресам. Поскольку адрес загрузки драйвера наперед неизвестен, коррекцию приходится осуществлять на лету. Заносим адреса машинных команд call target/jmp target в специальный массив, хранящийся в драйвере, а в процедуре инициализации обрабатываем все элементы, добавляя к непосредственному операнду базовый адрес загрузки, не забыв предварительно отключить защиту от записи, поскольку по
умолчанию кодовая секция доступна только на чтение.

 

Заключение

Заштопать ядро операционной системы без перезагрузки — очень сложно, но вполне реально. Конечно, далеко не всякому администратору это по силам, однако фирмы, занимающиеся поддержкой, могут выпускать неофициальные «горячие» заплатки, расхватываемые, словно пирожки! Ведь это не просто актуальная, а суперактуальная тема, в которой заинтересованы миллионы пользователей, так что насчет спроса можно не сомневаться.


Полную версию статьи
читай в июньском номере
Хакера! Пример реализации KLD-модуля (Dynamic Kernel Linker) для FreeBSD, отключающего защиту ядра от записи при загрузке и включающего ее обратно при выгрузке, приведен в полной версии. Ее ты сможешь найти на прилагаемом к журналу диске.

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии