Автор: (c)Крис Касперски ака мыщъх
В Windows постоянно обнаруживаются новые дыры, через которые лезет малварь, создающая новые процессы или внедряющаяся в уже существующие. Мыщъх предлагает универсальный метод обнаружения малвари, основанный на определении подлинного стартового адреса потока, чего другие приложения (включая могучий отладчик soft-ice) делать не в состоянии.
Антивирусы, брандмауэры и прочие системы защиты (вроде упомянутого во многих местах Buffer Zone) хорошо справляются с вирусами и червями, но в борьбе с малварью они бессильны. Чтобы не утонуть в терминологической путанице, здесь и далее по тексту под малварью мы будем понимать программное обеспечение, скрытно проникающее на удаленный компьютер и устанавливающее там back-door или ворующее секретную информацию.
В первую очередь нас будет интересовать малварь, не способная к размножению и зачастую написанная индивидуально для каждой конкретной атаки, а потому существующая в единственном экземпляре. При условии, что она не распознается эвристическим анализатором (а обмануть эвристический анализатор очень легко), антивирус ни за что не поймает ее, поскольку такой сигнатуры еще нет в его базе, да и откуда бы она там взялась?!
Персональный брандмауэр тоже не слишком надежная защита. Множество дыр дают злоумышленнику привилегии SYSTEM (что повыше администратора будет) с которыми можно творить все, что угодно - в том числе, и принимать/отправлять пакеты в обход брандмауэра.
Тем не менее, обнаружить присутствие малвари на компьютере все-таки возможно. Автор этой статьи проанализировал множество зловредных программ и обнаружил их слабые места, выдающие факт внедрения с головой.
Наиболее примитивные экземпляры малвари создают новый процесс, который внимательный пользователь легко обнаружит в "Диспетчере задач". Конечно, для этого необходимо знать, какие процессы присутствуют в "стерильной" системе и где располагаются их файлы. В частности, explorer.exe, расположенный не в WINNT, а в WINNT\System32, уже никакой не explorer, а самая настоящая малварь!
Впрочем, "Диспетчер задач" - крайне уязвимая штука и малварь без труда скрывает свое присутствие от его взора. То же самое относится к FAR'у, Process Explorer'у, tlist'у и прочим системным утилитам основанным на недокументированной API-функции NtQuerySystemInformation(), экспортируемой динамической библиотекой NTDLL.DLL, и потому очень легко перехватываемую даже с прикладного уровня без обращения к ядру и даже без администраторских привилегий.
Отладчик soft-ice - единственный известный мне инструмент, не использующий NtQuerySystemInformation() и разбирающий структуры ядра "вручную". Спрятаться от него на порядок сложнее и в "живой природе" такая малварь пока не замечена (а лабораторные экземпляры крайне нежизнеспособны и способны обманывать только известные им версии отладчика), так что на soft-ice вполне можно положиться. Для просмотра списка процессов достаточно дать команду "PROC" и проанализировать результат.
Кстати, малврь, скрывающаяся от "Диспетчера задача", немедленно выдает свое присутствие путем сличения "показаний" soft-ice с "диспетчером задач". Один из таких случаев продемонстрирован на рис. 1. Смотрите, soft-ice отображает процесс sysrtl, но в "диспетчере задач" он... отсутствует! Следовательно, это либо малварь, либо какой-нибудь хитроумный защитный механизм, построенный по root-kit технологии. В общем - нехорошая программа, от которой можно ждать все, что угодно и желательно избавиться от нее как можно быстрее!
Рисунок 1. Зловредный процесс sysrtl замаскировал свое присутствие от "Диспетчера задач", но не смог справиться с soft-ice.
Для достижения наибольшей скрытности малварь должна не создавать новый процесс, а внедряться в один из уже существующих, что она с успехом и делает. Классический алгоритм внедрения реализуется так:
Описанный алгоритм работает на всем зоопарке операционных систем, но довольно громоздок и сложен в реализации, поэтому малварь, ориентированная на поражение только одной NT, предпочитает создавать удаленный поток API-функций CreateRemoteThread(), при этом последовательность выполняемых ею действий выглядит так:
Единственный недостаток, присущий последнему способу внедрения - это требование перемещаемости кода, означающее, что его придется писать на ассемблере, используя только относительную адресацию, что весьма затруднительно.
Усовершенствованный алгоритм внедрения позволяет загружать внутрь чужого процесса свою собственную динамическую библиотеку, для чего достаточно передать функции CreateRemoreThread() в качестве стартового адреса удаленного потока адрес API-функции LoadLibraryA() или LoadLibraryW(), а вместо указателя на аргументы - указатель на имя загружаемой библиотеки. API-функция CreateRemoreThread() вызовет LoadLibraryA/LoadLibraryW вместе с именем библиотеки, в результате чего библиотека загрузится в память, а управление получит процедура DllMain(). Зловредная динамическая библиотека может быть написана на любом языке - хоть на Си/Си++, хоть на DELPHI, хоть... на Visual Basic'е, что значительно расширяет круг потенциальных малваре-писателей, поскольку ассемблер знают относительно немногие.
Вся беда в том, что имя библиотеки должно находиться в контексте удаленного процесса, а как оно там окажется?! Существует два пути: самое простое, но не самое умное - это выделить блок памяти вызовом VirtualAllocEx() и скопировать туда имя через WriteProcessMemory(), но для этого процесс должен быть открыт с флагом "виртуальные операции" (PROCESS_VM_OPERATION), прав на которые у малвари может и не быть.
Выручает тот факт, что библиотеки NTDLL.DLL и KERNEL32.DLL во всех процессах проецируются по одинаковым адресам. Получив базовый адрес загрузки NTDLL.DLL или KERNEL32.DLL с помощью LoadLibrary(), малварь сканирует свое собственное адресное пространство на предмет наличия ASCIIZ-строки, совершенно уверенная в том, что в удаленном процессе эта строка окажется расположенной по тому же самому адресу. Остается только переименовать зловредную динамическую библиотеку в эту самую строку. Кстати, приятным побочным эффектом такого алгоритма становится автоматическая генерация псевдослучайных имен (если, конечно, малварь не будет использовать первую попавшуюся ASCIIZ-строку).
Обобщив сказанное, мы получаем следующий план:
Вот три основных алгоритма внедрения в атакуемый процесс, которыми пользуется порядка 90% всей малвари.
Если количество процессов в системе вполне предсказуемо, то потоки многократно создаются/уничтожаются в ходе выполнения легальных программ и вопрос "сколько потоков должна иметь "стерильная" программа" лишен смысла. Достаточно открыть "диспетчер задач" и, некоторое время понаблюдав за колонкой "потоки", прийти в полное отчаяние. Но... если присмотреться повнимательнее, можно обнаружить, что потоки, созданные малварью, значительно отличаются от всех остальных.
При внедрении малвари по двум первым сценариям зловредный код располагается в блоках памяти, выделенных VirtualAllocEx() и имеющих тип MEM_PRIVATE, в то время как нормальные исполняемые файлы и динамические библиотеки загружаются в блоки памяти типа MEM_IMAGE. При внедрении по третьему сценарию, зловредный код как раз и попадает в такой блок, но стартовый адрес его потока совпадает с адресом функции LoadLibraryA() или LoadLibrayW(), а указатель на аргументы содержит имя зловредной библиотеки.
Таким образом, алгоритм обнаружения вторжения сводится к определению стартовых адресов всех потоков и, если он лежит внутри MEM_PRIVATE или совпадает с адресом LoadLibraryA()/LoadLibraryW() - этот поток создан малварью или чем-то сильно на нее похожим. Вот тут-то и начинается самое интересное! Ни soft-ice, ни Process explorer Марка Руссиновича определять стартовые адреса не умают (хоть и пытаются). Они очень часто ошибаются, особенно при работе с потоками, созданными малварью.
Давайте напишем "макетную" программу, создающую поток тем же самым методом, что и малварь и попробуем обнаружить факт "вторжения" при помощи подручных утилит. Предельно упрощенный исходный текст "макетника" выглядит так:
Листинг 1. "Макетная" программа va_thread.c, создающая поток тем же самым методом, что и малварь.
Компилируем с настройками по умолчанию (в случае MS VC++ командная строка выглядит так: "cl.exe va_thread.c") и запускаем. Загрузка процессора (даже на двухпроцессорной машине!) сразу подпрыгивает до 100%, но так и должно быть, поскольку мы создаем два потока, мотающих бесконечный цикл, один из которых "честный", а другой "зловредный" (имитирующий малварь). Плюс главный поток приложения, ожидающий нажатия на клавишу, по которой происходит завершение программы. Итого - три потока.
Загружаем soft-ice и нажимаем <CTRL-D>, дожидаясь его вызова, после чего даем команду "THREAD -x va_thread" для отображения детальной информации о потоках и смотрим на полученный результат (см. рис. 2). Да! Тут есть на что посмотреть! Стартовый адрес первого потока (Start EIP) определен как KERNEL32!SetUnhandledExceptionFilter+001A (77E878C1h), а двух остальных - KERNEL32!CreateFileA+00C3 (77E92C50h), что вообще ни в какие ворота не лезет, если не сказать, что это откровенная деза.
Рисунок 2. Отладчик soft-ice, пытающийся определить стартовые адреса потоков, но возвращающий вместо этого нечто необъяснимое.
Отбросив бесполезный soft-ice в сторону, обратимся к Process explorer'у. Щелкнув правой клавишей мыши по процессу "va_thread" (или нажав SHIFT-F10, если мыши под рукой нет), лезем в "properties" и открываем вкладку "threads". Что мы видим? Process explorer корректно определил адреса двух потоков (см. рис.3): va_thread+0x1405 (основной системный поток - если заглянуть дизассемблером по этому адресу, мы обнаружим точку входа в файл va_thread.exe) и va_thread+0x1000 ("честный" поток, созданный вызовом CreateThread(0, 0, (void *)&thread, 0x999, 0, &p) - это следует из того, что по адресу va_thread+0x1000 расположена процедура thread). Но вот вместо стартового адреса третьего, "нечестного" потока, Process explorer выдал какую-то ерунду, засунув его внутрь KERNEL32.DLL, а точнее - KERNEL32.DLL + B700h, где его заведомо не может быть.
Если бы Process explorer ошибался только на "нечестных" потоках, он вполне бы сгодился для определения малвари, но, увы, он ошибается слишком часто, в том числе и на легальных потоках, созданных операционной системой или ее компонентами.
Рисунок 3. Process explorer успешно определил стартовые адреса двух "честных" потоков, но споткнулся о "нечестный" поток.
Исследования, проведенные мыщъх'ем, показали, что истинный стартовый адрес потока лежит на дне пользовательского стека во втором или третьем двойном слове (считая от единицы), а следом за ним идет указатель на аргументы, в чем легко удостовериться с помощью отладчика OllyDbg.
Запустив отладчик, в меню "file" выбираем "attach" и подключаемся к процессу "va_thread.exe", после чего открываем окно "threads" (в меню "view") и видим не три, а целых четыре потока! Все правильно - четвертый поток создан отладчиком для своих нужд. Это единственный поток, чье поле entry (точка входа) не равно нулю. Стартовые адреса трех остальных потоков OllyDbg определить не смог, предоставив нам возможность сделать это самостоятельно.
Дважды щелкнув мышью по любому из потоков мы попадаем внутрь его "закромов". Отладчик обновляет содержимое регистров, окно CPU, дамп памяти и окно стека, которое нас интересует больше всего. Прокручиваем мышью ползунок до самого конца и обнаруживаем на дне нечто очень интересное (см. рис. 4), а именно - два двойных слова: 666h и 520000h. Первое из них до боли напоминает аргумент, переданный "нечестному" потоку (см. листинг 1), а по второму расположена функция, мотающая бесконечный цикл, весьма напоминающая нашу функцию thread(). Обратившись к карте памяти (view -> memory), мы убедимся, что этот адрес принадлежит региону MEM_PRIVATE, выделенному VirtualAlloc(). Аналогичным образом определяются стартовые адреса и двух других потоков.
Рисунок 4. Определение стартового адреса потока с помощью отладчика OllyDbg.
Свершилось! Мы научились определять подлинные стартовые адреса "честных" и "нечестных" потоков вместе с переданным им указателем на аргументы. Однако использовать для этих целей OllyDbg не слишком удобно. Потоков в системе много и пока их все вручную переберешь... рабочий день давно закончится и солнце зайдет за горизонт. Вообще-то, можно написать простой скрипт (OllyDbg поддерживает скрипты), но при этом отладчик придется всюду таскать за собой, что напрягает. Лучше (и правильнее!) написать свой собственный сканер, тем более, что он легко укладывается в сотню строк и на его разработку уйдет совсем немного времени.
Полный исходный текст содержится в файле, прилагаемом к статье (ftp://nezumi.org.ru/), а здесь для экономии места приводятся лишь ключевые фрагменты. Но, прежде чем углубляться в теоретическую дискуссию, проверим сканер в работе. Наберем в командой строке "proclist.exe > out" и загрузим образовавшийся файл out в любой текстовой редактор (например, встроенный в FAR). Нажмем <F7> (search) и введем имя интересующего нас процесса ("va_thead.exe"). Запомним его идентификатор (в данном случае равный 578h) и, снова нажав <F7>, введем его для поиска принадлежащих ему потоков. Вот они, все три, перечисленные в листинге 2, лежат рядышком:
LoadLibraryA at : 79450221h LoadLibraryW at : 794502D2h ----------------------------------------------------- szExeFile : va_thread.exe cntUsage : 0h th32ProcessID : 578h ... --thr------------------------------------------------ th32ThreadID : 5E0h th32OwnerProcessID : 578h ... handle : 3D8h ESP : 0012FD30h start address : 00401595h point to args : 00000000h type : MEM_IMAGE [0012FFF0h: 00000000 00000000 00401595 00000000] --thr------------------------------------------------ th32ThreadID : 608h th32OwnerProcessID : 578h ... handle : 3D8h ESP : 0051FFB4h start address : 00401000h point to args : 00000999h type : MEM_IMAGE [0051FFF0h: 00000000 00401000 00000999 00000000] --thr------------------------------------------------ th32ThreadID : 5C8h th32OwnerProcessID : 578h ... handle : 3D8h ESP : 0062FFB4h start address : 00520000h point to args : 00000666h type : MEM_PRIVATE [0062FFF0h: 00000000 00520000 00000666 00000000]
Листинг 2. Фрагмент отчета сканера proclist.exe, определяющего стартовые адреса всех потоков вместе с типами блоков памяти.
Первые два потока находятся внутри блоков MEM_IMAGE и ни у одного из них стартовые адреса не совпадают с адресами функций LoadLibraryA()/LoadLibraryW() - следовательно, это "честные" потоки, созданные легальным путем. А вот третий поток лежит внутри региона MEM_PRIVATE, выделенного API-функцией VirtualAlloc(). Значит это "нечестный" поток и мы не бьем тревогу только потому, что сами же его и создали.
Теперь, как было обещано, обсудим технические детали. Прежде всего, нам потребуется получить список потоков, имеющихся в системе. Это можно сделать как документированными средствами через TOOLHELP32, так и недокументированной native-API функций NtQuerySystemInformation(), на которой TOOLHELP32, собственно говоря, и основан. Конечно, если они перехвачены малварью, мы никогда не увидим зловредных потоков, но техника обнаружения/снятия перехвата - это тема отдельной статьи, пока же придется ограничиться тем, что есть (на всякий случай, сравните показания TOOLHELP32 c командой "THREAD" отладчика soft-ice, вдруг обнаружатся какие-то различия).
Короче, список потоков в простейшем случае получается так:
Листинг 3. Фрагмент кода, ответственный за перечисление всех имеющихся потоков.
Теперь нам необходимо прочесть контекст каждого из потоков, получив значение регистра ESP, указывающего куда-то внутрь стека (куда конкретно - не суть важно). Кстати говоря, считывать контекст можно и без остановки потока. На Windows 2000 (и ее потомках) это делается так (более ранние версии требуют использования native-API функции NtOpenThread() и поскольку доля таких систем сравнительного невелика, здесь они не рассматриваются):
Листинг 4. Фрагмент кода, считывающего значение ESP потока с идентификатором thr.th32ThreadID.
Заметим, что API-функция OpenThread() не входит ни в заголовочные файлы, ни в библиотеку KERNEL32.LIB, поставляемую вместе с компилятором Microsoft Visual C++, поэтому необходимо либо скачать свежий Platform SDK (а это очень-очень много мегабайт), либо загружать ее динамически через GetProcAddress(), либо преобразовать KERNEL32.DLL в KERNEL32.LIB (линкер unilink от Юрия Харона это сделает автоматически).
Зная идентификатор процесса, владеющий данным потоком (thr.th32OwnerProcessID), мы можем открыть его API-функцией OpenProcerss(), получив доступ к его адресному пространству (если, конечно, у нас на это есть права). Открывать мы будем с флагами PROCESS_QUERY_INFORMATION (просмотр виртуальной памяти) и PROCESS_VM_READ (чтение содержимого виртуальной памяти).
Следующий шаг - определение дна пользовательского стека. Передав API-функции VirtualQueryEx() значение ESP, полученное из регистрового контекста, мы узнаем базовый адрес выделенного блока (mbi.BaseAddress) и его размер в байтах (mbi.RegionSize). Путем алгебраического сложения базового адреса с его длинной, мы получим указатель на первый байт памяти, лежащий за концом стека. Отступив на несколько двойных слов назад (например, на четыре), нам остается только прочитать его содержимое API-функцией ReadProcessMemory().
Листинг 5. Фрагмент кода, определяющий положение дна стека и считывающий GET_FZ байт с его конца (в которых хранится стартовый адрес потока).
Ввиду того, что положение стартового адреса относительно дна стека непостоянно, применяется следующий эвристический алгоритм: если третье двойное слово со дня стека равно нулю, то стартовый адрес потока находится во втором двойном слове, а указатель на аргументы - в первом, в противном случае, стартовый адрес находится в третьем двойном слове, а указатель на аргументы - во втором. Конечно, предложенная схема не очень надежна: в некоторых случаях стартовый адрес находится в первом двойном слове, а бывает (хоть и редко), что на дне стека его вообще нет. В общем, этот вопрос еще требует дальнейших исследований и тщательной проработки, а пока воспользуйтесь тем, что дают:
Листинг 6. Декодирование содержимого буфера buf и определение стартового адреса потока эвристическим методом.
Остается последнее - "скормить" полученный стартовый адрес потока API-функции VirtualQueryEx() и определить тип региона, к которому он принадлежит (MEM_IMAGE, MEM_PRIVATE или MEM_MAPPED):
Листинг 7. Фрагмент кода, определяющий тип блока памяти, к которому принадлежит стартовый адрес потока.
Объединив все фрагменты мозаики воедино, мы получим вполне работоспособный сканер, показавший при тестировании на большой коллекции малвари, вполне удовлетворительный результат - ни одного ложного срабатывания и до 90% обнаруженной "заразы" (естественно, речь идет только о малвари, внедряющийся в уже существующие процессы, а не создающие новый).
Описанный метод выявления вторжения обладает рядом существенных недостатков, которые мыщъх даже и не пытается скрывать. Первое, и самое неприятное - стартовый адрес потока попадает на дно пользовательского стека лишь по "недоразумению". Никому он там не нужен и всякий поток может смело его обнулить или подделать. Но с этим еще можно хоть как-то бороться, например, просканировать все MEM_PRIVATE-блоки и попытаться найти в них машинный код, поискать в стеке адреса возврата, смотрящие в MEM_PRIVATE, и т.д., гораздо хуже, что существует возможность внедрения в атакуемый процесс без создания нового потока!
Самое простое, что только приходит в голову - прочитать текущий EIP одного из потоков атакуемого процесса, сохранить лежащие под ним машинные команды, после чего записать крохотный код, вызывающий LoadLibrary() для загрузки зловредной библиотеки в текущий контекст и устанавливающий таймер API-функций SetTimer() для передачи зловредному коду управления через регулярные промежутки времени. Сделав это, малварь восстанавливает оригинальные машинные команды и процесс продолжает выполняться как ни в чем не бывало.
При желании можно обойтись и без таймера. Достаточно воспользоваться асинхронными сокетами. В отличие от синхронных, они не блокируют выполнение текущего потока, а немедленно отдают управление, вызывая call-back процедуру при наступлении определенного события (например, при подключении удаленного пользователя по back-door порту, открытого малварью).
Также малварь может внедриться в процедуру диспетчеризации сообщений или подменить адрес оконной процедуры для главного окна GUI-приложения. Во всех этих случаях зловредный код будет выполняться в контексте уже существующего потока и малварь сможет обойтись без создания нового. Такой способ внедрения предложенный сканер не обнаруживает. Правда, это не сильно ему мешает, поскольку в живой природе подобной малвари до сих пор замечено не было. Универсальных способов борьбы против нее нет. Единственное, что можно предложить - периодически выводить список динамических библиотек и если вдруг среди них появилась новая - это сигнал! Но если малварь откажется от загрузки DLL, размещая свой код в свободном месте (например, в конце кодовой секции), мы опять окажемся в пролете.
Впрочем, не стоит пытаться решить проблемы задолго до их появления. Возможно, завтра случится глобальное оледенение (землетрясение, наводнение), мы все умрем и бороться с малварью станет некому и незачем...