Введение

Привет, Хабр!

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

Вот, это случилось, и я покажу на примере малвары, используемой группировкой Kimsuky в 2021 году, как упростить себе анализ, используя IDAPy для декодирования строк при статическом анализе сэмпла (или если и не упростить, то хотя бы сделать его более изящным:)).

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

Также узнать что-то про саму группировку можно на Malpedia.

Общий анализ

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

Загружаем образец в IDA Pro, при этом желательно убедиться, что подгрузились необходимые сигнатуры (Shift+F5), это можно назвать хорошей привычкой, которая иногда может спасти аналитика от анализа множества библиотечных функций. Для стандартных WinAPI приложений, скомпилированных в среде MS VS это, как правило, следующие наборы сигнатур:

vc32_14
vc32ucrt
vc32rtf
vcseh
mssdk32

или их 64-битные аналоги.

Как минимум, 978 функций не потребуется анализировать
Как минимум, 978 функций не потребуется анализировать

Так как анализируемый образец является библиотекой, посмотрим на его экспорт, где увидим функцию Run. Продвигаясь по графу управления и/или по декомпилированному представлению, попадаем в функцию sub_10002BB0 (ImageBase у нас 0x10000000). В этой функции можно встретить несколько интересных вызовов одной функции, которой в качестве аргумента передается кодированная строка (на то, что она шифрована, не очень похоже).

Примеры вызова интересной функции:

Скриншоты
Пример 1
Пример 1
Пример 2
Пример 2
Пример 3
Пример 3

Что можно сказать об этой функции на текущий момент:

  • первый аргумент передается через ECX, то есть похоже, что это __fastcall для x86 (дизассемблер подумал, что это __thiscall, но передаваемая строка не похожа на указатель на класс);

  • на функцию указывает 12 перекрестных ссылок, то есть она используется на протяжении работы образца несколько раз, это характерно как для библиотечных функций, так и для каких-нибудь обработчиков, заложенных в логику работы образца;

  • видно, что сразу после возврата из данной функции производится вызов по значению EAX, то есть функция возвращает указатель на другую функцию;

  • во многих случаях вызова данной функции предварительно что-то кладется на стек, при этом можно увидеть интересные строки, константы (вроде 0x80000001) и вообще что-то очень похожее на прототипы функций WinAPI.

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

Переименуем для дальнейшего удобства данную функцию (например, в resolve_func) и продолжим анализ. В resolve_func видно, что строка с кодированным названием функции, а также еще некая кодированная строка (например, “5WquWMKf.LMM”), передаются в качестве аргументов в функцию sub_10003B40.

Если предположить, что первая строка (“5WquWMKf.LMM”) - это название библиотеки, а вторая строка - название искомой функции, то это даже немного напоминает функцию GetProcAddress.

В вызываемой функции sub_10003B40 видно, что обе строки поочередно передаются в функцию sub_10003C80, после чего результат функции передается в WinAPI функции LoadLibraryA и GetProcAddress. Похоже, что в этой функции происходит декодирование строки, поэтому для удобства переименуем ее в decode

Для более комфортного представления я попытался сгруппировать неинтересные участки графа выполнения, чтобы показать на картинке передачу строки после декодирования в качестве аргумента к LoadLibraryA, надеюсь, что-нибудь видно:

Передача результата функции decode в LoadLibraryA
Передача результата функции decode в LoadLibraryA

В функции decode видим примерно следующую логику:

  • используется строка случайных и неповторяющихся 64 символов (думаю, можно назвать строкой подстановки);

  • исходная кодированная строка посимвольно в цикле сравнивается с символами строки подстановки, если такой символ встречается, то происходит его замена;

  • индекс символа для замены из строки подстановки выбирается путем вычитания 22d и применения к результату логического И со значением 0x3F.

Декомпилированное представление данной функции:

Скриншот

Декомпилированная функция декодирования
Декомпилированная функция декодирования

Таким образом происходит декодирование строки. Наверное, описано не очень разборчиво, но на примере скрипта IDAPy по декодированию должно быть понятнее.

Итак, после такого долго предисловия наконец добрались непосредственно до написания скрипта. Первое, что нужно сделать, это описать саму логику декодирования в python-представлении.

В качестве строки подстановки используется массив byte_1002AC08:

Начало массива
Начало массива

Чтобы было больше похоже на массив, представим его соответствующим образом (Numpad+*), после чего, выбрав byte_1002AC08 и нажав Shift+E, выберем представление “string literal”, чтобы скопировать в наш скрипт.

Новое представление массива
Новое представление массива

Интерпретация декодирования

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

Код
# строка подстановки
subst = 'zcgXlSWkj314CwaYLvyh0U_odZH8OReKiNIr-JM2G7QAxpnmEVbqP5TuB9Ds6fFt%'
coded = 'CP9STl-UP19poPvv' # пример исходной строки
decoded = ''
coded_counter = 0   # счетчик для символов в декодируемой строке
len_coded = len(coded)


# как и в деккомпилированном представлении, конструкция состоит 
# из двух while-циклов, при этом в самом образце внешний цикл 
# выполняется пока строк не закончится нулевым байтом,
# но в нашей реализации для этого есть счетчик обработанных символов
while len_coded != coded_counter:
    counter = 0
    next = False    # флаг нужен, чтобы выйти из внутреннего цикла и перейти к следующему шагу внешнего
    
    while coded[coded_counter] != subst[counter]:
        counter += 1
        # повторяем проверку количества сверенных символов 
        # со строкой подстановки
        if counter >= 0x40:
            coded_counter += 1
            next = True
            break
    
    if next:
        continue
    
    # если символ найден в строке подстановки, высчитываем индекс для 
    # подстановки и дописываем к новой строке
    decoded += subst[(counter - 22) & 0x3f]
    coded_counter += 1
    
    
print(decoded)

Такая интерпретация декодирования не обрабатывает случай, когда исходный символ не найден в строке подстановки, но что означает декодированная строка, можно понять и без этого (ясно, что строка “kernel32dll” несмотря на отсутствие точки означает название библиотеки).

Я не нашел, как правильно называется этот алгоритм декодирования, но если кто-то в курсе, будет интересно узнать об этом.

Написание итогового скрипта

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

Логика работы скрипта следующая:

  • найти все перекрестные ссылки функцию resolve_func (с помощью метода XrefsTo);

  • в каждом вызове найти передаваемый аргумент - кодированную строку (обход вверх по графу управления с помощью метода prev_head, работа с содержимым инструкции с помощью методов print_insn_mnem, get_operand_type, get_operand_value);

  • декодировать строку и добавить как комментарий к вызову функции (саму строку можно получить с помощью метода get_strlit_contents, декодировать и добавить как комментарий с помощью set_cmt).

Хорошим гайдом по IDAPy мне кажется этот, хотя здесь можно найти не все, что может пригодиться в ходе анализа вредоносного ПО.

Таким образом, итоговый скрипт примет следующий вид:

Код
import idautils
import idaapi
import idc

# уже подготовленное нами декодирование строки
def decode(coded):
    subst = 'zcgXlSWkj314CwaYLvyh0U_odZH8OReKiNIr-JM2G7QAxpnmEVbqP5TuB9Ds6fFt%'
    decoded = ''
    coded_counter = 0
    len_coded = len(coded)
    
    
    while len_coded != coded_counter:
        counter = 0
        next = False
        
        while coded[coded_counter] != subst[counter]:
            counter += 1
            if counter >= 0x40:
                coded_counter += 1
                next = True
                break
        
        if next:
            continue
        
        decoded += subst[(counter - 22) & 0x3f]
        coded_counter += 1
        
        
    return(decoded)


# в моем случае функция resolve_func расположена по указанному адресу,
# получаем перекрестные ссылки на неё
xrefs = XrefsTo(0x10003CD0)

# добавляем в список адреса источников вызова функции 
funcs_list = []
for i in xrefs:
    funcs_list.append(i.frm)
    

for ea in funcs_list:
    # перед каждым вызовом нужно найти инструкцию с передачей 
    # аргумента, чтобы из него найти указатель на кодированную строку
    # для обхода вверх от инструкции вызова будем использовать 
    # prev_head
    instr = prev_head(ea)
    
    while True:
        # для получения мнемоники инструкции будем использовать print_insn_mnem
        if print_insn_mnem(instr) == "mov":
            # get_operand_type дает возможность получить тип 
            # первого операнда, нас интересует регистр - тип 1
            if get_operand_type(instr, 0) == 1 and get_operand_type(instr, 1) != 1:
                # я не нашел, как правильно в IDAPy понять, какой 
                # именно регистр используется в роли операнда, 
                # поэтому просто решил проверять, что ecx есть 
                # в дизассемблированном представлении
                if "ecx" in generate_disasm_line(instr, 0):
                    string_address = get_operand_value(instr, 1)
                    # из второго операнда получаем указатель 
                    # на кодированную строку
                    coded_string = get_strlit_contents(string_address)
                    # декодировав в utf-8 и передав в нашу функцию, 
                    # добавляем декодированную строку как комментарий
                    set_cmt(ea,decode(coded_string.decode("utf-8")),1)
                else:
                    instr = prev_head(instr)
                    continue
            else:
                instr = prev_head(instr)
                continue
            
            break
        
        else:
            instr = prev_head(instr)

После выполнения скрипта каждая инструкция вызова функции resolve_func будет сопровождаться комментарием, в котором будет указано, что именно за функция вызывается далее.

Примеры результатов работы скрипта:
Пример 1
Пример 1
Пример 2
Пример 2

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

Вместе заключения

На этом образце я также потестировал свой плагин, который помогает найти паттерны неявного использования WinAPI функций, но логика его работы ломается на такой конструкции, как:

call resolve_func
call eax

хотя все-таки порой он отрабатывает вполне удачно, особенно когда дело касается анализа шеллкода под Windows.

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

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


  1. datacompboy
    09.06.2023 08:37

    Такая интерпретация декодирования не обрабатывает случай, когда исходный символ не найден в строке подстановки

    Вообще это шифр подстановки обычный и исходя из "5WquWMKf.LMM" символ, не входящий в таблицу перекодировки, просто копируется как есть.

    def decode(s: str) -> str:
      subst = 'zcgXlSWkj314CwaYLvyh0U_odZH8OReKiNIr-JM2G7QAxpnmEVbqP5TuB9Ds6fFt%'
      r = ''
      for c in s:
        p = subst.find(c)
        if (p>=0):
          r += subst[(p - 22) & 0x3F]
        else:
          r += c
      return r
    
    
    print(decode('5WquWMKf.LMM'))
    print(decode('CP9STl-UP19poPvv'))


    1. datacompboy
      09.06.2023 08:37

      Можно еще развернуть таблицу:

      def decode(s: str) ->str:
        subst = "Q&'()*+,a./FPvq5KMhSr:;<=>?@UIT-HGylCY3DL4We0kmit8Ep9X[\\]^z`BOAgj2xf1bVnZdcoRwJ7Nsu_6Q"
        r = ''
        for c in s:
          if c >= '%' and c <= 'z':
            r += subst[ord(c)-ord('%')]
          else:
            r += c
        return r
      
      print(decode('5WquWMKf.LMM'))
      print(decode('CP9STl-UP19poPvv'))
      
      


      1. nowaycantstay Автор
        09.06.2023 08:37

        Ага, спасибо за код и уточнение) получается, я не ошибся, используя словосочетание "строка подстановки"


        1. datacompboy
          09.06.2023 08:37
          +1

          Это шифр подстановки, в котором каждому символу на входе соответствует один символ на выходе.

          Реализован в форме шифра цезаря (сдвига) с перемешиванием алфавита.

          Строка представляет собой случайную перестановку алфавита, "-22" это ключ цезаря (берём -22ю букву из алфавита).

          Декодинг через поиск символа в строке неэффективен (O(N^2)*), для десятка строк может и пофиг, для сотен уже может быть заметно -- почему и предлагаю классическое решение здесь через конвертацию его в прямую таблицу подстановки -- декодинг в таком случае линеен к числу символов.

          * да-да, я знаю что "квадрат" тут некорректно, так как subst фиксированная и мы можем представить его как просто очень дорогую константу, но уж больно легко думать в категориях "на каждый символ строки мы должны пробежаться по всей другой строке" и плевать что это разные строки -- чаще всего вторая строка сильно больше первой так что оно даже хуже квадрата по факту