Анализ вредоносных программ, защищающих себя от анализа, — это всегда дополнительные трудности для вирусного аналитика. Программа может быть обфусцирована, чтобы избежать детектирования сигнатурными и эвристическими анализаторами антивирусов или затруднить специалисту ее статический анализ. Можно, конечно, запустить программу в виртуальной среде, но и от такого исследования ВПО могут иметь средства защиты. В общем, это постоянная борьба. Злоумышленники придумывают и дорабатывают свои методы обфускации, могут использовать их на этапе разработки программы или обрабатывать уже готовые, скомпилированные модули. Никто не мешает им воспользоваться готовыми продвинутыми решениями, которые созданы специально для защиты легитимного программного обеспечения от анализа и взлома.

Одним из таких популярных решений уже давно является протектор VMProtect. После того как вирусописатели стали активно использовать для своих программ подобные взломанные протекторы, антивирусные компании создали "черные" и "серые списки" таких решений и начали детектировать образцы по самому коду протекторов. Сейчас наблюдается очередная волна активного использования VMProtect злоумышленниками для защиты вредоносного ПО от детектирования и анализа. Но и исследователи не стоят на месте: есть замечательные решения по деобфускации и девиртуализации VMProtect. Основное из них — VTIL Project исследователя Can Boluk. Но и оно, к сожалению, не является панацеей.

Текущая волна использования 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 и соответствующих им оригинальных адресов импортируемых функций:

0002C130

FFFFF80002A4A6C0

0002C29D

FFFFF80002A4A6C0

0002C449

FFFFF80002BEECC0

0002C51C

FFFFF80002A4A6C0

0002C58E

FFFFF80002D1FAC4

0002C5D3

FFFFF80002A4A400

0002C65E

FFFFF80002A48330

0002C668

FFFFF80002A97718

...

...

Текст функции получения оригинального адреса импортируемой функции с использованием 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

NdisAdjustBufferLength

F880014AD370

NdisAdjustNetBufferCurrentMdl

F880014AD240

NdisAdvanceNetBufferDataStart

F880014E9910

NdisAdvanceNetBufferListDataStart

F880014B65C0

NdisAllocateBuffer

F880014B6630

NdisAllocateBufferPool

...

...

Различия в форме адресации на втором и третьем этапах, например, F880014D2D50 и FFFFF880014D2D50, обусловлены использованием канонической формы адреса, в соответствии с которой 47-й бит копируется в остальные 48-63 биты (аналогично расширению знака). При сравнении адресов надо учитывать этот факт и сразу приводить к канонической форме адреса.

С помощью другого скрипта Python из двух последних списков формируем список импортируемых функций для IDA:

0002C130

KeReleaseSpinLock

0002C29D

KeReleaseSpinLock

0002C449

ExFreePoolWithTag

0002C51C

KeReleaseSpinLock

0002C58E

PsLookupProcessByProcessId

0002C5D3

KeAcquireSpinLockRaiseToDpc

0002C65E

IofCompleteRequest

0002C668

_strnicmp

...

...

А в завершение скрипт IDAPython в соответствии с этим списком корректирует имена всех обфусцированных вызовов импортируемых функций драйвера в дизассемблере IDA.

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