Как и обещал в прошлой части, продолжим рассмотрение вирусных движков. На этот раз речь пойдет о полиморфизме исполняемого кода. Полиморфизм для компьютерных вирусов означает, что каждый новый зараженный файл содержит в себе новый код вируса-потомка. Чисто теоретически, для антивируса это должно было бы означать настоящий кошмар. Если бы существовал вирус, который в каждом новом поколении на 100% менял бы свой код, причем по настоящему случайным образом, никакой сигнатурный анализ не смог бы его детектировать.
Возможно, где-то есть супер-программист, который действительно написал такой код, и именно поэтому мы про него ничего не знаем. Мне не очень в это верится, и даже кажется, что математики, занимающиеся математическим обоснованием работы вычислительных систем, могли бы доказать, что не существует такого определенного алгоритма полиморфизма, результат работы которого нельзя было бы стопроцентно детектировать при помощи другого определенного алгоритма. Но мы — люди простые, нам просто интересна идея кода, который сам себя изменяет, а в свете «алгоритм против алгоритма», рассмотрение противостояния методов сокрытия исполняемого кода методам детектирования для программиста должно быть весьма интересным.
Вспомню наших героев из первой статьи: вирмейкера и программиста антивирусной компании и прицеплю к ним их кармических близнецов: разработчика навесной защиты и крэкера. Первые стараются скрыть исполняемый код и информацию о нем, вторые — получить доступ к характерному коду и его внутренним алгоритмам. В вирусной области превалируют автоматические методы (вирусный движок и антивирусный детектор), в области защиты — ручные (параметры навесной защиты контролируются вручную, процесс взлома софта также является ручной работой, несмотря на обилие вспомогательного софта).
По итогам первой статьи у нас есть вирус, умеющий корректно заражать исполняемый файл (т.е. умеет при запуске файла отработать сам и корректно выполнить код самого файла) и антивирусный детектор, который знает, что вирус размещается в строго определённых местах файла, и что на некотором расстоянии от характерной точки (от точки входа, от начала секции, от конца заголовка) существует фиксированный набор байт, который характеризует данный вирус. Также, чтобы статья не съезжала в обсуждение тем «что плохого делает вирус», давайте договоримся, что payload вирусa ничего не делает. Так можно отсеять все обсуждения касательно характера действий рассматриваемого кода и сосредоточиться на методах детектирования и сокрытия.
Продолжаем вспоминать и дополнять первую статью. Схему противостояния вируса и антивируса можно по аналогии рассмотреть с точки зрения взлома коммерческой программы. Вместо инфектора здесь работает собственно программа, «навешивающая» защиту на исполняемый файл. Она «портит» код самой программы, информацию, необходимую для его восстановления прячет в свое тело, и таким же хитрым способом, как и вирус, скрывая алгоритм своей работы, первым исполняет код защиты, который проверяет валидность серийного номера или время действия trial, и, затем, «починив» основную программу, передаёт ей управление. Крэкер в свою очередь исполняет роль антивируса, пытаясь добраться до внутреннего кода защиты и узнать его алгоритм. Получается, что он занимается тем же самым, что и авер, пытаясь найти (и сохранить) характерную часть кода. Разница лишь в том, что крэкер заберет весь оригинальный код и изготовит из него работоспособный файл, а авер найдет в нем характерный кусок и создаст сигнатуру.
Самым популярным способом снять такую защиту является dump образа программы в памяти на диск. Крэкер ищет момент, когда защита отработала, расшифровала и «починила» основную программу, и в памяти находится работоспособный образ кода этой основной программы. Фактически он ищет возможность остановить программу на так называемой OEP (Original Entry Point) — «старой» точке входа защищаемой программы. В этот момент образ в памяти можно сохранить на диск. Он, конечно, будет неработоспособен, но его можно починить, «перенастроив» Entry Point исполняемого файла так, чтобы он указывал на OEP, и, если программа в этот момент работоспособна, такой образ будет работать просто пропуская защиту (там есть еще много манипуляций с восстановлением вызовов внешних функций, многократным дампом на случай, если программа расшифровывается не полностью, и вообще, это тема для десятка статей, но главный принцип именно такой). Другим популярным способом является найти кусок кода, генерирующий серийный номер и, если это возможно, «выкусить» его, и сделать маленький исполняемый файл, генерирующий валидные серийные номера (keygen). Как мы увидим ниже, похожий образ действий не чужд и антивирусному детектору.
Еще мне нравится проводить аналогии с биологическими системами, я постараюсь сильно не обременять вас этим. Уж очень хочется поскорее увидеть искусственный разум и жизнь.
Понимание основных принципов их работы является важным для рассмотрения области защиты исполняемого кода. Вы наверняка об этом что-то знаете, раз читаете эту статью, но всё равно — либо немного потерпите, либо просто пропустите этот раздел.
Дизассемблер принимает на вход либо исполняемый файл, либо абстрактный буфер с кодом и, что довольно важно, первый адрес, с которого нужно начать дизассемблирование. В случае исполняемого файла это, например, точка входа. Поставив указатель на первую инструкцию, дизассемблер, определяет, что это за инструкция, её длину в байтах, является ли она инструкцией перехода, какие регистры использует, на какие адреса в памяти ссылается и т.п. Если инструкция не является инструкцией перехода, дизассемблер переходит к следующей инструкции, переместив указатель вперед на длину инструкции. Если это безусловный JMP или CALL, дизассемблер перемещает указатель следующей инструкции туда, куда указывает адрес перехода. Если это условный переход (JZ, JNA и т.п.) то дизассемблер помечает следующими к рассмотрению сразу два адреса — адрес следующей инструкции и адрес, на который возможен переход. Если комбинация байт не распознается, процесс дизассемблирования данной ветви останавливается. Также необходимо упомянуть о том, что дизассемблер сохраняет информацию о том, какие инструкции ссылаются на данную(!), что позволяет определять вызовы функций, и, самое главное, кто их вызывает.
Дизассемблер превращает последовательность байт в последовательность многосвязных структур, в которых хранится информация о каждом байте инструкции: является ли конкретный байт частью опкода (кода операции), данными, адресом, на который откуда-то совершается переход и т.п. Каждая структура может содержать ссылки на одну или две следующих структуры и при этом являться объектом, на который ссылается произвольное число других структур (например первая инструкция функции, которая вызывается множество раз). Также, умные дизассемблеры могут следить за указателем стека, или уметь распознавать и корректно помечать для дизассемблирования такие конструкции, как: mov eax, 0x20056789; call eax; Плюс распознавать характерные функции по набору инструкций, устанавливать начальные точки для дизассемблирования вручную, комментировать отдельные инструкции и сохранять результат дизассемблирования на диск, т.к. операция построения графа вызовов и разметки структур весьма затратна, ну а возиться с одним файлом можно днями. Но, как мы рассмотрели ранее, возможна ситуация, когда на диске в файле переход ведет на зашифрованный буфер, и дизассемблер в этом случае генерирует кашу из инструкций или останавливается. В этом случае, надо заполучить этот зашифрованный буфер прямо в runtime, когда он открыто лежит в памяти, а для этого требуется отладчик.
Основная задача отладчика — остановить программу в самом интересном месте. Для этого используется несколько способов. Можно открыть память процесса, и вместо одной из инструкций вписать int 3 — в этом случае процессор, выполняя эту инструкцию сгенерирует исключение, а отладчик обработает его, откроет свое окно, восстановит оригинальную инструкцию и покажет, что находится в этой области памяти. Можно включить в процессоре флаг трассировки, и тогда процессор будет генерировать это исключение на каждой инструкции. Наконец, у процессора есть отладочные регистры, можно поместиь в них некоторый адрес, и процессор, получив доступ к памяти по этому адресу, остановится. Так что, например, поставив breakpoint на доступ к адресу начала зашифрованного буфера мы остановимся первый раз, когда декриптор начнет расшифрование и прочитает первый байт буфера, а второй раз, когда передаст туда управление. В этот момент содержимое буфера можно записать на диск, натравить на него дизассемблер и узнать все его секреты. В продвинутых защитах вообще не существует такого момента времени, когда в памяти лежит полный рабочий код программы — части кода расшифровываются кусками по мере надобности. В этих случаях реверсеру приходится собирать dump по кускам.
Тема защиты исполняемого кода от исследования достойна десятка статей, поэтому в рамках рассматриваемого вопроса остановимся лишь на нескольких моментах. Статическая защита кода от исследования представляет собой различные методы запутывания исполняемого кода и шифрование буферов с важными участками кода с последующим расшифрованием в runtime. Запутывание кода можно реализовать при помощи специальных, обфусцирующих код компиляторов, а шифрование — при помощи рассматриваемых ниже полиморфных движков (к которым с точки зрения кода относятся и коммерческие защиты).
Динамическая защита подразумевает, что программа может в runtime определить отлаживают ли её и предпринять в связи с этим некоторые действия. Например, прочитав буфер с собственным кодом программа может сравнить его контрольную сумму с эталонной, и, если отладчик вставил в код int 3 (см.выше), понять что её отлаживают или каким-то другим способом модифицировали её код. Но самый, пожалуй, надежный и переносимый способ понять, что тебя отлаживают — это измерение времени исполнения характерных участков кода. Смысл простой: измеряется время (в секундах, попугаях или тактах процессора) между инструкциями в некотором буфере, и, если оно больше некоторого порогового значения — значит в середине программу останавливали. Защита, поняв, что ее отлаживают, может, к примеру, игнорировать ветви, внутри которых может остановиться реверсер и тупо не срабатывать, а вирус, удалить себя из системы. Для борьбы с такими ситуациями реверсеры работают в контролируемых средах, которые можно легко воспроизвести — например в виртуальных машинах, для которых можно воспроизвести все, вплоть до параметров BIOS. Поэтому, исследуя код вируса или защиты необходимо помнить о том, что программа вполне может обнаружить факт исследования и сделать что-нибудь нехорошее.
Вернёмся к вирусным движкам. В определенный момент развития DOS, после появления кучи мега-актуальных на тот момент упаковщиков, программисты, кроме файлов, начали упаковывать все, что упаковывается. А ".exe" файлы занимают кучу места, причем довольно большая часть такого файла — исполняемый код со стабильным распределением частот групп байт, который наверняка хорошо жмется правильным алгоритмом. Поэтому первыми шагами к полиморфным движкам стали упаковщики.
Принцип работы упаковщика довольно прост:
- берем буфер с исполняемым кодом (кодовую секцию, например);
- упаковываем её;
- берем позиционно независимый код распаковщика и дополняем его правильными адресами начала и конца буфера с запакованным кодом;
- добавляем в конец распаковщика переход на OEP (первую инструкцию распакованного кода);
- размещаем распаковщик и буфер со сжатым кодом в исполняемом файле (правим размеры секций и/или EP).
Получившийся файл по размеру получается намного меньше, чем оригинальный. После появления новых, крутейших винчестеров с объёмом 100Мб это стало не столь актуально, но, упаковка открыла вирмейкерам и разработчикам защит много новых возможностей:
- размер вируса (несмотря на наш крутейший винчестер в 100Мб) все таки важен. Если payload-код жирный и многофункциональный, то весь вирус будет труднее запихать в файл, особенно если используется что-то хитрее, чем дописывание в конец файла новой секции. Использование упаковки позволит почти весь большой и сложный код вируса упаковать в буфер, который в разы меньше оригинального размера
- буфер с упакованным кодом необязательно располагать в секции с флагом исполнения. Для продвинутых инфекторов это очень важный фактор, ведь основное тело вируса можно спокойно положить куда угодно. После распаковки распаковщик должен позаботиться, чтобы в области памяти, в которую был распакован код, было разрешено исполнение. Именно поэтому Windows API, работающие с атрибутами доступа к памяти (всякие VirtualProtect, VirtualProtectEx, VirtualOuery и VirtualQuervEx) неизменно привлекают внимание эвристиков
- ну и на сладкое, самое важное — вместо упаковки или после неё буфер с кодом можно зашифровать, а ключ положить в распаковщик. Теперь это будет не распаковщик, а декриптор. При каждом новом инфицировании (или навешивании защиты на исполняемый файл) буфер с кодом можно зашифровать при помощи нового ключа, и тогда буфер с кодом будет иметь совершенно новое содержание (разумеется при использовании хороших алгоритмов шифрования).:w В дальнейшем я не буду писать «упакован», но предполагаю, что упаковка может быть включена в процесс шифрования.
Ну вот он, собственно, и есть — первый полиморфный движок. Распишем поподробней примерный алгоритм инфицирования:
- Генерируем новый ключ шифрования.
- Берем код декриптора (где и как — поговорим позже, в простейшем случае тупо достаем готовый код из нашего тела).
- Внедряем в него (в код декриптора) наш новый ключ шифрования.
- Внедряем в код вируса передачу управления из файла-жертвы и обратно (пока код еще не зашифрован).
- Зашифровываем новым ключом наш большой буфер с кодом.
- Бесхитростно укладываем зашифрованный буфер в файл-жертву (он существенно отличается от предыдущего, поэтому можно особо не прятаться).
- Добавляем переход на начало зашифрованного буфера в конец декриптора.
- Хитро (насколько это возможно) укладываем декриптор в файл-жертву.
Посмотрим, что получилось: большая часть вируса (зашифрованный буфер) целиком меняется от файла к файлу, и неизменным остается только небольшой декриптор. Этот декриптор фактически содержит несколько адресов (меняющихся от файла к файлу), ключ расшифрования (также изменяющийся) и собственно код расшифровщика. Антивирусу теперь пришлось поднапрячься, характерные для данного вируса паттерны укрыты внутри зашифрованного буфера, и кусок кода для сигнатуры теперь приходится искать в декрипторе, а он небольшой и содержит в себе гораздо меньше характерных участков кода и данных.
Такое упрощение задачи послужило причиной появления более продвинутых полиморфных движков, которые при заражении изменяют только код декриптора — ведь разобраться с небольшим куском кода гораздо проще, чем со всем payload-кодом. Радостные вирмейкеры и разработчики защит потирают руки и изучают способы спрятать маленький декриптор похитрее, а аверы и крэкеры чинят дизассемблеры, у которых после попыток дизассемблировать рандомные строки байт, на которые в коде присутствует JMP, едет крыша.
Теперь авер тратит немного больше времени на создание сигнатур, т.к. работать приходится с небольшим объемом кода, в котором меньше характерных участков. А вирмейкер озабочен лишь мутацией довольно небольшого декриптора с довольно простым внутренним алгоритмом, и задача скрыть его от детектора теперь кажется более реальной. Учитывая, что антивирус сравнивает сигнатуру по фиксированному смещению, сначала вирмейкер пытается различными способами сдвигать код декриптора и, соответственно, дискредитировать характерную сигнатуру внутри него. Первым, простым приёмом, который приехал в вирусы из эксплоитинга уязвимостей, являются NOP-зоны. Когда атакующему удалось успешно провести эксплойт какой-либо уязвимости, и заставить процессор совершить переход по заданному адресу, но при этом неизвестен точный адрес расположения shellcode в памяти, атакующий может сделать так: кучу пространства перед собственно кодом эксплойта заполнить NOP-ами:
addr1: nop
nop
;... еще очень много NOP-ов
nop
addr2: jmp addr3; shellcode
pop esi; shellcode
xor edx,edx shellcode
;...
Теперь можно сделать переход «куда-то туда», в NOP-зону. Если известно только приблизительное расположение в памяти, этот прием позволяет успешно выполнить shellcode.
Так же беcхитростно можно поступить и с декриптором, просто при инфицировании помещаем его в разные места длинной NOP-строки. А кое-где (там, где это не разломает переходы) можно напихать этих NOP-ов прямо в код. В этом случае все будет корректно работать, но смещения характерной сигнатуры всегда будут разные. Разумеется смещения для инструкций переходов придется пересчитывать.
Слишком халявное решение не сильно напрягло авера, который просто добавил в базу признак «пропусти все NOP-ы при подсчете сигнатуры», но этот маленький шаг весьма примечателен тем, что впервые детектор начал рассматривать инструкции, а не байты. Но об этом позже.
Размышляя, как бы дискредитировать сравнение по сигнатуре, не разломав код декриптора, вирмейкер приходит к идее пермутации. Пермутация — это перестановка блоков кода в каждом новом поколении. Код состоит из некоторого количества блоков, эти блоки переставляются местами в каждом новом поколении вируса, и связываются JMP-ами. Как всегда, на бумаге всё просто, а проблемы начинаются в реализации. Внутри блоков есть условные и безусловные переходы и вызовы функций, поэтому такие, логические блоки должны оставаться целыми. При этом, чем толще блоки, тем меньше вариативность получившегося декриптора, а чем меньше размер блока, тем больше приходится добавлять переходов, раздувая код декриптора, и тем сложнее соблюсти целостность. Для выравнивания блоков по длине можно, например, использовать NOP-зоны.
Вот пример алгоритма: в теле вируса храним уже готовый набор из блоков с разметкой (которая представляет собой номер блока и его длину). Затем берем рандомный блок, записываем его в буфер, и правим JMP в конце предыдущего блока. Дополняем результат JMP-ом на первый блок и буфер с рандомно переставленными блоками готов. В отличие от предыдущих игр, это уже достаточно серьезная серьёзная заявка, каждое новое поколение, пускай и за счет безусловных переходов, но все таки порождает, с точки зрения смещений, совершенно другой код. Вирмейкер с довольной улыбкой засыпает.
[block 1] | [block 2] | [block 3] | [...] | [block N] | |
[jmp block 1] | [block 2] [jmp block 3] | [block 1] [jmp block2] | [block 3] [jmp block 4] | [...] | [block 4] [jmp block 5] |
Просыпается авер. Трассируя код нескольких поколений вируса, он понимает, что в декрипторе имеет дело с перестановкой блоков, и надо дорабатывать детектор, по возможности не лишив его производительности. Он решает написать быстрый автоматический дизасcемблер, который умеет бежать по инструкциям, останавливаться только на инструкциях перехода, вычислять адрес перехода и переходить к анализу инструкций, находящихся по адресу перехода.
Теперь в антивирусной базе лежит следующее указание: начиная с точки входа шагай по инструкциям, совершая переходы соответственно встреченным JMP-ам, и, пройдя N инструкций, сравни сигнатуру. Если сигнатура находится в десятом блоке, придется пройти до десятого перехода, если внутри возможны условные переходы (JZ), то их условно можно считать двумя переходами — на следующую инструкцию и на адрес перехода, и, соответственно, разветвлять проход по инструкциям. Разумеется, никто не отменял и детектирование попроще, например если блоки у вируса фиксированной длины L и их N штук, можно просто провести N сравнений по сигнатуре по смещениям [0, (1 * L), (2 * L), ..., ((N-1) * L)].
Оценим трудоёмкость процесса поиска с использованием дизассемблера. Дизассемблер минимально должен обеспечивать определение длины инструкции и преобразование VA (Vitual Address) to RVA (Relative Virtual Address) (адрес, указанный в JMP в смещение в файле). Определение длины инструкции — это в принципе достаточно быстрый алгоритм (обращение к элементу массива и вычисление следующего шага на основе флагов, записанных в соотв. элементе массива), а преобразование адреса это пара элементарных операций сложения адресов, на основе информации о том, какой секции принадлежит адрес. Плюс немного ума для определения дешевых трюков для замены банального JMP next_block_address, таких, например, как:
XOR eax,eax;
JZ next_block_address;
; или
PUSH next_block_address;
RET;
; или
MOV eax, next_block_address;
CALL eax;
Это не сильно страшные в плане производительности алгоритмы, но, тем не менее, на вычисление CRC32 от короткой строки по заданному смещению это уже совсем не похоже, и сердитый тестировщик ругается на то, что детектор уже полночи жуёт тестовую базу и сожрал весь процессор.
Как это водится, если что-то, что включили, а оно тормозит, надо либо оптимизировать это, либо постараться не включать это без необходимости. Первый способ, увы, не катит — в простом дизассемблере много не наоптимизируешь, поэтому авер переходит к любимому месту всех антивирусов — эвристическому анализатору.
В первой статье мы уже коснулись эвристического анализа — действительно, существуют признаки, которые с разной степенью достоверности могут говорить, что в файл был инжектирован код. И тогда, авер действительно выделил некоторые из них, которые были подозрительными, но никак не тянули на право заявлять о 100% факте зараженности файла. Тогда он их просто закомментировал, т.к. потратил на них немало времени и удалять их совсем было жаль. Теперь на их основе можно принять решение — запускать ли более тяжелый, использующий дизассемблирование, анализ файла, или нет.
Есть и еще одна проблема — т.к. эвристик реагирует на всё подозрительное, коммерческие защиты вызывают у него неподдельный интерес, поэтому аверу пришлось завести в базе сигнатур еще пару сотен «белых» под популярные навесные защиты — их трогать нельзя. Именно благодаря им мы все таки можем нормально запускать различные коммерческие софтины. А при написании собственного софта, использующего методы работы с исполняемым кодом, неплохо бы перед релизом прогнать все файлы своей программы на всех популярных антивирусах где нибудь на virustotal. За непопулярные можно сильно не волноваться, эвристический анализатор трудно утащить так же просто, как базы сигнатур и вряд ли анализатор малопопулярного антивируса будет так же крут, как то, который разрабатывался долгие годы.
Стоит, конечно упомянуть и о попытках вирмейкера замаскировать свой вирус под популярную защиту. Для этого, нужна собственно сигнатура, и он начинает разбирать антивирусную базу, чтобы понять, куда бы положить нужные байты, чтобы антивирус принял его вирус за защиту. Да и вообще, изготавливая следующую версию вируса, неплохо бы ознакомиться с кодом, который детектирует текущую. Так что антивирусные базы тоже являются объектами реверс-инжиниринга, а код детектора также подвергается анализу со стороны вирмейкеров.
Но вернёмся к нашему эвристическому анализатору, приведём несколько эвристических признаков:
- Точка входа в секции открытой для записи(rwx). Открытая для записи, исполняемая секция, в которую сразу передается управление, с большой вероятностью свидетельствует о наличии самомодифицирующегося кода, такие секции используются в подавляющем большинстве случаев вирусами и программными защитами.
- Инструкция перехода в точке входа. Особого смысла в размещении инструкции перехода в точке входа нет и такой признак указывает на наличие самомодифицирующегося кода в файле.
- Точка входа во второй половине секции. Вирусы, использующие расширение секции, в большинстве случаев располагаются в конце секции. Это нетипично для нормальных файлов, поэтому такая ситуация является подозрительной.
- Поломки в заголовке. Некоторые модификации заголовка после инфицирования оставляют файл работоспособным, но сам заголовок при этом содержит ошибки, которые линкер бы не допустил. Это тоже подозрительно.
- Нестандартный формат некоторых служебных секций. В исполняемых файлах есть служебные секции, такие как, например, .ctors, .dtors, .fini и т.п. Особенности этих секций могут использоваться вирусами для заражения файла. Нарушение формата такой секции также является подозрительным.
- … и еще сотня таких признаков
Таких признаков может быть множество, они имеют разную степень опасности, некоторые могут быть опасными лишь в комбинации с другими, но это мощнейший инструмент для принятия решения о необходимости более тщательного анализа и о факте заражения. Обойти эвристик весьма непросто (я имею в виду сделать так, чтобы он не выдал даже предупреждения). Это либо всякие платформозависимые решения, использующие особенности определенных компиляторов или фреймворков (типа перезаписи стандартных конструкторов или деструкторов), которые довольно быстро попадают в эвристическую базу, либо использование реально больших и сложных инфекторов, умеющих действительно качественно расположить код в файле.
Когда эвристические признаки говорят, что «файл 100% заражен», но тяжелый анализ ничего не нашел, антивирус пишет, что файл заражен вирусом с названием типа: «Generic Win32.Virus», или по-нашенски «Какой-то Win32 Вирус». Такие сообщения часто можно встретить на всяких кейгенах, лоадерах и т.п. В прошлой статье я уже говорил, что именно по этой причине в инструкциях по установке пиратского софта пишут «перед установкой отключите антивирус». Также я еще раз хочу обратить внимание на один из важнейших информационных активов антивирусных компаний — коллекцию исполняемых файлов достаточного объёма, чтобы на ней можно было бы тестировать анализатор, не боясь выпустить в мир версию, которая будет кидаться на легитимные файлы, которые туда обязательно добавляются. Обиженные кейгены и лоадеры наверняка возмущаются, что их туда не добавляют оперативно, но кто ж их, вирусню, слушает…
Итак, поработав над эвристиком авер приходит к следующему общему алгоритму детектирования:
- Проверить файл обычным сигнатурным поиском.
- Если успешно — считать файл заражённым.
- Если найдена «белая» защита — выйти молча.
- Проверить файл эвристическим анализатором.
- Если не найдено ни одного признака — выйти.
- Если найдены признаки достаточного веса, запустить анализ, использующий дизассемблирование.
При этом, если эвристические признаки достаточно серьёзны, чтобы говорить о заражении, считать файл зараженным вне зависимости от того, нашел анализатор что-либо, или нет.
Работы проделано много, и антивирус теперь, пусть и не идентифицируя угрозу, но с очень высоким процентом достоверности распознает факты инфицирования. Поддержка тестовой базы исполняемых файлов позволяет без опаски добавлять новые эвристические признаки, как только появляются новые алгоритмы инфицирования, и, наконец-то, антивирус умеет реагировать на угрозы до того, как новая зараза успевает распространиться. Надо отметить, что если раньше тестировать антивирус на всех исполняемых файлах в мире казалось совершенно нереальным, то сейчас сейчас база всех возможных в WWW исполняемых файлов уже не кажется фантастикой. Исполняемый файл штука, требущая серьезных временных затрат, и мир производит их не так уж и много. Кроме того тестирование на этой огромной базе файлов легко распараллеливается, поэтому вполне реально дрессировать эвристик на огромных массивах возможных данных. Счастливый авер пьёт своё какао и ложится спать…
Вирмейкер на этот раз решает не проводить манипуляции над уже существующим кодом, а генерировать новый код декриптора в каждом новом поколении. Это и есть метаморфизм — генерация нового кода в каждом новом поколении. В отличие от пермутации, в данном случае код не просто переставляет блоки внутри себя, а реально меняет своё содержание. В теории это должно означать безоговорочную победу вирмейкера над точным детектированием его вируса (эвристику-то никто не отменял). Теперь, сигнатура, сделанная для одного поколения вируса станет неактуальной для другого, а, даже если и продолжит детектировать вирус, то не даст гарантии работоспособности в следующем поколении.
Что же представляет собой метаморфный генератор? Основой для генерации нового поколения декриптора является некий «базовый код», причем на каком языке он написан — несущественно. Он хранится внутри зашифрованного тела вируса, поэтому может быть постоянным. Там же, в теле вируса, лежит движок, который на основе каждой инструкции этого «базового» кода каждый раз генерирует новый, исполняемый, код. Это очень напоминает компилятор — на входе некоторые семантические конструкции, на выходе готовый к исполнению процессором код. Еще подобная генерация исполняемого кода на основе базового кода происходит в виртуальных машинах — в момент, когда на определённой платформе виртуальная машина исполняет подготовленный байт-код. Именно в этот момент «базовый» байт-код превращается в конкретный исполняемый, который понимает данный процессор. И, если каждую новую платформу считать новым поколением кода, то совокупность виртуальных машин под разные платформы является метаморфным генератором.
Если вспомнить, что мы генерируем код декриптора, который максимально независим от того, где и когда он исполняется (не содержит системные вызовы, не обращается к сохраненному состоянию, не содержит сложные объекты), и работает с уже готовыми данными в памяти по известным смещениям, то задача кажется вполне себе разрешимой. На входе у генератора три основных параметра — адрес зашифрованного буфера, его длина и ключ. Ну, пусть будет еще seed для псевдослучайной генерации всяких констант, будущих ключей и т.п. Также декриптор содержит условные переходы, но только в пределах своего тела, что также немного упрощает задачу.
Вирмейкер решает подойти к вопросу, используя генерацию множества лишних инструкций, и «размешивать» истинный код декриптора в них. Пусть даже исходные инструкции останутся неизменными, в куче других инструкций вычленить необходимые для сравнения по сигнатуре будет очень проблематично. Несмотря на невзрачное название, генератор мусора является самой сложной и интересной частью метаморфного движка, ведь мусор или не мусор, а сгенерировать надо исполняемый код, который не будет ломаться сам и не будет портить основной код декриптора. В процессе «замешивания» необходимо будет:
— следить за смещениями характерных точек (адресов переходов, выходов из цикла и т.п.);
— следить, чтобы мусорный код не испортил необходимые регистры и регистр флагов.
Очень заманчивыми кандидатами на звание мусорных инструкций являются всякие MMX, SSE, floating-point инструкции, их можно легко сгенерировать сколько надо, главное — не трогать стек, не писать в регистры общего назначения и не ломать флаги, необходимые декриптору, и первый метаморфный код выглядит вот так:
mov ecx, 100h; ; декриптор
lbl0: mov eax, [esi + ecx] ; декриптор
xor eax, edi ; декриптор
mov [ebx], eax ; декриптор
add ebx, 4h ; декриптор
movd mm0,edx ; мусор
movd mm1,eax ; мусор
psubw mm1,mm0 ; мусор
lbl1: jcxz lbl2 ; декриптор, выход из цикла
psubw mm1,mm0 ; мусор
movd mm3,ecx ; мусор
jmp lbl0 ; декриптор, продолжение цикла
lbl2: sub ebx, 100h ; декриптор
Авер не сильно волнуется, т.к. эвристик всё-таки продолжает ругаться на заражённые файлы (работая над генератором, вирмейкеру неохота возиться с серьёзным инфектором), но точно идентифицировать конкретный вирус уже не может. Поэтому тёмной ночью аверу снится инфектор, который не поддается эвристику, и его навязчивой идеей становится необходимость задетектить гада со 100% точностью. Чтобы точно идентифицировать вирус, детектор надо дорабатывать — теперь необходимо, начав с точки входа, шагать по инструкциям, пропускать все мусорные и добавлять в анализируемые только значимые, а это означает, что дизассемблер в детекторе начинает расти. Если вы помните про NOP зоны в абзаце про пермутацию, то пропуск NOP-ов при набивании буфера для сравнения по сигнатуре, фактически, и есть первый подход к сняряду — детектор пропускает NOP-ы, как мусорные инструкции. Теперь авер вместо сравнения с 0x90 (опкод NOP) использует дизассемблер (чем быстрее, тем лучше), который:
- Сдвигает указатель на начало следующей инструкции (дизассемблер длин).
- Говорит, является ли данная инструкция мусорной (NOP, MMX, SSE и т.п.).
- Значимые инструкции добавляет в анализируемый буфер.
- В случае безусловного перехода помечает адрес перехода, как следующий анализируемый.
- В случае условного перехода помечает обе возможных ветки кода для дальнейшего анализа.
Таким образом авер собирает буфер из инструкций, которые составляют основной код декриптора, и уже в нём может провести сравнение по сигнатуре. Это пока еще довольно быстрая процедура, но, программируя её, авер все больше волнуется: «всегда ли я смогу отличить мусорную инструкцию от значимой?» Вирмейкер, чувствуя это, дорабатывает свой генератор мусора. Теперь он зовет на помощь инструкции сохранения контекста: pushad/popad (положить или достать со стека все регистры общего назначения) и pushfd/popfd (то же самое для регистра флагов).
mov ecx, 100h; ; декриптор
lbl0: mov eax, [esi + ecx] ; декриптор
xor eax, edi ; декриптор
mov [ebx], eax ; декриптор
add ebx, 4h ; декриптор
pushad ; сохраняем регистры
pushfd ; сохраняем флаги
mov eax, 12321h ; мусор
xor edx,edx ; делаем что хотим
sub eax, esi ; продолжаем мусорить
popfd ; восстанавливаем флаги
popad ; восстанавливаем регистры
lbl1: jcxz lbl2 ; декриптор, выход из цикла
pushad ; сохраняем регистры
pushfd ; сохраняем флаги
shr ebx, 4 ; мусор
popfd ; восстанавливаем флаги
popad ; восстанавливаем регистры
jmp lbl0 ; декриптор, продолжение цикла
lbl2: sub ebx, 100h ; декриптор
Теперь дизассемблер анализатора должен следить не только за тем какие инструкции он анализирует, но и находятся ли они в области «делаем что хотим». А это означает, что у дизассемблера появляются глобальные переменные, хранящие информацию о том, в каком месте программы мы находимся. Все становится еще интереснее. Ну а вообще, инструкции сохранения контекста для любого реверс-инженера как красная тряпка для быка — при анализе исполняемых файлов любая встреча с такой инструкцией означает «скорее ставь сюда breakpoint!».
Следующей итерацией в развитии метаморфного кода является генерация необходимого действия различными способами при помощи различных арифметических операций и всяких ассемблерных хитростей. Типа того:
«базовая инструкция» | сгенерированный код 1 | сгенерированный код 2 |
virt_mov eax, 10h | mov eax, 20h; sub eax, 10h; | mov edx, 10h; mov eax, edx; |
virt_mov ecx, 08h | xor ecx,ecx; add ecx, 08h;; | mov ecx, 04h; add ecx, 04h; |
virt_sub eax, ecx | neg ecx; add eax, ecx; | mov edx, ecx; sub eax, edx; |
Например, так можно работать со всеми константами: предположим в «базовом коде» лежат две инструкции «virt_mov edx, 10h» и «virt_mov ecx, 100h». Тогда генерируя новый код, движок выбирает случайную константу, например, «50h», и использует ее для работы со всеми абсолютными значениями, и «virt_mov edx, 10h» мутирует в «mov edx, 50h; sub edx, 40h;», a «virt_mov ecx, 100h» в «mov ecx, 50h; add ecx, B0h». Различные константы порождают различные байт-паттерны, что вынуждает авера добавлять в дизассемблер всё больше логики, реализовывать wildcards в сигнатурах по инструкциям, делая для инструкций что-то наподобие «mov eax, ; ; mov ecx, ». Это уже не очень просто, и уже не очень быстро, и вообще пахнет жареным…
После анализа кода детектора, помимо констант, для модификации данных в инструкциях, вирмейкер теперь хочет менять и весь набор регистров, используемый в декрипторе. Чтобы позволить себе такое, необходимо использовать разделение регистров — часть регистров являются рабочими для декриптора, а остальные — для генератора мусора. В этом случае генератор мусора не трогает рабочие регистры, а также не портит регистр флагов. Например, весь декриптор может работать только с eax, edx и esi. Тогда все порождённые генератором инструкции должны работать только с ebx,ecx, edi и не менять флаги. При этом, набор регистров должен изменяться в каждом новом поколении вируса.
. . .
mov eax, 10h ; декриптор
mov ebx, 20h ; декриптор, ebx - мусорный регистр, его можно испортить командой xchg
xchg edx, ebx ; для того, чтобы загрузить 20h в регистр edx
xor ecx,ecx ; мусор
inc ebx ; мусор
add ecx,ebx ; мусор
add eax, edx ; декриптор
mov edx, [esi] ; декриптор
xchg edi,ebx ; мусор
cmp edx, 0 ; декриптор
. . . ;
Генератор мусора, в общем-то, может менять и «запрещенные» регистры и флаги, но при этом возвращать их состояние обратно. В этом случае «истинные» инструкции декриптора можно внедрять не в любое место буфера с мусором, а только в те места, где значения в регистрах и флаги «чистые».
По-настоящему серьёзным испытанием становится изготовление сигнатуры по инструкциям, если используются различные ассемблерные хитрости, позволяющие реализовать необходимые действия при помощи совершенно непохожих паттернов, например:
«базовая инструкция» | сгенерированный код 1 | сгенерированный код 2 |
virt_push eax | sub esp, 04h; mov [esp], eax; | mov edx, esp; sub edx, 04h; mov [edx], eax; |
virt_mov eax, ebx | lea eax,[ebx]; | push ebx; xchng eax,ebx; pop ebx; |
Таких паттернов — огромное количество, при желании вы их легко найдёте. Теперь генератор становится намного сложней, т.к. сильно усложнаяется работа со смещениями, стеком, флагами и т.п. Но это еще один серьёзный шаг к идеальному генератору.
Итак, для порождения кода, в котором детектору будет максимально сложно утверждать, является ли текущая инструкция мусорной, необходим генератор, способный генерировать код со следующими свойствами:
- содержать ходовые целочисленные инструкции с регулярными регистрами;
- не использовать инструкции сохранения-восстановления контекста для отделения мусорного кода от истинного;
- набор регистров как в мусорных блоках, так и в инструкциях декриптора в каждом поколении должен быть различным;
- базовые инструкции декриптора должны превращаться в блоки инструкций различной длины;
- байт-структура мутировавших инструкций должна быть максимально вариативной.
Предположим, что вирмейкер через 42 месяца работы написал-таки почти идеальный метаморфный генератор, и детектор не может фильтровать инструкции по принципу «мусор-не мусор» и, соответственно, не может собрать достаточно данных для сравнения по сигнатуре. Но и у авера в запасе есть ответ, столь же сложный в реализации, но способный справиться с детектированием конкретного вируса использующего даже столь продвинутые методы метаморфизма. В процессе противостояния всё новым генераторам и изготовления всё более сложных дизассемблерных сигнатур дизассемблирующий движок дошёл до состояния, когда помимо текущей инструкции он хранит также и все окружение данной конкретной инструкции, следя за изменениями в регистрах, флагах, указателем стека и т.п. Критически взглянув на получившийся код авер вдруг понимает, что, фактически, запрограммировал софтовую модель процессора. Проходя по инструкциям его код обновляет переменные, соответствующие регистрам, следит за флагами, чтобы предсказать условный переход, отслеживает верхушку стека и т.п., т.е. фактически исполняет читаемый код виртуально. Метод детектирования, использующий эмулирующий исполнение движок так и называется — эмуляция.
Вспомним, как крекеры снимают навесную защиту исполняемых файлов, остановив ее на первой инструкции в момент, когда работоспособный распакованный код лежит в памяти (на Original Entry Point). Для понимания того, как эмулятор может помочь детектору добраться до вкусного, зашифрованного payload кода я коротко опишу один простой, но действенный способ остановить программу на OEP. Основан он на том, что в момент старта основной программы указатель стека должен быть установлен в своё изначальное значение, т.к. на стеке лежат данные, относящиеся к окружению программы, агрументы, переменные среды и т.п. Поэтому, можно быть уверенным, что после того, как защита отрботает, esp вернётся к тому значению, которое было установлено в начале. Крекер останавливает программу прямо в точке входа, запоминает значение указателя стека esp и ставит условный breakpoint, который остановит программу в тот момент, когда esp станет равным тому самому значению, которое было зафиксировано на момент старта. С большой вероятностью именно в этот момент он будет находиться на OEP (ну или в корне декриптора с точки зрения вложенности функций). Декриптор вируса (если он использует стек, конечно) также должен вернуть указатель стека на место, и код детектора, бегущий по инструкциям, может следить за этим указателем, заведя переменную cur_esp и изменяя её каждый раз, когда встречает инструкции, меняющие esp.
. . . ; base_esp = cur_esp;
push eax ; cur_esp -= 4;
mov eax, 1h ; -
push edx ; cur_esp -= 4;
. . . ; -
pop edx ; cur_esp += 4;
pop eax ; cur_esp += 4; (cur_esp == base_esp) !!!
. . . ; здесь возможно отработал декриптор или весь вирус
Как раз в этот момент, когда стек восстановлен, в памяти находится расшифрованое тело вируса, а в нем вкусные данные для постоянной сигнатуры. В случае с вирусом даже необязательно дожидаться всей распаковки, наверняка внутри декриптора все таки есть постоянные данные типа длины ключа, длины блока, смещения (которое зависит от расмещения секция в файле). Другими словами, если идти по декриптору инструкция за инструкцией, то каким бы ни был порождённый метаморфным генератором код, обязательно наступит момент, когда где-то в памяти или в регистрах будут лежать характерные для именно этого вируса данные. Поймав этот момент, можно определить что это за вирус. Также идя таким образом по инструкциям можно ожидать опасные действия — вызовы подозрительных API, запись в подозрительные файлы и т.п.
Остался пустячок — автоматизировать этот процесс. Как я уже упоминал, эмулятор — это софтовая модель процессора, исполняющего код нашего файла. Причем процессора-халявщика, потому что ему не надо писать в память, делать ввод-вывод и вообще, ему интересно только то, что позволит остановить программу в нужном месте. Он не умеет MMX, SSE и вообще, чем меньше он умеет (при этом выполняя свою функцию), тем лучше (т.к. халявщик он условный, и весьма тяжеловесен). Предположим, в какой-то момент декриптор кладет на стек строку «BANANAS», зная это авер может исполнять код на виртуальном процессоре постоянно проверяя верхушку стека на наличие этой строки.
Движок эмулятора имеет в себе переменные, соответствующие регистрам, флагам, память под эмуляцию стека и т.п. Я намеренно оставил блоки между pushad/popad, чтобы продемонстрировать, что эмулятор может пропускать в том числе и блоки кода, а не отдельные инструкции, т.к. эмуляция — процедура не из простых. Вот как оно примерно работает (пусть по адресу в ESI лежит этот самый «BANANAS\0»).
mov ecx, 0h; ; ecx_var = 0;
lbl0: mov eax, [esi + ecx] ; esi и eax мы знаем (из прошлой эмуляции),
; поэтому загрузим из правильного места в памяти
; указатель на "BANANAS"
xor eax, edi ; eax_var = eax_var XOR edi_var;
push eax ; esp_var -= 4; *esp_var = eax_var;
pushad ; включаем режим безделья
pushfd ; skip
mov eax, 12321h ; skip
xor edx,edx ; skip
sub eax, esi ; skip
popfd ; skip
popad ; выключаем режим безделья
; "качественные" мусорные инструкции
; эмулятор не знает об этом
; и вынужден исполнять их виртуально
mov edx, 23h ; edx_var = 23h;
or edx, eax; ; edx_var = edx_var OR eax_var;
lbl1: inc ecx ; ecx_var++;
cmp ecx, 8h; ; if (ecx_var == 8) { goto lbl2; }:
pushad ; включаем режим безделья
pushfd ; skip
shr ebx, 4 ; skip
popfd ; skip
popad ; выключаем режим безделья
jmp lbl0 ; goto lbl0
lbl2: sub ebx, 100h ; на стеке лежит "BANANAS" - попался!
Разумеется, эмуляция требует обработки множества специфических ситуаций, никаких ресурсов не хватит, чтобы честно эмулировать каждую инструкцию, и при этом не забывать выполнять необходимые проверки окружения. Поэтому эмуляторы детектируют циклы, передают части кода на исполнение реальному процессору, в общем там, как и в метаморфных генераторах, простора для творчества хоть отбавляй.
Итак, где-то в идеальном мире существует идеальный метаморфный генератор, порождающий абсолютно недетектируемый код. И там же, в противовес ему, существует идеальный эмулятор, на котором можно исполнить этот метаморфный код и продетектировать его. Есть ли еще развитие темы самомодифицирующегося кода? Попробуем расширить тему модификации исполняемого кода в каждом новом поколении. Ведь именно изменчивость каждого нового поколения лежит в основе эволюции, поэтому, тема самомодифицирующегося кода в аспекте информационных технологий попахивает уже креационизмом. Кто знает, возможно мы постепенно создаем новую вселенную и новую жизнь?
Рассматривая шифрование буфера с основным кодом вируса мы говорили об изменении вируса с точки зрения байт — т.е. менялся набор байт, составляющих вирус. Это самый примитивный уровень, отдельный байт несет немного информации о свойствах вируса и изменениями на этом уровне не добиться вариативности поколений. Если проводить аналогию с развитием жизни, это напоминает многообразие простых химических соединений. Множество простых соединений, в различных комбинациях, вода, аммиак, углекислый газ, кислотные остатки и гидроксильные группы — в течение миллионов лет этот коктейль не мог породить ничего сложного. Но в конце концов удачные комбинации привели к появлению сложных органических молекул — основы жизни.
Рассматривая мутацию декриптора с помощью метаморфного генератора мы в общем рассматривали вирус уже как набор инструкций, а не байт. Это важный факт, означающий, что теперь мы работаем с информационными элементами следующего порядка. Теперь мы работаем с «обнуляем eax» вместо того, чтобы вдаваться в подробности, как мы это делаем (xor eax,eax или sub eax, eax) и все наши дизассемблеры-детекторы — это замена детектирования по байтам детектированием по цепочке инструкций. Заменяя одну инструкцию другой, мы меняем в том числе и набор байт, т.е. этот уровень включает в себя предыдущий, и, с точки зрения эволюции видов, он куда более продвинут. Изменением набора инструкций можно добиться более целенаправленной вариативности, нежели тупым псевдо-случайным «перемешиванием» байт. Биологическим аналогом, наверное, могли бы выступить аминокислоты, каждая из которых уже сама по себе способна порождать некоторое биологическое действие, при этом они целостны, умеют комбинироваться в более сложные структуры и несут в себе больший квант информации, нежели простейшие соединения.
Если продолжать аналогию, следующим уровнем, на котором вирус может изменяться от поколения к поколению, будут функции, т.е. сущности, состоящиие из набора инструкций. Это что-то типа огромной коллекции функций в нескольких экземплярах на каждый отдельный кусок функционала, например, множество функций, которые ищут файл для заражения, зашифровывают и расшифровывают тело вируса, инфицируют файл-жертву и т.п. В каждом новом поколении вируса набор используемых функций рекомбинируется, меняя весь внутренний функционал. Каждая функция написана отдельным способом, содержит в себе другой набор инструкций и байт, т.е. этот уровень также включает в себя все предыдущие. При возможности реализовать такую схему без архитектурных уязвимостей, такой вирус можно будет теоретически детектировать только детектором, умеющим работать с кодом также на уровне функций, т.е., фактически, оценивающим поведенческий сценарий вируса, а не отдельные инструкции. Не уверен, что сейчас существует что-то похожее на движок, умеющий мутировать на уровне функций, хотя в нескольких статьях читал про идеи, например, скачивания вирусом собственных частей с хоста вирмейкера и замещении собственного функционала, или генетических алгоритмах (когда два вируса меняются функциональными блоками между собой). Тем не менее, огромное количество различных высокоуровневых языков и фреймворков по идее должно способствовать появлению такого рода программ, но уже, как мне кажется, не в области вирусов (ниже расскажу почему). Наверное, биологической аналогией функций такого вируса могут быть белки. Внутриклеточные функции могут обеспечиваться различными типами белков, белки могут замещаться другими, при этом клетка останется целостной, и будет продолжать существование, несмотря на изменившиеся внутренние функции.
Ну а выше только мутация на уровне общего алгоритма программы. Например вирус, который сейчас детектируется антивирусом в следующем поколении становится вообще другой программой, хорошей и пушистой, и антивирусу его просто не нужно детектировать, хехе. Хотелось бы растечься мыслью, как в прошлом абзаце, но… Это фантастика.
Ну и где это всё, спросите вы? Где тысячи страшных метаморфных генераторов, заполонивших компы пользователей, отправившие в психиатрические клиники сотни программистов антивирусных компаний, где жуткие вирусные эпидемии кладущие сеть на недели, где это всё? По моему мнению причин тут несколько.
Первая причина, техническая — это ограничения среды. До NT-шного ядра, NTFS и Linux на домашних PC коду вирусов жилось очень привольно — пиши куда хочешь, исполняйся где хочешь. Сейчас использовать зараженную систему намного сложней и не так уж интересно. Нет, я не хочу сказать, что всё потеряно, но о былом могуществе осталось только мечтать и с каждым годом ситуация всё хуже — права на файлы и процессы, подписи файлов, online-валидация запускаемого софта — все это практически убило «чистокровные» компьютерные вирусы. Но кто знает, по отчетам рынок мобильных зараз растет весьма активно, не означает ли это, что мобильным разработчикам придется пройти по тем же граблям, что и разработчикам больших ОС? Очень надеюсь, что это не так, и технологии защиты в полной мере перекочевали на мобильные устройства.
Ну а главная, как мне кажется, причина — это квалификация программиста. Если вы в состоянии написать хороший метаморфный генератор, с которым специалисты провозятся хотя бы несколько дней, или эмулятор детектирующий сигнатуру внутри качественного вирусного движка, или сделать качественный crackme, который публично зауважают, то… просто напишите мне. Я не рекрутер, но если вас будет много — сменю профессию, просто представляя вас компаниям, которые занимаются безопасностью. Будьте уверены, ваши доходы и стабильность по жизни во много раз перевесят то, что можно получить распространяя вирусы или взламывая софт. В этом и есть главная причина — продвинутые движки пишут очень немногие энтузиасты с очень хорошей подготовкой, и, в большинстве случаев, находят в жизни куда более привлекательные применения своему таланту. Ну а еще, по моему мнению, коммерческая часть современной заразы откровенно скучна, поэтому те, кто поддался соблазну получать ворованные деньги, надолго зависают в сугубо прикладных аспектах и бросают заниматься совершенствованием внутренних алгоритмов.
Жалко, что коду, способному порождать собственные изменённые копии, пока не нашлось другого применения, кроме как прятаться от антивирусов и противостоять взлому. Я искренне желаю этим методам дальнейшего развития, желательно без уклона в зловредство. И пускай программы, порожденные человеческим разумом, станут немного ближе к тому, что мы называем жизнью.
This entry passed through the Full-Text RSS service - if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.
Комментариев нет:
Отправить комментарий