Ancient Processor

Reverse 912 pts

Мы получаем stripped ELF-бинарник `checker`. По названию таска и описанию можно уловить намек, что внутри сидит эмулятор какого-то «древнего процессора» — значит, вероятно, что сработает reverse эмулятора и восстановление его байткода. ## Решение Открываем `checker` в IDA или Ghidra и сразу ищем главный цикл интерпретатора — он выдаёт себя большим `switch`'ем по опкодам. По коду VM восстанавливается набор инструкций: | Категория | Опкоды | |---|---| | Стек | `PUSH`, `POP`, `DUP`, `SWAP`, `ROT` | | Арифметика | `ADD`, `SUB`, `XOR`, `MUL`, `AND`, `OR`, `NOT`, `SHL`, `SHR`, `MOD` | | Память / переходы | `LOAD`, `STORE`, `JMP`, `JZ`, `CALL`, `RET` | | I/O | `READ`, `PRINT`, `HALT` | | Антианализ | `SYSCALL`, `CHECKTIME`, `SELFMOD2` | Самое интересное лежит в `vm.c`: перед выполнением программы в массив `code` сначала копируется `decoy_program`, а затем поверх него пишется уже расшифрованный `real_program_encrypted`. То есть первая программа — декорация, её можно даже не разбирать. А реальная цель — именно зашифрованная. Рагадка тут, на самом деле, элементарная: каждый байт XOR'ится с 8-байтным ключом. Можно либо выгрузить уже готовый массив из памяти в рантайме, либо вытащить ключ и шифротекст из бинарника и раскрутить всё статикой. После расшифровки байткод раскладывается в повторяющийся шаблон проверки очередного символа: ```text READ PUSH xor_key XOR PUSH add_key ADD PUSH sub_key SUB [иногда MUL] PUSH expected CMP JZ fail ``` Каждый символ флага проверяется независимо, и в обычном случае формула получается такой: $$\bigl((ch \oplus k_{\mathrm{xor}}) + k_{\mathrm{add}} - k_{\mathrm{sub}}\bigr) \bmod 256 = \mathrm{expected}$$ Отсюда символ восстанавливается в одну строчку: $$ch = \bigl((\mathrm{expected} + k_{\mathrm{sub}} - k_{\mathrm{add}}) \bmod 256\bigr) \oplus k_{\mathrm{xor}}$$ Но, вохможно и к сожалению, это еще не конец. Если на этом месте пройтись по всем символам и подставить константы — часть результатов не сойдётся. На этом этапе можно логично предположить, что причиной этому скорее всего служит самоизменяющийся код. Инструкция `SELFMOD2` меняет байты прямо внутри выполняющейся программы, и несколько уже прочитанных символов флага используются для модификации XOR-ключей в будущих проверках: | Символ-источник | Модифицирует ключ символа | |:---:|:---:| | 5 | 12 | | 10 | 18 | | 15 | 25 | Для этих позиций статический `xor_key` из байткода брать нельзя — надо считать его динамически с учётом уже восстановленной части флага. Здесь заложена еще одна мелкая подлянка: каждый четвёртый символ перед сравнением умножается на константу (`MUL`), и чтобы его обратить, нужен [modular inverse](https://en.wikipedia.org/wiki/Modular_multiplicative_inverse) по модулю 256. > Обратное $a^{-1} \bmod 256$ существует только для нечётных $a$ (чтобы $\gcd(a, 256) = 1$) — множители `MUL` в байткоде специально подобраны нечётными, иначе символ флага был бы однозначно невосстановим. Остальные «защиты» можно игнорировать — это шум: | Инструкция | Что делает | Эффект | |---|---|---| | `SYSCALL` | Читает байт из `/dev/urandom`, кладёт `rnd ^ rnd` в стек | Стабильный `0` | | `CHECKTIME` | XOR'ит значение с `0xFF` при медленном выполнении | Не триггерится в памяти | ## Флаг `caplag{v1rtu4l_m4ch1n3_m4st3r}`