← Reverse Engineering
RE

ACTF 2026 Reverse Writeups

ACTF 2026 RE 方向复盘,覆盖 LoongArch Go 程序和 abyssgate 内核模块分析。

flagchecker

题目概况

题目给了一个名为 flagchecker 的 64 位 LoongArch ELF

$ file flagchecker
flagchecker: ELF 64-bit LSB executable, LoongArch, version 1 (SYSV), statically linked, stripped

程序逻辑是输入 flag,正确时输出 Correct!,错误时输出 Wrong!。由于本地不是 LoongArch 环境,使用 qemu-loongarch64 运行

$ printf 'test\n' | qemu-loongarch64 ./flagchecker
Enter the flag: Wrong!

虽然二进制被 stripped,但它是 Go 静态链接程序,可以从 .gopclntab 恢复函数名和函数地址。

Go 函数恢复

手动解析 .gopclntab 后可以定位到主要逻辑:

main.main                     0x22ed30
main.check                    0x22e350
main.(*JGqVVFpm).CT77IKGJ     0x22e510

核心校验函数在 0x22e350。它会先检查输入长度和结尾:

len(flag) == 0x26
flag[-1] == '}'

也就是总长度必须为 38 字节,并且以 } 结尾。

随后程序取前 5 字节做一次名字派生:

method = base32(SHA256(flag[:5])[:5])

这个字符串会作为 Go 反射调用的方法名。测试常见前缀后可以得到:

base32(SHA256(b"ACTF{")[:5]) = CT77IKGJ

二进制中正好存在 main.(*JGqVVFpm).CT77IKGJ,因此 flag 结构为:

ACTF{xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx}

中间部分长度为 32 字节。

二级校验

CT77IKGJ 方法会对中间 32 字节调用 0x22e590,再把返回结果送入最终比较函数 0x22eaf0

0x22eaf0 会从文件中拷贝一段 LoongArch 机器码到可执行内存并运行。动态代码先检查 /proc/self/status 中的 TracerPid,然后使用 SM4 加密 32 字节数据并和目标密文比较。

SM4 key 为:

LoongArch64SM4!!

目标密文为:

47 43 1c 8e ca 01 2e 7a 3a 6f f6 c3 80 7a 16 8d
e2 ed 5b 5b 80 26 86 6f c8 ba 44 60 32 03 08 fe

用该 key 对目标密文做 SM4 解密,可以得到 0x22e590 应输出的 32 字节:

01 8f 47 71 d5 2f b3 b9 70 bc da ce 9e f3 e2 4b
c1 92 6f 9b b0 54 53 7e f0 0b a5 e1 ef da 48 01

记为 y[0..7],每 4 字节一组。

还原中间变换

0x22e590 会把 32 字节中间 flag 分成 8 个 4 字节块:

x[0], x[1], ..., x[7]

每个块的 key 不是固定值,而是由另一个块做 HMAC-SHA256 派生:

KEY = b"DragonAbyssLoong64ReverseCTF2026"
key[i] = HMAC_SHA256(KEY, x[(i + 3) % 8])[:4]

每组输出满足:

y[i] = F(x[i], key[i])

其中 F 是一个 4 轮 Feistel-like 结构,使用 AES S-box。关键是这个结构可逆,因此只要知道 x[(i + 3) % 8],就能推出 x[i]

逆变换代码如下:

def rol(x, n):
    return ((x << n) & 0xff) | (x >> (8 - n))

def invF(out, key_word):
    key = list(key_word.to_bytes(4, "little"))
    st = list(out)

    for i in range(3, -1, -1):
        c, d, e, f = st
        r0 = key[i] ^ ((55 * i) & 0xff)
        r1 = key[(i + 1) & 3] ^ ((0x9b * i) & 0xff)
        s1 = AES_SBOX[c ^ r0]
        s2 = AES_SBOX[d ^ r1]
        st = [
            (e ^ (rol(s2, 3) ^ s1)) & 0xff,
            (f ^ (rol(s1, 5) ^ s2)) & 0xff,
            c,
            d,
        ]

    return bytes(st)

依赖关系是:

x[j] -> x[(j - 3) % 8]

也就是形成一个长度为 8 的环:

0 -> 5 -> 2 -> 7 -> 4 -> 1 -> 6 -> 3 -> 0

因此可以枚举一个起点块,比如 x[0],沿着环一路反推,最后检查是否能回到原来的 x[0]

题目 flag 中间部分看起来是十六进制风格字符串,所以先用字符集:

charset = b"abcdefghijklmnopqrstuvwxyz0123456789_"

枚举 37^4 个 4 字节块即可,复杂度很低。

exp

import hashlib
import hmac
import itertools

KEY = b"DragonAbyssLoong64ReverseCTF2026"
Y = bytes.fromhex(
    "018f4771d52fb3b970bcdace9ef3e24b"
    "c1926f9bb054537ef00ba5e1efda4801"
)

blocks_y = [Y[i:i + 4] for i in range(0, 32, 4)]
order = [0, 5, 2, 7, 4, 1, 6, 3, 0]
charset = b"abcdefghijklmnopqrstuvwxyz0123456789_"

def hmac4(block):
    return hmac.new(KEY, block, hashlib.sha256).digest()[:4]

for guess in itertools.product(charset, repeat=4):
    x = [None] * 8
    x[0] = bytes(guess)

    ok = True
    for a, b in zip(order, order[1:]):
        key = int.from_bytes(hmac4(x[a]), "little")
        nxt = invF(blocks_y[b], key)

        if x[b] is not None and x[b] != nxt:
            ok = False
            break
        x[b] = nxt

    if ok and x[0] == bytes(guess):
        middle = b"".join(x)
        print(b"ACTF{" + middle + b"}")
        break

得到:

ACTF{fce553ec44532f11ff209e1213c92acd}

abyssgate

先用 syscall/ptrace 跟踪程序行为,可以看到主要 ioctl:

0xab00       初始化
0xc018ab04   query
0x4018ab05   submit
0xab02       finalize 前置
0x4024ab03   finalize

第二阶段程序本身有反调试和 root/sudo 环境检查,先 patch 掉反调试后,放到 QEMU + 题目内核里跑。为了看清楚 eBPF 的效果,我写了一个 ioctl_trace_loader 跟踪 enter/exit 两侧数据,同时 dump 了 BPF 程序和 map。后面求解时就不再依赖完整 QEMU,而是把 abyss.ko 链接到本地 harness 里模拟模块逻辑。

eBP改写

BPF map 里主要有三块数据:

map+0x000: submit 顺序置换 [2, 0, 3, 1]
map+0x010: query exit 用 S-box
map+0x110: query enter / submit enter 用 S-box
map+0x210: 32 字节 key

query 在进入内核前会被 BPF 先改 seed,返回用户态时又会被 exit hook 改输出:

query_out[8:16] ^= ac04cb84993d3aef
query_out[16:20] = sbox10[query_out[16:20]]

submit 更关键。模块实际使用的是 BPF 改写后的 submit buffer。对每轮来说,模块中参与 _s5 的 8 字节 block 是:

block = submit_post[4:12] ^ submit_post[16:24]

其中 submit_post[16:24] 由 stage2 根据 query 输出生成的 pre-buffer 再经 BPF S-box 得到;submit_post[4:12] 则由 flag 相关的 8 字节经过 BPF 的四轮 S-box/key 变换得到。

内核模块逻辑

模块做了进程名、时间等检查。把 _s0/_s2 等环境检查 patch 成直接返回成功,然后把 abyss.ko 当普通目标文件链接进本地 harness,补齐 _copy_from_user_copy_to_userkmalloc 等 stub,这样可以直接调用模块的 init/query/commit/finalize handler。

最终常量在 stage2 中:

a58353a9c2c24b5f7eb82b77e35c9f4de38da70dce9596137f480a81f9968718

先逆 finalize,得到四轮 submit 应该产生的历史值:

2cc41a6d2a715362
4f78eb7eeb42bb61
71df0badae7fb0b2
bfec2e658844867b

commit 中的核心函数是 _s5(state, block, out)。它是 8 轮 Feistel-like 结构,state 前 8 个 little-endian dword 作为 round key,block 拆成两个 big-endian dword:

def T(x, k):
    y = sbox_word(x ^ k)
    return y ^ rol(y, 2) ^ rol(y, 10) ^ rol(y, 18) ^ rol(y, 24)

for k in rk:
    t = T(a ^ b, k)
    a, b = a ^ b, a ^ t

因为结构可逆,可以对每轮目标 history 反推出该轮需要的 block

stage2 的 flag 变换

stage2 把 flag 内部 32 字节分成 4 组,每组 8 字节。每组生成 submit pre-buffer 的 buf[4:12]。这个变换是三角形的 XOR 前缀结构,可以用单字符差分确认:

base = [
    bytes.fromhex("eb2606437a51683a"),
    bytes.fromhex("7e268a8b36f4bf30"),
    bytes.fromhex("24e61ef8aba21959"),
    bytes.fromhex("a1ed46b6f4298a5f"),
]

shift = [
    [3,1,1,3,2,2,4,4],
    [1,0,6,6,5,4,3,3],
    [6,6,5,4,7,7,1,0],
    [4,3,3,2,1,1,0,6],
]

out = bytearray(base[group])
for i, c in enumerate(chunk):
    v = rol8(c ^ 0x41, shift[group][i])
    for j in range(i, 8):
        out[j] ^= v

所以只要知道某轮所需的 buf[4:12],就能从后往前还原该组 flag 字节。

求解流程

每一轮独立求 flag chunk,但后一轮 query 依赖前面 commit 的 history,所以需要按顺序推进模块状态。 用 native harness 跑到当前轮 query,得到模块原始 query 输出。 按 BPF exit 逻辑得到 stage2 实际看到的 query 输出。 用 stage2_fake_trace 伪造 /dev/abyss 和 ioctl 返回值,让真实 stage2 生成当前轮 submit pre-buffer 模板。 在 commit 调 _s5 时截取实际 state,用目标 history 逆 _s5 得到所需 block。 已知 block = post[4:12] ^ post[16:24],且 post[16:24] 由 submit 模板确定,因此能算出所需 post[4:12]。 逆 BPF submit 的 buf[4:12] 变换,得到所需 submit pre-buffer,再逆 stage2 的 flag 三角变换,得到 8 字节 flag chunk。

四轮得到:

b02f97ee
296b1218
c4b771e3
cbc21120

拼起来就是:

ACTF{b02f97ee296b1218c4b771e3cbc21120}