Название — 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 crackmecrackme: ELF 64-bit LSB executable, x86-64, dynamically linked, stripped$ echo AAAA | ./crackmeWelcome 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] ; pcmov eax, [rbp+rax*4-0x950] ; program[pc] -> opcodetest eax, eaxjne <handler>
В фрейме — pc, длина программы, sp, пара регистров и стек данных. Стековая машина, классика.
План
-
Свой трассировщик на
ptrace— записать поток исполнения на известном вводе (ABCDEFGH…). -
Свой дампер — вытащить
program[](586 ячеек) прямо из памяти. -
Свой эмулятор x86-64 на Python, который проигрывает записанный поток управления и валидируется порегистрово против настоящей трассы. Поток управления от пароля не зависит (VM ветвится по опкодам), так что одна выверенная трасса = универсальный движок для любого ввода.
-
Декодировать опкоды VM.
-
Обратить проверку.
Инструменты и грабли
Трассировщик. 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, ®s); 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);}
На ABCDEFGH — 187 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 187064rand_seq 192REACHED 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 = 0x4854427b → HTB{ и таким образом собираем наш флаг.
Результат
Вот как показывается в консоли:
$ echo 'HTB{******_**_***_********_*_**}' | ./crackmeWelcome to callfuscated crackme.To register enter your password: Correct. Validate the challenge using the flag: HTB{******_**_***_********_*_**}
Полученный флаг буквально описывает, что сделал обфускатор — нарезал одну verify() на тысячи гаджетов вокруг VM. VM + MBA + opaque + junk впечатляют только на статике; у всей четвёрки одна общая дырка и им всё равно надо исполниться.
Задачка была очень интересной 🙂
ссылка на оригинал статьи https://habr.com/ru/articles/1061634/