国赛2022年初赛

baby_tree #

swift 语言的抽象语法树

AST 的主体是一个 check 函数

写一个语法树到源码的程序太浪费时间,为了减少逆向的难度,可以分层折叠语法树单元,尽量以语句为单位

归纳出源码

def check(b, k):
    r0, r1, r2, r3 = 0, 0, 0, 0

    for i in range(0, len(b)-3):
        r0, r1, r2, r3 = b[i], b[i+1], b[i+2], b[i+3]
        b[i] = r2 ^ ((k[0] + (r0>>4)) & 0xff)
        b[i+1] = r3 ^ ((k[1] + (r1>>2)) & 0xff)
        b[i+2] = r0 ^ k[2]
        b[i+3] = r1 ^ k[3]
        k[0], k[1], k[2], k[3] = k[1], k[2], k[3], k[0]

    return b == [88, 35, 88, 225, 7, 201, 57, 94,
				77, 56, 75, 168, 72, 218, 64, 91,
				16, 101, 32, 207, 73, 130, 74, 128,
				76, 201, 16, 248, 41, 205, 103, 84,
				91, 99, 79, 202, 22, 131, 63, 255, 20, 16]

assert(check(input(), k='5y34'))

可以看到是 4 字节一组步进加密,解密时从尾到头反过来即可

s = bytearray([88, 35, 88, 225, 7, 201, 57, 94, 77, 56, 75, 168, 72, 218, 64, 91, 16, 101, 32, 207,
                   73, 130, 74, 128, 76, 201, 16, 248, 41, 205, 103, 84, 91, 99, 79, 202, 22, 131, 63, 255, 20, 16])
k = bytearray(b'5y34')

f = s.copy()
for i in range(38, -1, -1):
    r1 = f[i+3] ^ k[3]
    r0 = f[i+2] ^ k[2]
    r3 = f[i+1] ^ ((k[1] + (r1>>2)) & 0xff)
    r2 = f[i] ^ ((k[0] + (r0>>4)) & 0xff)
    f[i], f[i+1], f[i+2], f[i+3] = r0, r1, r2, r3
    k[0], k[1], k[2], k[3] = k[3], k[0], k[1], k[2]

print(f.decode())
#flag{30831242-56db-45b4-96fd-1f47e60da99d}

baby_code #

文件开头字节是 RITE0300

查了一下,这是 mruby bytecode

扒下所有字节码

mruby -b babycode.mrb

几个关键要点:

  • 最后一个函数显然是 check 函数
  • XX 看起来像魔数 0x12345678
  • YY 看起来像块长或者轮数 16 而从 times 的调用可以知道这是轮数
  • mask 为 0xffffffff 确定块长为 32

可以看出是魔改的 XTEA

u5+=(((u6<<3)^(u6>>5))+u6)^(u3[((u7>>11)+1)&3]+u7)  
u7+=0x12345678
u6+=(((u5<<3)^(u5>>5))+u5)^(u3[(u7+1)&3]+u7)

需要注意的是加密之前还对输入做了预处理

last=0
for i in range(len(p)):
	last,p[i]=p[i],p[i]^last^(i+1)
#flag{6ad1c70c-daa4-11ec-9d64-0242ac1200}

secreeeeet #

看附件显然是对 PNG 进行加解密

造一个假 PNG 给它加密,发现对同一个文件每次加密结果都不一样

在 main 函数输入的地方调试

此处是和一个长度为 3 的 key(图中 v103)进行循环 XOR

在 main 函数输出的地方调试

此处是做一个流加密,并且步长 16 字节

每次步进都对 sbox(图中 v186)做一次混淆

其中 sub_1DF0 是一个难以逆向的 shuffle 函数,不调用任何其他的函数

追溯 sbox 的来源可以在 main 开头看到 sbox 的密钥长度为 5(图中 v102),并且 sbox 一开始的值只由它扩散而成

通过调试可以知道

这两个密钥都由数字组成,不过长度为 5 的那个对码表 qscfthnjik 取了下标

例如数字为 12345,则种子为 scfth

可以从上面看到两个数字均来源于时间,因此必须爆破

考虑到对 PNG 头进行 XOR 时使用的密钥在一个很小的范围内,利用这一点进行爆破

我的规则:

  • pt 是 PNG 头 \x89PNG
  • ct 来自题目 \x7B\x92\x1F\x3E
  • pt^ct 的前三位每位均属于 0-9
  • pt^ct 的第四位和第一位相同(因为是循环 XOR)

爆破时需要顺着逆向出流生成算法

这里考虑到流的生成过程中没有任何外界干扰,采用 angr 模拟执行输出两个相关联的 BitVec

只要将一个作为输入(可视为已知 因为要爆破)一个作为输出,调用 claripy 的约束求解即可模拟 box 的生成

def sim(key5, stream_length):

    base_addr = 0x400000
    project = angr.Project(
        "secreeeeet.exe", main_opts={"base_addr": base_addr}, auto_load_libs=False
    )

    begin_addr = 0x3980 + base_addr
    heap_addr, stack_addr = 0x500000, 0x600000 + 8  # to align rsp with 16 bytes

    begin_state = project.factory.blank_state(addr=begin_addr)
    begin_state.options.update(
        {
            angr.options.ZERO_FILL_UNCONSTRAINED_MEMORY,
            angr.options.ZERO_FILL_UNCONSTRAINED_REGISTERS,
            angr.options.LAZY_SOLVES,
        }
    )

    fake_flag = claripy.BVV(b'\x00'*stream_length, stream_length *8)
    begin_state.memory.store(heap_addr, key5)
    begin_state.memory.store(heap_addr + 0x1000, fake_flag)

    rbp = stack_addr - 0x3C8
    rsp = stack_addr - 0x4C8
    begin_state.regs.rbp = rbp
    begin_state.regs.rsp = rsp

    begin_state.regs.r12 = heap_addr
    begin_state.mem[rsp + 0x28].uint32_t = stream_length
    begin_state.mem[rsp + 0x68].uint64_t = heap_addr + 0x1000

    end_addr = 0x3CE8 + base_addr

    simgr = project.factory.simgr(begin_state)
    simgr.explore(find=end_addr)
    try:
        end_state = simgr.found[0]
    except:
        end_state = simgr.errored[0]

    # print(end_state)

    return end_state.memory.load(heap_addr + 0x1000, stream_length)

其中长度为 5 的 key 地址位于 r12

由于要和 16 字节对齐不然某些指令会报错,stack_addr 加上 8

相关的两个变量一个是位于 0x28 的长度,另一个是位于 0x68 的数据指针

生成的流将会和数据指针指向的内容 XOR,所以将数据置 0 将得到纯粹的流

用 pickle 将模型序列化,放到 Linux 机器去爆

A = claripy.BVS("A", 5*8)
B = sim(A, 16)
with open("prg16.bin","wb") as fp:
	pickle.dump((A,B),fp)
with open("prg16.bin", "rb") as fp:
    A, B = pickle.load(fp)
    B = B.chop(4*8)[0]

ct = b"\x7b\x92\x1f\x3e"
png = b"\x89PNG"
charset = "qscfthnjik"

def f(x):
    S = claripy.Solver()
    S.add(A == claripy.BVV(x.encode(), 5 * 8))
    stream = l2b(S.eval(B, 1)[0], blocksize=4)
	b = [x ^ y for x, y in zip(ct, stream)]
    m = [x ^ y for x, y in zip(b, png)]
    if (
        m[0] == m[3]
        and 0x30 <= m[0] <= 0x39
        and 0x30 <= m[1] <= 0x39
        and 0x30 <= m[2] <= 0x39
    ):
        print(f'key: {x}')
        print(f'xor_key: {bytes(m[:3]).decode()}')
        return True
    return False

应用上述爆破规则从码表 qscfthnjik 中选取 5 个

mbruteforce 的进度条不是很健壮

使用 tqdm 和分段爆破手搓一个进度条

context.log_level = "critical"

for i in tqdm(range(500)):
    x = mbruteforce(f, charset, length=5, method="fixed", start=(i + 1, 500))
    if x is not None:
        break

大约 20 分钟后得出结果 iqsqn 和 002

在得到两个密钥之后,同样再调用一次 sim 函数

不过这次不是为了获得模型,而是为了直接计算出结果

key=b"iqsqn"
xor_key=b"002"

with open("flag.png.enc.bak", "rb") as fp:
    ct = fp.read()

    A, B = sim(claripy.BVV(key, 5*8), len(ct))
    S = claripy.Solver()
    stream = l2b(S.eval(B, 1)[0], blocksize=len(ct))

    m = bytes(xor_key[i%3] ^ ct[i] ^ stream[i] for i in range(len(ct)))
    with open("flag_decrypted.png", "wb") as fq:
        fq.write(m)