Анализ вредоносных программ, защищающих себя от анализа, — это всегда дополнительные трудности для вирусного аналитика. Программа может быть обфусцирована, чтобы избежать детектирования сигнатурными и эвристическими анализаторами антивирусов или затруднить специалисту ее статический анализ. Можно, конечно, запустить программу в виртуальной среде, но и от такого исследования ВПО могут иметь средства защиты. В общем, это постоянная борьба. Злоумышленники придумывают и дорабатывают свои методы обфускации, могут использовать их на этапе разработки программы или обрабатывать уже готовые, скомпилированные модули. Никто не мешает им воспользоваться готовыми продвинутыми решениями, которые созданы специально для защиты легитимного программного обеспечения от анализа и взлома.
Одним из таких популярных решений уже давно является протектор VMProtect. После того как вирусописатели стали активно использовать для своих программ подобные взломанные протекторы, антивирусные компании создали "черные" и "серые списки" таких решений и начали детектировать образцы по самому коду протекторов. Сейчас наблюдается очередная волна активного использования VMProtect злоумышленниками для защиты вредоносного ПО от детектирования и анализа. Но и исследователи не стоят на месте: есть замечательные решения по деобфускации и девиртуализации VMProtect. Основное из них — VTIL Project исследователя Can Bölük. Но и оно, к сожалению, не является панацеей.
Текущая волна использования VMProtect характеризуется активным применением протектора китайскими вирусописателями для защиты своих вредоносных драйверов Windows x64. Известно, что анализ подобных драйверов — головная боль вирусных аналитиков. Получив очередной такой драйвер на анализ, Андрей Жданов, специалист по проактивному поиску киберугроз Group-IB, решил поделиться достаточно простыми подходами, которые облегчат анализ этих вредоносных программ.
Что нам потребуется:
1. The Interactive Disassembler (IDA) 7.0 и выше
2. Виртуальная среда — гостевая ОС Windows 7 x64 или выше
3. Python
4. Volatility (я использовал Volatility 3)
5. Unicorn
Этап 1: получение дампа драйвера
Загружаем драйвер в виртуальной среде. Для этого можно воспользоваться штатной утилитой sc.exe:
sc create <svc_name> binpath= <driver_path> type= kernel start= demand
Или загрузить драйвер с помощью утилиты DriverLoader, которая использует функцию NtLoadDriver:
DriverLoader_x86-64.exe <driver_path> <svc_name>
Если при загрузке возникли проблемы, связанные с цифровой подписью драйвера, — можно воспользоваться утилитой dseo013b.exe (Driver Signature Enforcement Overrider).
После успешной загрузки снимаем полный дамп памяти. Если виртуальная машина (например, VMware) при снимке создает корректный дамп памяти, то можно обойтись и снимком памяти.
Используем Volatility для извлечения всех модулей ядра из дампа:
vol -f <dump_path> -o <dest_dir> Modules --dump
Проверяем, что среди них есть и наш драйвер, а остальные модули пока оставляем — они пригодятся нам позже.
Мы получили дамп исследуемого драйвера. Это не исходный файл до обработки с помощью VMProtect — начальные значения данных утеряны — но его уже можно открыть в IDA и пытаться анализировать, хоть и не в полной мере.
Этап 2. Получение списка вызовов импортируемых функций
Весь код дампа драйвера содержит вызовы, подобные call sub_F88004CFEFE9
. Тело самой функции содержится в секции .vmp0
и представляет собой обфусцированный код с множеством условных и безусловных переходов и манипуляциями с регистрами. Таким образом VMProtect обфусцирует каждый вызов импортируемой функции в "защищенном" файле. Обычно вызов импортируемой функции выглядит так:
FF 15 08 2A 00 00 call cs:LoadLibraryA
VMProtect заменяет его на следующий вызов:
E8 08 72 03 00call vmp_LoadLibraryA
Функция vmp_LoadLibraryA
в процессе работы получает фактический адрес функции LoadLibraryA
и передает ей управление. Но, как мы видим, после вызова такой обфусцированной функции может оставаться байт, что надо учитывать при анализе в IDA. Возврат из обфусцированной функции в этом случае осуществляется правильно, на следующий после этого байта адрес.
На данном этапе необходимо получить список адресов таких функций. Для этого мы с помощью скрипта IDAPython осуществляем перебор всех функций секции .vmp0
, вызов которых осуществляется извне, из другой секции.
def get_vmp_import_func_list():
segm = ida_segment.get_segm_by_name('.vmp0')
if (segm is None) or (segm.sclass != SEG_CODE):
return None
func_list = []
ea = segm.start_ea
while True:
func = ida_funcs.get_next_func(ea)
if (func is None):
break
ea = func.start_ea
if (ea >= segm.end_ea):
break
xref = ida_xref.get_first_fcref_to(ea)
if (xref == ida_idaapi.BADADDR):
continue
while (xref != ida_idaapi.BADADDR):
if (xref >= segm.start_ea) and (xref < segm.end_ea):
break
xref = ida_xref.get_next_fcref_to(ea, xref)
else:
func_list.append(ea)
return func_list
В итоге получаем список RVA (Relative Virtual Address) таких функций в текстовом файле:
0002C130
0002C29D
0002C449
0002C51C
0002C58E
0002C5D3
0002C65E
0002C668
…
Этап 3. Получение оригинальных адресов импортируемых функций
Чтобы получить адреса оригинальных импортируемых функций, воспользуемся кодом самих обфусцированных функций VMProtect. Для этого загрузим полученный дамп драйвера как shellcode в отладчике x64dbg в виртуальной среде. Для запуска в качестве shellcode можно воспользоваться готовой утилитой или разработать свою, которая просто выделяет память (VirtualAlloc), копирует туда shellcode и передает ему управление. Однако здесь следует сделать замечание: это справедливо для дампа, где RVA и позиции в файле совпадают. В противном случае необходимо загружать дамп как PE-файл, по секциям.
Передавать управление на заголовок MZ драйвера мы, конечно, не будем, а поместим на это место код вызова каждой обфусцированной функции. Будем пошагово отлаживать ее код и в конечном итоге извлекать оригинальный адрес импортируемой функции. С помощью x64dbgpy и скрипта на Python можно полностью автоматизировать этот процесс: сначала скрипт считывает из текстового файла список RVA обфусцированных функций, а по окончании сохраняет уже в другой текстовый файл список RVA и соответствующих им оригинальных адресов импортируемых функций:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Текст функции получения оригинального адреса импортируемой функции с использованием x64dbgpy:
def get_original_import_addr(vmp_import_addr):
start_addr = GetRIP()
save_rsp = GetRSP()
# call $+vmp_import_addr
WriteByte(start_addr, 0xE8)
WriteDword(start_addr + 1, vmp_import_addr - 5)
orig_import_addr = None
for _ in range(MAX_STEPS):
StepIn()
rip = GetRIP()
inst = ReadByte(rip)
# retn ?
if (inst == 0xC3) or (inst == 0xC2):
rsp = GetRSP()
orig_import_addr = ReadQword(rsp)
break
SetRIP(start_addr)
SetRSP(save_rsp)
return orig_import_addr
Такой способ получения не очень сложный, однако требует запускать отладчик в виртуальной среде и использовать дополнительные программы для загрузки дампа как shellcode.
Более предпочтительным вариантом будет использование эмулятора вместо отладчика. Реализация на Python с использованием эмулятора Unicorn:
# callback for tracing instructions
def hook_code(uc, address, size, orig_addr_wrapper):
inst = uc.mem_read(address, 1)
# retn ?
if (inst[0] != 0xC3) and (inst[0] != 0xC2):
return
esp = uc.reg_read(UC_X86_REG_ESP)
addr_size = 0
if (UC_MODE == UC_MODE_64):
addr_size = 8
fmt = '<Q'
elif (UC_MODE == UC_MODE_32):
addr_size = 4
fmt = '<L'
if (addr_size != 0):
addr = uc.mem_read(esp, addr_size)
orig_addr_wrapper[0], = struct.unpack(fmt, addr)
uc.emu_stop()
def get_orig_import_func_list(dump_data, vmp_func_list):
orig_addr_wrapper = [0]
image_size = (len(dump_data) + 0xFFFF) & ~0xFFFF
try:
# Initialize emulator
mu = Uc(UC_ARCH_X86, UC_MODE)
# tracing all instructions with customized callback
mu.hook_add(UC_HOOK_CODE, hook_code, orig_addr_wrapper)
# map memory for this emulation
mu.mem_map(BASE_ADDR, image_size + STACK_SIZE)
# write machine code to be emulated to memory
mu.mem_write(BASE_ADDR, dump_data)
except UcError as e:
print('Unicorn Engine Error: %s' % e)
return None
orig_func_list = []
for vmp_func_rva in vmp_func_list:
try:
# write vmp function call code
call_code = b'\xE8' + struct.pack('<L', vmp_func_rva - 5)
mu.mem_write(BASE_ADDR, call_code)
# initialize stack
mu.reg_write(UC_X86_REG_ESP,
BASE_ADDR + image_size + STACK_SIZE // 2)
orig_addr_wrapper[0] = 0
# emulate machine code in infinite time
mu.emu_start(BASE_ADDR, BASE_ADDR + len(dump_data))
if (orig_addr_wrapper[0] != 0):
orig_func_list.append((vmp_func_rva,
orig_addr_wrapper[0]))
except UcError as e:
print('Unicorn Engine Error: %s' % e)
return orig_func_list
Этап 4. Получение списка импортируемых функций, корректировка имен в IDA
Здесь нам пригодятся извлеченные на первом этапе модули ядра: они содержат фактические адреса экспортируемых функций. С помощью разработанного скрипта на Python мы получаем общий список всех экспортируемых функций извлеченных модулей ядра:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Различия в форме адресации на втором и третьем этапах, например, F880014D2D50
и FFFFF880014D2D50
, обусловлены использованием канонической формы адреса, в соответствии с которой 47-й бит копируется в остальные 48-63 биты (аналогично расширению знака). При сравнении адресов надо учитывать этот факт и сразу приводить к канонической форме адреса.
С помощью другого скрипта Python из двух последних списков формируем список импортируемых функций для IDA:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
А в завершение скрипт IDAPython в соответствии с этим списком корректирует имена всех обфусцированных вызовов импортируемых функций драйвера в дизассемблере IDA.
В результате всех этих действий получаем вполне пригодный для анализа код драйвера.