Трюки от Крыса Крис Касперски Хакер, номер #108, стр. 108-120-1 В прошлом выпуске мы говорили о том, как упросить отладку. Сегодня мы займемся обратной задачей, сосредоточив свое внимание на антиотладочных приемах, препятствующих взлому программ. Сразу же предупрежу, все они системно-зависимы и выходят за рамки классического Си, а потому применять их или не применять - супернеоднозначный вопрос. Но если ты все же надумаешь их применять, эта статья научит тебя это делать правильно. 01. самомодифицирующийся кодУпрощенная стратегия создания самомодифицирующегося кода выглядит приблизительно так:
Так или примерно так поступают тысячи программистов, удивляющихся, почему программа падает при изменении ключей компиляции. А она и должна падать! Формально язык Си позволяет получать указатель на функцию, но оставляет компилятору большую свободу, и во многих случаях вместо указателя на функцию мы получаем указатель на переходник к ней вида JMP [MEM]. Существует только один надежный способ достоверно определить адрес и размер функции - спросить об этом у нее самой! Идея заключается в следующем: при передаче «магических» аргументов функция либо возвращает адрес своего начала/конца и тут же завершается, либо самостоятельно копирует себя в обозначенную локацию. Для нейтрализации возможных побочных эффектов следует использовать квалификатор naked, поддерживаемый MS VC, при котором компилятор не вставляет в функцию никакой отсебятины и даже пролог, эпилог и инструкцию возврата мы должны точить самостоятельно. Простейший вариант реализации приведен ниже. Обработка аргументов для упрощения не показана. Функция foo просто возвращает адрес своего начала. Адрес конца определяется аналогичным образом. Достоверное определение адреса начала функции __declspec(naked) foo(int x) { __asm{ call xxx ; заталкиваем в стек адрес xxx xxx: pop eax ; адрес xxx -> EAX sub eax, 5 ; отнимаем длину CALL retn ; возвращаем адрес функции в регистре EAX } } Для обхода аппаратного DEP (по умолчанию задействованного только для системных приложений) необходимо выделить блок памяти функцией VirtualAlloc с флагами PAGE_EXECUTE_READWRITE или же изменить атрибуты уже выделенного блока вызовом VirtualProtect. 02. функции, которые не возвращают управлениеНичто не раздражает хакеров так, как функции, не возвращающие управления. При трассировке типа Step Over (то есть без захода в функции) отладчик как бы проваливается внутрь очередного CALL'а, теряя управление над отлаживаемой программой. И чем чаще это происходит, тем больше матерится хакер. Конечно, пошаговая трассировка (Step Into) отрабатывает нормально, но это такой геморрой! От взлома не остановит, но по крайней мере доставит психологическое удовлетворение о того, что мы нагадили хакеру. Мелочь, а приятно! Кажется, что для решения поставленной задачи идеальным образом подходит пресловутый оператор goto, но он действует только в пределах одной функции, а за ее пределы вылетать обламывается. Это связано с тем, что каждая функция имеет свой стековый фрейм, свои аргументы и т.д., и потому попытка перехода в середину «чуждой» функции в общем случае ведет к краху программы, даже если использовать различные ассемблерные извращения. Структурные исключения позволяют передавать управление за пределы функции, однако о них знают практически все хакеры, и устроить им ловушку не получится. Только время зря потратим. К тому же дальнобойность структурных исключений также ограничена телом одной функции, а для ловли исключений за ее пределами приходится прибегать к ассемблерным вставкам и ручной установке фильтра посредством модификации указателя, хранящегося в ячейке FS:[0]. Но, во-первых, это нельзя перенести, а во-вторых, на FS:[0] легко установить точку останова на доступ к данным, и тогда отладчик будет отлавливать все исключения и утраты управления над отлаживаемой программой не произойдет. Я полагаю, что самое лучшее, что можно сделать, - подменить адрес возврата из функции. Для этого даже необязательно прибегать к ассемблеру и можно получить практически полностью переносимый вариант, что очень даже хорошо! В упрощенном виде реализация выглядит так: Подмена адреса возврата из функции без ассемблерных извращений// функция, вызываемая из foo // путем подмены адреса возврата __cdecl bar(int a, int b) { // печатаем аргументы printf("%x, %x ***\n",a,b); // сейчас на вершине стека расположен // указатель на функцию exit, которой // и будет передано управление при // выходе из функции bar } __cdecl foo(int a, int b) { // подменяем адрес возврата в main // на адрес функции bar, которой // и будет передано управление *( (int*)&a - 1 ) = (int*)bar; } main() { // вызываем foo foo((int)exit, 2); // сюда мы уже не вернемся } Идея основана на том, что в Си-соглашении аргументы функции заносятся в стек справа налево, то есть в момент вызова функции на вершине стека оказывается крайний левый аргумент, поверх которого забрасывается адрес возврата. Получив указатель на крайний левый аргумент (что можно сделать легальными средствами) и уменьшив его на величину машинного слова, мы локализуем положение адреса возврата, которое можно беспрепятственно модифицировать по своему усмотрению, в частности заменить его адресом другой функции (в данном случае это функция bar). Конструкция «( (int*)(&a) - 1 )» определяет местоположение адреса возврата, опираясь на тот факт, что на 32-разрядных платформах указатель на функцию имеет размер, равный 32 битам, и потому может адресоваться как int*. Это единственный системно-зависимый участок, и, чтобы избываться от зависимости, необходимо вместо int* использовать указатели на функцию, однако они имеют чуть более сложный синтаксис, загромождающий пример лишними круглыми скобками, что отнюдь не способствует его пониманию. При запуске программы на экран вывалится «2, 12ffc0 ***». Как легко заметить, аргументы функции bar оказались сдвинутыми на одну позицию. Почему это произошло? А потому что передача управления на bar осуществляется командой RET, которая работает как JMP, то есть совершает прыжок на bar, забыв положить в стек адрес возврата, роль которого приходится играть аргументу a функции foo, соответствующему аргументу b функции bar. Следовательно, чтобы сохранить все аргументы, необходимо добавить к функции foo один фиктивный аргумент, расположенный слева. 03. Необрабатываемые исключенияО возможности передачи управления посредством структурных исключений мы уже говорили. Да и не только мы говорили. Об этом все говорят. Толку-то от этих исключений… Это даже не антиотладочный прием, а так… Однако в win32 API есть одна довольно любопытная функция SetUnhandledExceptionFilter, устанавливающая фильтр для необрабатываемых исключений, получающий управление только в том случае, если программа находится не под отладкой. Причем это не баг, а документированная фича. Если отладчик установлен, то все необрабатываемые исключения будет ловить он. До выполнения фильтра дело просто не дойдет! Рассмотрим простой пример: Программа, защищенная фильтром необрабатываемых исключенийint a = 0; // делитель // фильтр необрабатываемых исключений // (выполняется, только когда программа не под отладкой) LONG WINAPI foo(struct _EXCEPTION_POINTERS *ExceptionInfo) { // отмечаем свое присутствие на экране printf("***\n"); // увеличиваем делитель a++; // пытаемся разделить еще раз return -1; } main() { int *p=0; int b=0; // устанавливаем фильтр необрабатываемых исключений SetUnhandledExceptionFilter(foo); // совершаем недопустимую операцию b = b /a; // отмечаем свое присутствие на экране printf("here!\n"); } При нормальном выполнении программа выбрасывает исключение, возникающее при делении на ноль, подхватываемое функцией-фильтром foo, которая выводит на экран «***», увеличивает делитель на единицу, повторяя операцию деления еще раз. В результате этого все работает нормально и ни одной мыши при выполнении программы не страдает. А теперь запустим программу под OllyDebugger'ом или другим прикладным отладчиком. И что же?! Отладчик, спотыкнувшись об исключение, застывает, как кролик перед питоном. Попытаемся передать исключение в программу (в OllyDebugger'е это Shift-F7/F8/F9). Обычно это помогает, но только не сейчас! Отладчик зацикливается на исключении, отлавливая его вновь и вновь, а все потому, что функция-фильтр, увеличивающая делитель, не получает управления и увеличивать его становится некому. SoftICE (будучи запущенным) также всплывает, ругаясь на исключение (даже если программа и не находится под отладкой), но после выхода из него все продолжает работать без проблем. А потому против SoftICE этот прием реально никак не действует, хотя если исключения будут сыпаться как из рога изобилия, то хакеру придется конкретно попотеть! |