Callfuscated — Виртуализация, MBA, opaque predicates и мусорные инструкции в одном задании

от автора

Задание: 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} патчей)")

Декомпилируем, и видим это:

Чистый main

Чистый main

Да это же ВМ! Узнаётся по характерному while (true): одно и то же значение (опкод) сравнивается с набором констант, и на каждую константу свой блок кода — хендлер.

Разбор ВМ

Тут обыкновенная стековая ВМ, разбираем её и видим хендлеры с вызовами функций:

Хендлеры с вызовами функций

Хендлеры с вызовами функций

Смотрим на эти функции и понимаем, что будет весело

Функция из хендлера №2

Функция из хендлера №2

А вот и обещанная 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/