Содержание статьи
В сентябре 2008 датчики распределенных антивирусных сетей зафиксировали
необычную активность — эпидемия малвари нового поколения начала свое
распространение практически одновременно с Индонезии, Вьетнама и Таиланда,
обходя антивирусные преграды на форсаже. Я это дело раскрутил, написав пару
POC’ов, демонстрирующих технику сокрытия истинной точки входа в файл. Это
актуально не только для зловредных программ, но и для легальных протекторов.
История началась еще несколько лет назад, когда я ковырял червей, обходивших
персональные брандмауэры путем создания удаленного потока и вызывавших API-вызов
CreateRemoteThread. В качестве стартового адреса ему передавался указатель на
вредоносный код, впрыснутый в адресное пространство жертвы — например,
выделением памяти на куче с последующим копированием shell-кода API-вызовом
WriteProcessMemory. Чтобы «выкупить» левые потоки, достаточно взглянуть на их
стартовый адрес. У «нормальных» потоков он указывает в область страничного
имиджа PE-файла, а у «рукотворных» — лежит в стеке, куче (или еще непонятно
где).
Вот только ни Process Explorer Марка Руссиновича, ни даже SoftICE не желали
отображать истинные стартовые адреса «рукотворных» потоков. Высвечивали какой-то
невменяемый адрес, ведущий в недра KERNEL32.DLL, что, конечно, весьма
подозрительно, но на вещественное доказательство не тянет. Поковырявшись в
системном загрузчике, я выяснил, что подлинный стартовый адрес потока находится
практически на самом дне стека. И если малварь не предпринимает дополнительных
способов маскировки, то «рукотворные» потоки обнаруживаются тривиальным
сканером, который и был написан мной, что называется по горячим следам и послан
Марку Руссиновичу вместе с баг-рапортом на Process Explorer. Поскольку никакой
реакции так и не последовало, рапорт был обнародован на вполне респектабельной
хакерской тусовке OpenRCE.org. Но и там он остался незамеченным.
Годом позже, разгребая завалы своих заметок, нацарапанных на клочках бумаги,
я заинтересовался — на каком этапе загрузки файла стартовый адрес базового
потока попадает в стек и можно ли его изменить из TLScallback’ а или функции
Dllmain статически прилинкованной DLL. Оказалось, что стартовый адрес
формируется до инициации TLS/Dllmain, а после инициализации обращения к
оригинальной точке входа, прописанной в PE-заголовке, уже не происходит. Система
использует адрес, сохраненный в стеке, и этот адрес действительно может быть
изменен. Отладчики (не говоря уже о дизассемблерах) к такой «подлянке»,
разумеется, не готовы и в своей массе просто теряют контроль над исполняемым
файлом! Я тут же написал crackme, выложил его на OpenRCE, но хакерская
общественность не оценила предложенный трюк по достоинству. В результате чего я
потерял к этой затее всякий интерес…
Тем временем, хакерская группировка (пожелавшая остаться в тени, в смысле не
оставившая в коде никаких инициалов) переоткрыла технологию изменения точки
входа, использовав ее в малвари нового поколения. Статическими антивирусными
сканерами она не обнаруживается в принципе! Как минимум, нужен полноценный
эмулятор ЦП с качественным эмулятором окружения Windows, учитывающим
недокументированные особенности системного загрузчика PE-файлов (а именно на них
держится механизм подмены точки входа). Из всех существующих антивирусов малварь
нового поколения сегодня могут обнаруживать только NOD32, F-Secure, Symantec,
KAV, Dr.Web, да и то, лишь после усовершенствования эмулятора окружения Windows.
Остальные тихо курят в сторонке. Неудивительно, что рассылка POC’ов знакомым
сотрудникам антивирусных компаний (Checkpoint, Symantec, F-Secure, K7Computing)
вызывала конкретный резонанс, завершившийся посылом меня... нет, туда меня
послали горячие парни из KAV’а.
Ладно, это все шутки. А ситуация вполне серьезна. Уж не я ли спровоцировал
рождение малвари нового поколения? Нет и еще раз нет. Это совершенно независимая
находка неизвестной хакерской группы (что элементарно доказывается анализом
кода). Несмотря на то, что мой crackme и обозначенная малварь исповедуют
идентичные концепции, детали реализации совершенно различны. Позиция стартового
адреса потока в стеке непостоянна и варьируется даже в рамках отдельно взятой
версии операционной системы. Это высадило меня на разработку
системно-независимого алгоритма, сканирующего стек на предмет поиска наиболее
вероятных кандидатов на роль стартового адреса. В то же самое время, пойманная
малварь использует фиксированные локации и потому функционирует только под
строго определенными версиями операционной системы. Логично, если бы хакеры
увидели мой crackme, они бы непременно позаимствовали системно-независимый
алгоритм, но этого не произошло. Значит, есть все основания предполагать, что
это — продукт параллельных исследований, и я тут совсем не причем.
В настоящее время компания Endeavor Security, Inc работает над армированием
AMP (Active Malware Protection), присобачивая к ней перехаченный x86emu вместе с
эмулятором окружения Windows, «осведомленным» о недокументированных возможностях
системного загрузчика PE-файлов. Так что AMP имеет все шансы оказаться первым
коммерческим продуктом, способным распознавать малварь нового поколения и не
только распознавать, но и блокировать. А проблема действительно встает в полный
рост и чем дальше, тем выше. Какое-то время антивирусы не смогут обнаруживать
малварь, изменяющую точку входа в PE-файл, и нам придется сражаться с ней
вручную, а для этого нужно не только уверенно держать IDA-Pro/HIEW в руках, но и
разбираться в недокументированных тонкостях работы системного загрузчика. О них
мы сейчас и поговорим.
Точки входа — живые и мертвые
Спецификация на PE-файл от Microsoft описывает специальное поле в
PE-заголовке, хранящее относительный виртуальный адрес (RVA) точки входа в файл,
с которого как бы и начинается его выполнение. «Как бы», потому что точка входа
(она же Entry Point) выполняется не первой, а последней.
До передачи управления на Entry Point система загружает все статически
прилинкованные динамические библиотеки, вызывая функции Dllmain, исполняющиеся в
контексте загрузившего их процесса и способные воздействовать на него любым
образом. TLS-callback’и (да-да, те самые, о которых мы уже говорили в #5
«Энциклопедии антиотладочных приемов») также получают управление до выполнения
точки входа, которой, вообще-то, может и не быть. В самом деле! Если Dllmain или
TLS-callback «забудет» возвратить управление, точку входа вызывать станет уже
некому и потому она может указывать на любой код! Но не будем забегать вперед.
Рассмотрим классический алгоритм определения точки входа, взятый на
вооружение многими антивирусами, отладчиками, дизассемблерами, определителями
упаковщиков и т.д., и т.п. — устали перечислять.
Классический способ определения точки входа в файл
#define PE_off 0x3C // PE magic word raw offset
#define EP_off 0x28 // relative Entry Point filed
offset
BYTE* GetEP()
{
static BYTE* base_x, *ep_adr;
static DWORD pe_off, ep_off;
char buf [_MAX_PATH];
// obtain exe base address
GetModuleFileName(0, buf, _MAX_PATH);
base_x = (BYTE*) GetModuleHandle(buf);
pe_off = *((DWORD*)(base_x + PE_off));
ep_off = *((DWORD*)(base_x + pe_off + EP_off));
ep_adr = base_x + ep_off; // RVA to VA
return ep_adr;
}
Конечно, «промышленная» реализация функции GetEP() выглядит чуть сложнее,
поскольку осуществляет множество проверок, опущенных в листинге для простоты
понимания. Но не в проверках дело, а в концепции. Дырявой, естественно.
Отладчики ставят сюда бряк, в надежде, что он сработает. Но он не сработает,
если адрес точки входа изменен. Стоп! Я опять устроил кавардак. Еще раз,
медленно и по порядку. Когда отладчик порождает отладочный процесс и запускает
его на выполнение, то первым срабатывает бряк, засунутый системой в функцию
NTDLL!DbgBreakPoint. Она сигнализирует о том, что отлаживаемый файл спроецирован
в адресное пространство и с ним можно работать как со своим собственным. В
частности, считывать PE-заголовок и устанавливать бряк на точку останова. Дело в
том, что операционная система никак не информирует отладчик о передаче
управления на точку входа и отслеживать этот процесс отладчик должен
самостоятельно.
ntdll!DbgBreakPoint:
77f9193c cc int 3
Дизассемблеры никаких бряков не устанавливают, а просто тупо начинают
дизассемблирование с точки входа. Что же касается антивирусов, то вообще-то тут
действуют разные стратегии. Сигнатура может быть привязана не только к точке
входа, но и к смещению относительно конца/начала файла (или секции). В этом
случае, изменение точки входа никак не повлияет на умственные способности
антивируса. Однако, прежде чем искать сигнатуру, файл необходимо распаковать, а
в случае полиморфных вирусов — еще и прогнать их через эмулятор. И вот тут-то
без правильного определения точки входа не обойтись! Некоторые вирусы используют
довольно хитрый трюк, совершая jump из TLS-callback’а, то есть, фактически,
выполняя TLS-callback без возврата управления. В результате чего оригинальная
точка останова идет лесом и может содержать что угодно. Конечно, в разумных
пределах. Наглеть не стоит. В частности, начиная с XP, системный загрузчик
выполняет ряд проверок, и файлы с точкой останова, вылетающей за пределы
страничного образа, просто не загружаются в память! Но даже если бы они и
загружались, для антивируса такая точка останова выглядит слишком подозрительно
и легко ловится эвристическим анализатором. Какой смысл палить себя на мелочах?
Лучше засунуть в точку входа безобидный код, а из TLS-callback’а совершить
переход на вирусное тело. В грубом приближении это выглядит так.
Псевдокод малвари старого поколения, перекрывающей точку входа в PE -файл
посредством блокировки возврата управления из TLS-callback’а
EntryPoint:
XOR EAX, EAX
PUSH EAX
CALL d, ds:[ExitProcess]
…
VirusBody:
…
…
…
TLS_Callback1:
JMP VirusBody
На самом деле, антивирус, эмулирующий TLS (а проэмулировать его несложно,
благо, он уже давно документирован), разломает эту комбинацию и даже не крякнет.
Хотя на практике подавляющее большинство антивирусов либо вообще не знают о
существовании TLS, либо эмулируют их некорректно, что открывает большой простор
для творческих махинаций по сокрытию зловредного кода в самых неожиданных
местах.
А теперь посмотрим на псевдокод малвари нового поколения:
EntryPoint:
XOR EAX, EAX
PUSH EAX
CALL d, ds:[ExitProcess]
…
VirusBody:
…
…
…
TLS_Callback1:
ADD d, ds:[ESP+magic_offset], offset VirusBody
— offset EntryPoint
RETN 0Ch
На первый взгляд никакой разницы, но если подумать головой, то разница будет
просто драматической. В первом случае мы имеем ничем не прикрытый бесстыдный
jump из TLS-callback’а. И хотя его можно замаскировать с помощью
самомодифицирующегося кода или запутанных математических преобразований, целевой
адрес перехода декодируется однозначно и указывает на вирусное тело. Во втором
случае TLS-callback добавляет какое-то значение к некоторой ячейке памяти,
лежащей в области стека, и возвращает управление системе. Человек с отладчиком
чисто теоретически может трассировать тонны машинных инструкций, ответственных
за инициализацию файла. Он может даже дождаться момента передачи управления на
точку входа или хотя бы область памяти, не принадлежащую системе, а находящуюся
в границах PE-файла или одной из динамических библиотек. И тогда товарищ с
удивлением обнаружит, что точка входа каким-то магическим образом не получает
управления! Вот только трассировать придется долго и непродуктивно.
Антивирусам приходится еще хуже. Они не могут трассировать код, исполняющийся
на живой операционной системе (вряд ли стоит объяснять, почему). Откуда им
знать, что именно находится в данной конкретной ячейке памяти? А что, собственно
говоря, там находится и как оно туда попадает? Попробуем разобраться!
Тайны системного загрузчика
Существуют различные уровни недокументированности. Определенные свойства
системы возникают в силу особенностей реализации конкретно взятого билда.
Содержимое стека на момент старта потока, значение регистров и флагов на момент
выхода из API-функций — не только не документированы, но еще и варьируются в
широких пределах. А потому в долгосрочной перспективе закладываться на них
нельзя.
Стартовый адрес потока представляет собой редкое исключение из правил. Он
абсолютно недокументирован и в то же время неизбежно следует из
документированных функций и особенностей работы системного загрузчика. Системный
загрузчик в грубом приближении работает так:
- Проецирует PE-файл в память.
- Считывает PE-заголовок, извлекает оттуда значение точки останова,
представляющее собой относительный виртуальный адрес (RVA) и переводит его в
линейный адрес (VA) путем сложения с базовым адресом, также хранящимся в
PE-заголовке. - VA-адрес точки входа передается функции CreateRemoteThread() в качестве
одного из аргументов. А передается он через стек, поскольку 32‑разрядные
версии Windows используют stdcall-соглашение для всех API-функций без
исключения (64‑разрядные версии Windows используют fastcall-соглашение,
передавая аргументы через регистры и стартовый адрес потока в стек не
попадает). - Стартовый адрес базового потока (указывающий на точку входа) после
прохождения всех проверок заталкивается в стек и больше к PE-заголовку
система не обращается (это очень важный момент). - CreateRemoteThread() выполняет инициализацию потока, в процессе которой
отрабатываются функции Dllmain статически прилинкованных DLL, а также
имеющиеся TLS-callback’и (если они, конечно, есть). - На завершающем этапе инициализации PE-файла, CreateRemoteThread() зовет
недокументированную, не экспортируемую, сугубо внутреннюю функцию
BaseProcess(), передавая ей стартовый адрес потока, почерпнутый из стека в
качестве аргумента. - BaseProcess() передает управление по указанному адресу.
Какая интересная картина получается! Точка входа представляет собой аргумент
API-функции CreateRemoteThread(), имеющей документированный прототип (Microsoft
его совершенно не собирается изменять). Причем, этот аргумент попадает на стек
до отработки Dllmain/TLS-callback, а извлекается из стека после того, как они
возвратят управление. Никакие проверки валидности стартового адреса потока не
выполняются, а это значит, что Dllmain/TLS-callback могут изменять стартовый
адрес потока по своему усмотрению. Собственно говоря, это справедливо для всех
потоков, а не только для базового. Просто адрес базового потока прописан в точке
входа в PE-файл, а адреса остальных потоков передаются как аргументы функции
Create[Remote]Thread().
Другими словами, точка входа в файл попадает в стек не случайно, а согласно
логике работы системного загрузчика, которая справедлива для всей 32‑разрядной
линейки NT-подобных систем и потому замечательно работает как на W2K, так и на
Server 2008. Единственная проблема в том, что положение аргумента функции
CreateRemoteThread() системно-зависимо. Множество внутренних функций с
недокументированными прототипами используют стек, складируя туда свои аргументы.
Это, блин, затрудняет нашу задачу.
Разработчики обозначенной малвари пошли по пути наименьшего сопротивления.
Они просто протестировали несколько версий операционных систем, определив
смещение стартового адреса базового потока относительно дна стека, после чего
загнали их в одну таблицу. Попав на чужой компьютер, малварь определяет версию
оси, извлекая соответствующее «магическое смещение» из сопутствующей таблицы.
Это в том случае, если данная версия Windows поддерживается малварью. Ну а если
нет? Я пошел по другому пути. Менее надежному (и допускающему ложные
срабатывания), но зато намного более универсальному. Все просто! Считываем
PE-заголовок, извлекаем оттуда подлинную точку входа и затем сканируем стек на
предмет поиска подходящих кандидатов. Естественно, точка входа может совпасть со
значением, хранящимся в стеке, но не имеющем к ней никакого отношения. Подобная
ситуация называется «коллизией» и ничего хорошего в себе не несет. Нет никакой
возможности определить, какое именно значение следует изменить, а какое — лучше
не трогать. Тем не менее, я решил изменять все значения, совпадающие с точкой
входа, ну, а чтобы уменьшить количество ложных срабатываний, выбирать точку
входа, хранящуюся в PE-файле так, чтобы она была ни на что не похожа. То есть,
представляла собой уникальное значение, не совпадающее ни с однимиз прочих полей
PE-файла.
Ниже приводится законченный алгоритм подмены точки входа из Dllmain/TLS-callback,
протестированный на всем спектре Windows-систем и показавший хороший результат.
Подмена точки входа в файл из функции Dllmain статически прилинкованной
DLL
// new EP (SystemLoader'll ignore the old one)
#define NEW_EP 0x0040100A
// simple immediate constant obfuscation...
#define EP_KEY 0xA11CE169
// ...anti IDA Pro trick
unsigned int EP_key = EP_KEY;
BOOL WINAPI dllmain(
HINSTANCE hinstDLL,
DWORD fdwReason,
LPVOID lpvReserved)
{
BYTE* ep_adr;
DWORD RegionSize;
BYTE* BaseAddress;
MEMORY_BASIC_INFORMATION lpBuffer;
ep_adr = GetEP();
// get stack top allocated base address
VirtualQuery((LPCVOID)&hinstDLL, &lpBuffer,
sizeof(lpBuffer));
BaseAddress = ((BYTE*)lpBuffer. BaseAddress);
RegionSize = lpBuffer.RegionSize - sizeof(DWORD);
/*
EP is KERNEL32!CreateRemoteThread() function argument,
and this argument is near to the bottom of the stack
*/
for(; RegionSize > 0; RegionSize -= sizeof(DWORD))
if (((DWORD)ep_adr) == ( *(DWORD*)(BaseAddress +
RegionSize)))
(*(DWORD*)(BaseAddress + RegionSize)) =
NEW_EP ^ EP_KEY,
(*(DWORD*)(BaseAddress + RegionSize)) ^= EP_key;
return 1;
}
Охотники за привидениями
Сложность эмуляции работы системного загрузчика связана с «плавающим»
смещением точки входа. Даже если антивирус и положит ее в стек, то у него нет
никаких гарантий, что вирус ее там найдет, а не попытается изменить соседнюю
ячейку. Антивирус не знает, какую версию операционной системы «поддерживает»
анализируемый вирус и каким образом он ее определяет. Помимо тупого вызова
GetVersionEx, вирус может обратиться к реестру или посчитать контрольную сумму
определенных системных библиотек. Короче, возможных вариантов намного больше
одного!
Конечно, мой код (в «канонической» форме) легко попадется на ловушки,
расставленные антивирусами, поскольку даже не пытается отличить подлинный
стартовый адрес потока, засунутый в стек системой, от его имитации антивирусом.
Но, во-первых, антивирусы ничего об этом трюке пока не знают, а, во-вторых, я
преследовал совсем иные цели, и код замечательно работает в защитных механизмах
в силу того, что совместим со всеми системами, а не только со строго
определенными версиями.
Реальная бомба
Малварь, изменяющая точку входа, только-только начинает появляться и
предсказать пути ее дальнейшего развития весьма затруднительно. Пока ясно одно —
это бомба, реальная бомба. Первый серьезный вызов антивирусам за последние
несколько лет! Интересно наблюдать за реакцией их разработчиков, варьирующейся
от ужаса до тупого непонимания проблемы. Что ж, если технология получит
развитие, то кое-кто очень скоро вылетит с рынка.