R0 CREW

Руководство ESET по деобфускации и девиртуализации FinFinsher

Оригинал: www.welivesecurity.com

Введение

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

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

Когда мы приступили к тщательному анализу этой малвари, наши усилия были направлены в первую очередь на преодоление техник анти-анализа применённых в версии FinFisher под Windows. Совокупность продвинутых техник обфускации и проприетарного формата виртуальной машины привело к тому, что оригинальный код FinFisher очень тяжело восстановить.

С целью поделиться тем, что мы узнали в процессе деобфускации этой малвари, мы написали это руководство в помощь всем желающим заглянуть внутрь FinFisher и проанализировать его. Помимо, собственно, практического взгляда внутрь ВМ FinFisher это руководство может так-же помочь читателям научиться понимать защиту на её основе в целом – а именно то, как могут быть устроены проприетарные форматы ВМ, которые используются коммерческими протекторами. Мы не будем рассматривать ВМ использующиеся в интерпретируемых ЯП, которые осуществляют переносимость кода между платформами, такие как например Java VM.

Мы также проанализировали версию FinFisher для Android. Там защитный механизм основан на библиотеке с открытым исходным кодом LLVM. Такая защита не настолько продвинута или интересна как та, что используется в версии для Windows, по этой причине мы не будем затрагивать её в этом руководстве.

Мы надеемся, что эксперты от безопасников до вирусных аналитиков воспользуются этим руководством, чтобы лучше научиться лучше понимать инструменты и тактики используемые группой FinFisher и защитить своих клиентов от этой продвинутой угрозы.

Анти-дизассемблер

Когда мы открыли образец FinFisher в IDA Pro, первый метод защиты, который мы заметили сразу на точке входа (main) был простой, но при этом очень эффективный приём – трюк против дизассемблера.

FinFisher использует известную технику – скрытие потока исполнения при помощи замены одного безусловного перехода на два дополняющихся друг друга условных. Оба этих перехода указывают на один адрес, так что в не зависимости от того, какой из них сработает, управление перейдёт на один и тот же участок кода. За условными переходами следуют мусорные байты. Это сделано для того, чтобы запутать дизассемблер, который обычно не способен определить мертвый код и попытается дизассемблировать мусор.

Однако, особенным эту малварь делает то, как используется такая техника. В других образцах, которые мы исследовали подобный приём применялся лишь несколько раз, однако FinFisher использует этот трюк после каждой инструкции.

Эта защита очень эффективна в деле запутывания дизассемблера – многие участки кода не дизассемблируются правильно. И, конечно же, невозможно использовать режим графа в IDA Pro. Нашей первой задачей было избавиться от этой защиты.

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

Всего есть два различных типа пар – ближние (near) c 32-битным смещением и короткие (short) с 8-битным.

Опкоды для ближних (near) условных переходов (смещение прыжка - DWORD) начинаются с байта 0x0F; второй байт это 0x8?, где ? различается между инструкциями в точности на один бит. Это так, потому что опкоды архитектуры x86 для взаимно обратных переходов последовательны. Например, такой приём обфускации всегда ставит в пару JE c JNE (опкоды JE: 0x0F 0x84; JNE: 0x0F 0x85), JP с JNP (опкоды JP: 0x0F 0x8A; JNP: 0x0F 0x8B) и так далее. Сразу за этими опкодами следует 32-битный аргумент – смещение до точки назначения перехода. Так как размер таких инструкций 6 байт, то смещения смежных переходов различается в точности на 6 (Рисунок 1).

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

Нижеприведенный код может быть использован для обнаружения подобных последовательностей:

def is_jump_near_pair(addr):
	jcc1 = Byte(addr+1)
	jcc2 = Byte(addr+7)
	# do they start like near conditional jumps?
	if Byte(addr) != 0x0F || Byte(addr+6) != 0x0F:
		return False
	# are there really 2 consequent near conditional jumps?
	if (jcc1 & 0xF0 != 0x80) || (jcc2 & 0xF0 != 0x80):
		return False
	# are the conditional jumps complementary?
	if abs(jcc1-jcc2) != 1:
		return False
	# do those 2 conditional jumps point to the same destination?
	dst1 = Dword(addr+2)
	dst2 = Dword(addr+8)
	if dst1-dst2 != 6:
		return False
	return True

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

Опкод короткого условного перехода 0x7?, после которого следует 1 байт – смещение перехода. Еще раз, если мы хотим обнаруживать два последовательных ближних условных перехода, нам необходимо искать опкоды 0x7?; смещение; 0x7? ± 1; смещение - 2. Первый опкод предшествует 1 байту, который различается на 2 в двух смежных переходах (что, как уже упоминалось ранее, есть длина инструкции)

Код для обнаружения двух коротких условных переходов:

def is_jcc8(b):
	return b&0xF0 == 0x70

def is_jump_short_pair(addr):
	jcc1 = Byte(addr)
	jcc2 = Byte(addr+2)
	if not is_jcc8(jcc1) || not is_jcc8(jcc2):
		return False
	if abs(jcc2–jcc1) != 1:
		return False
	dst1 = Byte(addr+1)
	dst2 = Byte(addr+3)
	if dst1 – dst2 != 2:
		return False
	return True

После обнаружения такой пары условных переходов, мы деобфусцируем код путем преобразования первого условного в безусловный (используя опкод 0xE9 для ближних и 0xEB для коротких) и затирания всех остальных инструкцией NOP (0x90).

def patch_jcc32(addr):
	PatchByte(addr, 0x90)
	PatchByte(addr+1, 0xE9)
	PatchWord(addr+6, 0x9090)
	PatchDword(addr+8, 0x90909090)
	
def patch_jcc8(addr):
	PatchByte(addr, 0xEB)
	PatchWord(addr+2, 0x9090)

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

Сделав все эти патчи, IDA Pro начинает «понимать» новый код и готова (или практически готова) построить граф. Возможен случай когда нам нужно сделать еще одно улучшение: присоединить «хвосты» функций, то есть объединить ноду графа перехода с его точкой назначения. Для этого, можно воспользоваться функцией IDA Python: append_func_tail.

Рисунок 2. Пример инструкций, за которыми каждый раз следуют два коротких условных перехода

Последний шаг в преодоления приемов против дизассемблирования состоит из исправления объявлений функций. Все еще возможны случаи, когда инструкция после перехода это push ebp и IDA Pro (ошибочно) будет считать это началом функции и помечать её как таковую. В таком случае нам необходимо удалить это определение, создать правильное и присоединить «хвосты» еще раз.

Таким образом, мы можем избавиться от первого слоя защиты FinFisher – противодействия дизассемблеру.

Виртуальная машина FinFisher

После успешной деобфускации первого слоя мы можем посмотреть на более чистую версию функции main, единственной задачей которой является запуск собственной ВМ, интерпретирующей байткод – в котором и скрывается логика приложения.

В отличие от обычного исполняемого файла, файл с ВМ внутри, вместо того, чтобы использовать инструкции процессора напрямую, использует набор виртуальных (прим. но сама ВМ, конечно же исполняется на реальном процессоре). Виртуальные инструкции исполняются на виртуальном процессоре, у которого есть своя собственная архитектура и который не переводит их в машинный код реального процессора (прим. поэтому ВМ в данном случае - это не JIT). Виртуальный процессор, точно также как и байткод (виртуальные инструкции), определяется разработчиком ВМ. (Рисунок 3)

Как уже было упомянуто во введении, наиболее известный пример ВМ это Java Virtual Machine. Но в данном случае исполнитель ВМ находится прямо внутри целевого файла, так что мы имеем дело с ВМ, которая используется как защита от реверс-инжениринга. Существуют несколько хорошо-известных протекторов на основе ВМ, это например VMProtect и Code Virtualizer.

FinFisher spyware было изначально скомпилировано из исходного кода и лишь затем защищено виртуальной машиной, то есть защита была применена на уровне ассемблерных инструкций. Этот процесс включает в себя трансляцию инструкций оригинального исполняемого файла в виртуальные, а затем создание нового исполняемого файла, который состоит из байткода и исполнителя ВМ. Инструкции ассемблера оригинала можно считать утраченными. Однако, защищенный ВМ файл обязан обладать идентичным функционалом с незащищенным.

Чтобы проанализировать исполняемый файл защищенный ВМ, исследователь должен:

  1. Изучить исполнитель ВМ.
  2. Написать собственный дизассемблер для этого виртуального CPU и извлечь байткод.
  3. Необязательный шаг: скомпилировать дизассемблированный код в исполняемый файл, с целью исключить из работы виртуальный исполнитель.

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

Термины и определения

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

Рисунок 3. Байткод интерпретируется виртуальным CPU

  • Виртуальная машина (ВМ) – нестандартный, виртуальный CPU; состоит из таких частей как vm_dispatcher, vm_start, vm_handler
  • vm_start – инициализационная часть; здесь выполняется выделение памяти и процедуры дешифровки
  • Байткод (или pcode) – в нём хранятся виртуальные опкоды vm_instruction с их аргументами
  • vm_dispatcher – считывает и расшифровывает виртуальный опкод; по существу занимается подготовкой к исполнению vm_handler
  • vm_handler – реализация логики vm_instruction; исполнение одного vm_handler приравнивается к исполнению одной vm_instruction
  • Интерпретатор (также известен как vm_loop) - vm_dispatcher + vm_handler – виртуальный
    CPU (или виртуальный исполнитель)
  • Виртуальный опкод – аналог опкода реального процессора
  • vm_context (vm_structure) – внутренняя структура используемая интерпретатором
  • vm_params – подструктура внутри vm_context; параметры виртуальной инструкции, которые используются в vm_handler; включает в себя vm_opcode и аргументы.

В момент интерпретации байткода, ВМ использует виртуальный стек и единственный виртуальный регистр.

  • vm_stack – аналог стека реального CPU, но используется ВМ
  • vm_register – аналог регистра реального CPU, но используется ВМ; Далее упоминается как tmp_REG
  • vm_instruction – инструкция определенная разработчиком ВМ; тело (реализация логики) инструкции называется vm_handler.

В последующих разделах мы опишем части ВМ более подробно с технической точки зрения и объясним как их анализировать.

Деобфусцированный граф главной функции состоит из трех частей – инициализационная часть и двух других, названных ранее vm_start и интерпретатор (vm_dispatcher + vm_handler).

Инициализационная часть получает уникальный идентификатор, который можно воспринимать как начальную точку исполнения байткода, и сохраняет его на вершине стека. Затем происходит передача управления на vm_start, где выполняется начальная инициализация самой ВМ. Там происходит расшифровка байткода и управление передается vm_dispatcher, который одну за другой исполняется виртуальные инструкции - интерпретирует их, используя соответствующий vm_handler.

vm_dispatcher начинается с инструкции pushad и заканчивается jmp dword ptr [eax+ecx*4] (или аналогом), который осуществляет переход на соответствующий vm_handler.

vm_start

Граф, который получился после деобфускации первого уровня можно увидеть на рисунке 4. Для анализа интерпретатора нам не сильно важна часть vm_start. Однако она может помочь нам понять реализацию ВМ в целом; то, как она использует и обрабаотывает виртуальные флаги (виртуальный EFL), виртуальный стек и т.д. Вторая часть – vm_dispatcher с vm_handler – наиболее значимая.

Вызовы vm_start можно найти из практически любой функции, включая функцию main. Вызывающая сторона сохраняет на стеке виртуальный идентификатор, а затем передает управление на vm_start. Для каждой виртуальной инструкции есть свой свой виртуальный идентификатор. В данном примере, идентификатор виртуальной точки входа, а именно места где начинается исполнение логики оригинальной main, – это значение 0x21CD0554. (Рисунок 5)

Рисунок 4. Граф функций vm_start и vm_dispatcher

Рисунок 5. vm_start вызывается каждой из 119 виртуализированых функций. Идентификатор первой виртуальной инструкции соответствующей функции передается как аругумент.

Рисунок 6. Весь код от vm_start до vm_dispatcher в виде сгруппированных нод, каждой присвоено имя в соответствии с назначением

В этой части располагатся много кода относящегося к подготовке исполнения vm_dispatcher – в основном это загрузка байткода и выделения памяти для всего интерпретатора. Самые важные части делают следающее:

  1. Выделить 1МБ с правами RWX для байткода и нескольких переменных.
  2. Выделить 0x19999 байт RWX для локальных переменных ВМ текущего потока – vm_stack.
  3. Расшифровать часть кода с помощью функции XOR_decryption_code. Этот код – aPLib распаковщик.

XOR шифрование использованное в данном образце – это немного модифицированная версия обычного XOR со значением DWORD. В действительности она пропускает первый DWORD, а затем XOR’ит оставшиеся 5 переданным ей ключем. Ниже приведен алгоритм этой процедуры (далее будем ссылаться на него как XOR_decryption_code):

int array[6];
int key;
for (i = 1; i < 6; i++) {
	array[i] ^= key;
}
  1. Вызвать функцию apLib для распаковки байткода. После распаковки, виртуальные опкоды все еще зашифрованы (Рисунок 6).

Подготовительные шаги (шаги 1, 2 и 4) делаются только один раз – в начале – и пропускаются во всех последующих исполнениях vm_start, в тот момент исполняются только инструкции относящиеся к обработке флагов и регистров.

Интерпретатор FinFisher

Эта часть включает в себя vm_dispatcher и все vm_handler (34 в образцах FinFisher) и является важнейшей частью для осуществления анализа и/или девиртуализации этой ВМ. Интерпретатор исполняет байткод.

Инструкция jmp dword ptr [eax+ecx*4] передает управление на один из 34 vm_handler. Каждый из них реализует одну инструкцию ВМ. Для того чтобы понять, что делает vm_handler, нам сперва необходимо разобрать vm_context и vm_dispatcher.

1. Построение графа в IDA

Но сначала заметим, что построение хорошо структурированного графа может оказаться очень полезным помощником в понимании интерпретора. Мы рекомендуем разделять граф на две части – vm_start и vm_dispatcher. То есть, например, определять начало функции на первой инструкции vm_dispatcher. То, чего нам не хватает – это ссылка из vm_dispather на каждый vm_handler. Для того, чтобы соединить все эти обработчики с графом vm_dispatcher, может быть использованы следующие функции:

AddCodeXref(addr_of_jmp_instr, vm_handler, XREF_USER|fl_JN)
добавление ссылок от последней инструкции vm_dispatcher на начало каждого vm_handler

AppendFchunk
присоединение «хвостов»

После присоединения каждого vm_handler к функции диспатчера, граф должен выглядеть как на рисунке 7.

Рисунок 7. Граф vm_dispatcher и все 34 vm_handler.

2. vm_dispatcher

Эта часть ответственна за выборку и декодирование байткода. Она выполняет следующие шаги:

  • Исполнение pushad и pushfd инструкций в качестве подготовки виртуальных регистров и флагов для дальнейшего инсполнения виртуальной инструкции.
  • Получает базовый адрес образа и адрес vm_stack
  • Считывает 24 байт с ленты пикода, которые определяют следующую для исполнения инструкцию и её аргументы.
  • Расшифровывает байткод с помощью ранее описанной XOR_decryption_routine
  • Прибавляет базовый адрес образа к адресу байткода, если аргумент – глобальная переменная (прим. наверное имеется в виду аналог релокаций для аргументов инструкций реального CPU).
  • Считывает виртуальный опкод (число 0-33) из расшифрованного байткода.
    Передает управление на соответствующий vm_handler, который интерпретирует виртуальный опкод.

После того, как соответсвующий vm_handler завершил своё исполнение, все шаги повторяются для следующей виртуальной инструкции.

В случае вызова обработчика vm_call, поток управления передается на vm_start (кроме случаев когда вызывается невиртуализированная функция).

3. vm_context

В этой части мы опишем структуру vm_context, которая используется ВМ и в которой содержится вся информация необходимая для исполнения vm_dispatcher и каждого vm_handler.

При подробном просмотре кода vm_dispather и vm_handler можно заметить, что присутствует много инструкций оперирующих с данными, на которые происходит ссылка вида ebx+offset, где offset – это числа от 0x00 до 0x50. На рисунке 8 можно увидеть, как выглядит главная часть vm_handler под номером 0x05.

Регистр ebx указывает на структуру, которую мы назвали vm_context. Мы должны обязательно разобрать эту структуру – узнать каковы её элементы, что они из себя представляют и как используются. Начать следует с предположений, которые позже можно будет подтвердить или дополнить, продвигаясь дальше по исследованию.

Например, давайте взглянем на последовательность инструкций в конце vm_dispatcher:

movzx ecx, byte ptr [ebx+0x3C] // opcode for vm_handler
jmp dword ptr [eax+ecx*4] // jumping to one of the 34 vm_handlers

Так как мы уже знаем что последняя инструкция это передача управления на vm_handler мы можем заключить, что ecx содердит виртуальный опкод и следовательно элемент на смещении 0x3С в vm_struct это и есть номер виртуального опкода.

Сделаем еще одно обоснованное предположение. В конце практически каждого vm_handler присутсвует следующая инструкция:

add dword ptr [ebx], 0x18

Точно такой же элемент vm_context был использован ранее в коде vm_dispatcher – прямо перед прыжком в код vm_handler. vm_dispather копирует 24 байта из элемента структуры в другую локацию ([ebx+38h]) и расшифровывает их с помощью XOR_decryption_routine, чтобы получить часть байткода.

Отсюда мы можем начать предполгать что первый элемент vm_context ([ebx+0x00]) это есть vm_instruction_pointer и начать считать, что расшифрованная память (от [ebx+38h] до [ebx+50h])) это ID виртуальной инструкции, её опкод и аргументы. Все эти поля вместе мы назовем структурой vi_params.

Следуя шагам описаным выше и используя отладчик, чтобы увидеть какие значения сохранены в соответствующих членах структуры, мы можем объявить vm_context так:

struct vm_context {
	DWORD vm_instruct_ptr; // instruction pointer to the bytecode
	DWORD vm_stack; // address of the vm_stack
	DWORD tmp_REG; // used as a "register" in the virtual machine
	DWORD vm_dispatcher_loop; // address of the vm_dispatcher
	DWORD cleanAndVMDispatchFn; // address of the function which pops values and jumps to‌ the‌ vm_dispatcher‌ skipping‌ the‌ first‌ few‌ instructions‌ from‌ it
	DWORD cleanUpDynamicCodeFn; // address of the function which cleans vm_instr_ptr and calls cleanAndVMDispatchFn
	DWORD jmpLoc1; // address of jump location
	DWORD jmpLoc2; // address of next vm_opcode – just executing next vm_instruction
	DWORD Bytecode_start; // address of the start of the bytecode in data section
	DWORD DispatchEBP;
	DWORD ImageBase; // Image base address
	DWORD‌ESP0_flags;‌//‌ top‌ of‌ the‌ native‌ stack‌(there‌ are‌ saved‌ flags‌ of‌ the‌ vm_code)
	DWORD‌ESP1_flags;‌//‌ same‌ as‌ previous
	DWORD LoadVOpcodesSectionFn;
	vi_params bytecode; // everything necessary for executing vm_handler, see below
	DWORD limitForTopOfStack; // top limit for the stack
};

struct vi_params {
	DWORD Virtual_instr_id;
	DWORD OpCode; // values 0 – 33 -> tells which handler to execute
	DWORD Arg0; // 4 dword arguments for vm_handler
	DWORD Arg4; // sometimes unused
	DWORD Arg8; // sometimes unused
	DWORD ArgC; // sometimes unused
};

Рисунок 8. Тело одного vm_handler

4. vm_handler - имплементации виртуальных инструкций

Каждый vm_handler обрабатывает один виртуальный опкод – и так как у нас 34 vm_handler, то максимум может быть 34 виртуальных опкода. Исполнение одного vm_handler эквивалентно исполнению одной vm_instruction так что, для того чтобы понять что делает vm_instruction нам необходимо проанализировать соответствующий vm_handler.

После того, как мы описали структуру vm_context и сопоставили имена всем смещениям ebx, ранее показанный vm_handler теперь предстает в более удобочитаемом виде, как показано на рисунке 9.

Рисунок 9. Ранее рассмотренный vm_handler после восстановления структуры vm_context

В конце функции можно заметить последовательность инструкций, начинающихся с того, что vm_instruction_pointer увеличивается на 24 – размер структуры vi_params для любой vm_instruction. Так как эта последовательность повторяется в конце каждого обработчика vm_handler мы можем заключить, что это стандартный эпилог обработчика, в таком случае его тело может быть записано просто как:

mov [tmp_REG], Arg0

Итак, готово – мы только что проанализировали первую инструкци ВМ. :slight_smile:

Для демонстрации того, как проанализированная инструкция работает при исполнении, допустим что структура vi_params заполнена следующим образом:

struct vi_params {
	DWORD ID_of_virt_instr = doesn’t matter;
	DWORD OpCode = 0x0C;
	DWORD Arg0 = 0x42;
	DWORD Arg4 = 0;
	DWORD Arg8 = 0;
	DWORD ArgC = 0;
};

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

mov [tmp_REG], 0x42

Теперь мы можем понять, что делает одна из vm_instruction. Шаги, которым мы следовали являются демонстрацией того, как интерпретатор работает в целом.

Однако, существуют обработчики vm_handler, которые понять сложнее. Условные переходы в этой ВМ работают не совсем очевидным образом из-за способа работы с флагами.

Как было замечено ранее, vm_dispatcher начинаяется с того, что регистры реального процессора EFLAGS сохраняются на вершине реального стека. Затем, когда обработчик соответствующего перехода решает совершать переход или нет, он смотрит на EFLAGS в реальном стеке и реализует свою собственную логику вычисления ответа. Рисунок 10 иллюстрирует то, как обработчик вирутальной JNP реализован путем проверки флага четности (Parity Flag).

Рисунок 10. JPN обработчик

Для некоторых других условных переходов возможно потребуется проверять сразу несколько флагов – например результат для виртуализированной JBE зависит сразу от значений и CF и ZF – однако принцип остаётся тот-же.

После изучения всех 34 обработчиков в FinFisher VM мы можем описать его виртуальные инструкции следующим образом:

Рисунок 11. vm_table со всеми 34-мя распознаными vm_handler

Обратите внимание, что термин “tmp_REF” ссылается на виртуальный регистр, используемый ВМ – врменный регистр, хранящийся в структуре vm_context, в то время как термином “reg” обозначается регистр реального процессора, например eax.

Давайте взглянем на разобранные обработчики ВМ. Например, case3_vm_jcc является обобщенным обработчиком переходов, он может исполнять как безусловные так и условные переходы.

Как выяснилось, ВМ способна виртуализовать не все инструкции защищаемой функции – в таких случаях применяются обработчики case_4_exec_native_code и case_6_exec_native_code.

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

Еще один важный момент, vm_register всегда находится на вершине реального стека, в то время как идентификатор регистра сохраняется в последнем байте arg0 виртуальной инструкции. Следующий код может быть использован чтобы получить доступ к соответствующему виртуальному регистру:

def resolve_reg(reg_pos):
	stack_regs = [‘eax’, ‘ecx’, ‘edx’, ‘ebx’, ‘esp’, ‘ebp’, ‘esi’, ‘edi’]
	stack_regs.reverse()
	return stack_regs[reg_pos]
reg_pos = 7 – (state[arg0] & 0x000000FF)
reg = resolve_reg(reg_pos)

5. Написание своего дизассемблера

После правильного разбора всех vm_instructions остайтся еще один шаг перед тем как начать анализ образца – необходимо написать собственный дизассемблер байткода (ручной разбор байткода сделать будет слишком трудно из-за его большого размера)

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

Давайте начнём с vm_handler под номером 0x0C, который исполняет следующую инструкцию:

mov [tmp_REG], reg

Эта инструкция принимает в точности один аругмент – идентификатор регистра реального CPU, который займет место reg. Этот идентификатор должен быть переведен в имя регистра, в данном случае используя функцию resolve_reg как было описано выше.

Код для дизассемблирования такого vm_handler:

def vm_0C(state, vi_params):
	global instr
	reg_pos = 7 – (vi_arams[arg0] & 0x000000FF)
	tmpinstr = "mov [tmp_REG], %s" % resolve_reg(reg_pos)
	instr.append(tmpinstr)
	return

vm_handler, которые отвечают за эмуляцию переходов сложнее распознать. В случае переходов, члены vm_context.vi_params.Arg0 и vm_context.vi_params.Arg1 хранят смещение на которое происходит переход. Это «смещение перехода» на самом деле смещение внутри байткода. В момент разбора переходов, нам необходимо поместить маркер в локацию, на которую он указывает. Например, может быть использован следующий код:

def computeLoc1(pos, vi_params):
	global instr
	jmp_offset = (vi_params[arg0] & 0x00FFFFFF) + (vi_params[arg1] & 0xFF000000)
	if jmp_offset < 0x7FFFFFFF:
		jmp_offset /= 0x18 # their increment by 0x18 is my increment by 1
	else:
		jmp_offset = int((-0x100000000 + jmp_offset) / 0x18)
	return pos+jmp_offset

И наконец, существует обработчик, ответственный за исполнение инструкций переданных через аргумент на реальном CPU, и его следует обрабатывать специальным образом. Для этого случая нам потребуется дизассемблер x86 инструкций, например библиотека с открытым исходным кодом Distorm.

Длина инструкции, сохранена в vm_context.vi_params.OpCode & 0x0000ff00. Опкод инструкции реального CPU, что будет исполнена хранится в аргументе. Следующий код может быть использован, чтобы разобрать vm_handler, который исполняет инструкции реального CPU:

def vm_04(vi_params, pos):
	global instr
	nBytes = vi_params[opCode] & 0x0000FF00
	dyn_instr = pack("<LLLL", vi_params[arg0], vi_params[arg4], vi_params[arg8], vi_params[argC])[0:nBytes]
	dec_instr = distorm3.Decode(0x0, dyn_instr, distorm3.Decode32Bits)
	tmpinstr = "%s" % (dec_instr[0][2]) instr.append(tmpinstr)
	return

К этому моменту мы разработали несколько функций на Python, чтобы дизассемблировать vm_handler. Все это в совокупности с кодом ответственным за обозначение точек переходов, нахождение идентификатора виртуальных инструкций после вызова и некоторых других операций - необходимо для написания собственного дизассемблера.
После этого, мы можем передать байткод на вход дизассемблера.

Рисунок 12. Часть распакованного и расшифрованого байткода FinFisher VM
Например, для части опкодов, показаных на рисунке 12 мы можем получить следующий вывод дизассемблера:

mov tmp_REG, 0
add tmp_REG, EBP
add tmp_REG, 0x10
mov tmp_REG, [tmp_REG]
push tmp_REG
mov tmp_REG, EAX
push tmp_REG

6. Идеи стоящие за реализацией рассматриваемой ВМ

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

Сперва мы должны понять, что защита с помощью ВМ была выполнена на ассемблерном уровне. Авторы преобразовали инструкции реального процессора в свой, в каком-то роде усложнённый, формат, который необходимо исполнять на виртуальном процессоре. Для этой трансформации используется «временный» регистр (tmp_REG).

Взглянем на несколько примеров, чтобы увидеть как работает эта трансляция. Возьмём инструкцию из предыдущего примера –
mov tmp_REG, EAX
push tmp_REG

  • это было получено из инструкции реального процессора push eax. В виртуализованной инструкции временный регистр используется как промежуточный шаг.

Рассмотрим еще один пример:

mov tmp_REG, 0
add tmp_REG, EBP
add tmp_REG, 0x10
mov tmp_REG, [tmp_REG]
push tmp_REG

Далее приведены инструкции из которых получился такой байткод (где reg это один из регистров реального процессора):

mov reg, [ebp+0x10]
push reg

Это не единственный способ реализовать виртуализацию набора инструкций. Существуют и другие протекторы с защитой на основе ВМ, которые используют альтернативные подходы. Для примера, один из таких коммерческих протекторов транслирует каждую математическую операцию в группу из операций NOR, используя при этом не один, а множество временных регистров.

ВМ FinFisher, напротив, не пытается сделать что-то более сложное чем скрыть все инструкции реального процессора. В то время как многие из них могут быть виртуализованы, некоторые, такие как математические add, imul, div - не могут. Если эти инструкции встретятся в оригинальном коде, то в защищенном коде будет использован обработчик ответственный за исполнение инструкций на реальном процессоре. Единственное изменение в том, что EFLAGS и реальные регистры забираются со стека перед исполнением такой инструкции и возвращаются назад после в уже изменённом инструкцией реальной виде. Таким образом авторы ВМ избежали необходимости виртуализировать каждую инструкцию реального процессора.

Значительный недостаток использования защиты на основе виртуализации заключается во влиянии, которое она оказывает на производительность. В случае FinFisher VM мы оцениваем просадку в более чем сотню раз по сравнению с незащищённым кодом, основываясь на том, какое количество инструкций исполняется, чтобы обработать каждую vm_instruction (vm_dispatcher + vm_handler).

Отсюда следует, что имеет смысл защищать только некоторые части исполняемого файла – что собственно и является случаем в анализируемом нами образце FinFisher.

Более того, как было упомянуто ранее, некоторый обработчики ВМ могут вызывать незащищенные функции напрямую. Как результат пользователи такой защиты (а именно авторы FinFisher) могут просматривать функции на уровне ассемблера и помечать те из них, которые следует защитить с помощью виртуальной машины. Для тех функций, что были отмечены, инструкции будут преобразованы в байткод, а для тех что нет, будут вызываться оригиналы через специальный обработчик. Таким образом, исполнение станет менее затратным по времени, сохраняя при этом наиболее инстересные части исполняемого файла в зашифрованном виде. (Рисунок 13).

Рисунок 13. Схема представляющая всю ВМ защиту FinFisher и то, как исполнение может выходить за границы ВМ.

7. Автоматизация процесса дизассемблирования для других образцов FinFisher

В дополнение к длине байткода, которую наш парсер должен обработать, нам необходимо не забывать о том, что между образцами FinFisher присутвует некоторая рандомизация. Хотя одна и таже виртуальная машина переиспользуется для защиты, маппинг опкодов и их обработчиков не всегда постоянен. Они могут (и в рассматриваемом случае так и есть) распределяться случайно и соответственно различаться для каждого из образцов FinFisher что мы изучили. Это означает, что если vm_handler для 5 виртуального опкода в образце обрабатывается как инструкция mov [tmp_REG], arg0, то он может быть назначен другому виртуальному опкоду в ином образце.

Чтобы побороть эту проблемы, мы можем составить сигнатуру для каждого vm_handler. Скрипт IDA Python из Приложения A может быть использован сразу после того, как мы сгенерировали граф показаный на картинке 7 (особенно важно, чтобы jz/jnz обфускация была снята – как было описано в начале данного руководства), чтобы присвоить имена обработчикам основываясь на их сигнатруах. (с некоторой модификацией скрипт может также быть использован для того, чтобы пересоздать сигнатуры в случае если какой-нибудь и обработчиков изменится в будущем обновлении FinFisher)

Как было упомянуто ранее, первый vm_handler в образце FinFisher который будете анализировать вы, может отличаться от JL в образце из примера, однако скрипт идентифицирует все vm_handler правильно.

8. Компиляция дизассемблированого кода с исключенной ВМ

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

Многие из vm_instructions могут быть скомпилированы без каких либо модификаций, так как наш дизассемблер генерирует инструкции, которые очень похожи на те, что использует реальный процессор. Однако некоторые случаи требуют особой обработки:

  • tmp_REG – по причине того, что мы определили tmp_REG как глобальную переменную, нам необходимо сделать некоторые модификации в коде на случай, если адрес, который в ней был сохранён попытаются прочитать (мы знаем, что в x86 нельзя разименовать адрес хранящийся в памяти с помощью одной инструкции). Например ВМ содердит виртуальную инструкцию mov tmp_REG, [tmp_REG] которая должна быть переписана следующим образом:
push eax
mov eax, tmp_REG
mov eax, [eax]
mov tmp_REG, eax
pop eax
  • Флаги – виртуальные инструкции их не изменяют, в то время как это делают руальные инструкции процессора. Таким образом, нам необходимо удостовериться, что если виртуализированые математические инструкции не изменяют флаги, то и в девиртуализированном исполняемом файле это остается так-же, это означает, что мы должны сохранять флаги перед исполнением инструкции и восстанавливать их после исполнения.
  • Переходы и вызовы – нам необходимо оставлять метку на точке назначения виртуальных переходов и вызова функции.
  • Вызовы API – в большинстве случаев API функции резолвятся динамически, в том месте, где они на них была ссылка указывающая на IAT исполняемого файла, так что данные вызовы должны обрабатываться соответственно.
  • Глобальные переменные и не виртуализированые инструкции реального процессора – некоторые глобальные переменные необходимо сохранить в девиртуализированом бинарнике. Кроме того в дропере FinFisher присутствует функция для переключения режима исполнения на x64 из x86, что исполняется как код реального CPU (в действительности это делается с помощью единственной инструкции retf). Все это необходимо сохранить в коде при компиляции.

В зависимости от выдачи дизассемблера, вам возможно будет все еще нужно внести некоторые модификации, для получения инструкций реального CPU который можно скомпилировать. Затем, останется только собственно скомпилировать код с удаленной ВМ в исполняемый файл, используя ваш любимый ассемблер-компилятор.

Заключение

В данном руководстве мы описали то, как FinFisher применяет две сложные техники для сокрытия своей внутренней логики. Основное назначание такой защиты это не противодействие обнаружениям AV, а скрытие конфигурационных файлов и новых техник реализованных в данной spyware путем усложнения процесса анализа реверс-инженерам. По причине того, что на момент написания этого текста еще не было опубликовано детального анализа обфусцированных образцов FinFisher spyware, кажется разработчики достигли успеха в защите своего ПО.

Мы продемонстрировали то, как автоматически преодалеть приёмы против дизассемблера и то как ВМ может быть эффективно разобрана.

Мы надеемся что данное руководство поможет реверс-инженерам изучать защищенные ВМ образцы FinFisher spyware и в том числе лучше понять протекторы основанные на ВМ в общем.

Приложение A

Скрипт IDA Python для распознавания имен vm_handler ВМ FinFisher.

© Translated by sysenter special for r0 Crew
1 Like