Задание: 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 2
2:
pop r8
; что-то
; и так далее

call кладёт на стек адрес возврата и передаёт управление, pop r8 тут же его снимает. Стек в итоге не изменился, испорчен только r8. Если r8 дальше не читается — перед нами jmp, записанный в два действия.

Давайте докажем, что r8 нигде не читается

import io
import struct
from pathlib import Path

from capstone import Cs, CS_ARCH_X86, CS_MODE_64
from elftools.elf.elffile import ELFFile
from unicorn import Uc, UC_ARCH_X86, UC_MODE_64, UC_PROT_ALL
from 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, 0x60000000
STACK, PAGE = 0x70000000, 0x1000
SRAND, SCANF, RAND = 0x401050, 0x401060, 0x401070
IMPORTS = {0x401030, 0x401040, SRAND, SCANF, RAND}
MAX_INSTRUCTIONS = 2_000_000


def 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 s


def 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) & -PAGE
hi = (max(s["p_vaddr"] + s["p_memsz"] for s in segments) + PAGE - 1) & -PAGE
uc = 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 - 8
uc.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 = True
state = None
reads = set()
executed = 0

while 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 += 1

print(f"executed: {executed}")
print(f"r8 reads: {len(reads)}")
if reads:
    print("addresses:", ", ".join(f"{address:#x}" for address in sorted(reads)))

Получаем:

executed: 186802
r8 reads: 0

Доказали.

В самом call меняется один байт: call rel32 (E8) и jmp rel32 (E9) одной длины и считают адрес одинаково, от конца инструкции. Меняем опкод — цель сохраняется. pop r8 (41 58) забиваем двумя nop.

Снимаем мусор

Я уже описал, как оно работает, так что просто пишем скрипт для этого

import io
from pathlib import Path

from capstone import Cs, CS_ARCH_X86, CS_MODE_64
from capstone.x86 import X86_OP_IMM
from elftools.elf.elffile import ELFFile

FILE = 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 = True
count = 0

for 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 io
import sys
import subprocess
from pathlib import Path

from elftools.elf.elffile import ELFFile
from triton import ARCH, Instruction, MemoryAccess, OPCODE, TritonContext

sys.path.insert(0, str(Path(__file__).resolve().parent))
from triton_to_cobra import triton_ast_to_cobra

FILE = 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 = 20
REGISTERS = ("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 * rsi
sub_401f18: Triton...
  681 instructions, 2663 chars; CoBRA full AST...
  rsi + rdi
sub_4025d8: Triton...
  SUPEROP/unsupported by CoBRA grammar:  ...
sub_4050aa: Triton...
  1141 instructions, 4318 chars; CoBRA full AST...
  rdi + -rsi
sub_405c1f: Triton...
  1813 instructions, 7026 chars; CoBRA full AST...
  rdi ^ rsi
sub_406e47: Triton...
  1857 instructions, 7375 chars; CoBRA full AST...
  rdi | rsi
sub_4080d6: Triton...
  1509 instructions, 5535 chars; CoBRA full AST...
  rdi & rsi

sub_4025d8 не упростился, но спойлер: он не используется. Заодно закрывается вопрос про rand: двум функциям я символизировал по 4 аргумента, а в упрощённом виде остались только rdi и rsi. Аргументы с rand на результат не влияют — ровно то, чем и должен быть непрозрачный предикат. Теперь можно разобраться со всеми хендлерами! Я пропущу этап с опознаванием поведения остальных хендлеров — это довольно просто, я верю, что у вас это самим получится.

Пишем девиртуализатор

Сейчас мы знаем структуру ВМ, знаем поведение опкодов, байткод можно получить, проэмулировав его запись через Unicorn, или сдампив его руками.

Что делать-то теперь? У нас есть несколько путей: написать плагин для декомпилятора, который добавит поддержку архитектуры этой ВМ, но по моему опыту это муторное занятие. Также можем в лоб перенести поведение опкодов ВМ на C, а компиляция с оптимизациями всё упростит. Так мы и поступим. Этот подход я взял отсюда. Может, есть и другие способы девиртуализации, но мне они не встречались.

Вот так я дизассемблировал и собирал программу

import io
import struct
from pathlib import Path

from elftools.elf.elffile import ELFFile
from unicorn import Uc, UC_ARCH_X86, UC_MODE_64, UC_PROT_ALL
from unicorn.x86_const import UC_X86_REG_RBP, UC_X86_REG_RIP, UC_X86_REG_RSP


FILE = Path(r"H:\UserMoved\Downloads\crackme_patched")
OUTPUT = Path("devirtualized.c")
DUMP = Path("bytecode_dump.bin")
START = 0x409002
BYTECODE_READY = 0x40978F
INPUT_BASE = 0x40F080
STACK = 0x70000000
PAGE = 0x1000
MAX_SETUP_INSTRUCTIONS = 5_000_000

OPERATORS = {
    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 uc


def 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 c


def 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 struct

blocks = [
    (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 — и на каждом этапе у меня остаётся декомпиляция.