Задание: https://app.hackthebox.com/challenges/Callfuscated
Почему я вообще пишу это
Меня зарейджбайтила эта статья. Решение по трассе, без декомпилятора, MBA объявлен «арифметикой первого курса» — и при этом 2 опкода разобраны неверно, просто ошибка не всплыла из-за особенностей таска. Разберу иначе: статикой, с упрощением MBA и снятием мусора.
Первый взгляд
Скачиваем, открываем, видим это:
Ладно, есть куча вызовов, задание всё-таки называется Callfuscated, а что за огромное количество arg_*? Так и не понял, откуда декомпилятор их берёт. Переход к любой из этих функций ведёт куда-то в середину того же main. Что-то тут не так. Посмотрим на ассемблер
Повторяется странная комбинация инструкций: call, pop r8 и какая-то инструкция. Потыкаем на другие функции уже в ассемблере и посмотрим, куда они ведут; замечаем, что call ведёт на pop r8 и инструкцию, последующий call ведёт на ту же структуру. Вот что по итогу происходит:
call 1; цель колла не обязательно следующая за ним инструкция, я просто так написал1:pop r8; что-тоcall 22:pop r8; что-то; и так далее
call кладёт на стек адрес возврата и передаёт управление, pop r8 тут же его снимает. Стек в итоге не изменился, испорчен только r8. Если r8 дальше не читается — перед нами jmp, записанный в два действия.
Давайте докажем, что r8 нигде не читается
import ioimport structfrom pathlib import Pathfrom capstone import Cs, CS_ARCH_X86, CS_MODE_64from elftools.elf.elffile import ELFFilefrom unicorn import Uc, UC_ARCH_X86, UC_MODE_64, UC_PROT_ALLfrom unicorn.x86_const import ( UC_X86_REG_R8, UC_X86_REG_RAX, UC_X86_REG_RDI, UC_X86_REG_RIP, UC_X86_REG_RSI, UC_X86_REG_RSP,)FILE = Path(r"crackme")START, STOP = 0x409002, 0x60000000STACK, PAGE = 0x70000000, 0x1000SRAND, SCANF, RAND = 0x401050, 0x401060, 0x401070IMPORTS = {0x401030, 0x401040, SRAND, SCANF, RAND}MAX_INSTRUCTIONS = 2_000_000def rng(seed): s = [seed & 0xffffffff or 1] for _ in range(30): s.append(16807 * s[-1] % 2147483647) s += s[:3] for i in range(34, 344): s.append((s[i - 31] + s[i - 3]) & 0xffffffff) return sdef ret(uc): rsp = uc.reg_read(UC_X86_REG_RSP) uc.reg_write(UC_X86_REG_RSP, rsp + 8) uc.reg_write(UC_X86_REG_RIP, struct.unpack("<Q", uc.mem_read(rsp, 8))[0])data = FILE.read_bytes()elf = ELFFile(io.BytesIO(data))segments = [s for s in elf.iter_segments() if s["p_type"] == "PT_LOAD"]lo = min(s["p_vaddr"] for s in segments) & -PAGEhi = (max(s["p_vaddr"] + s["p_memsz"] for s in segments) + PAGE - 1) & -PAGEuc = Uc(UC_ARCH_X86, UC_MODE_64)uc.mem_map(lo, hi - lo, UC_PROT_ALL)for segment in segments: uc.mem_write(segment["p_vaddr"], segment.data())uc.mem_map(STACK, PAGE * 16, UC_PROT_ALL)uc.mem_map(STOP, PAGE, UC_PROT_ALL)rsp = STACK + PAGE * 16 - 8uc.mem_write(rsp, struct.pack("<Q", STOP))uc.reg_write(UC_X86_REG_RSP, rsp)uc.reg_write(UC_X86_REG_RIP, START)cs = Cs(CS_ARCH_X86, CS_MODE_64)cs.detail = Truestate = Nonereads = set()executed = 0while uc.reg_read(UC_X86_REG_RIP) != STOP: rip = uc.reg_read(UC_X86_REG_RIP) if executed >= MAX_INSTRUCTIONS: raise RuntimeError("instruction limit exceeded") if rip == SRAND: state = rng(uc.reg_read(UC_X86_REG_RDI)) ret(uc) continue if rip == RAND: if state is None: raise RuntimeError("rand before srand") value = (state[-31] + state[-3]) & 0xffffffff state.append(value) uc.reg_write(UC_X86_REG_RAX, value >> 1) ret(uc) continue if rip == SCANF: uc.mem_write(uc.reg_read(UC_X86_REG_RSI), b"A" * 63 + b"\0") uc.reg_write(UC_X86_REG_RAX, 1) ret(uc) continue if rip in IMPORTS: ret(uc) continue ins = next(cs.disasm(bytes(uc.mem_read(rip, 15)), rip, count=1)) if any(cs.reg_name(reg).lower() in {"r8", "r8d", "r8w", "r8b"} for reg in ins.regs_access()[0]): reads.add(rip) uc.emu_start(rip, 0, count=1) executed += 1print(f"executed: {executed}")print(f"r8 reads: {len(reads)}")if reads: print("addresses:", ", ".join(f"{address:#x}" for address in sorted(reads)))
Получаем:
executed: 186802r8 reads: 0
Доказали.
В самом call меняется один байт: call rel32 (E8) и jmp rel32 (E9) одной длины и считают адрес одинаково, от конца инструкции. Меняем опкод — цель сохраняется. pop r8 (41 58) забиваем двумя nop.
Снимаем мусор
Я уже описал, как оно работает, так что просто пишем скрипт для этого
import iofrom pathlib import Pathfrom capstone import Cs, CS_ARCH_X86, CS_MODE_64from capstone.x86 import X86_OP_IMMfrom elftools.elf.elffile import ELFFileFILE = Path(r"crackme")OUT = FILE.with_name(FILE.name + "_patched")original = FILE.read_bytes()patched = bytearray(original)elf = ELFFile(io.BytesIO(original))segments = [s for s in elf.iter_segments() if s["p_type"] == "PT_LOAD"]def file_offset(address): segment = next( s for s in segments if s["p_vaddr"] <= address < s["p_vaddr"] + s["p_filesz"] ) return segment["p_offset"] + address - segment["p_vaddr"]cs = Cs(CS_ARCH_X86, CS_MODE_64)cs.detail = Truecount = 0for segment in segments: if not segment["p_flags"] & 1: continue for instruction in cs.disasm(segment.data(), segment["p_vaddr"]): if instruction.mnemonic != "call": continue operand = instruction.operands[0] if operand.type != X86_OP_IMM: continue target = operand.imm try: target_offset = file_offset(target) except StopIteration: continue is_pop_r8 = original[target_offset:target_offset + 2] == b"\x41\x58" if not is_pop_r8: continue call_offset = file_offset(instruction.address) patched[call_offset] = 0xE9 # call -> jmp patched[target_offset:target_offset + 2] = b"\x90\x90" count += 1 print(f"{instruction.address:#x}: call -> jmp, {target:#x}: pop r8 -> nop nop")OUT.write_bytes(patched)print(f"Готово: {OUT} ({count} патчей)")
Декомпилируем, и видим это:
Да это же ВМ! Узнаётся по характерному while (true): одно и то же значение (опкод) сравнивается с набором констант, и на каждую константу свой блок кода — хендлер.
Разбор ВМ
Тут обыкновенная стековая ВМ, разбираем её и видим хендлеры с вызовами функций:
Смотрим на эти функции и понимаем, что будет весело
А вот и обещанная MBA. Также если присмотреться к коду 2 хендлера, то можно заметить использования rand в двух параметрах вызова, это наверное обещанные непрозрачные предикаты
Решаем MBA
Для тех, кто не знает: MBA — это замена простых операций (сложение, XOR и тд) на раздутые гигантские выражения. У нас результат MBA функции кладётся на вершину стека как результат опкода, а опкод стековой машины — это одна примитивная операция. Значит, за деревом на сотни узлов прячется что-то вроде a + b.
Что такое MBA разобрались, а что делать-то будем? Сначала попробовал simplify() из Triton: не справился. Потом GAMBA — тоже мимо. Разобравшись, понял почему: Triton — не MBA-солвер, а GAMBA заточен под специальные классы MBA. В итоге нагуглил CoBRA — современную решалку MBA, которая покрывает больше случаев, чем GAMBA. Она сработала, про неё и буду рассказывать.
В неё нужно передать математическое выражение, но у меня не выражение, а машинный код — нужен лифт. Тут пригодился Triton: он умеет эмулировать код и в нужный момент отдать AST-формулу регистра или ячейки памяти. Triton тут только лифтер: строит AST, а упрощает уже CoBRA.
Поднимаем
Нужно проэмулировать каждую функцию из хендлера. Вот код, через который я это делал:
import ioimport sysimport subprocessfrom pathlib import Pathfrom elftools.elf.elffile import ELFFilefrom triton import ARCH, Instruction, MemoryAccess, OPCODE, TritonContextsys.path.insert(0, str(Path(__file__).resolve().parent))from triton_to_cobra import triton_ast_to_cobraFILE = Path(r"crackme_patched")COBRA = Path(r"cobra-cli.exe")HANDLERS = { 0x401166: 4, 0x401F18: 4, 0x4025D8: 2, 0x4050AA: 2, 0x405C1F: 2, 0x406E47: 2, 0x4080D6: 2,}COBRA_MAX_VARS = 20REGISTERS = ("rdi", "rsi", "rdx", "rcx")def symbolic_formula(address, argc, segments): ctx = TritonContext(ARCH.X86_64) for segment in segments: ctx.setConcreteMemoryAreaValue(segment["p_vaddr"], segment.data()) for name in REGISTERS[:argc]: ctx.symbolizeRegister(getattr(ctx.registers, name), name) ctx.setConcreteRegisterValue(ctx.registers.rsp, 0x70000000) ctx.setConcreteMemoryValue(MemoryAccess(0x70000000, 8), 0) ctx.setConcreteRegisterValue(ctx.registers.rip, address) for count in range(100000): rip = ctx.getConcreteRegisterValue(ctx.registers.rip) instruction = Instruction(rip, bytes(ctx.getConcreteMemoryAreaValue(rip, 16))) ctx.processing(instruction) if instruction.getType() == OPCODE.X86.RET: ast = ctx.getAstContext().extract(31, 0, ctx.getRegisterAst(ctx.registers.rax)) return ctx.getAstContext().unroll(ast), count + 1 raise RuntimeError(f"sub_{address:x}: ret not found")data = FILE.read_bytes()elf = ELFFile(io.BytesIO(data))segments = [segment for segment in elf.iter_segments() if segment["p_type"] == "PT_LOAD"]for address, argc in HANDLERS.items(): print(f"sub_{address:x}: Triton...", flush=True) formula, instructions = symbolic_formula(address, argc, segments) try: expression = triton_ast_to_cobra(formula) except ValueError as error: print(f" SUPEROP/unsupported by CoBRA grammar: {error}", flush=True) continue print(f" {instructions} instructions, {len(expression)} chars; CoBRA full AST...", flush=True) input_path = Path(f"sub_{address:x}.mba") input_path.write_text(expression, encoding="ascii") process = subprocess.run( [str(COBRA), "--mba-file", str(input_path), "--bitwidth", "32", "--max-vars", str(COBRA_MAX_VARS)], text=True, capture_output=True, timeout=600, ) if process.returncode: print(" FAILED:", process.stderr.strip(), flush=True) else: result = process.stdout.strip() Path(f"sub_{address:x}.simplified.txt").write_text(result + "\n", encoding="ascii") print(f" {result}", flush=True)
Вкратце: эмулируем функцию, получаем AST для rax, конвертируем AST Triton в выражение, понятное CoBRA, используя этот скрипт, упрощаем. Получаем это:
sub_401166: Triton... 1385 instructions, 5835 chars; CoBRA full AST... rdi * rsisub_401f18: Triton... 681 instructions, 2663 chars; CoBRA full AST... rsi + rdisub_4025d8: Triton... SUPEROP/unsupported by CoBRA grammar: ...sub_4050aa: Triton... 1141 instructions, 4318 chars; CoBRA full AST... rdi + -rsisub_405c1f: Triton... 1813 instructions, 7026 chars; CoBRA full AST... rdi ^ rsisub_406e47: Triton... 1857 instructions, 7375 chars; CoBRA full AST... rdi | rsisub_4080d6: Triton... 1509 instructions, 5535 chars; CoBRA full AST... rdi & rsi
sub_4025d8 не упростился, но спойлер: он не используется. Заодно закрывается вопрос про rand: двум функциям я символизировал по 4 аргумента, а в упрощённом виде остались только rdi и rsi. Аргументы с rand на результат не влияют — ровно то, чем и должен быть непрозрачный предикат. Теперь можно разобраться со всеми хендлерами! Я пропущу этап с опознаванием поведения остальных хендлеров — это довольно просто, я верю, что у вас это самим получится.
Пишем девиртуализатор
Сейчас мы знаем структуру ВМ, знаем поведение опкодов, байткод можно получить, проэмулировав его запись через Unicorn, или сдампив его руками.
Что делать-то теперь? У нас есть несколько путей: написать плагин для декомпилятора, который добавит поддержку архитектуры этой ВМ, но по моему опыту это муторное занятие. Также можем в лоб перенести поведение опкодов ВМ на C, а компиляция с оптимизациями всё упростит. Так мы и поступим. Этот подход я взял отсюда. Может, есть и другие способы девиртуализации, но мне они не встречались.
Вот так я дизассемблировал и собирал программу
import ioimport structfrom pathlib import Pathfrom elftools.elf.elffile import ELFFilefrom unicorn import Uc, UC_ARCH_X86, UC_MODE_64, UC_PROT_ALLfrom unicorn.x86_const import UC_X86_REG_RBP, UC_X86_REG_RIP, UC_X86_REG_RSPFILE = Path(r"H:\UserMoved\Downloads\crackme_patched")OUTPUT = Path("devirtualized.c")DUMP = Path("bytecode_dump.bin")START = 0x409002BYTECODE_READY = 0x40978FINPUT_BASE = 0x40F080STACK = 0x70000000PAGE = 0x1000MAX_SETUP_INSTRUCTIONS = 5_000_000OPERATORS = { 2: "+", 3: "-", 5: "*", 6: "&", 7: "|", 8: "^",}def load_elf(file_name): data = file_name.read_bytes() elf = ELFFile(io.BytesIO(data)) segments = [segment for segment in elf.iter_segments() if segment["p_type"] == "PT_LOAD"] low = min(segment["p_vaddr"] for segment in segments) & -PAGE high = (max(segment["p_vaddr"] + segment["p_memsz"] for segment in segments) + PAGE - 1) & -PAGE uc = Uc(UC_ARCH_X86, UC_MODE_64) uc.mem_map(low, high - low, UC_PROT_ALL) for segment in segments: uc.mem_write(segment["p_vaddr"], segment.data()) uc.mem_map(STACK, PAGE * 16, UC_PROT_ALL) uc.reg_write(UC_X86_REG_RSP, STACK + PAGE * 16 - 8) uc.reg_write(UC_X86_REG_RIP, START) return ucdef capture_bytecode(file_name): uc = load_elf(file_name) for _ in range(MAX_SETUP_INSTRUCTIONS): if uc.reg_read(UC_X86_REG_RIP) == BYTECODE_READY: rbp = uc.reg_read(UC_X86_REG_RBP) length = struct.unpack("<i", uc.mem_read(rbp - 0x1C, 4))[0] if not 0 < length <= 0x10000: raise RuntimeError(f"invalid bytecode length: {length}") raw = bytes(uc.mem_read(rbp - 0x950, length * 4)) return list(struct.unpack("<" + "I" * length, raw)) uc.emu_start(uc.reg_read(UC_X86_REG_RIP), 0, count=1) raise RuntimeError("bytecode initialization did not finish")def bytecode_to_c(words): c = [] ip = 0 while ip < len(words): opcode = words[ip] comment = f" // bytecode {ip:04x}" if opcode == 0: c.append(f" stack[++sp] = 0x{words[ip + 1]:08x};{comment}") ip += 2 elif opcode == 1: c.append(f" --sp;{comment}") ip += 1 elif opcode in OPERATORS: op = OPERATORS[opcode] c.append(f" stack[sp - 1] = stack[sp - 1] {op} stack[sp]; --sp;{comment}") ip += 1 elif opcode == 9: address = words[ip + 1] c.append( f" stack[++sp] = *((const uint8_t *)(uintptr_t)0x{address:08x});{comment}" ) ip += 2 elif opcode == 10: c.append(f" stack[sp] = input[stack[sp] - 0x{INPUT_BASE:08x}];{comment}") ip += 1 else: raise RuntimeError(f"unknown VM opcode {opcode} at {ip:#x}") return cdef main(): words = capture_bytecode(FILE) c_lines = bytecode_to_c(words) DUMP.write_bytes(struct.pack("<" + "I" * len(words), *words)) OUTPUT.write_text("\n".join([ "#include <stdint.h>", "", "__declspec(dllexport) uint32_t devirtualized(const uint8_t *input) {", " uint32_t stack[256];", " int sp = -1;", *c_lines, " return stack[sp];", "}", "", ]), encoding="utf-8") print(f"Dumped {len(words)} words to {DUMP}") print(f"Disassembled {len(c_lines)} VM instructions") print(f"Generated {OUTPUT}")if __name__ == "__main__": main()
Получив devirtualized.c, собираем его
clang-cl.exe /LD /O2 /Fe:C:\tmp\devirtualized.dll C:\tmp\devirtualized.c
Открываем в декомпиляторе и видим
Нам нужно, чтобы в результате получилось 0
Пишем решалку:
import structblocks = [ (0x0915033A, -0x41414141), (0x427D7872, -0x11111111), (0x30310A00, -0x55555555), (0x2A052E32, -0x5A5A5A5A), (0xCFF5ECDF, 0x55555556), (0x1914031E, -0x77777777), (0xF6F7C6AD, 0x66666667), (0x6C6A524E, -0x33333333),]flag = b""for xor_c, add_c in blocks: need = (-add_c) & 0xFFFFFFFF val = need ^ xor_c # bswap — переворот байтов uint32, struct делает это через смену endian flag += struct.pack(">I", val)print(flag.decode())
И у нас флаг!
Напоследок — почему решение по трассе здесь вообще сработало.
Повезло дважды. Первое: вся логика лежит внутри ВМ, рядом с интерпретатором ничего содержательного нет. Если бы рядом с ВМ было много какого-то кода, то пришлось бы довольствоваться лишь неинтерактивным дизассемлером: ни переименований, ни комментариев, ни декомпиляции.
Второе: нет условных переходов, поток управления не зависит от ввода — поэтому одна выверенная трасса годится для любого пароля.
Те же две оговорки относятся и к самому байткоду. Статический разбор от этого не зависит: мне достаточно дописать ветку в транслятор байткода в C — и на каждом этапе у меня остаётся декомпиляция.
ссылка на оригинал статьи https://habr.com/ru/articles/1076974/