SuiGeneris
Это не x86. Это не ARM. Это нечто sui generis — единственное в своём роде.
Разбор решения
В начале нам выдается vm_runner и firmware.bin
Попытаемся проверить что делает и для чего задействован бинарь:
file vm_runner
strings -n 5 vm_runner | head -n 20
./vm_runner
./vm_runner firmware.bin
Так получим, что, во-первых, это ELF x86_64. Также заметим, если не передать никаких аргументов, тогда бинарник напечатает: usage: %s firmware.bin. И, наконец, если в качестве аргумента воспользуемся файлов с прошивкой, тогда у нас попросит ввести флаг Enter flag: и затем получим ответ Correct! или Wrong..
То есть задача сводится к тому, чтобы понять, как именно раннер валидирует ввод.
Изучим функцию загрузки прошивки. Для этого обратимся к строкам bad firmware header и failed to load firmware. Там обнаружим следующие факты:
- сначала читаются 24 байта заголовка;
- проверяются
magicиversion; - потом читаются
code_lenиdata_len32-битных слов.
Общий формат заголовка выглядит следующим образом:
| Поле | Тип | Назначение |
|---|---|---|
magic |
uint32_t |
Сигнатура прошивки |
version |
uint32_t |
Версия формата |
code_len |
uint32_t |
Длина code-секции (в словах) |
data_len |
uint32_t |
Длина data-секции (в словах) |
flag_len |
uint32_t |
Ожидаемая длина флага |
target |
uint32_t |
Целевой параметр проверки |
Выполним быструю проверку:
import struct
from pathlib import Path
b = Path("firmware.bin").read_bytes()
magic, version, code_len, data_len, flag_len, target = struct.unpack("<6I", b[:24])
print(hex(magic), version, code_len, data_len, flag_len, hex(target))
print("file_size", len(b))
print("expected", 24 + 4 * code_len + 4 * data_len)
В цикле исполнения декод инструкции такой:
| Поле | Формула извлечения | Биты |
|---|---|---|
dst |
insn & 0xFFF |
0..11 |
src |
(insn >> 12) & 0xFFF |
12..23 |
guard |
(insn >> 24) & 0xFF |
24..31 |
То есть инструкция кодируется как:
MOVE: dst | (src << 12) | (guard << 24)
Логику guard удобнее воспринимать как развилку:
flowchart TD
B{guard == 0xFF?}
B -- Да --> C[Выполнить команду]
B -- Нет --> D{R_guard even}
D -- Да --> E[Пропустить команду]
D -- Нет --> C
guard = 0xFFвсегда запускает команду; иначе команда выполняется только при нечётномR[guard].
Сформируем карту портов. Восстановим её из read_src/write_dst:
| Блок | Порты | Назначение |
|---|---|---|
| Registers | 0x00..0x0F |
R0..R15 |
| ALU | 0x10, 0x11, 0x12 |
A, B/trigger, OUT |
| MUL | 0x20, 0x21, 0x22 |
A, B/trigger, OUT |
| ROT | 0x30, 0x31, 0x32 |
A, S/trigger, OUT |
| PRNG | 0x40, 0x41, 0x42 |
seed, step/trigger, OUT |
| HASH | 0x60, 0x61, 0x62 |
A, B/trigger, OUT |
| INPUT | 0x51 |
Чтение байтов введенного флага |
| CONST | 0x90 |
Чтение 32-битных слов из data[] |
| OUTPUT | 0x52 |
Буфер выхода VM |
Теперь попытаемся определить, что делают блоки ALU/MUL/ROT/HASH/PRNG. Семантика из write_dst:
alu(a,b) = a + b
mul(a,b) = a + b
rot(a,s) = rol(a, s)
hash(a,b) = a + b + 0x9E3779B9
prng(x,t) = xorshift32(x ^ t)
чтение *_OUT просто возвращает последнее значение. Никакой блокировки чтения по ready тут нет
После выполнения VM в main идет финальная проверка:
- считается
output_base = 3 + 3 * flag_len; - проверяется, что длина
data[]достаточна; - сравниваются
vm.output_bufи хвостdata[]; - дополнительно сравнивается
R15с отдельным словомtarget2.
Из этого получается такой layout:
Смещение в data[] |
Содержимое |
|---|---|
0 |
seed для R14 |
1 |
seed для R15 |
2 |
seed для PRNG |
3..(3 + 3*flag_len - 1) |
Константы c_mul, c_rot, c_tw |
далее flag_len слов |
expected_output |
| последнее слово | target2 (целевое значение R15) |
Если декодировать блок кода VM, он повторяется для каждого байта ввода. Псевдокод получается такой:
R14 = seed_r14
R15 = seed_r15
PRNG = seed_prng
MUL_OUT = 0
for i in 0..n-1:
byte = input[i]
stale_mul = MUL_OUT
stale_prng = PRNG
MUL_OUT = byte + c_mul[i]
rot = rol(R14, c_rot[i])
alu = rot + stale_prng
R14 = hash(stale_mul, alu)
PRNG = prng(PRNG, c_tw[i])
R15 = hash(R15, byte)
OUTPUT[i] = R15
В прошивке лежит полный массив OUTPUT, а это просто последовательные значения R15. Обновление регистра:
R15_next = R15_prev + byte + 0x9E3779B9
Отсюда напрямую:
byte = R15_next - R15_prev - 0x9E3779B9
Проходим по всем OUTPUT[i], для каждого считаем byte, проверяем что это диапазон 0..255, и получаем весь флаг.
Автоматический solver
В репозитории также прикреплен файл с авторешением: solver.py. Запуск можно осуществить при помощи команды:
python3 solve/solver.py firmware.bin