Противостояние с малварью продолжаетсяКрис Касперски Хакер, номер #103, стр. 024 Боремся с продвинутыми руткитами вручную Создание удаленных потоков — популярный способ внедрения вредоносного кода в доверенные приложения, используемый малварью для обхода антивирусов, персональных брандмауэров и других защитных механизмов, большинство из которых к такому повороту событий явно не готово. Полный дестрой, в общем. Но, чтобы положить ему конец, достаточно немного подумать головой! Попав на целевой компьютер, малварь должна хоть как-то на нем закрепиться. Фактически у малвари есть два пути: создать свой собственный исполняемый файл на диске или внедриться в уже существующий. Первое слишком заметно, да и к тому же персональные брандмауэры, обнаружив новую программу, ломящуюся в сеть, тут же поднимут тревогу, обращаясь к пользователю с вопросом: казнить эту тварь или помиловать? Внедрение в существующие файлы чревато еще более серьезными последствиями. Система блокирует запись в запущенные файлы, и малвари приходится извращаться не по-детски, чтобы преодолеть это ограничение. Небольшой хинт. Переименовать файл, например, explorer.exe в _explorer_.exe, система не запрещает. Можно скопировать _explorer_.exe в explorer.exe и тут же внедрить в последний вредоносный код, после чего убить процесс _explorer_.exe, чтобы система перезапустила его вновь, открывая уже зараженный explorer.exe. Только это все равно не поможет, поскольку не пройдет и секунды, как возмутится SFC, ответственная за контроль целостности системных файлов. Теоретически ее можно отключить (или обновить эталонную копию explorer.exe), но это даже не обсуждается, так как не вариант. Ну, поборем мы SFC, так при очередном обновлении explorer.exe программа windows update сделает круглые глаза: что, это, мол, за китайские новости? Не знает она такого файла и обновлять его не будет! Короче, трогать файлы на диске - неправильно. Более продвинутая малварь внедряется прямо в адресное пространство доверенных процессов (то есть таких процессов, которым заведомо разрешен выход в сеть по меньшей мере по одному порту, например Outlook Express, IE, FireFox, Opera, etc). Такая малварь живет только в оперативной памяти, не оставляя на диске никаких следов, и умирает сразу же после перезагрузки, что существенно затрудняет ее обнаружение. А на счет смерти можно не волноваться, поскольку если компьютер подключен к сети, то малварь будет приходить через дыры вновь и вновь до тех пор, пока жертва их не залатает. Но свежие дыры появляются регулярно, а вот заплатки зачастую не скачиваются годами… Покорение чужого адресного пространства Существует по меньше мере два способа внедрения вредоносного кода в постороннее адресное пространство. Первый: получаем дескриптор процесса-жертвы вызовом OpenProcess, выделяем в нем немного памяти посредством VirtualAllocEx (не забыв установить ей атрибуты «исполняемый регион», поскольку на машинах с активным аппаратным DEP выполнение кода в области данных невозможно), копируем туда вредоносный код через WriteProcessMemory, после чего с помощью недокументированных функций добываем дескриптор основного потока. Далее замораживаем его (SuspendThread), получаем и запоминаем регистровый контекст (GetThreadContext), перебрасываем регистр-указатель команд (EIP) на начало вредоносного кода, обновляем регистровый контекст (SetThreadContext) и размораживаем поток (ResumeThread), передавая управление на свой вредоносный код. Последний делает все, что задумано, и возвращает управление по оригинальному EIP. Все! Программа изнасилована! Достоинство этого способа в том, что он работает как на 9x (95, 98, Me), так и на NT (W2K, XP, Server 2003, Виста, Longhorn). А недостаток - в его заметности. Последовательность вызовов типа OpenProcess\VirtualAllocEx\WriteProcessMemory\SuspendThread\GetThreadContext\ SetThreadContext\ResumeThread практически никогда не встречается в честных программах и выдает малварь с головой. А вот другой подход: получаем дескриптор процесса-жертвы вызовом OpenProcess, выделяем в нем память посредством VirtualAllocEx, копируем туда вредоносный код через WriteProcessMemory и создаем удаленный поток с помощью API-функции CreateRemoteThread (причем, как показано во врезке, без VirtualAllocEx и WriteProcessMemory в некоторых случаях можно обойтись). Достоинства такого подхода в простоте технической реализации и хорошей маскировке, поскольку удаленные потоки активно создаются и многими честными программами. Вот и попробуй определи, кто создал удаленный поток и зачем! Но мы все-таки определим. Долгое время этот способ был второстепенным, поскольку удаленные потоки существуют только в NT, а на 9x вместо функции CreateRemoteThread вставлена «заглушка», всегда возвращающая ошибку. Однако за последние несколько лет доля NT-подобных систем превысила 90% рынка осей, и на совместимость с 9x все махнули рукой, в результате чего создание удаленных потоков стало основным способом внедрения малвари в доверенные процессы. О борьбе с ней мы сейчас и поговорим. Первые эксперименты Загружаем SoftICE (именно SoftICE, OllyDbg и другие отладчики не прокатят), нажимаем <Ctrl-D> и, дождавшись появления на экране черного прямоугольника окна, устанавливаем точку останова на API-функцию CreateRemoteThread, после чего выходим из SoftICE: КОД # установка точки останова на CreateRemoteThread :bpx CreateRemoteThread # выход из SoftICE :x Теперь мы сможем контролировать вызовы CreateRemoteThread, и при каждом таком вызове отладчик будет послушно всплывать, отображая состояние стека, регистров, памяти, передавая нам в руки штурвал. Теперь запустим какое-нибудь приложение, например FAR. Сразу же после появления голубых панелей на экране (ну у кого - голубых, а у автора — черных) всплывает SoftICE, высвечивая имя функции (CreateRemoteThread) в окне CODE и имя программы (FAR) в правом нижнем углу статусной строки (смотри рисунок 1). Ни фига себе! Вроде бы честное приложение… и вдруг удаленные потоки. С какой это стати FAR лезет своими грязными лапами в чужие программы?! Может, у нас потому и глючит все! Но прежде чем высаживаться на полное неглиже и жуткую измену (от которой не спасет даже смесь вальяны с мелатонином), попробуем все-таки разобраться, что это за безобразие такое происходит. Команда stack (смотри рисунок 1) не скажет ничего интересного, и потому приходится прибегать к помощи тяжелой артиллерии. Даем команду dd для переключения дампа в режим вывода двойных слов (смотри рисунок 2), после чего смотрим командой d esp содержимое стека, на вершине которого расположен адрес возврата из функции CreateRemoteThread в вызывающий ее код, равный в данном случае 77E9652Ch (в других версиях NT этот адрес будет отличаться). Смотрим, какой функции он принадлежит: «u 77E9652C», и… видим функцию CreateThread, создающую локальный поток внутри самого FAR'а. И действительно, дизассемблирование библиотеки KERNEL32.DLL с помощью IDA Pro или любого другого дизассемблера доказывает (смотри рисунок 3), что на низком концептуальном уровне (уровне исполнительной системы, являющейся частью ядра) в NT существует только функция CreateRemoteThread. А CreateThread – лишь тонкая «обертка», создающая удаленные потоки в текущем процессе (локальный поток представляет собой частный случай удаленного). Таким образом, чтобы отбросить локальные потоки (нам совершенно неинтересные), необходимо запомнить адрес возврата из CreateRemoteThread в CreateThread, установив простейший фильтр в виде условной точки останова. Согласно синтаксисту SoftICE, она задается так: КОД # удаляем ранее установленные точки останова :bc * # устанавливаем условную точку останова на CreateRemoteThread, # игнорирующую вызовы из CreateThread (локальные потоки) :bpx CreateRemoteThread if (esp->0 != 77E9652C) # выходим на свободу :x Теперь SoftICE уже не всплывает при запуске FAR'а, но тут же выпрыгивает при его закрытии. Как?! Еще один удаленный поток?! Смотрим на правый нижний угол статусной строки (смотри рисунок 4), где сейчас поселился CSRSS, расшифровывающийся как Client/Server Runtime Sub-System (клиент-серверная подсистема времени исполнения), чей адрес возврата равен 5FFAF3F4h. Просматривая карту памяти (командой mod в SoftICE), определяем, что адрес 5FFAF3F4h принадлежит модулю CSRSS.EXE, то есть это действительно клиент-серверная подсистема времени исполнения. Так что все нормально. КОД # определяем адрес возврата из функции CreateRemoteThread # (он лежит на вершине стека и выделен полужирным шрифтом с подчеркиванием) :d esp 0023:00DBF440 5FFAF3F4 00000430 00DBF480 00000000 ..._0........... 0023:00DBF450 77E88C27 00000002 00000000 00DBF48C '..w............ 0023:00DBF460 00DBF53C 00000000 00000001 000C0001 <............... # определяем принадлежность адреса возврата по карте памяти # (адрес принадлежащего ему модуля выделен полужирным шрифтом с подчеркиванием) :mod hMod Base PEHeader Module Name File Name 80400000 804000C8 ntoskrnl \WINNT\System32\NTOSKRN1.EXE 5FF80000 5FF800C0 csrsrv \WINNT\system32\csrsrv.dll 5FF90000 5FF900D0 basesrv \WINNT\system32\basesrv.dll 5FFA0000 5FFA00C8 winsrv \WINNT\system32\winsrv.dll 5FFF0000 5FFF00D0 csrss \WINNT\system32\csrss.exe 77E10000 77E100D8 user32 \WINNT\system32\user32.dll 77F80000 77F800C0 ntdll \WINNT\system32\ntdll.dll И ведь ни фига не нормально! Не можем же мы терпеть постоянные всплытия отладчика, каждый раз разбираясь, «правильно» это или «неправильно»?! Так что наш фильтр нуждается в совершенствовании. Удаляем прежнюю точку останова и создаем новую, отбрасывающую как CreateThread, так и CSRSS. КОД # удаляем ранее установленные точки останова :bc * # устанавливаем условную точку останова на CreateRemoteThread, # игнорирующую вызовы из CreateThread и из CSRSS.EXE :bpx CreateRemoteThread if ((esp->0 != 77E9652C) && (esp->0 != 5FFAF3F4)) # выходим из SoftICE :x Ага! Теперь, даже если ничего не трогать, SoftICE все равно всплывет, ругаясь на системные сервисы (service), имя которых отображено в правом нижем углу статусной строки. Коварство NT не знает пределов! Удаленные потоки создает множество легальных компонентов! Попробуй тут за всеми ними уследи… Так, заканчиваем с нытьем! Системных компонентов не так уж и много. В стерильной W2K SP0 (из которой выкинуты все лишние службы) их меньше десятка. Правда, удаленные потоки могут создавать также и драйверы, но с драйверами все проще: их адрес возврата всегда больше 80000000h, и для фильтрации достаточно забить всего одно условие: (esp->0 < 80000000). Если оно выполняется, отладчик всплывает, если же нет — тихо фильтрует вызов и ждет дальше. В итоге SoftICE превращается в мощный проактивный антивирус, всплывающий только при вызове CreateRemoreThread из зловредных программ, пытающихся внедриться в доверенные приложения. Как этому помешать? Да очень просто! Достаточно заменить дескриптор процесса (лежащий по смещению +4 байта от регистра ESP) нулем, обломив функцию CreateRemoteThread с созданием удаленного потока. КОД # забиваем оригинальный дескриптор потока :E (esp->4) 0 # выходим из SoftICE :X К сожалению, этот метод борьбы с малварью невозможно рекомендовать непродвинутым пользователям — вид SoftICE приводит их в ужас, граничащий с суицидом. Да и таскать SoftICE за собой накладно (и памяти он кушает будь здоров). К тому же адреса точек вызова CreateRemoteThread могут измениться при установке очередной заплатки… А что если попробовать обойтись без SoftICE, написав относительно несложную программу?! Вот этим мы сейчас и займемся! Ловля малвари своими руками на Dll Прямой перехват CreateRemoteThread кажется очевидным решением, но это далеко не лучший и не самый легкий в реализации метод. А давай напишем простейшую DLL, с помощью которой будем отслеживать создание новых потоков! Если динамическая библиотека имеет точку входа, то она вызывается при всяком создании/завершении потоков (а также при загрузке/выгрузке DLL, но нам они сейчас не интересны). Вызов происходит в контексте подопытного процесса до окончательного формирования потока, то есть в тот момент, когда можно получить идентификатор потока, прочитать его контекст и даже завершить поток вызовом TerminateThread, но сам поток еще не начал выполняться, и потому зловредный код отдыхает. Ну а на стеке находится... собственно говоря, гарантированно там находятся только аргументы функции DllMain, а все остальное — системно-зависимо и недокументировано. С другой стороны, user-space стек один, и потому в него неизбежно попадают адреса возврата предыдущих системных функций, участвующих в подготовке потока к запуску. Автор расковырял стек до самого основания, пытаясь определить, зависит ли его содержимое от типа создаваемого потока (локальный или удаленный), и, представь себе, определил! В результате получилось мощное оружие против хакеров, бьющее точно в цель и укладывающееся в пару десятков строчек кода на Си. Нам потребуются две тестовые программы и одна динамическая библиотека-монитор. DLL будет отслеживать создание новых потоков в тех процессах, которые ее загрузят в свое адресное пространство, распечатывая содержимое первых 20h двойных слов от верхушки стека. Одним из таких процессов и будет первая тестовая программа, создающая внутри себя локальный поток. Вторая программа необходима для создания удаленного потока в первой. В общем, все просто как дважды два. Рассмотрим следующий код, совмещающий в себе DLL-монитор и первую тестовую программу. Сделано это совмещение исключительно с целью экономии бумаги, чтобы не переводить лес на дублирующиеся листинги. Алгоритм работы программы ясен из сопутствующих комментариев: КОД #include <stdio.h> #include <windows.h> #include <process.h> // главная (и единственная) функция локального потока, // получающая управление и тут же завершающая поток по return int thread(void *x){ return 0; } // чтобы создать динамическую библиотеку, необходимо // экспортировать хотя бы одну функцию, иначе линкер // от Microsoft нас не поймет, а искать обходные пути // выйдет дороже, чем набить одну строку (и еще 5 строк // комментария к ней, что в сумме дает 6) __declspec(dllexport) char* demo() { return 0;} // главная функция первой тестовой программы // (в DLL она не работает) main() { // объявляем буфер для fgets char buf[666];
// загружаем DLL-монитор в свое адресное пространство LoadLibrary("KPNC.DLL");
// создаем локальный поток _beginthread(thread,0,0);
// ждем нажатия на <ENTER> и отваливаем printf("press enter to exit\n");fgets(buf,66,stdin); } // точка входа в DLL // в тестовой программе она не работает BOOL APIENTRY DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) { int a, *p; // объявляем переменные
switch (fdwReason) // мониторим события { case DLL_THREAD_ATTACH: // создан новый поток!
// распечатываем первые 20h двойных слов от вершины стека p = &a; for(a=0;a<0x20;a++) printf("%08Xh\n",*p++); printf("создание потока: %08Xh,%08Xh\n", hinstDLL,lpvReserved); break;
case DLL_THREAD_DETACH: // поток ушел в суицид printf("завершение потока %08Xh,%08Xh\n",hinstDLL,lpvReserved); break; }
// карету мне, карету, мотаю я отсюда!!! return TRUE; } Компилировать KPNC.c приходится в два прохода. Сначала создаем динамическую библиотеку-монитор, после чего генерируем исполняемый файл тестовой программы. Пара слов об используемых ключах. Ключ ‘/LD’ предписывает компилятору Microsoft Visual Studio создавать динамическую библиотеку. По умолчанию (если его не указать) создается исполняемый файл. Ключ ‘/MT’ (сокращение от Multi-Thread) указывает на необходимость использования многопоточной версии стандартной библиотеки Си. КОД $cl /MT /LD KPNC.c Microsoft (R) 32-bit C/C++ Optimizing Compiler Version 12.00.8168 for 80x86 Copyright (C) Microsoft Corp 1984-1998. All rights reserved. KPNC.c Microsoft (R) Incremental Linker Version 6.00.8168 Copyright (C) Microsoft Corp 1992-1998. All rights reserved. /out:KPNC.dll /dll /implib:KPNC.lib KPNC.obj Creating library KPNC.lib and object KPNC.exp Теперь напишем тестовую программу номер два, создающую удаленный поток в первой тестовой программе. Для простоты идентификатор процесса определяется вручную (через диспетчер задач), а не автоматически. В остальном код программы не содержит ничего интересного: КОД #include <stdio.h> #include <windows.h> #define PID 1368 // идентификатор процесса для создания удаленного потока main() { // объявляем переменные char buf[666]; DWORD id; HANDLE h;
// получаем дескриптор процесса для создания удаленного потока h = OpenProcess(PROCESS_ALL_ACCESS, 0, PID);
// создаем удаленный поток printf("%x\n",CreateRemoteThread(h, 0,0, 0x401481, 0, 0, &id));
// ждем нажатия на клавишу и выходим printf("press enter to exit\n"); fgets(buf,66,stdin); } Все необходимое для экспериментов готово. Самое время приступить к делу! Запустим KPNC.exe на выполнение (смотри рисунок) и вызовем диспетчер задач нажатием <Ctrl-Shift-Esc>. Найдем в нем KPNC.exe, запомнив идентификатор процесса, в данном случае равный 1104. Подставим идентификатор в макрос PID программы remotethread.c, откомпилируем ее с ключами по умолчанию и тут же запустим, не обращая внимания на ругательства компилятора. Тестовая программа номер один тут же грохнется (удаленный поток создается по случайному адресу), но библиотека-монитор успеет вывести на экран содержимое стека, которое нам и нужно. Сравнение содержимого стека в случае создания локального и удаленного потоков обнаруживает одно, но очень существенное их различие (смотри таблицу). На самом дне стека, после цепочки нулей, лежит двойное слово, которое в случае локального потока указывает внутрь NTDLL.DLL, а вот при удаленном потоке туда попадает указатель на user-space стек, равный 0012F788h (естественно, его значение в зависимости от ситуации может варьироваться в очень широких пределах). Вырисовывается следующий алгоритм: спускаемся на дно стека, находим там цепочку нулей, поднимаясь вверх по которой (вверх — это в область младших адресов), встречаем первое ненулевое двойное слово. Если оно лежит внутри NTDLL.DLL (базовый адрес загрузки которой легко получить вызовом GetModuleHandle), то все ОК, это локальный поток. В противном случае давим поток через TerminateThread, предварительно убедившись, что мы не находимся внутри системного процесса, такого как Service.exe или CSRSS.EXE (имя определяется API-функцией GetModuleFileName). И последнее. Чтобы подключить нашу динамическую библиотеку-монитор ко всем запускаемым процессам, ее достаточно прописать в следующей ветке системного реестра: HKLM\SOFTWARE\Microsoft\WindowsNT\CurrentVersion\Windows\AppInit_DLLs, после чего останется только радоваться жизни и обмывать победу над малварью свежим пивом. Такие пироги Стопроцентных защит не существует, и предложенный прием не является исключением. Умная малварь и его сможет обойти, используя более продвинутые механизмы внедрения. Проблема меча и щита не имеет решения, но это не значит, что все щиты идут лесом. Даже плохой щит лучше, чем полное отсутствие такового. К тому же по меньшей мере 2/3 существующих сегодня зловредных программ легко ловятся DLL-монитором, в то время как разрекламированные антивирусные комплексы KAV, Dr.Web, NOD32 сидят себе в трее и даже не хрюкают. Выводы делай сам! Жизнь без WriteProcessMemory Оба метода внедрения используют VirtualMemoryEx/WriteProcessMemory для выделения памяти в адресном пространстве чужого процесса и копирования туда вредоносного кода, что легко демаскирует малварь, поскольку к числу распространенных эти API-функции явно не относятся. Ну, ладно, без VirtualMemoryEx еще как-то можно обойтись, забросив код на вершину стека, но чем заменить WriteProcessMemory?! А вот чем!!! Помещаем зловредный код в динамическую библиотеку с неприметным названием. Находим в адресном пространстве чужого процесса функцию LoadLibrary, находящуюся в KERNEL32.DLL, которая вплоть до Висты у всех процессов располагается по тем же самым виртуальным адресам (то есть нам достаточно прочитать адресное пространство своего процесса, что никакая защита не запрещает). Теперь создаем удаленный поток внутри жертвы, указав в качестве стартового адреса потока адрес LoadLibrary и передав ей указатель на имя нашей DLL с единственным аргументом. Естественно, это имя должно присутствовать в ее адресном пространстве, а потому нам опять-таки приходится сканировать внутренности KERNEL32.DLL (или, например, NTDLL.DLL), выискивая подходящую текстовую строку. Вот, собственно, и все. Зловредная динамическая библиотека будет загружена в адресное пространство процесса-жертвы, DllMain получит управление, и DLL сможет делать все, что ей заблагорассудиться. Предложенный способ легко отлавливает такое проникновение, а защитные механизмы, основанные на WriteProcessMemory (их, кстати говоря, больше всего), - нет. WARNING Рассматривать представленный способ как стопроцентный ни в коем случае нельзя. Даже сейчас есть множество руткитов, которые легко обходят описанный механизм защиты. DVD Исходные тексты программы, а также компилятор для сборки мы заботливо выложили на наш DVD-диск. |