ByteCTF2022-maze6d复盘

main 函数反编译失败,原因是撞到了 __stack_chk_fail,而这个函数是 __noreturn

但是调试可以发现,__stack_chk_fail 的 got 项在运行时被换成了 sub_403608,这个函数就是异或 0xdeadbeef

补丁打法:mov eax, xxx; xor eax, 0xdeadbeef,正好也是 8 字节

F5 可以看到平坦化,每次状态转换后都将状态变量异或 0xdeadbeef

除了平坦化之外这题还有一个 trick:从一个偏移表中动态加载函数指针,偏移是 0x13371337

可以观察到函数指针都是预先计算出来存储的,打补丁比较容易

唯一需要注意的是函数指针最终存储的地点,有时是寄存器,有时是栈上,这里将 add 全部换成 mov imm,忽略加载表项的 mov,存回栈上的 mov 予以保留

import idaapi
import ida_bytes
import ida_ua
import idc
from keystone import Ks, KS_ARCH_X86, KS_MODE_64

ks = Ks(KS_ARCH_X86, KS_MODE_64)

def patch_dyn_resolv(fake_got=0x6E6390, off=0x13371337):
    insn = idaapi.insn_t()
    buffer = []

    begin = idc.read_selection_start()
    end = idc.read_selection_end()

    ea = begin
    while ea < end:
        idaapi.decode_insn(insn, ea)
        mnem = insn.get_canon_mnem()
        assert mnem == "mov" or mnem == "add"

        if insn.Op2.type == ida_ua.o_displ or insn.Op2.type == ida_ua.o_phrase:
            assert insn.Op1.type == ida_ua.o_reg

            reg = idaapi.get_reg_name(insn.Op1.reg, 8)
            disp = insn.Op2.addr
            value = idaapi.get_qword(disp + fake_got) - off
            asm = f"mov {reg}, {hex(value)}"
            print(asm)
            buffer.extend(ks.asm(asm)[0])
        elif mnem == "mov":
            buffer.extend(idaapi.get_bytes(ea, insn.size))

        ea += insn.size

    pb = bytes(buffer + (end - begin - len(buffer)) * [0x90])
    idaapi.patch_bytes(begin , pb)
    ida_bytes.del_items(begin, ida_bytes.DELIT_SIMPLE, end - begin)
    idaapi.create_insn(begin, insn)

选中需要打补丁的地方,运行上述脚本,现在 main 函数可以 F5 了

sub_403EF0 会对输入进行初步检查,输入的要求是 52 字节的大写 HEX

可以在字符串中找到迷宫,从左上角走到右下角的 * 处正好 26 步

...#....#.#.
.######...#.
..#..##.#.#.
#.##.##.#.#.
#....##.#...
...#.#..#.##
##.#.#.##.##
.#.#.#.##.##
.#.#.#.#####
.###...#...#
.....#.#.#.#
.#####...#.*
DDRDDRRRDDDDDRRDDRRUURRDDR

重复输入 26 个 41 可以得到 ‘game init’ 和 ‘start!’,而重复 26 个 61 可以得到 ‘game init’ 和 ‘invalid input’

这里有两个思路:第一是通过爆破确定合理的输入,第二则是找到 game 类的虚表,从而定位走迷宫的函数

爆破的结果是 0x00 ~ 0x1a, 0x40 ~ 0x5a, 0x80 ~ 0x9a, 0xc0 ~ 0xda,显然,输入字节的高 3 位代表上下左右四个方向,但是低 5 位依然不确定

边调试边观察输出可以推断出 game 类的构造函数位于 sub_405FC0,虚表位于 0x6E0738

虚表 func2 是游戏真正的入口,func3 检测是否通关并输出结果

这些成员函数中的指针加密使用了另一个偏移表 0x6e6e70

实际上,func3 的判断逻辑在 func5,除了要达到终点之外,还需要一个在 +26 的变量积攒到 26,这里也可以推断出 maze 的 vector 指针在 +32,行号在 +48,列号在 +52

再看 func2 是很明显的循环取命令的结构,其中 func10 的输入 v7 调试易得就是命令解码后的行列变化值,0x00 对应向下 (0, 1),0x40 对应向右 (1, 0),0x80 对应向上 (0, -1),0xc0 对应向左 (-1, 0)

为了得到低 5 位的具体内容,还需要分析 func10

可以看到除了走迷宫之外,func10 执行一些算法进行检测,检测通过之后就自增上面见过的关键变量

单步检测可以单步爆破,用 libdebug 在图中所示位置 (+6b60) 下断点,检测返回值是否是 1 即可,要注意确定了多少输入,就 cont 多少次


from libdebug import debugger

D = 0x00
R = 0x40
U = 0x80
L = 0xc0

path = [D,D,R,D,D,R,R,R,D,D,D,D,D,R,R,D,D,R,R,U,U,R,R,D,D,R,]
cracked = [0x19] * 26

def make_payload():
    payload = ''
    for i in range(26):
        x = path[i] | cracked[i]
        payload += hex(x)[2:].zfill(2).upper()
    #print(payload)
    return payload

d = debugger("./maze6d.elf") 

for i in range(26):
    print(i)
    for j in range(0x0, 0x1a):
        cracked[i] = j
        payload = make_payload()

        io = d.run() 
        my_bp = d.bp(0x6b60, file="binary")
        d.cont() 
        io.sendline(payload.encode())
        #print(hex(d.regs.rip))

        for _ in range(i):
            d.cont()

        # now after the breakpoint hit
        if d.regs.rax != 0:
            print(j)
            d.kill()
            break
    else:
        raise Exception

payload = make_payload()
print(payload)
#0C0C4A0E0C4A4C4C0E0C0C0C0C4A4C0E0C4A4C8A8C4E4C0E0C4A