В первой части статьи мы рассмотрели общий вид работы конвейера виртуальной машины, а также немного коснулись возможных подходов для анализа виртуальной машины.

Следуя описанной в конце первой части последовательности мер, начну описывать, что я пытался делать на каждом из этапов, с чем возникли сложности, какие из них я решил обойти и что в итоге получилось.

Напомню, что в этой статье не приводится гарантированного способа снятия виртуализации, я просто хочу поделиться опытом анализа ВМ который позволяет более-менее понять что-то о работе ВМ и может быть полезен при анализе схожих реализаций. 

Применение Triton

Так как о том, что использование символьного представления для снятия обфускации обработчиков иснтрукций ВМ с помощью Miasm (как это описано в статье ESET ) мне не помогло, так как мешались различные переходы и нужно было снимать трассу каждого обработчика, чтобы передать его на анализ, я решил попробовать избавиться от обфускации с помощью Triton. 

Напомню, что на этом этапе анализа мне хотелось найти наиболее быстрый и доступный способ проанализировать обфусцированные обработчики виртуальных инструкций, чтобы описать (дизассемблировать) байткод ВМ и, таким образом, понять заложенный в виртуализованный участок кода функционал.

Я начал смотреть, с какой стороны лучше попробовать применить Tenet и наткнулся на любимую директорию examples, а именно на пример dead_store_elimination. В этом примере в качестве исходного обфусцированного кода даже приводится фрагмент с обфускацией VMProtect, очень здорово. 

Не изобретая велосипед, я решил воспользоваться указанным примером, в котором, по сути, все сводится к применению метода simplify из TritonContext. Сразу вспоминается это:

Опять python все упростил
Опять python все упростил

При этом возникает такая же проблема, как и с Miasm - нужно как-то получить полный листинг обработчика инструкции ВМ. Наиболее доступным способом мне показалось воспользоваться трассой, снятой с исполнения кода обработчика. На тот момент у меня была трасса, снятая для инструмента Tenet (это инструмент для анализа трассы, о нем я расскажу при описании следующего этапа). К сожалению, в этой трассе нет самих дизассемблированных команд внутри обработчика, но были необходимые eip. Трасса для Tenet в виде текста выглядит так:

Фрагмент трассы
Фрагмент трассы

Таким образом, у нас есть значения eip (то есть адреса каждой выполненной в ходе работы анализируемого ПО инструкции). Если мы хотим получить трассу конкретного обработчика ВМ, очевидно, что нам надо читать значения в eip c того места, где eip равен адресу обработчика инструкции ВМ. Массив с адресами обработчиков мы нашли в начале первой части статьи. В анализируемом мною образце есть обработчик, который расположен по адресу 0x50260c. В качестве последней инструкции трассы работы обработчика возьмем инструкцию, предшествующую MAIN_LOOP (упоминается в первой части статьи, в моем случае MAIN_LOOP расположен по адресу 0x50281a). Таким образом, из трассы Tenet извлекаем такой участок:

Участок
esp=0x2f1f51c,eip=0x50260c,mr=0x2f1f4f0:0c265000	// начало трассы обработчика 0x50260c
edx=0x502621,eax=0x2d,eip=0x50260f
eip=0x502610
eax=0xf4,eip=0x502612,mr=0x4ccbdf:f4
edx=0x502608,eip=0x502615
eax=0xc2,eip=0x502617
esp=0x2f1f518,eip=0x502618,mw=0x2f1f518:82020000
esp=0x2f1f514,eip=0x50261d,mw=0x2f1f514:a2a1d06c
eip=0x503a1b
esi=0x4ccbe0,eip=0x503a1c
edx=0x5000c2,eip=0x503a20
edx=0x500000,eip=0x503a23
eax=0xc3,eip=0x503a25
eip=0x503a26
eax=0x3d,eip=0x503a28
edx=0x5000ff,eip=0x503a2a
esp=0x2f1f510,eip=0x503a2e,mr=0x2f1f518:82020000,mw=0x2f1f510:82020000
esp=0x2f1f4f0,eip=0x503a2f,mw=0x2f1f4f0:1cf5f102e0cb4c00e0f5f10210f5f10236fe7f08ff0050002ac44c003d000000
edx=0x5000fe,eip=0x503a31
eax=0x3c,eip=0x503a33
edx=0x50c4fe,eip=0x503a35
esp=0x2f1f4ec,eip=0x5030c5,mw=0x2f1f4ec:3a3a5000
edx=0x5031fe,eip=0x5030c7
edx=0x500001,eip=0x5030cb
ebx=0x87ffe0a,eip=0x5030cd
eip=0x5030cf
edx=0x500002,eip=0x5030d0
edx=0x500200,eip=0x5030d4
edx=0xf7ccccd8,eip=0x5030d7,mr=0x2f1f5e0:d8ccccf7
esp=0x2f1f4e8,eip=0x5030da,mr=0x2f1f4ec:3a3a5000,mw=0x2f1f4e8:3a3a5000
esp=0x2f1f4e4,eip=0x5034f9,mw=0x2f1f4e4:df305000
eip=0x5034fa
eip=0x5034fe
ebp=0x2f1f5e4,eip=0x503501
esp=0x2f1f4e0,eip=0x502759,mw=0x2f1f4e0:06355000
esp=0x2f1f4dc,eip=0x50275a,mw=0x2f1f4dc:06020000
eip=0x502760,mw=0x2f1f4dc:d610
eip=0x502763,mw=0x2f1f558:d8ccccf7
esp=0x2f1f4d8,eip=0x502764,mw=0x2f1f4d8:d8ccccf7
eip=0x502767,mw=0x2f1f4d8:d8ccccf7
esp=0x2f1f4d4,eip=0x50276c,mw=0x2f1f4d4:3d582af5
eip=0x502770,mw=0x2f1f4d4:d4f4
esp=0x2f1f51c,eip=0x502774	// конец трассы обработчика 0x50260c
eip=0x50281a				// eip указывает на MAIN_LOOP

Чтобы получить выполненные машинные команды в виде массива из последовательностей байт (так как пример Triton по упрощению кода работает именно с таким форматом), воспользуемся IDAPy:

Скриптик
import idautils
import idaapi
import idc


def GetInsnLen(ea):
	insn = ida_ua.insn_t()
	inslen = ida_ua.decode_insn(insn, ea)
	if inslen:
    		return inslen
	return 0

eips = '''0x50260c
0x50260f
0x502610
0x502612
0x502615
0x502617
0x502618
0x50261d
0x503a1b
0x503a1c
0x503a20
0x503a23
0x503a25
0x503a26
0x503a28
0x503a2a
0x503a2e
0x503a2f
0x503a31
0x503a33
0x503a35
0x5030c5
0x5030c7
0x5030cb
0x5030cd
0x5030cf
0x5030d0
0x5030d4
0x5030d7
0x5030da
0x5034f9
0x5034fa
0x5034fe
0x503501
0x502759
0x50275a
0x502760
0x502763
0x502764
0x502767
0x50276c
0x502770
0x502774'''


eips_list = eips.split('\n')
for i in range(len(eips_list)):
	eips_list[i] = int(eips_list[i], 16)
	print(idc.get_bytes(eips_list[i], GetInsnLen(eips_list[i])))

На выходе мы получаем каждую команду из трассы обработчика инструкции ВМ, чтобы далее передать эти команды в скрипт Triton. При этом все инструкции перехода (JMP, JZ, JNE и т.д.) нужно удалить, а инструкции вызова (CALL) заменить на PUSH (как будто на стек кладется адрес возврата). 

Результат далёк от успеха
Это листинг до
Это листинг до
А это после
А это после

Видно, что попытка удаления мусорного кода не привела к желаемому результату, как минимум остались инструкции работы со стеком, которые больше выглядят как мусорные, на это также указывает инструкция LEA, которая, как видно, очищает стек, загружая сохраненный указатель.

Несмотря на то что чистую и доступную версию обработчика инструкции ВМ мы все-таки не получили, все равно у нас теперь есть листинг этого обработчика, можно попытаться посмотреть, что же в нем происходит (сделаем это на немного “упрощенной” версии, не зря же мы использовали скрипт из примеров Triton ?). 

Напомню, что в первой части статьи мы узнали, что esi является VM_EIP, то есть указателем на байт в байткоде. Акцентируя внимание на ключевых моментах листинга можно заметить, что в AL загружается значение из [ESI] (1),  далее с ним происходят какие-то преобразования (2), спустя ряд преобразований EAX используется как смещение (3), по которому записывается значение, полученное из [EBP] (4), при этом EBP также увеличивается на 4 (5):

Листинг
0x50260c: xadd al, dl
0x50260f: mov al, byte ptr [esi]	// 1
0x502611: shl dl, 3
0x502614: xor al, bl			// 2
0x502616: pushfd
0x502617: push 0x6cd0a1a2
0x50261c: pushal
0x50261d: movzx dx, al
0x502621: sete dl
0x502624: inc al
0x502626: neg al
0x502628: dec dl
0x50262a: push dword ptr [esp + 4]
0x50262e: pushal
0x50262f: dec al
0x502631: push dword ptr [esp + 4]
0x502635: push 0x6cd0a1a2
0x50263a: xor bl, al
0x50263c: mov edx, dword ptr [ebp]		// 4
0x50263f: push dword ptr [esp]
0x502642: push dword ptr [esp + 4]
0x502646: push 0x6cd0a1a2
0x50264b: add ebp, 4				// 5
0x50264e: push dword ptr [esp + 4]
0x502652: push 0x6cd0a1a2
0x502657: pushfd
0x502658: mov word ptr [esp], 0x10d6
0x50265e: mov dword ptr [eax + edi], edx	// 3
0x502661: push edx
0x502662: mov dword ptr [esp], edx
0x502665: push 0xf52a583d
0x50266a: mov word ptr [esp], sp
0x50266e: lea esp, [esp + 0x48]

Посмотрев на указанные участки, можно предположить, что в указанном обработчике читается один операнд размером 1 байт, этот операнд используется как указатель на виртуальном стеке (VM_ESP), по которому кладется значение из [EBP].  Инструкцию можно записать примерно так: 

MOV VM_EIP:OP1, [EBP]

Таким образом, примерно стали ясны ключевые компоненты, применяемые при работе обработчика инструкции ВМ. Теперь стало понятнее, что именно нужно искать, чтобы понять, что делают остальные обработчики, поэтому я решил не пытаться с помощью Triton до конца снять обфускацию, а воспользоваться ручным анализом работы каждого обработчика, используя Tenet.

Анализ трассы с помощью Tenet и без помощи Tenet

Как уже было сказано выше, Tenet это инструмент для анализа трассы с помощью IDA. В репозитории подробно описано, как снимать трассу анализируемого ПО, я использовал Intel Pin, для него уже собран необходимый модуль.

В результате снятия трассы в нужном формате и передачи ее плагину Tenet мы получаем очень удобную возможность перемещаться в оба направления по трассе, внимательно отслеживая интересующие нас изменения:

Участок в 0x50260c
Участок в 0x50260c

Теперь, после того как мы немного разобрались в работе обработчиков инструкций ВМ, а также получили возможность удобно анализировать трассу, можно приступить к анализу каждого обработчика, чтобы разобраться в выполняемом байткоде.

Предварительно я также записал все выполненные инструкции виртуальной машины -
то есть снял трассу ВМ. Это можно сделать с помощью отладчика x64dbg или другими способами (например, тем же Pin). Если делать это через x64dbg, то нужно не забыть установить плагин ScyllaHide и выбрать профиль защиты от VMProtect. Это можно сделать, установив точку останова инструкции RET, которая совершает переход с обработчику инструкции ВМ (об этом подходе рассказывалось в первой части), и добавив запись в лог значения, лежащего на стеке (адрес обработчика очередной команды из байткода ВМ):

Как добавить запись в лог в x64dbg
Как добавить запись в лог в x64dbg

Оглядываясь назад я бы еще добавил запись в лог значение ESI, так как это VM_EIP и с ним удобнее смотреть перемещения по байткоду, хотя понять, что происходит, получилось и так.

В итоге мы получим трассу ВМ, адреса в которой необходимо перевернуть (преобразовать из-за Little Endian):

Трасса ВМ до и после преобразования
Трасса ВМ до и после преобразования

Конечно, нам также нужно получить количество уникальных адресов, т.е. количество всех обработчиков, используемых в трассе, чтобы потом, например, отмечать, какие мы проанализировали и что именно в них происходит. Получилось 31, не очень много.

Постепенно анализируя обработчики и разбираясь, что в них происходит, мы начинаем описывать трассу, приводя к подобному виду:

Вид трассы после описания обработчиков
Вид трассы после описания обработчиков

Конечно, хотелось бы еще увидеть конкретные значения операндов, но для этого нужно было реализовать декодирование, которое происходит в каждом обработчике, поэтому я решил пока посмотреть трассу в представленном виде.

В ходе анализа обработчиков инструкций ВМ мне наиболее интересными мне показались инструкции, интерпретирующие запись в память, вызовы и переходы, а также реализация подсчета контрольной суммы.

Запись в память в моем примере реализовывается обработчиком 0x502b1c, вот пример участка трассы, где запись реализовывается 4 раза подряд:

Фрагмент с вызовами инструкции ВМ на запись в память
Фрагмент с вызовами инструкции ВМ на запись в память

Если посмотреть внутрь обработчика, то можно увидеть, что сама запись в память происходит при выполнении MOV [EAX], EDX. Значения обоих операндов берутся из [EBP+0] и [EBP+4] соответственно, которые заполняются на предыдущих командах ВМ (видно на трассе).

Tenet позволяет нам полистать каждое выполнение инструкции записи, поэтому мы можем увидеть, что же именно записывается. Например, в примере ниже после четырех вызовов записалась строка “kernel32.dll”:

В окне состояния памяти видно знакомое название kernel32.dll
В окне состояния памяти видно знакомое название kernel32.dll

Таким же образом записываются другие строки с названиями функций, например, CreateFileA или MapViewOfFile. Где-то ранее они, очевидно, расшифровываются. Так мы уже понимаем, какие функции WinAPI вредонос будет использовать и для чего.

Функция перехода характеризуется просто записью в ESI значения из [EBP+0], по которому также происходит запись при выполнении инструкций ВМ ранее. Переходы помогают нам найти циклы, то есть какие-то повторяющиеся операции вроде проверок, записей, расшифровки и т.д.:

Аналог JMP в данной ВМ
Аналог JMP в данной ВМ

Подсчет контрольной суммы происходит для выбранного участка памяти в цикле внутри обработчика инструкции ВМ с помощью арифметических операций (например, XOR или SHL, в анализируемом мною образце это обработчик по адресу 0x50288e).

Если внимательнее посмотреть на то, какие функции WinAPI вызываются и для чего используется проверка целостности, то становится ясно, что вредонос осуществляет мапинг своего образа в память, а затем сравнивает контрольные суммы участков образа с содержимым собственных секций, чтобы установить наличие точек останова или других изменений.

При успешном завершении проверки осуществляется запись в секцию .text распакованного участка вредоноса, реализующего основной функционал. Это можно увидеть, проанализировав, как вызывались инструкции ВМ по записи в память. На скриншоте видно, что между первыми инструкциями ВМ по записи (в которых была работа со строками с названиями функций) и последующими инструкциями ВМ по записи есть разрыв в примерно в 28 тысяч инструкций ВМ: 

Видно, что что-то пишется в память
Видно, что что-то пишется в память

То есть в этом разрыве проводилась проверка и распаковка кода с основным функционалом, после чего началась запись распакованного кода в секции .text и .data (инструкции записи теперь вызываются интервалом в 4-5 шагов и в значительно бОльшем объеме).

Так, изучив функционал каждого (или большинства) обработчиков инструкций ВМ, мы разобрались в том, что делает виртуализованный код - проводит мапинг собственного образа, проверяет его целостность, распаковывает основной участок. Если снять дамп после распаковки, можно увидеть понятные строки как признак того, что распаковка прошла (в частности, имена функций, указывающих на работу с ядром):

Пример строк
Пример строк
И еще пример строк
И еще пример строк

Также заметно, что само дизассемблированное и декомпилированное представления  в районе OEP и далее стали гораздо более читаемыми:

Наконец-то без виртуализации
Наконец-то без виртуализации

Теперь можно продолжить анализ исследуемого образца.

Заключение

Вы будете смеяться, но получается, что в данном случае достаточно было просто поставить точку останова на выполнение на секции кода и включить профиль VMProtect в ScyllaHide, чтобы получить распакованную версию ?. Но целью анализа было разобраться именно в работе виртуальной машины, чтобы понимать, как можно подходить к исследованию образцов, защищенных с помощью ВМ более сложным образом (при виртуализации основного функционала). К тому же возможно, что некоторые функции распакованного кода остались виртуализованы.

Таким образом, при анализе работы ВМ мы использовали понемногу несколько инструментов, при этом если постараться, то скорее всего можно было бы решить задачу только одним из них.

Комментарии (2)


  1. rra_roro
    13.08.2024 19:59
    +5

    Удивительно, что еще появляются годные статьи на Хабре.


    1. nowaycantstay Автор
      13.08.2024 19:59

      Спасибо за такую оценку)