OSPFv3
Мудрец
- Сообщения
- 236
- Реакции
- 220

Автор: Souhail Hammou
Перевод: OSPFv3
September 29, 2015
Перевод: OSPFv3
September 29, 2015
В обфускации кода виртуальная машина - это механизм, используемый для выполнения набора команд, отличного от того, который используется машиной, выполняющей программу. Например, виртуальная машина может поддерживать выполнение набора команд ARM на 32-разрядной архитектуре x86. Виртуальные машины, используемые для обфускации кода, полностью отличаются от обычных виртуальных машин, способных запускать операционные системы (например, VMware ...), они очень специфичны для выполнения ограниченного набора инструкций для достижения своей цели, игнорируя все другие задачи, которые выполняют виртуальные машины
Задача ревёрсинга защиты виртуальной машины была бы простой, поскольку бы архитектура, которую она выполняет, нам была бы известна. Таким образом, поиск опкодов в справочнике набора инструкций архитектуры займет небольшое количество времени. К сожалению, большинство современных обфускаторов кода используют кастомный набор команд. Другими словами, каждой команде присваивается кастомный опкод (часто выбираемый случайным образом) и пользовательский формат, который обязывает реверс-инженера вручную декодировать каждую команду, а это утомительно. Для примера давайте рассмотрим разницу между 32-разрядным набором команд x86 и кастомным набором команд, над которым мы будем работать в этой статье.
Ясно, что каждая из этих инструкций перемещает байт из ячейки памяти, указанной вторым операндом, в регистр в первом операнде. Однако двоичное представление двух инструкций отличается в основном в части опкода, в которой 0x56 был совершенно случайным выбором. Второй байт в обеих инструкциях представляет два регистра, соответствующих опкоду, где бит (4 бита) указывает на регистр.
Прежде чем мы перейдем к ревёрсу примера, важно знать, как обфускация кода работает за кулисами. Виртуальная машина сначала запускается, устанавливая свое “адресное пространство” в виртуальном адресном пространстве выполняющегося процесса. Другими словами, он выделяет необходимое пространство для своей памяти, стека и регистров(Register File или вот(25-ая страница)), а затем начинает выполнять код. Выполнение кода происходит в рамках так называемого цикла виртуальной машины. Внутри этого цикла виртуальная машина выполняет роль процессора, анализируя каждый из своих предопределенных опкодов и их операндов, а затем используя изначальную архитектуру для выполнения инструкций. Итерация по циклу виртуальной машины будет продолжаться до тех пор, пока не будет достигнут специальный опкод выхода.
Переходим к разбору примера.
Для примера я потратил время на создание виртуальной машины с кастомным набором инструкций на языке Си. Полный исходный код на github доступен прямо в конце этой статьи. Как вы могли догадаться, виртуальная машина сама по себе не будет полезна для этой статьи без опкодов, которые она может выполнять, поэтому я написал небольшую программу(crackMe), которая запрашивает у пользователя пароль и выполняет его проверку.
Как упоминалось во введении, эта виртуальная машина использует кастомный набор команд и начинается с чтения опкодов из файла в “адресное пространство”, установленное виртуальной машиной после фазы инициализации.
Давайте дадим программе случайный пароль и посмотрим, что будет:
Выпала “гадина”! Поэтому наша цель - найти правильный пароль, чтобы программа выдала правильное сообщение. Давайте начнем с изучения файла opcodes (vm_file) с помощью HEX редактора или вот на гите:
Мы уже можем увидеть некоторые строки в файле vm_file, такие как “ Right pass!”, “ Wrong pass!” и “ Password:” Для того, чтобы определить, где находятся инструкции и данные, необходим ревёрс виртуальной машины. Так что давайте запустим IDA и начнем ревёрсить.
После открытия exe в IDA мы переходим непосредственно к циклу виртуальной машины, расположенному по виртуальному адресу: 0x00401334. Представление графика показывает нам функцию значительного размера, но мы можем крэкнуть нашу программу, если подойдем к ней правильно.
Давайте посмотрим, какие инструкции выполняются при входе в функцию:
Код:
push ebp
push edi
push esi
push ebx
sub esp, 2Ch
mov esi, [esp+3Ch+arg_0]
mov ebx, [esp+3Ch+arg_4]
mov ax, [ebx+0Ah]
lea ebp, [esi+1200h]
loc_40134D: ; This is where the loop starts
movzx edx, ax
mov cl, [esi+edx]
lea edx, [eax+1]
mov [ebx+0Ah], dx
sub ecx, 10h
cmp cl, 0E1h ; switch 226 cases
jbe short loc_40136C
Сразу после считывания байта мы замечаем inc и перемещение регистра DX в [EBX+0Ah], тут виртуальная машина выделила место для своих регистров. К настоящему времени мы знаем, что EBX указывает, где находятся регистры, а ESI - где находятся данные файла в памяти.
Непосредственно перед сравнением мы можем заметить, что компилятор выполнил оптимизацию, и теперь код вычитает 0x10 из каждого значения опкода перед обращением к switch table.
Код:
loc_40136C:
movzx ecx, cl
jmp ds:switchTable[ecx*4] ; switch jump
Первая инструкция.
Первый switch jump приводит нас к этой небольшой рутине:
Итак, в основном мы сейчас находимся в “0x18 :”, поскольку компилятор решил провести некоторую оптимизацию и добавить операцию вычитания. Теперь, если вы вернетесь назад и проверите HEX вид vm_file, вы заметите, что самый первый байт равен 0x18. Этот опкод, по-видимому, нуждается в некоторых операндах, поэтому виртуальная машина считает еще один байт, используя регистр DX. Затем instruction pointer(IP) виртуальной машины на [EBX +0Ah] обновляется до EAX +2, это в основном означает, что IP переместился на следующий байт. После этого прочитанный байт сравнивается с 3, и если он превышает его, то программа выходит из цикла, вы можете увидеть это как исключение для виртуальной машины. В нашем примере мы в порядке, поскольку второй байт в файле равен 0x1. Таким образом, мы не будем совершать прыжок и приземлимся здесь:
Позвольте мне напомнить вам, что EBX - это указатель на память, в которой хранятся регистры, поэтому в нашем случае первая команда инициализирует [ebx+1*2] значением 0, которое является вторым регистром в массиве регистров.
Итак, теперь у нас есть достаточно информации, чтобы сделать вывод, что наша виртуальная машина содержит 4 регистра; давайте назовем их R0, R1, R2 и R3.
Затем остальная часть кода считывает 2 байта из данных, загруженных из файла (0x250 в big endian), и сохраняет их в регистре R1. В результате instruction pointer(IP) виртуальной машины увеличивается, указывая на следующую инструкцию, которая находится со смещением 0x4 файла (проверьте шестнадцатеричный дамп). Наконец, переход снова приведет нас к проверке цикла в loc_40134D.
До сих пор мы могли знать только то, что делает первая команда, и это всего лишь простая команда перемещения, которая перемещает абсолютное значение в регистр. Инструкция может быть написана следующим образом:
MOV R1,250h
Вторая инструкция
Давайте теперь посмотрим, что делает следующий опкод 0xFA:
Первый блок кода идентичен тому, что мы видели в инструкции MOV. По-видимому, этот опкод принимает регистр в качестве операнда, и в нашем случае этот регистр равен R1 (0x1). Что он делает дальше, так это обращается к регистру по адресу [EBX+0Ch]. Мы знаем, что этот регистр не является одним из R0, R1, R2 или R3, потому что R3 расположен по адресу EBX+6. Мы также знаем, что это не instruction pointer, поскольку IP находится в EBX+0Ah. Итак, чтобы выяснить, что на самом деле делает этот регистр, нам нужно вернуться назад и проверить инициализацию этих регистров в основной функции:
.text:00402703 mov word ptr [eax+0Ch], 256
Возвращаясь к нашей процедуре, мы замечаем, что регистр уменьшается, а затем проверяется на 0xFFFF(65,535). Поскольку он был инициализирован равным 256, этот регистр не будет принимать 0xFFFF(65,535) до тех пор, пока его значение перед декриментом не станет равным 0. Если это действительно так, то виртуальная машина выходит из цикла. Однако, поскольку мы впервые выполняем эту процедуру, мы уверены, что [EBX+0Ch] равно 255.
Следующие две инструкции после перехода считывают значение R1 (0x250) и сохраняют его в регистре DX. Теперь перейдем к интересной инструкции:
mov [esi+eax*2+1000h], dx
Если вы помните, ESI указывает на то, где хранятся код и данные. Кроме того, ESI+1000h находится на расстоянии 4 КБ от этого местоположения. В результате мы можем предположить, что ESI+1000h указывает на другой “section” выделенного “адресного пространства” виртуальной машины.
Если мы хотим посмотреть на код, выполненный выше, в виде псевдокода, то он будет выглядеть следующим образом:
Код:
WORD section[256];
[…]
section[ –reg ] = R1;
Теперь мы знаем, что регистр в [EBX+0Ch] является stack pointer’ом(SP) виртуальной машины, а раздел представляет собой не что иное, как стек размером 256*sizeof(uint16_t). Кроме того, если вы попытаетесь сравнить stack pointer(SP) виртуальной машины с указателем стека машины архитектуры x86, вы увидите, что указатель стека виртуальной машины на самом деле является индексом массива, тогда как SP машины x86 является адресом.
Третья инструкция.
Следующий опкод - 0xC2, давайте посмотрим, что он делает:
Этот опкод, по-видимому, обращается к стеку и считывает его верхний WORD, но перед этим проверяет, пуст ли стек. Если он действительно пуст, он выходит из цикла виртуальной машины (исключение). Поскольку значение уже было введено, мы знаем, что стек не пуст! После того, как WORD был прочитан в DX, указатель стека увеличивается. Мы знаем, что значение DX равно 0x250, и это часть раздела, содержащего код и данные. Следовательно, “адрес” (offset) не должен превышать 0x1000, иначе виртуальная машина будет выполнять чтение / запись в пространство стека. Следующее, что делается, - это использование ESI + DX в качестве указателя на строку, строка впоследствии печатается с помощью printf. В нашем примере строка, хранящаяся со смещением 0x250 в файле vm_file, имеет вид: “Password:”, и это то, что printf покажет на экране.
Мы можем сделать вывод, что опкод 0xC2 ожидает, что offset строки будет запушен в стек, извлекает его, а затем печатает.
Как вы можете видеть, после всей этой работы мы добрались только до инструкции, где печатается “Password :”, и, без сомнения, вы заметили, сколько времени может потребоваться для ревёрса одной единственной инструкции. К сожалению, мы не сможем говорить о программе инструкция за инструкцией. Однако к настоящему времени вы должны быть в состоянии ревёрснуть защиту виртуальной машины или даже создать свою собственную.
Ищем рабочий пароль.
Вот краткое пошаговое руководство о том, как получить правильный пароль.
При открытии vm_file в HEX редакторе байты со смещением от 0x80 до 0x17F представляют собой массив из 256 случайно сгенерированных байт. Давайте назовем это рандомом. Каждый байт из пароля, введенного пользователем, фиксируется случайным [байтом], а затем сравнивается с предопределенным массивом байтов, хранящихся со смещением 0x240.
Я закодил сценарий решения на C, который вы можете найти в приведенных ниже ссылках. Скрипт выполняет поиск и выводит действительный пароль для программы:
Заключение.
Задача ревёрса кода виртуальной машины - непростая задача, особенно если обфускация основана на кастомном наборе команд. Пример виртуальной машины, над которым мы работали, реализует больше функций, таких как сравнение (использует выделенные биты флага: ZF и CF), условные и безусловные переходы, доступ к памяти... и т.д. Поэтому я предоставляю вам самим открывать их для себя. Чтобы сделать это, вы можете просто получить доступ к исходному коду виртуальной машины (ссылка в ссылках ниже), выполнить поиск HEX кода инструкции, и вы найдете мнемонику инструкции с примером в комментарии чуть выше инструкции case.
Доп. информация.