Сборка, разборка и эмуляция с использованием Python

Бывают такие нелегкие для прикладника времена, когда надо работать на низком уровне и просто ловить биты в центральном процессоре. Бывают они не часто, но случилось. Последний раз что-то подобное мне приходилось делать лет этак 30-35 назад. Так, что вспоминаем молодость и различные архитектуры процессоров, но уже не с «С++», а на Python. Он теперь ближе…

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

Дизассемблер — это процесс, обратный сборке, байты данных анализируются и преобразуются в инструкции сборки (которые более удобочитаемы для пользователей).

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

Есть много сценариев, в которых может быть полезна сборка, дизассемблирование или эмуляция кода для разных архитектур, один из основных интересов — обучение (большинство университетов преподают сборку MIPS) для запуска и тестирования программ, написанных для различных устройств, таких как маршрутизаторы (фаззинг и т. д.), а также для обратного проектирования.

В этом руководстве мы соберем, дизассемблируем и эмулируем ассемблерный код, написанный для ARM с использованием движка Keystone, Capstone engine и Unicorn engine, которые представляют собой фреймворки, удобные привязки Python для управления кодом сборки, они поддерживают различные архитектуры (x86, ARM (архитектура), MIPS (архитектура), SPARC и другие) и имеют встроенную поддержку основных операционных систем (включая Linux, Windows и MacOS. ).
Сначала установим эти три фреймворка:

pip3 install keystone-engine capstone unicorn

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

Мы также дизассемблируем функцию x86 (чтобы показать, как легко можно обрабатывать несколько архитектур).

Ассемблер для ARM

Начнем с импорта того, что нам понадобится для сборки ARM:

# Нам нужно эмулировать ARM
from unicorn import Uc, UC_ARCH_ARM, UC_MODE_ARM, UcError
# для доступа к регистрам R0 и R1
from unicorn.arm_const import UC_ARM_REG_R0, UC_ARM_REG_R1
# Нам нужно собрать код ARM
from keystone import Ks, KS_ARCH_ARM, KS_MODE_ARM, KsError

Напишем наш ассемблерный код ARM, который вычисляет factorial(r0), где r0 — входной регистр:

ARM_CODE = """
// n равно r0, мы передадим его из python, ans - это r1
mov r1, 1       	// ans = 1
loop:
cmp r0, 0       	// while n >= 0:
mulgt r1, r1, r0	//   ans *= n
subgt r0, r0, 1 	//   n = n - 1
bgt loop        	// 
                	// ответ в r1
"""

Соберем вышеуказанный ассемблерный код (преобразуем его в байт-код):

print("Assembling the ARM code")
try:
    # инициализируем объект keystone с архитектурой ARM
    ks = Ks(KS_ARCH_ARM, KS_MODE_ARM)
    # Собираем код ARM
    ARM_BYTECODE, _ = ks.asm(ARM_CODE)
	# преобразовываем массив целых чисел в байты
    ARM_BYTECODE = bytes(ARM_BYTECODE)
    print(f"Code successfully assembled (length = {len(ARM_BYTECODE)})")
    print("ARM bytecode:", ARM_BYTECODE)
except KsError as e:
    print("Keystone Error: %s" % e)
    exit(1)

Функция Ks возвращает ассемблер в режиме ARM, метод asm() собирает код и возвращает байты и количество собранных инструкций.

Теперь байт-код можно записать в область памяти и выполнить с помощью процессора ARM (или, в нашем случае, эмулировать):

# адрес памяти, с которого начинается эмуляция
ADDRESS = 0x1000000

print("Emulating the ARM code")
try:
    # Инициализировать эмулятор в режиме ARM
    mu = Uc(UC_ARCH_ARM, UC_MODE_ARM)
    # отведём 2 МБ памяти для этой эмуляции
    mu.mem_map(ADDRESS, 2 * 1024 * 1024)
    # записываем машинный код для эмуляции в память
    mu.mem_write(ADDRESS, ARM_BYTECODE)
    # Устанавливаем в коде регистр r0, вычисляем factorial(5)
    mu.reg_write(UC_ARM_REG_R0, 5)
    # эмулировать код за бесконечное время и неограниченное количество инструкций
    mu.emu_start(ADDRESS, ADDRESS + len(ARM_BYTECODE))
    # теперь распечатать регистр R0
    print("Эмуляция сделана. Посмотрите")
    # получаем результат из регистра R1
    r1 = mu.reg_read(UC_ARM_REG_R1)
    print(">>  R1 = %u" % r1)
except UcError as e:
    print("Unicorn Error: %s" % e)

В приведенном выше коде инициализируем эмулятор в режиме ARM, ограничиваем 2 МБ памяти по указанному адресу (2 * 1024 * 1024 байта), записываем результат нашей сборки в отображаемую область памяти, устанавливаем регистр r0 в 5, и начинаем эмулировать наш код.

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

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

Assembling the ARM code
Code successfully assembled (length = 20)
ARM bytecode: b'\x01\x10\xa0\xe3\x00\x00P\xe3\x91\x00\x01\xc0\x01\x00@\xc2\xfb\xff\xff\xca'
Emulating the ARM code
Эмуляция сделана. Посмотрите
>>  R1 = 120

И действительно получаем ожидаемый результат, факториал 5 равен 120.

Дизассемблирование кода x86-64

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

# We need to emulate ARM and x86 code
from unicorn import Uc, UC_ARCH_X86, UC_MODE_64, UcError
# для доступа к регистрам RAX и RDI
from unicorn.x86_const import UC_X86_REG_RDI, UC_X86_REG_RAX
# We need to disassemble x86_64 code
from capstone import Cs, CS_ARCH_X86, CS_MODE_64, CsError

X86_MACHINE_CODE = b"\x48\x31\xc0\x48\xff\xc0\x48\x85\xff\x0f\x84\x0d\x00\x00\x00\x48\x99\x48\xf7\xe7\x48\xff\xcf\xe9\xea\xff\xff\xff"
# адрес памяти, с которого начинается эмуляция
ADDRESS = 0x1000000
try:
      # Инициализировать дизассемблер в режиме x86
      md = Cs(CS_ARCH_X86, CS_MODE_64)
      # перебрать каждую инструкцию и распечатать ее
      for instruction in md.disasm(X86_MACHINE_CODE, 0x1000):
            print("0x%x:\t%s\t%s" % (instruction.address, instruction.mnemonic, instruction.op_str))
except CsError as e:
      print("Capstone Error: %s" % e)

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

Вот что получим:

0x1000: xor     rax, rax
0x1003: inc     rax
0x1006: test    rdi, rdi
0x1009: je      0x101c
0x100f: cqo
0x1011: mul     rdi
0x1014: dec     rdi
0x1017: jmp     0x1006

Теперь попробуем сымитировать это с помощью движка Unicorn:

try:
    # Инициализировать эмулятор в режиме x86_64
    mu = Uc(UC_ARCH_X86, UC_MODE_64)
    # отдадим 2 МБ памяти для этой эмуляции
    mu.mem_map(ADDRESS, 2 * 1024 * 1024)
    # записываем машинный код для эмуляции в память
    mu.mem_write(ADDRESS, X86_MACHINE_CODE)
    # Установить регистр r0 в коде на число 7
    mu.reg_write(UC_X86_REG_RDI, 7)
    # эмулировать код за бесконечное время и неограниченное количество инструкций
    mu.emu_start(ADDRESS, ADDRESS + len(X86_MACHINE_CODE))
    # теперь распечатайте регистр R0
    print("Эмуляция сделана. Посмотрите")
    rax = mu.reg_read(UC_X86_REG_RAX)
    print(">>> RAX = %u" % rax)
except UcError as e:
    print("Unicorn Error: %s" % e)

Получите, распишитесь:

Эмуляция сделана. Посмотрите
>>> RAX = 5040

Мы получаем результат 5040 для 7. Если мы внимательно посмотрим на этот ассемблерный код x86, то заметим, что этот код вычисляет факториал регистра rdi (5040 — факториал 7).

И-го-го

Три фреймворка одинаковым нообразом манипулируют кодом сборки, как можно видеть в коде, имитирующем сборку x86-64 и, который действительно похож на версию, эмулирующую ARM. Дизассемблирование и сборка кода также выполняется таким же образом с любой поддерживаемой архитектурой.

Следует иметь в виду, что эмулятор Unicorn эмулирует необработанный машинный код, он не эмулирует вызовы Windows API, а также не анализирует и не эмулирует такие форматы файлов, как Portable Executable и Executable and Linkable Format.

В некоторых сценариях полезно эмулировать всю операционную систему или программу в виде драйвера ядра или двоичный файл, предназначенный для другой операционной системы. Есть отличная платформа, построенная на основе Unicorn, которая обрабатывает эти ограничения, а также позволяет работаь из Python, то есть инфраструктуру Qiling, она также позволяет использовать двоичные инструменты (например, поддельный системный вызов возвращаемые значения, файловые дескрипторы и т. д.).

После тестирования трех фреймворков Python мы пришли к выводу, что манипулировать кодом сборки с помощью Python очень легко, простота Python в сочетании с удобными и единообразными интерфейсами Python, предлагаемыми Keystone, Capstone и Unicorn, упрощают сборку даже для новичков, дизассемблировать и эмулировать ассемблерный код для разных архитектур.

Использованы маериалы Assembly, Disassembly and Emulation using Python

Print Friendly, PDF & Email

CC BY-NC 4.0 Сборка, разборка и эмуляция с использованием Python, опубликовано К ВВ, лицензия — Creative Commons Attribution-NonCommercial 4.0 International.


Респект и уважуха

Добавить комментарий