Накладывание обновлений на сервера Windows и *nix без перезагрузки
Крис Касперски
Наложение заплаток на ядро обычно требует перезагрузки системы, что не всегда приемлемо (особенно, в отношении серверов), однако, ядро можно залатать и в «живую». Аналогичным образом поступают и защитные системы, rootkit’ы и прочие программы, модифицирующие ядро на лету, но! Практически все они делают это неправильно! Ядро нужно хачить совсем не так! Мыщъх укажет верный путь, пролегающий сквозь извилистый серпантин технических проблем и подводных камней, особенно характерных для многопроцессорных систем.
Содержание:
- Введение
- Техника поиска различий
- Техника горячей модификации ядра
- Проблема когерентности и пути ее решения
- Проблема атомарности и пути ее решения
- Советы и рецепты по наложению заплатки
- Заключение
Самомодифицирующийся код долгое время считался дурным тоном программирования и уделом хакеров-извращенцев. Теоретики от программирования уходили от практических потребностей, порождая сферических коней в вакууме, совершенно не заботясь о проблемах тех, кому на них приходится ездить.
Аналогичным образом обстоят дела и с модификацией ядер операционных систем, разработчики которых предоставляют программисту набор API-функций для управления памятью, процессами и прочими системными ресурсами, но только не самим ядром!
Вмешательство во внутреннюю жизнь ядра — это грязный хак, всегда таящий в себе потенциальную опасность развалить все и вся. Большинство программ, модифицирующих ядро, делают это настолько небрежно, что при знакомстве с ними остается только удивляться — как же они ухитряются работать и не падать? На самом деле, они падают, причем на многопроцессорных машинах частота падений существенно увеличивается.
Корректная модификация отличается от некорректной тем, что гарантирует сохранение работоспособности, и потому практически безопасна. Она может применяться не только в хакерских программах, не обращающих никакого внимания на стабильность, но и в «промышленных» установках.
Расплатой за корректность становится резко возросшая сложность техники модификации, а также некоторое замедление работы системы, поэтому к hot-patch’у на серверах следует прибегать лишь в тех случаях, когда перезагрузка невозможна или крайне нежелательна.
Для создания «горячей» заплатки необходимо иметь diff-файл, показывающий каким образом была заткнута дыра, после чего нам остается только перевести исправления на язык ассемблера, модифицируя ядро непосредственно в оперативной памяти.
К сожалению, раздобыть diff-файл удается далеко не всегда. Зачастую, разработчики распространяют кумулятивные обновления, включающие в себя множество исправлений, не имеющих к дыре никакого отношения и модифицирующие внутренние структуры ядра, в результате чего «горячая» модификация кода влечет за собой необходимость перестройки данных, с которыми работает ядро, а это уже нереально, особенно с учетом того, что обработка данных не атомарна, и в момент наложения заплатки старые данные могут находиться на различных стадиях обработки, будучи загруженными в локальные переменные и регистры.
А что делать, если исходные тексты недоступны (как, например, в случае Windows) или в них не удается разобраться?! Тогда необходимо прошмыгнуться по security-сайтам, раскурить имеющиеся exploit’ы, вообщем, так или иначе разобраться, где прячется уязвимость, и как ее устранить.
Достаточно часто первооткрыватели дыры не только сообщают параметры вектора атаки, но и приводят дизассемблерные листинги двоичных модулей (или реконструированный псевдокод) с указанием ошибок, либо описывают обстоятельства атаки, позволяющие найти дыру самостоятельно.
Техника горячей модификации ядра
В операционных системах семейства 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.
Память ядра доступна с прикладного уровня через псевдоустройство \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, техника обхода которой описана мыщъх’ем в статье "".
В NT/W2K/XP/Виста-x86 существует два способа отключения защиты от непреднамеренной модификации из нулевого кольца: статический и динамический. Статический сводится к созданию параметра EnforceWriteProtection типа REG_DWORD со значением 0×0 в HKLM\SYSTEM\CurrentControlSet\Control\SessionManager\MemoryManagement, а динамический осуществляется сбросом WP-бита в управляющем регистре CR0, который расшифровывается как Write Protection. Повторная установка бита включает защиту.
Аналогичным способом можно отключить и защиту ядра в UNIX-подобных системах. Сброс WP-бита действует на аппаратном уровне, открывая все accessibly-станицы для модификации независимо от того, разрешена ли в них запись или нет. Естественно, текущий уровень привилегий (CPL) не должен превышать CPL модифицируемой страницы, иначе процессор сгенерирует исключения типа «ошибка доступа» (т.е. с прикладного уровня ядро все равно остается недоступно).
Сброс WP-бита носит глобальное воздействие, затрагивающее не только ядро, но так же распространяющиеся и на прикладные процессы, поэтому отключать защиту на долгое время крайне нежелательно. Некоторые программы (особенно протекторы исполняемых файлов и некоторые защиты) явно закладываются на генерацию исключения, возникающего при попытке записи в ReadOnly-страницу, и после сброса WP-бита перестают работать.
Как вариант, можно поиграться низкоуровневыми функциями семейства pte_x (например, pte_mkwrite), работающих с каталогом страниц. Это более красивый и надежный, однако, увы, системно-зависимый путь, поэтому на практике приходится идти на компромисс, жертвуя надежностью в пользу переносимости.
Проблема когерентности и пути ее решения
ОК, теперь мы можем модифицировать ядро, накладывая «горячие» заплатки или перехватывая системные функции, внедряя в их начало команду перехода на свое тело. Большинство rootkit’ов именно так и поступает, забыв о том, что «подопытный» код может исполняться одновременно с его модификацией, приводя к краху системы. Причем, эта «одновременность» довольно относительна. Как известно, на однопроцессорных машинах потоки выполняются последовательно, а не параллельно, и иллюзия «одновременности» создается лишь за счет быстрого переключения между ними.
Допустим поток А был прерван при исполнении функции 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 и записывающая одним махом целых восемь байт! Для внедрения пятибайтовой инструкции jmp near target этого более чем достаточно. Естественно, чтобы не затереть оставшиеся три байта, сначала мы должны прочитать восемь байт из памяти, наложить на них 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 ; удаляем три незначащих нуля 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 в специальный массив, хранящийся в драйвере, а в процедуре инициализации обрабатываем все элементы, добавляя к непосредственному операнду базовый адрес загрузки, не забыв предварительно отключить защиту от записи, поскольку по умолчанию кодовая секция доступна только на чтение.
Заштопать ядро операционной системы без перезагрузки — очень сложно, но вполне реально. Конечно, далеко не всякому администратору это по силам, однако фирмы, занимающиеся поддержкой, могут выпускать неофициальные «горячие» заплатки, расхватываемые словно пирожки! Ведь это не просто актуальная, а супер-актуальная тема, в которой заинтересованы миллионы пользователей, так что на счет спроса можно не сомневаться.
DVD
-
Пример реализации KLD-модуля (Dynamic Kernel Linker) для FreeBSD, отключающего защиту ядра от записи при загрузке и включающий ее обратно при выгрузке, приведен в полной версии статьи. Ее ты сможешь найти на прилагаемом к журналу диске.
INFO
-
В Linux и xBSD существует возможность скомпилировать монолитное ядро без поддержки загружаемых модулей, что благотворно сказывается на безопасности, но затрудняет наложение «горячих» заплаток. Однако, если псевдоустройство /dev/mem остается доступным (а чаще всего дела обстоят именно так), мы можем найти в памяти таблицу системных вызовов и внедрить в ядро свой собственный код, работающий на нулевом кольце и накладывающий заплатку по описанной мыщъхем методике.
Статья опубликована в июньском номере журнала «Xakep» за 2007 год.



