Files
Geroi-Kodeksa/SuiGeneris-Reverse/README.md
2026-03-02 21:44:22 +03:00

6.5 KiB
Raw Blame History

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_len 32-битных слов.

Общий формат заголовка выглядит следующим образом:

Поле Тип Назначение
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