Автор: (c)Крис Касперски ака мыщъх
В прошлом выпуске мы говорили о том, как упростить отладку. Сегодня же займемся обратной задачей, сосредоточив свое внимание на антиотладочных приемах, препятствующих взлому программ. Сразу же предупредим - все они системно-зависимы и выходят за рамки классического Си, а потому применять их или не применять - неоднозначный вопрос, но если вы все же их применяете, то эта статья научит вас применять их правильно.
Упрощенная стратегия создания самомодифицирующегося кода выглядит приблизительно так: 1) получаем указатель на "подопытную" функцию; 2) вычисляем размер функции, вычитая из указателя на следующую функцию указатель на "подопытную", надеясь, что компилятор расположит функции в памяти в порядке их объявления; 3) выделяем блок памяти в стеке или куче; 4) копируем туда подопытную функцию; 5) издеваемся над ней как заблагорассудится - например, расшифровываем на лету.
Так или примерно так поступают тысячи программистов, удивляющихся - почему программа падает при изменении ключей компиляции. А она и должна падать. Формально язык Си позволяет получать указатель на функцию, но оставляет компилятору большую свободу и во многих случаях вместо указателя на функцию мы получаем указатель на переходник к ней вида JMP [MEM].
Существует только один надежный способ достоверного определить адрес и размер функции - спросить об этом у нее самой! Идея заключается в следующем: при передаче "магических" аргументов функция либо возвращает адрес своего начала/конца и тут же завершается, либо самостоятельно копирует себя в обозначенную локацию.
Для нейтрализации возможных побочных эффектов следует использовать квалификатор naked, поддерживаемый MS VC, при котором компилятор не вставляет в функцию никакой "отсебятины" и даже пролог, эпилог и инструкцию возврата мы должны точить самостоятельно. Простейший вариант реализации приведен ниже. Обработка аргументов для упрощения не показана. Функция foo просто возвращает адрес своего начала. Адрес конца определяется аналогичным образом.
Листинг 1. Достоверное определение адреса начала функции.
Для обхода аппаратного DEP (по умолчанию задействованного только для системных приложений) необходимо выделить блок памяти функцией VirtualAlloc с флагами PAGE_EXECUTE_READWRITE или же изменить атрибуты уже выделенного блока вызовом VirtualProtect.
Ничто не злит хакеров так, как функции, не возвращающие управления. При трассировке типа Step Over (т.е. без захода в функции), отладчик как бы "проваливается" внутрь очередного CALL'а, теряя управление над отлаживаемой программой. И чем чаще это происходит, тем больше матерится хакер. Конечно, пошаговая трассировка (Step Into) отрабатывает нормально, но это такой геморрой - от взлома не остановит, но, по крайней мере, доставит психологическое удовлетворение, что мы нагадили хакеру. Мелочь, а приятно!
Кажется, что для поставленной задачи идеальным образом подходит пресловутый оператор goto, но он действует только в пределах одной функции, а за ее пределы вылетать обламывается. Это связано с тем, что каждая функция имеет свой стековый фрейм, свои аргументы, etc и потому попытка перехода в середину "чужой" функции в общем случае ведет к краху программы, даже если использовать различные ассемблерные извращения.
Структурные исключения позволяют передавать управление за пределы функции, однако о них знают практически все хакеры и устроить им ловушку не получится. Только время зря потратим. К тому же, дальнобойность структурных исключений также ограничена телом одной функции, а для ловли исключений за ее пределами приходится прибегать к ассемблерным вставкам и ручной установке фильтра посредством модификации указателя, хранящегося в ячейке FS:[0], что во-первых, не переносимо, а во-вторых, на FS:[0] легко установить точку останова на доступ к данным и тогда отладчик будет отлавливать все исключения и утраты управления над отлаживаемой программой не произойдет.
ИМХО, самое лучшее, что можно сделать - подменить адрес возврата из функции. Для этого даже необязательно прибегать к ассемблеру и можно получить практически полностью переносимый вариант, что очень даже хорошо!
В упрощенном виде реализация выглядит так:
Листинг 2. Подмена адреса возврата из функции без ассемблерных извращений.
Идея основана на том, что в Си-соглашении аргументы функции заносятся в стек справа налево, то есть в момент вызова функции на вершине стека оказывается крайний левый аргумент, поверх которого забрасывается адрес возврата. Получив указатель на крайний левый аргумент (что можно сделать легальными средствами) и уменьшив его на величину машинного слова, мы локализуем положение адреса возврата, которое можно беспрепятственно модифицировать по своему усмотрению, в частности, заменить его адресом другой функции (в данном случае это функция bar).
Конструкция "((int *)(&a) - 1 )" определяет местоположение адреса возврата, закладываясь на тот факт, что на 32-разрядных платформах указатель на функцию имеет размер равный 32 битам и потому может адресоваться как int*. Это единственный системно-зависимый участок и чтобы избываться от зависимости, необходимо вместо int* использовать указатели на функцию, однако они имеют чуть более сложный синтаксис, загромождающий пример лишними круглыми скобками, что отнюдь не способствует его пониманию.
При запуске программы на экран вывалится "2, 12ffc0 ***" Как легко видеть, аргументы функции bar оказались сдвинутыми на одну позицию. Почему это произошло? А потому, что передача управления на bar осуществляется командой RET, которая работает как JMP, то есть совершает прыжок на bar, "забыв" положить в стек адрес возврата, роль которого приходится играть аргументу "a" функции foo, соответствующего аргументу "b" функции bar. Следовательно, чтобы сохранить все аргументы, необходимо добавить к функции foo один фиктивный аргумент, расположенный слева.
Про возможность передачи управления посредством структурных исключений мы уже говорили. Да и не только мы говорили. Об этом все говорят. Толку-то от этих исключений... Это даже не антиотладочный прием, а так... Однако, в Win32 API есть одна довольно любопытная функция SetUnhandledExceptionFilter, устанавливающая фильтр для необрабатываемых исключений, получающий управление только в том случае, если программа находится не под отладкой. Причем, это не баг, а документированная фича. Если отладчик установлен, то все необрабатываемые исключения будет ловить он. До выполнения фильтра дело просто не дойдет!
Рассмотрим простой пример:
Листинг 3. Программа, защищенная фильтром необработанных исключений.
При нормальном выполнении он выбрасывает исключение, возникающее при делении на ноль, подхватываемое функцией-фильтром foo, которая выводит на экран "***", увеличивает делитель на единицу, повторяя операцию деления еще раз, в результате чего все работает нормально и ни одной мыши при выполнении программы не страдает.
А теперь запустим программу под OllyDebugger'ом или другим прикладным отладчиком. И что же?! Отладчик, споткнувшись об исключение, застывает, как кролик перед питоном. Пытаемся передать исключение в программу (в OllyDebugger'е это Shift-F7/F8/F9). Обычно это помогает, но только не сейчас! Отладчик зацикливается на исключении, отлавливая его вновь и вновь, а все потому, что функция-фильтр, увеличивающая делитель, не получает управления и увеличивать его становится некому.
Soft-Ice (будучи запущенным) также всплывает, ругаясь на исключение (даже если программа и не находится под отладкой), но по выходу из него все продолжает работать без проблем, а потому против Soft-Ice этот прием реально никак не действует, хотя если исключения будут сыпаться как из рога изобилия, то хакеру придется конкретно попотеть!