Как стать автором
Обновить

Средства автоматизации анализа вредоносных программ

Время на прочтение10 мин
Количество просмотров2.4K

Часть 3-я

Добрались-таки до окончания статьи. Правда, долог и тернист был этот путь...

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

В первой части я достойно "похоронил" группу REvil, а к 3-й они бодрые и полные сил после отпуска, но, правда, с некоторыми ключевыми потерями вернулись к своим "темным делам" и возобновили ненадолго работу своих серверов. Но как оказалось, это все же были "предсмертные судороги". К слову сказать, декриптор для Kaseya, оказывается, был "слит по ошибке"… Такие дела!


В предыдущей части мы наконец-то расшифровали строки в образце REvil, а главное, научились делать это автоматизировано. Конечно, чудес не бывает, надо предварительно пореверсить и разобраться в функции расшифровки строк, а потом уже написать скрипт. При наличии навыков и готовых аналогичных скриптов, от которых можно отталкиваться, обычно сделать это не сложно, и времени займет не так много. Но зато при анализе других образцов того же семейства вредоносных программ готовый инструментарий значительно облегчит жизнь. Поэтому всегда стараюсь аккуратно раскладывать разработанный инструментарий по семействам и версиям, чтобы всегда иметь его под рукой. Под инструментарием я понимаю не только различные программы на Python, IDAPython, x64dbgpy и т.п., но и, например, различные правила, прежде всего, имею в виду, конечно, YARA-правила.

Также не могу не упомянуть об альтернативном способе автоматизированной расшифровки строк, заключающемся в непосредственных вызовах функций расшифровки. Для этого можно также воспользоваться богатым функционалом IDA Pro. Возможно, мы коснемся этого в одной из статей. Для меня же более предпочтителен способ, который я изложил во 2-й части. Но случаи, как известно, бывают разными…

Деобфускация вызовов функций API

Вариант 1 (REvil)

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

Код функции _alloc (sub_404E2C)
Код функции _alloc (sub_404E2C)

Внутри функции _alloc (sub_404E2C) осуществляются вызовы функций, адреса которых содержатся в глобальных переменных dword_41150C и dword_411320 из секции данных ".data". По переменной dword_41150C смотрим список ссылок на нее (xrefs).

Список ссылок (xrefs) на dword_41150C
Список ссылок (xrefs) на dword_41150C

Как видно на изображении, осуществляются только вызовы функции, а непосредственного присвоения переменной нет. Если посмотреть соседние переменные, выяснится, что они также содержат адреса функций, при этом некоторые даже не используются. Можно предположить, что перед нами, по сути, IAT (Import Address Table), элементы которой изначально содержат некоторые "магические" 32-битные значения. Эти значения используются для идентификации функций и при старте программы заменяются фактическими адресами функций. Остается найти только начало таблицы.

Начало таблицы адресов функций API
Начало таблицы адресов функций API

После недолгого поиска находим начало таблицы, переменная dword_411298 является ее нулевым элементом. В окне xrefs видно, что присвоение элементам таблицы осуществляется с использованием индексной адресации со смещением в функции sub_4073A3.

Код функции получения адресов функций API (sub_4073A3)
Код функции получения адресов функций API (sub_4073A3)

Функция sub_4073A3 осуществляет заполнение таблицы импорта, для этого используется функция sub_4075AE, которая на вход получает "магическое" значение из таблицы, а возвращает фактический адрес функции. То есть функция sub_4075AE "разрешает" адрес функции по некоему значению. Обычно это значение представляет собой контрольную сумму (хеш) от имени функции API. Контрольная сумма может также включать в себя и имя библиотеки (DLL), которая экспортирует эту функцию. Алгоритмы подсчета контрольной суммы могут быть самыми разнообразными, это может быть и стандартный CRC32, и вообще любой, который взбредет в голову автору. Очевидно, что коллизий для функций API при этом быть не должно. Далее для получения адресов функций API производится перечисление DLL и экспортируемых ими функций, обычно это делается через PEB (Process Environment Block). При совпадении контрольной суммы (хеша) с указанным в коде программы извлекается адрес найденной функции.

Начальный фрагмент кода функции "разрешения" адреса функции API (sub_4075AE)
Начальный фрагмент кода функции "разрешения" адреса функции API (sub_4075AE)

Код функции sub_4075AE навевает легкую грусть, причем на изображении приведен лишь небольшой фрагмент кода функции. Разбираться в коде функции для получения алгоритма вычисления контрольной суммы или нет? Ответ определяется, прежде всего, целесообразностью.

Мы же в данном случае пойдем легким путем. Итак, определимся с исходными данными. Итак, таблица начинается с RVA (relative virtual address) 11298h, содержит 2F8h / 4 = 190 элементов, то есть функций API. Если поставить точку останова (breakpoint), например, на RVA 73С3h, когда таблица импорта будет полностью заполнена, и извлечь значения всех элементов таблицы импорта, то можно получить полный список используемых функций API.

Что для этого потребуется? Виртуальная среда Windows 7 и выше с установленным дополнительным программным обеспечением:

Осталось только разработать скрипт x64dbgpy. И он получился очень даже очень несложным.

import io
from x64dbgpy.pluginsdk import *


BREAK_RVA = 0x73C3
IMPORT_LIST_RVA = 0x11298
NUM_IMPORT_FUNCS = 0x2F8 // 4


Run()

base_addr = BaseFromAddr(GetEIP())

break_addr = base_addr + BREAK_RVA
SetBreakpoint(break_addr)

Run()

DeleteBreakpoint(break_addr)

with io.open('ida_import.txt', 'wb') as f:

    fnc_name_buf = ctypes.create_string_buffer(x64dbg.MAX_LABEL_SIZE)

    fnc_addr_rva = IMPORT_LIST_RVA

    for _ in range(NUM_IMPORT_FUNCS):

        fnc_addr = ReadDword(base_addr + fnc_addr_rva)
        if x64dbg.DbgGetLabelAt(fnc_addr, x64dbg.SEG_DEFAULT, fnc_name_buf):
            f.write(b'%08X\t%s\r\n' % (fnc_addr_rva, fnc_name_buf.value))

        fnc_addr_rva += 4

Разберемся в его работе. Скрипт запускается сразу после открытия образца в отладчике. По умолчанию, при запуске отладчик первоначально останавливает отладку в точке входа образца (entry point), для этого в начале скрипта вызывается функция плагина x64dbgpy Run.

Окно отладчика x64dbg
Окно отладчика x64dbg

При останове в точке входа в скрипте определяется базовый адрес, по которому загружен модуль, устанавливается точка останова для извлечения функций API (RVA 73C3h) и возобновляется выполнение (Run). После останова в данной точке, в цикле извлекается значение каждого элемента таблицы, то есть фактический адрес функции API с помощью функции ReadDword, а далее по этому адресу определяется имя функции API с помощью замечательной функции DbgGetLabelAt, осуществляющей получение метки для указанного адреса.

В итоге после запуска скрипта получим текстовый файл ida_import.txt, который содержит RVA элементов таблицы импорта и соответствующие им имена функций API.

Результат работы скрипта x64dbgpy
Результат работы скрипта x64dbgpy

Для переименования элементов таблицы импорта можно воспользоваться универсальным для подобных случаев скриптом на IDAPython.

import sys
import io
import idautils
import idaapi


IDA_IMPORT_FILENAME = 'ida_import.txt'


def read_import_func_list():

    func_list = []

    with io.open(IDA_IMPORT_FILENAME, 'rt') as f:

        for line in f:
            s = line.strip()
            if (s == ''):
                continue
            fnc_entry = s.split('\t', 2)
            fnc_rva = int(fnc_entry[0], 16)
            if (fnc_rva != 0) and (fnc_entry[1] != ''):
                func_list.append((fnc_rva, fnc_entry[1]))

    return func_list
            

func_list = read_import_func_list()
print(str(len(func_list)) + ' import functions loaded.')

for fnc_entry in func_list:

    fnc_ea = fnc_entry[0] + ida_nalt.get_imagebase()
    ida_bytes.create_data(fnc_ea, ida_bytes.FF_DWORD, 4, BADADDR)
    ida_name.set_name(fnc_ea, fnc_entry[1],
                      ida_name.SN_FORCE | ida_name.SN_NOWARN)

Результат выполнения скрипта:

И напоследок с чего начинали, собственно функция _alloc (sub_404E2C):

Как видим, все отлично, причем IDA сама при переименовании установила прототипы функций API.

Вариант 2 (BlackMatter)

Ну а что у конкурентов? Тот же джентльменский набор: зашифрованные конфигурационные данные, обфускация строк и вызовов функций API. Но реализация несколько иная. Для демонстрации взял достаточно свежий образец BlackMatter v2.0 (сборка 2021-09-26 08:10:51):

VT

ANY.RUN

Обфускация вызовов функций API
Обфускация вызовов функций API

Сразу приведу фрагмент функции, которая отвечает за импорт в программе. Немного "причесал" ее, чтобы проще было разбираться.

Начальный фрагмент кода функции load_import
Начальный фрагмент кода функции load_import

Функция api_resolve – собственно и есть та функция, которая "разрешает" адрес функции API по некоему магическому числу. Функция load_lib_and_IAT загружает DLL и заполняет для нее таблицу, своего рода IAT (Import Address Table), то есть получает с помощью api_resolve адреса необходимых функций API, экспортируемых данной DLL, и заносит их в таблицу. На самом деле, как потом выясним, в таблицу заносятся не совсем адреса функций API… На входе load_lib_and_IAT используется таблица, соответствующая каждой DLL, которую  назвал IT (Import Table). В начале каждой такой таблицы содержится 32-битное число, которое идентифицирует DLL, а далее следуют 32-битные числа, идентифицирующие функции API. В качестве маркера конца таблицы используется значение 0CCCCCCCCh. Все 15 таблиц IT, соответствующих 15 DLL, содержатся в кодовой секции программы-вымогателя.

Таблицы IT в кодовой секции
Таблицы IT в кодовой секции

С образцом REvil мы поленились разбираться с функцией получения адреса функции API. В данном же случае мы обойдемся исключительно статическим анализом.

Начало кода функции api_resolve
Начало кода функции api_resolve

Не могу пройти мимо и не отметить оригинальное решение. В начальном фрагменте api_resolve видим 2 рекурсивных вызова, то есть при самом первом вызове функция дважды рекурсивно вызовет себя для получения адресов двух важных функций API, в принципе не трудно догадаться каких. В чем же оригинальность? По моему мнению, в самом подходе. Адреса функций unknownfunc0 и unknownfunc1 будут гарантированно проинициализированы первыми при вызове api_resolve.

Теперь основной фрагмент функции, непосредственно отвечающий за "разрешение" адресов функций API.

"Разрешение" адресов функций API
"Разрешение" адресов функций API

Вначале осуществляется завуалированное получение адреса PEB (fs:[30h]). Понятно, для чего это сделано! Для затруднения анализа и автоматизированного выявления такого кода. Допустим, мое YARA-правило, написанное для выявления подобного кода перечисления модулей с помощью PEB, попросту не сработает. Такая же штука проделана при получении адреса PE-заголовка. В остальном далее все стандартно: перечисляются все загруженные модули и эскпортируемые ими функции. Для каждого такого сочетания считается контрольная сумма. Имя DLL содержится в Unicode, поэтому для подсчета контрольной суммы используется  функция под названием get_wide_str_hash, а для имени функции, которая в ANSI, – соответственно get_str_hash. Причем в качестве начального значения контрольной суммы для имени функции используется контрольная сумма имени DLL.

Теперь код самих функций получения контрольных сумм.

Код функции получения контрольной суммы строки Unicode
Код функции получения контрольной суммы строки Unicode
Код функции получения контрольной суммы строки ANSI
Код функции получения контрольной суммы строки ANSI

По сути, не считая различия в типах используемых символов, эти функции практически идентичны, только в версии для Unicode при подсчете контрольной суммы все символы латиницы преобразуются в нижний регистр. Вообще инструкция or ax, 20h представляет собой простой и эффективный способ преобразования из верхнего регистра латиницы в нижний. Как видим, для подсчета контрольной суммы используется в цикле круговой сдвиг промежуточной суммы вправо на 13 (ROR 13) и сложение с кодом текущего символа. Вариации этого алгоритма достаточно популярны у авторов вредоносного ПО. Когда первоначально анализировал BlaсkMatter, мне, например, сразу вспомнился Metasploit / Cobalt Strike. Также обращаю внимание, что "бесполезный" код сложения и вычитания 61h может стать отличным паттерном для YARA-правила для детектирования BlackMatter.

Реализация кода получения контрольных сумм на Python:

ror32 = lambda val, shift: \
    ((val & 0xFFFFFFFF) >> (shift & 0x1F)) | \
    ((val << (32 - (shift & 0x1F))) & 0xFFFFFFFF)


def get_wide_str_hash(s, n=0):

    for ch in s:

        m = ord(ch)
        if (m >= 0x41) and (m <= 0x5A):
            m |= 0x20
        n = m + ror32(n, 13)

    return ror32(n, 13)


def get_str_hash(s, n=0):

    for ch in s:

        n = ord(ch) + ror32(n, 13)

    return ror32(n, 13)


def get_api_func_name_hash(lib_name, fnc_name):

    return get_str_hash(fnc_name, get_wide_str_hash(lib_name, 0))

А что дальше? Дальше следует получить все возможные имена DLL и функций API. Задача состоит в том, что необходимо написать скрипт, который получает список экспортируемых функций определенных DLL или же попросту всех DLL из System32. Для этого можно воспользоваться, например, готовым инструментом на Python для работы с PE–файлами pefile авторства Ero Carrera. Я же пользуюсь своим классом, который есть на моем GitHub'е. На самом деле, что будет использоваться для достижения результата, не важно, главное, результат. Вариант такого списка, полученного в виде текстового файла api_names.txt, в качестве символа-разделителя используется символ табуляции:

Такой готовый список в дальнейшем можно использовать в неизменном виде и для других аналогичных случаев. Далее с помощью него и скрипта на Python получаем список контрольных сумм имен функций API для данного конкретного случая:

import io


XOR_MASK = 0x43013FCC


with io.open('api_names.txt', 'rt') as f:
    func_names = f.read().splitlines()

with io.open('api_hashes.txt', 'wt') as f:
    for name in func_names:
        name = name.strip()
        if (name == ''):
            continue
        names = name.split('\t')
        h = get_api_func_name_hash(names[0], names[1]) ^ XOR_MASK
        f.write('%08X\t%s\n' % (h, names[1]))

На изображении с начальным фрагментом api_resolve видно, что в коде программы содержатся не сами значения контрольных сумм функций API, а сложенные по модулю 2 (XOR) с неким 32-битным числом 43013FCCh. И это дополнительный прием для усложнения анализа, в коде программы он используется практически везде, не только для контрольных сумм функций API, но и для строк, формируемых в стеке. В таблицах IT также содержатся значения контрольных сумм, сложенные по модулю 2 с числом 43013FCCh. Поэтому при получении списка контрольных сумм этот факт выше был учтен, и полученные контрольные суммы были дополнительно сложены по модулю 2 с 43013FCCh. Это число, с которым производится операция XOR, является произвольным, и от образца к образцу BlackMatter различается.

Фрагмент полученного списка с контрольными суммами функций API (текстовый файл api_hashes.txt), среди которых есть те самые, для получения адресов которых в api_resolve использовалась рекурсия.

Это две низкоуровневые функции из ntdll.dll, которые используются для реализации функций GetProcAddress и LoadLibraryExW соответственно.

Вернемся снова к фрагменту функции load_import, но уже более понятному и читаемому. Теперь мы видим, что создается куча (heap), в которой допустимо выполнение кода, осуществляется получение адреса функции HeapAlloc. Дескриптор созданной кучи и адрес HeapAlloc вместе с адресом таблицы IT, содержащей контрольные суммы функций API, а также адресом таблицы IAT, куда в результате заносятся адреса функций, передаются в качестве аргументов при вызовах функций load_lib_and_IAT.

Начальный фрагмент кода функции load_import
Начальный фрагмент кода функции load_import

Код функции load_lib_and_IAT, пожалуй, приведу практически весь.

Начальный фрагмент функции load_lib_and_IAT
Начальный фрагмент функции load_lib_and_IAT

Как уже было сказано ранее, первый элемент таблицы IT содержит контрольную сумму имени DLL, и с помощью load_lib осуществляется загрузка требуемой DLL. А далее для всех следующих элементов и до конца таблицы (0ССССССССh) определяется соответствующий адрес функции API, выделяется блок памяти на куче размером 16 байт, в который записывается случайно выбранный обфусцированный код вызова функции API (5 вариантов). Об этом также говорилось выше, что в IAT записываются не совсем адреса функций API.

Создание обфусцированного кода вызова функции API
Создание обфусцированного кода вызова функции API

Как видим, таблицы IAT в итоге содержат адреса на обфусцированный код вызова соответствующих функций API. Я специально остановился на этом моменте, чтобы продемонстрировать, какие сложности вызовет деобфускация только с помощью отладчика. А для нас же это совершенно не важно, как обфусцируется вызов функций, анализ осуществляется статически, каждый элемент таблицы IAT можно смело переименовать соответственно имени функции API, и суть от этого совершенно не изменится, так как в том или ином виде осуществляется вызов конкретной функции API. Таким образом, остается только написать скрипт IDAPython, который, используя ранее полученный список контрольных сумм функций API, переименует элементы всех таблиц IAT. Для этого можно воспользоваться подходом, аналогичным тому, что был использован в предыдущей части для деобфускации строк. Приводить это скрипт, пожалуй, не буду. :-) Интереснее и полезнее его будет написать самостоятельно.

Конечный результат же будет таков:

И фрагмент кода BlackMatter, который был вначале:

Большая часть приведенных в статье скриптов содержится в моем GitHub'е.


Всем огромное спасибо за внимание и терпение!

Теги:
Хабы:
Всего голосов 5: ↑5 и ↓0+5
Комментарии4

Публикации

Истории

Работа

Ближайшие события