• XSS.stack #1 – первый литературный журнал от юзеров форума

Статья Анализируем драйвер Windows x64, защищенный VMProtect

baykal

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

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

Текущая волна использования VMProtect характеризуется активным применением протектора китайскими вирусописателями для защиты своих вредоносных драйверов Windows x64. Известно, что анализ подобных драйверов — головная боль вирусных аналитиков.

Что нам потребуется:
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. Получение списка вызовов импортируемых функций​

60bfe9c09533794d0e6848a2803c0e14.png

Весь код дампа драйвера содержит вызовы, подобные 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. Возврат из обфусцированной функции в этом случае осуществляется правильно, на следующий после этого байта адрес.

273c695ebf072490fb88584537621f7e.png

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

0002C130FFFFF80002A4A6C0
0002C29DFFFFF80002A4A6C0
0002C449FFFFF80002BEECC0
0002C51CFFFFF80002A4A6C0
0002C58EFFFFF80002D1FAC4
0002C5D3FFFFF80002A4A400
0002C65EFFFFF80002A48330
0002C668FFFFF80002A97718
......
Текст функции получения оригинального адреса импортируемой функции с использованием 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 мы получаем общий список всех экспортируемых функций извлеченных модулей ядра:

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

С помощью другого скрипта Python из двух последних списков формируем список импортируемых функций для IDA:
0002C130KeReleaseSpinLock
0002C29DKeReleaseSpinLock
0002C449ExFreePoolWithTag
0002C51CKeReleaseSpinLock
0002C58EPsLookupProcessByProcessId
0002C5D3KeAcquireSpinLockRaiseToDpc
0002C65EIofCompleteRequest
0002C668_strnicmp
......
А в завершение скрипт IDAPython в соответствии с этим списком корректирует имена всех обфусцированных вызовов импортируемых функций драйвера в дизассемблере IDA.

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


Автор Андрей Жданов, специалист по проактивному поиску киберугроз Group-IB
 


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх