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_user、kmalloc 等 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}