Обложка к публикации статьи
Обложка к публикации статьи

Название — Callfuscated

Категория — Reversing

Сложность — Insane

Ссылка — https://app.hackthebox.com/challenges/Callfuscated

Вступление

Наша задачка запрашивает пароль и на правильном печатает Correct. Validate the challenge using the flag: %s. Внутри труп, разложенный обфускатором: виртуальная машина со своим байткодом на 586 ячеек, MBA поверх каждой арифметической операции, opaque-предикаты, раздувающие CFG, и фирменная нарезка кода на тысячи call/pop- гаджетов (собственно как и в названии, Call-ob-fuscated).

Что за зверь

Арсенал, который навешан на функцию проверки:

  • VM. Пароль проверяет не нативный код, а интерпретатор поверх массива program[]. В дизассемблере видно диспетчер и таблицу опкодов, а не алгоритм. Значит, реверсить придётся дважды: сначала саму VM, потом её байткод.

  • MBA. Каждая примитивная операция размазана в цепочку смешанной булево-арифметической эквивалентности. На статике выглядит жутко, на трассе схлопывается в одно значение.

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

  • Junk / call-обфускация. Каждая реальная инструкция завёрнута в call gadget; gadget: pop r8; <insn>; call next. Отсюда десятки тысяч инструкций на ровном месте.

Общий знаменатель у всех четырёх один: они переживают статику, но не переживают исполнение. На конкретном прогоне MBA — это число, opaque — это всегда одна и та же ветка, junk — это шум с предсказуемым адресом возврата. Значит, всё решается динамикой.

Разведка

$ file crackme
crackme: ELF 64-bit LSB executable, x86-64, dynamically linked, stripped

$ echo AAAA | ./crackme
Welcome to callfuscated crackme.
To register enter your password: Incorrect flag. Try again

Строки дают якоря: %63s (scanf пишет в буфер 0x40f080), Correct...%s, Incorrect flag. Try again. Диспетчер VM в дизассемблере узнаётся мгновенно:

mov  eax, [rbp-0x18]              ; pc
mov  eax, [rbp+rax*4-0x950]       ; program[pc] -> opcode
test eax, eax
jne  <handler>

В фрейме — pc, длина программы, sp, пара регистров и стек данных. Стековая машина, классика.

План

  1. Свой трассировщик на ptrace — записать поток исполнения на известном вводе (ABCDEFGH…).

  2. Свой дампер — вытащить program[] (586 ячеек) прямо из памяти.

  3. Свой эмулятор x86-64 на Python, который проигрывает записанный поток управления и валидируется порегистрово против настоящей трассы. Поток управления от пароля не зависит (VM ветвится по опкодам), так что одна выверенная трасса = универсальный движок для любого ввода.

  4. Декодировать опкоды VM.

  5. Обратить проверку.

Инструменты и грабли

Трассировщик. PTRACE_SINGLESTEP в цикле, лог rip и нужных регистров после каждой инструкции, с отсечкой по диапазону образа:

ptrace(PTRACE_TRACEME, 0, 0, 0);
execv(bin, argv);
while (1) {
    ptrace(PTRACE_SINGLESTEP, pid, 0, 0);
    waitpid(pid, &status, 0);
    if (WIFEXITED(status)) break;
    ptrace(PTRACE_GETREGS, pid, 0, &regs);
    if (regs.rip >= 0x401000 && regs.rip < 0x40d000)
        fprintf(out, "%llx eax=%x edx=%x ecx=%x esi=%llx rax=%llx\n",
                regs.rip, (u32)regs.rax, (u32)regs.rdx,
                (u32)regs.rcx, regs.rsi, regs.rax);
}

На ABCDEFGH187 064 инструкции. Отдельный дампер снимает program[] и память VM.

Эмулятор. Парсинг листинга objdump в (addr, mnemonic, operands), обработчики на ~30 реально встречающихся опкодов (mov/movzx/movsx/lea/add/sub/and/or/xor/imul/shl/shr/push/pop/call/leave/…), затем прогон трассы с порегистровой сверкой на каждом шаге. Расхождение = баг. Два были показательными.

rand() зовётся из четырёх мест, 192 раза. VM подмешивает rand(). Наивное предположение «все вызовы возвращаются в 0x40923a» — мимо: сайтов четыре (409235, 40ad5b, 40af37, 40b10b), суммарно 192 вызова, каждый возвращается в call+5. Старая логика «мотать до 0x40923a» съедала сотни живых инструкций. Фикс: адрес возврата считать обобщённо как (адрес call)+5 и захватить все 192 результата по порядку.

WORD — подстрока DWORD. Детект размера операнда был if k in text for k in ('BYTE','WORD','DWORD','QWORD'). 'WORD' in 'DWORD PTR' == True, поэтому все 32/64-битные обращения к памяти резались до 16 бит и всплывало это только на значениях больше 0xffff. Лечится границей слова:

def opsize(op):
    m = re.search(r'\b(BYTE|WORD|DWORD|QWORD) PTR', op)
    return {'BYTE':1,'WORD':2,'DWORD':4,'QWORD':8}[m.group(1)] if m else None

После фиксов:

trace lines 187064
rand_seq 192
REACHED final 40b537 idx 187018 eax=ffffffff rand_used 192

187 064 инструкции воспроизведены побайтово, финал eax = 0xffffffff — ровно то, что бинарник считает для неверного пароля.

Разбор VM

На готовом эмуляторе снимаю снимок стека данных на каждом диспетче и раскручиваю программу из 586 ячеек. Вся «криптография» опкодов, спрятанная за MBA, оказывается арифметикой уровня первого курса:

  • PUSH imm — константа на стек.

  • H2 — сложение. Именно им считается адрес: 0x40cbb5 + 0x24cb = 0x40f080, т.е. &input[0]. Каждый блок символа берёт 0x40f080 + i.

  • DEREF — чтение байта ввода по адресу.

  • H5 — умножение аккумулятора на 256.

  • H7 — прибавление байта.

То есть каждая четвёрка символов сворачивается в 32-битное big-endian число:

acc = (((c0)*256 + c1)*256 + c2)*256 + c3 == uint32_be(c0 c1 c2 c3)

Дальше на группу подмешиваются две константы через G8 и G3 — обе декодируются в XOR. Групп восемь (32 символа), у каждой своя пара:

Группа

G8

G3

0

0x0915033a

0x41414141

1

0x427d7872

0x11111111

2

0x30310a00

0x55555555

3

0x2a052e32

0x5a5a5a5a

4

0xcff5ecdf

0xaaaaaaaa

5

0x1914031e

0x77777777

6

0xf6f7c6ad

0x99999999

7

0x6c6a524e

0x33333333

Финальное 32-битное значение — битовая маска: 0xffffffff на мусоре, 0 на верном пароле, каждая сошедшаяся группа гасит свои биты. Условие группы сводится к uint32_be(4 символа) == G8 ^ G3, то есть символы восстанавливаются в лоб:

groups = [(0x0915033a,0x41414141),(0x427d7872,0x11111111),
          (0x30310a00,0x55555555),(0x2a052e32,0x5a5a5a5a),
          (0xcff5ecdf,0xaaaaaaaa),(0x1914031e,0x77777777),
          (0xf6f7c6ad,0x99999999),(0x6c6a524e,0x33333333)]
flag = b''.join((g8 ^ g3).to_bytes(4,'big') for g8,g3 in groups)
print(flag.decode())

Группа 0: 0x0915033a ^ 0x41414141 = 0x4854427bHTB{ и таким образом собираем наш флаг.


Результат

Решенная задачка :)
Решенная задачка :)

Вот как показывается в консоли:

$ echo 'HTB{******_**_***_********_*_**}' | ./crackme
Welcome to callfuscated crackme.
To register enter your password: Correct. Validate the challenge using the flag: HTB{******_**_***_********_*_**}

Полученный флаг буквально описывает, что сделал обфускатор - нарезал одну verify() на тысячи гаджетов вокруг VM. VM + MBA + opaque + junk впечатляют только на статике; у всей четвёрки одна общая дырка и им всё равно надо исполниться.

Задачка была очень интересной :)