TCTF/0CTF Parsing Writeup

Nonsense

最近事情有点多,一些比赛的赛后 Writeup 都没有时间整理。工作日大部分时间都在复现分析链上的合约攻击事件,周末得闲才会看一看 CTF 相关的东西,比赛的参与程度也被迫降低了,不然年纪大了身体恢复不过来 ;)

话说回来,TCTF RisingStar 2022 Final 和 0CTF/TCTF 2023 接连举办,往返杭州上海连轴转,有那么一点“极限挑战”的味道了。

Solution

由于没有高强度做题,这次比赛只认真研究了一下 Parsing 这道题目。题目下载链接

简而言之,这是一道另类迷宫题目,出题人用 rust 的 nom 模块实现了一个 flag 检查器。题目中有很多形如 t_0ctf_parser::eats::eat* 的函数,作用为“吃掉”字符串中的一些字节。简单逆向后不难发现,除去最开始匹配 flag{ 与 3 字节 hex 以外,剩下的都通过 nom::bytes::complete::tag 进行逐字节匹配,将形如 body[0-9]* 的函数视作点,body[0-9]*_[0-9]* 视作边,则题目可抽象成一张有向图,在经过边时,t_0ctf_parser::eats::SCORE 会随之减少,减少的数值可视作该边权重。

需要注意的是,一些特殊的点权重为正,到达这些点时,如果匹配失败,则路径会进行回溯,回到上级 nom::branch::Alt::choice 继续匹配,因此对这些点要进行特殊处理,且由于存在匹配的先后顺序关系,在获取图的数据时,边与匹配的执行顺序之间应是保序的。

进一步地,假设从点 $x$ 出发有两条边匹配了相同字符,先后顺序为 $x \to y$ 与 $x \to z$,其中从点 $y$ 出发若匹配失败,则权重增加 $w$ 后回溯 ($w < 0$),那么可以在局部更新 $x \to z$ 边的权重为 $\min(W_{x \to y} + w,W_{x \to z})$.

下为解题使用的 gdb script.

import re
import gdb
import ctypes

from collections import deque, defaultdict

int32 = lambda x: ctypes.c_int(x).value

def get_reg_info(name) -> str:
    gdb.execute(f"set $_{name} = ${name}")
    return gdb.convenience_variable(f"_{name}").format_string()


def bellman_ford(start, end, graph):
    node_scores = defaultdict(lambda: int32(0x80000000))
    node_scores[start] = 0
    pred = {}

    for _ in range(len(graph) - 1):
        err = False
        for u in graph.keys():
            for v in sorted(list(graph[u])):
                if (u, v) not in dists:
                    continue
                if u not in node_scores:
                    continue
                if v not in node_scores or node_scores[u] + dists[(str(u), str(v))] > node_scores[v]:
                    node_scores[v] = node_scores[u] + dists[(str(u), str(v))]
                    pred[v] = u
                    err = True
        if err == False:
            break
    
    assert not err
    path = [end]
    cur = end
    while cur != start:
        path.append(pred[cur])
        cur = pred[cur]

    return node_scores[end], path[::-1]


# ======================= search in disassembly to find distances between node and scores =======================
gdb.execute("set confirm off")
gdb.execute("set pagination off")
gdb.execute("set disassembly-flavor intel")
infos = gdb.execute("info functions t_0ctf_parser::eats::eat_body[0-9]*_[0-9]*$", to_string=True).split('\n')
dists = defaultdict(lambda: int32(0x80000000))
for i in infos:
    res = re.findall(r"t_0ctf_parser::eats::eat_body([0-9]*)_([0-9]*)$", i)
    if len(res) == 1:
        address = re.findall(r"(0x[0-9a-f]*)", i)[0]
        disasm = gdb.execute("disas %s" % address, to_string=True).split('\n')
        for i, line in enumerate(disasm):
            if "mov    eax,DWORD PTR [rip" in line and "SCORE" in line:
                oper = disasm[i + 1]
                if "eax,ecx" in oper:
                    continue
                if "sub    eax" in oper:
                    dists[res[0]] = -int32(int(re.findall(r"sub    eax,(0x[0-9a-f]*)$", oper)[0], 16))
                    break
                else:
                    raise ValueError()

gdb.execute("delete")
node_with_scores = {}
infos = gdb.execute("info functions t_0ctf_parser::eats::eat_body[0-9]*$", to_string=True).split('\n')
for i in infos:
    res = re.findall(r"t_0ctf_parser::eats::eat_body([0-9]*)$", i)
    if len(res) == 1:
        address = re.findall(r"(0x[0-9a-f]*)", i)[0]
        disasm = gdb.execute("disas %s" % address, to_string=True).split('\n')
        for i, line in enumerate(disasm):
            if "mov    eax,DWORD PTR [rip" in line and "SCORE" in line:
                oper = disasm[i + 1]
                if "eax,ecx" in oper:
                    continue
                if "sub    eax" in oper:
                    node_with_scores[res[0]] = -int32(int(re.findall(r"sub    eax,(0x[0-9a-f]*)$", oper)[0], 16))
                    break
                else:
                    raise ValueError()

# ======================= bfs to get all one byte tags, this step should take a while ;) =======================
tags = {}
queue = deque()
queue.append(('s_0', 0, "flag{000000"))
graph = defaultdict(list)
gdb.execute("delete") # clear up
while queue:
    cur, depth, flag = queue.popleft()
    if '_' in cur:
        _, y = cur.split('_')
        gdb.execute("b t_0ctf_parser::eats::eat_body%s" % y)
    else:
        gdb.execute("rb t_0ctf_parser::eats::eat_body%s_[0-9]*$" % cur)
    gdb.execute("set args %s" % flag)
    gdb.execute("r")
        
    # now should hit your targets
    gdb.execute("b nom::bytes::complete::tag")
    while True:
        try:
            rip = get_reg_info("rip")
        except:
            break
        r = re.findall(r"<t_0ctf_parser::eats::eat_body([0-9_]*)>", rip)
        if not r:
            break
        target, *_ = r
        if target in tags:
            break
        if '_' in target: # an edge
            x, y = target.split('_')
            graph[x].append(y)
        gdb.execute("c") # now should hit tag of current target
        rdi = get_reg_info("rdi")
        data = gdb.execute("x/1bx %s" % rdi, to_string=True)
        ch = chr(int(data.strip('\n').split('\t')[1], 16))
        tags[target] = ch
        queue.append((target, depth + 1, flag + ch))
        gdb.execute("c")
    gdb.execute("delete")
    
# ======================= note that nodes with scores can rollback, update the distances and find path =======================
can_rollbacks = []
for gk, gv in graph.items():
    choices = [tags[gk + '_' + i] for i in gv]
    dups = [i for i, c in enumerate(choices) if c in choices[:i]]
    if dups:
        can_rollbacks.append(gk)
        dup_ch = choices[dups[0]]
        dups = [choices.index(dup_ch)] + dups
        new_dist = 0
        for d in dups:
            print("%s -> %s" % (gk, gv[d]), end=' ')
            if gv[d] in node_with_scores:
                new_dist += node_with_scores[gv[d]]
            new_dist += dists[(gk, gv[d])]
            if gv[d] in node_with_scores:
                print("(*)", end=' ')
        print()
        if new_dist > dists[(gk, gv[dups[-1]])]:
            for d in dups[:-1]:
                del dists[(gk, gv[d])] # update distances of node
            dists[(gk, gv[dups[-1]])] = new_dist

result, path = bellman_ford('0', '599', graph)
assert result + 0x30B > 0

flag = 'flag{000000'
for i in range(len(path)):
    flag += tags[path[i]]
    if i == len(path) - 1:
        flag += '}'
        break
    flag += tags[path[i] + '_' + path[i + 1]]

print(flag)