Издательский дом ООО "Гейм Лэнд"ЖУРНАЛ ХАКЕР #110, ФЕВРАЛЬ 2008 г.

Обзор эксплойтов

Крис Касперски

Хакер, номер #110, стр. 110-050-1

Новогоднюю ночь я провел наедине с самым близким мне существом — с монитором. Ковырял разные оси. И наковырял! В одном только ядре Висты шесть дыр, болтающихся там со времен NT. И половина критических. И это без учета мелких брызг в прочих программных продуктах! Под бой курантов я реализовал принципиально новый тип атак на стек ring-3 (условно названный мной stack-crossover attack). В общем, новогодние праздники оказались необычайно продуктивными, и два последующих обзора эксплойтов было решено посвятить описанию багов, собственноручно обнаруженных мной. Демонстрационные эксплойты прилагаются, а вот заплаток пока еще нет, и неизвестно, когда они вообще будут...

Windows MessageBeep API: отказ в обслуживании

Brief

Применительно к незатейливой системной функции MessageBeep выражение «Кричи хоть до посинения» приобретает отнюдь не фигуральный, а вполне конкретный смысл, сопровождаемый синим экраном смерти. Но все по порядку. Функция MessageBeep (издающая простой набор звуков в стиле SystemAsterisk, SystemExclamation, SystemHand, SystemQuestion и SystemDefault) экспортируется динамической библиотекой USER32.DLL, при дизассемблировании которой мы наталкиваемся на тонкую обертку, ведущую с прикладного уровня вглубь ядра через прерывание INT 2Eh (W2K) или же машинную команду SYSENTER (XP и все последующие системы). Ядро, в свою очередь, перекладывает обработку вызова MessageBeep на драйвер WIN32K.SYS, в котором и сосредоточена львиная доля подсистем USER32 и GDI32. Однако сам по себе драйвер WIN32K.SYS не может издавать никаких звуков (ну разве что бибикнуть встроенным спикером) и потому поручает это дело драйверу звуковой карты, ставя соответствующий музон в очередь и возвращая управление до того, как он будет проигран. Ну и какая проблема? А вот какая: за короткий отрезок времени прикладной код может поставить в очередь на воспроизведение сотни тысяч звуков. В результате этого мы в лучшем случае получим ~90%-ную загрузку ядра, продолжающуюся до тех пор, пока вся эта симфония не отыграет, а играть она будет долго, так что семь бед — дави «Ресет». Причем никаким путем очистить очередь невозможно, и звуковая карта будет пиликать даже после завершения зловредного процесса. Но это еще что! Подумаешь, компьютер тормозит как асфальтный каток. Достаточно многие драйверы звуковых карт содержат ошибки, приводящие к выпадению в BSOD со всеми вытекающими отсюда последствиями.

Targets

NT, W2K, XP, Server 2003, Server 2008, Виста.

Exploit

Исходный код эксплойта прост до безобразия и состоит фактически из одной строки:

for (int a = 0; a < 966666666; a++) MessageBeep(0);

Естественно, если вызывать MessageBeep из разных потоков, то дело пойдет быстрее и вероятность выпадения в BSOD многократно возрастет, причем эта атака может быть реализована не только локально, но и через скриптовые языки, поддерживаемые браузерами.

Solution

Я не сообщал об этой проблеме Microsoft, так что официальная позиция последней по рассматриваемому вопросу отсутствует. Лично я просто пропатчил код функции MessageBeep, воткнув перед выходом вызов Sleep(69), выдерживающий паузу в 69 мс и только потом возвращающий управление. На нормальной работе системы это обстоятельство никак не отражается, а вот забить очередь зловредному коду уже не удастся.

SetUnhandledExceptionFilter: design bug

Brief

API-функция SetUnhandledExceptionFilter, экспортируемая динамической библиотекой KERNEL32.DLL, позволяет процессу устанавливать фильтр необрабатываемых структурных исключений, заменяющий собой системный фильтр, завершающий приложение в аварийном режиме с предсмертной надписью: «Программа совершила недопустимую операцию». Архитектурно SetUnhandledExceptionFilter относится ко всему процессу в целом, но конструктивно обработчик вызывается в контексте потока, возбудившего исключение, что требует определенного количества стековой памяти. Если же свободного стекового пространства нет, то вместо ожидаемой генерации EXCEPTION_STACK_OVERFLOW процессор возбуждает исключение EXCEPTION_ACCESS_VIOLATION. Это совсем неудивительно, так как сегмент стека занимает все адресное пространство и реальное переполнение стека происходит только тогда, когда ESP вплотную приближается к нулевому адресу. Но поскольку первые 64 Кб адресного пространства в NT зарезервированы для отлова нулевых указателей, такая ситуация никогда не случается, и операционная система лишь эмулирует EXCEPTION_STACK_OVERFLOW. Всякий раз, когда процессор генерирует ошибку доступа к памяти (EXCEPTION_ACCESS_VIOLATION), ядро смотрит, выходит ли стек потока за отведенный ему регион памяти (а по умолчанию потоку выделяется 1 Мб), и если да, то обработчику исключений передается код EXCEPTION_STACK_OVERFLOW, который вместе с прочими параметрами кладется в стек! Но ведь стека у нас уже нет, так? И как же мы можем туда что-то положить?

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

Как это можно использовать для атаки? Очень просто: внедряемся в процесс, сбрасываем ESP в ноль и все. Процесс умирает. То же самое происходит при создании в нем удаленного потока API-функцией CreateRemoteThread.

Вот тут некоторые могут спросить: а зачем так извращаться? Если мы можем внедриться в процесс-жертву, достаточно вызывать API-функцию TerminateProcess и все! Ан нет. У процесса легко отобрать право завершать себя; прикладные функции ExitProcess/TerminateProcess защитному механизму легко перехватить; наконец, «легальная» смерть процесса элементарно «документируется» путем рассылки широковещательных сообщений, подхватываемых теневым процессом для перезапуска текущего. Именно так антивирусы и брандмауэры сражаются с малварью. Но вот сброс ESP в ноль при первом же обращении к стеку порождает исключение, после которого не может быть выполнена ни одна API-функция прикладного уровня.

Targets

NT, W2K, XP, Server 2003, Server 2008, Виста.

Exploit

Ядро эксплойта, срубающего любой процесс при внедрении в него, выглядит так: «asm{xor esp, esp};».

Solution

Решения описываемой проблемы, по-видимому, не существует, но есть некий workaround – все критические процессы запускать из под отладочного процесса и мониторить его состояние. Процесс-отладчик получает исключение EXCEPTION_ACCESS_VIOLATION до завершения отлаживаемого процесса, что позволяет разрулить ситуацию и продолжить нормальное выполнение. Впрочем, отладчики типа OllyDebugger на это неспособны, и в настоящий момент я пишу свою собственную утилиту для отражения возможных атак.

OllyDebugger: неверное определение адреса падения

Brief

OllyDebugger, ставший де-факто стандартным отладчиком ring3, широко используется не только для взлома программ, но и для их отладки. Ну да, ведь это же отладчик, а не лом :). А отлаживать приходится в том числе и программы, находящиеся в состоянии клинической смерти (то есть после критического сбоя). Естественно, при этом мы неявно постулируем, что сам отладчик работает правильно и выдает достоверную информацию. К сожалению, OllyDebugger 1.10 содержит ряд ошибок, и вот одна из них. Когда стековое пространство реально заканчивается (на самом деле там остается еще одна страница с атрибутами, выставленными по умолчанию в PAGE_NOACCESS), система генерирует EXCEPTION_ACCESS_VIOLATION по адресу 00031000h (в однопоточной программе, скомпилированной MS VC 6.0 с настройками по умолчанию и без рандомизации стекового пространства, впервые появившейся в Висте). Однако OllyDebugger, перепутав trap с fault'ом, выносит неверное суждение и сообщает об ошибке доступа по адресу 00030FFCh (при условии, что запись в стек производится командой PUSHD). Последствия — весь анализ летит к черту и хакер не понимает, откуда тут взялось 00030FFCh, когда по всему ведь должно быть 00031000h. Но человек хакер — это ладно. Наступит пару раз на грабли и образумится. С реанимационными скриптами все намного сложнее.

Еще несколько лет назад я опубликовал в «Системном администраторе» статью «Практические советы по восстановлению системы в боевых условиях», где рассказал о том, как написать собственный обработчик критических ошибок, восстанавливающий работоспособность программы и возвращающий ее в более или менее стабильное состояние, которое как минимум позволяло бы сохранить все несохраненные данные (текст самой статьи можно бесплатно скачать с моего сервера: http://nezumi.org.ru/zq-degluck.zip). В качестве основного движка сначала использовался отладчик (сперва MS WinDbg, затем Olly), и оказалось, что в некоторых ситуациях Olly спотыкается и программа падает окончательно, поэтому пришлось возвращаться к MS WinDbg, который, кстати говоря, за последние несколько лет резко поумнел и превратился в достойный инструмент с хорошо документированным интерфейсом расширений.

Target

OllyDebugger 1.10/2.00.

Exploit

__asm{rool: push eax/jmp rool}

Solution

Я написал автору OllyDebugger'а письмо с описанием ошибки, но ответа так и не получил. Что ж, будем ждать.

Новый тип атак на стек — stack-crossover

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

Беглый анализ, выполненный мной в новогоднюю ночь, выявил большое количество приложений, подверженных угрозе подобного типа, от которой никто из программистов даже и не пытался защищаться (действительно, сложно защищаться от того, чего не знаешь). Начнем с азов, то есть с документированных, но малоизвестных особенностей организации стековой памяти. При создании нового потока система создает и новый стек, резервируя (MEM_RESERVE) необходимое количество страниц памяти (по умолчанию 1 Мб, но эта величина может быть изменена параметром dwStackSize API-функции CreateThread, а размер первичного стека, создаваемого при старте процесса, берется из заголовка PE-файла и может меняться линкером).

На дно стека ложится, выражаясь в терминах DEC, так называемая «желтая сторожевая страница» (yellow guard page), или в терминологии Microsoft просто PAGE_GUARD. Это выделенная (MEM_COMMIT) страница памяти с атрибутами (PAGE_READWRITE | PAGE_GUARD). При первом обращении к ней генерируется исключение STATUS_GUARD_PAGE_VIOLATION, перехватываемое системой, которая снимает атрибут PAGE_GUARD с текущей страницы, выделяет следующую страницу памяти и назначает ее сторожевой путем присвоения атрибута PAGE_GUARD. Таким образом, по мере роста стека сторожевая страница перемещается наверх, а стеку выделяется все больше и больше памяти.

Это достаточно известный факт, описанный в MSDN. А вот что в MSDN не описано, так это то, что сразу же при создании стека в непосредственной близости от его вершины размещается (опять-таки выражаясь в терминах DEC) «красная сторожевая страница» (red guard page), за которой идет выделенная страница памяти с атрибутами PAGE_READWRITE, и уже на самой вершине стека располагается страница PAGE_NOACCESS. В результате этого фактический размер стека на три страницы (12 Кб) меньше обозначенного.

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

Проблема в том, что ни MSDN, ни популярные книги по программированию не говорят, что именно нужно делать, и 99,9% программистов допускают одну и ту же фатальную ошибку. Предположим, что в нашей программе имеется рекурсивная функция, которая при определенных обстоятельствах может съесть весь стек целиком (на приложениях С++ такое часто случается). Когда желтая сторожевая страница докатывается до красной, генерируется исключение EXCEPTION_STACK_OVERFLOW, но при этом красная сторожевая страница становится «зеленой», то есть лишенной каких бы то ни было защитных атрибутов. Если программист устанавливает фильтр, отлавливающий EXCEPTION_STACK_OVERFLOW и, например, завершающий рекурсивную функцию с тем или иным кодом ошибки, то... все «как бы» работает, но при повторном возникновении аналогичной ситуации красная сторожевая страница уже будет отсутствовать и при достижении предыдущего барьера исключение EXCEPTION_STACK_OVERFLOW уже не будет генерироваться. Рекурсивная функция продолжит исполняться и дальше, отъедая одну страницу за другой. А вот когда она со всего маху врежется в последнюю стековую страницу (в ту, которая с атрибутами PAGE_NOACCESS), процессор сгенерирует исключение EXCEPTION_ACCESS_VIOLATION и передаст его ядру. Ядро увидит, что стек исчерпан и передавать управление SEH-обработчику нет никакой возможности, поскольку он сам нуждается в стеке, а стека-то нет. В результате произойдет тихая смерть процесса на ядерном уровне без передачи управления на ring-3.

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

А теперь задумаемся: зачем Microsoft застолбила последнюю стековую страницу, выставив ее в PAGE_NOACCESS?! Ответ: вот для пионеров и застолбила, в противном случае при повторном переполнении стека (когда красной сторожевой страницы уже нет) программа вылетела бы за пределы за его пределы и пошла «чесать» совершенно посторонние данные, никоим образом ей не принадлежащие. Вот такой, значит, механизм защиты стека от переполнения мы имеем в Windows-системах. Чисто программный и совсем не аппаратный. Почему не аппаратный? Так ведь тогда на стек каждого потока ядру пришлось бы заводить свой собственный селектор, а их количество в x86 небезгранично и намного меньше, чем потоков в средненагруженной системе. К тому же адресовать локальные переменные пришлось бы через префикс SS – прощай, плоская модель памяти, и здравствуйте, тормоза!

К нашему хакерскому счастью (и большому программистскому несчастью), программную защиту легко одолеть. Для этого достаточно вызывать из shell-кода API-функцию VirtualAlloc, присвоив последней странице стека статус MEM_COMMIT. А если еще сбросить атрибут PAGE_GUARD у красной сторожевой страницы (что можно сделать вызовом VirtualProtect), то совсем хорошо. Аналогичного результата можно добиться, просто перезаписав адрес возврата из функции с переполняющимся буфером указателем на API-функции VirtualAlloc/VirtualProtect, передав им атрибуты через стек. Это работает на системах с неисполняемым стеком (XP SP2 с аппаратной поддержкой DEP), но, увы, на Висте из-за рандомизации адресного пространства приходится искать более изощренные пути.

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

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

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

Содержание