LilacCTF2026-WriteUp
- 本次lz雷泽战队排名第9,感谢各位师傅们辛苦付出!

WEB
keep
php<= 7 . 4 . 21 development server源码泄露漏洞
1 | |

1 | |
放进去看看

是个一句话木马
bak文件要解析成php,根据漏洞特性,第二个请求加解析

要注意admin的长度,ls/是21,ls是19,cat /f*是24

拿到flag:cyberpeace{5adae4b50cb705043521800c610c08e9}
checkin
打开题目所给网址,是一个python编译器,无论怎么输入都不给结果,看起来是一个黑盒题目,先用dirsearch扫一下
1 | |

访问 /backup.zip ,下载文件后解压得到 jail.py
1 | |
这个是一个Python 沙箱****程序,核心通过多层过滤 + 自定义LockedList限制用户输入代码,仅当代码将status[LockedList([False])]原地修改为status[0]为真值、且对象内存地址不变时,才输出 Flag。
代码先做个 idna 编码,再过滤符号和关键词,最后eval
接下来就是构造payload:
主要逻辑就是通过vars()+min(dir())动态获取status对象,调用其pop()移除原假值False,经~按位取反得到真值-1后,通过append()原地追加,最终让status[0]为真值且对象 id 不变,满足沙箱出旗条件
1 | |
直接写出脚本
1 | |

得出flag:LilacCTF{Pyth0n_3_13_N3w_f3A7u@3}
Nailong
1. 题目分析
题目提供了一个基于 Streamlit 构建的 Web 应用,允许用户上传 PyTorch 模型文件 (.pth 或 .pt)。
应用界面提示:”To prevent hackers from causing damage, we’ve added a security scan for models. But is it really secure?”
这明确暗示题目考察的是 PyTorch 模型文件的安全性 以及 绕过安全扫描器。
我们知道,PyTorch 模型通常使用 Python 的 pickle 模块进行序列化。pickle 存在众所周知的反序列化漏洞,允许执行任意代码 (RCE)。为了防御这种攻击,通常会使用 picklescan 等工具来静态扫描模型文件。
2. 漏洞挖掘
2.1 安全扫描器绕过
通过测试发现,如果直接上传带有 RCE Payload 的 pickle 文件,会提示 “Your model contains malicious content”。这证实了后端使用了扫描器(推测为 Hugging Face 的 picklescan 或类似工具)。
通过调研最新的 picklescan 漏洞,发现了 **CVE-2025-10156 (CRC Error Bypass)**。
- 原理:
picklescan在处理 Zip 格式的文件(PyTorch 新版模型格式本质上是 Zip)时,如果发现 Zip 文件中的 CRC 校验和不匹配,可能会抛出错误并放弃扫描,或者无法正确解压分析。 - 利用: 然而,Python 的
zipfile模块(被 PyTorch 的torch.load使用)在默认情况下对 CRC 错误非常宽容,仍然可以正常解压和加载文件。 - 结论: 我们可以构造一个恶意的 PyTorch Zip 模型,故意篡改其中
data.pkl的 CRC 头,从而骗过扫描器,但仍能在服务器上被加载。
2.2 回显 Flag
题目环境可能限制了出站流量(尝试反弹 Shell 遇到困难),或者我们希望通过更简单的方式获取 Flag。
由于 Streamlit 会在前端显示后端抛出的错误信息(例如 Failed to load model: ...),我们可以利用这一点:
- 在恶意代码中读取
/flag。 - 主动抛出一个异常(如
RuntimeError),并将 Flag 内容作为异常消息。 - Streamlit 捕获异常后,会将 Flag 直接打印在网页上。
3. Exploit 构造
我们需要编写一个脚本来完成以下工作:
- 构造一个恶意的 Pickle 对象,利用
__reduce__执行exec(),运行读取 Flag 并抛出异常的 Python 代码。 - 将该 Pickle 对象打包成符合 PyTorch 规范的 Zip 文件结构(包含
archive/data.pkl等)。 - 二进制修改 Zip 文件,破坏
data.pkl的 CRC-32 校验和字段。
Exploit
1 | |
4. 攻击过程
- 运行脚本生成
exploit_final.pt。 - 在网页侧边栏上传该模型文件。
- 系统提示
✅ Model file passed the security scan(成功绕过扫描)。 - 紧接着报错
❌ Failed to load model: LilacCTF_FLAG: LilacCTF{n4il0ng_d3t3ct_r34dy_0r_n0t_7twe86b}。 - 成功获取 Flag。

Path
\\?\GLOBALROOT\Device\Mup\是 Windows 内核命名空间路径Device\Mup(Multiple UNC Provider) 是 Windows 处理 UNC 路径的驱动- 验证器使用大小写敏感的字符串匹配检测
GLOBALROOT - 但 Windows NTFS 和路径解析是大小写不敏感的
- 使用小写
globalroot绕过检测,Windows 仍能正确解析路径
1 | |

Reverse
Kilogram
给chall.exe脱个壳看一下,感觉帮助不是很大

看到会立刻把数写成lilac_
本地看一下flag.enc文件,发现文件头固定为“lilac”
看到里面的字段,猜测是类似于RC4加密逻辑,与标准 RC4 不同,仅执行密钥调度算法(KSA)初始化 S 盒,未执行伪随机生成算法(PRGA),直接将 S 盒作为 256 字节循环密钥流使用,异或操作实现加解密对称。
文件里面存的不是key1,有一个密钥层级关系:先通过 salt 派生 key2,再用 key2 解密 key1_obf 得到 key1,最终用 key1 做一次KSA得到的对应的密钥流解密密文。

可以看到迭代次数0x2710就是10000
编写脚本解密
1 | |
得出还原出来的flag


ezPython
pyinstxtractor-ng解包(使用pyinstxtractor可能会因为python版本与源程序版本不一致,导致解包后的PYZ-00.pyz_extracted文件为空)

用pycdc反编译main.pyc得到源码
1 | |
发现导入了自定义模块myalgo,crypto也经过了自定义(不是标准的Crypto),去PYZ-00.pyz_extracted目录下找到myalgo.pyc和crypto.pyc,反编译结果如下
1 | |
这是一个变种的 XXTEA 加密算法,使用了自定义的 DELTA 值 0x45555254。
而且还在crypto.pyc中a85decode()发现字节码自修改(self-modifying code)
1pycdc.exe .\crypto.pyc > crypto.py 2>&1
1 | |
原始 MX 函数:
1 | |
第一次修改(操作符):
- 位置 4:
RSHIFT→LSHIFT(z >> 5→z << 5) - 位置 24:
LSHIFT→RSHIFT(z << 4→z >> 4)
第二次修改(常量):
z << 5→z << 3(常量 5 → 3)y >> 2→y >> 5(常量 2 → 5)y << 3→y << 4(常量 3 → 4)z >> 4→z >> 2(常量 4 → 2)
最终 MX 函数:
1 | |
解密脚本
1 | |
JustROM
只给了二进制数据,需要自己识别架构,前0x1000字节是向量表

从0x4000开始9D E3 BF A0 是典型的 SPARC 架构的函数序言(save %sp, -96, %sp)

知道架构以后可以通过ghidra来反编译,架构设置为SPARC-32bit-Big-endian,基址设置为0x10000000(向量表地址指向 0x108xxxxx),总共6个功能函数

FUN_10004028 - PC相对寻址
1 | |
FUN_10004060 - 循环左移 (ROTL)
1 | |
FUN_10004d20 - 控制虚拟机(但是好像用不到)
1 | |
0x10: ptr = 00x11: ptr++0x22: ptr–0x33: mem[ptr] = 00x44: mem[ptr]++0x55: mem[ptr] *= 20xee: 调用flag验证函数0xff: halt
FUN_10004238 - ChaCha20块加密(总共8轮)
1 | |
FUN_10004ab0 - Flag验证函数
1 | |
根据验证逻辑,将plaintext xor data xor keystream就可以还原原始数据
FUN_1000409c - ChaCha20状态初始化
1 | |
ChaCha20算法结构
ChaCha20 的核心是一个伪随机函数,它基于一个 20轮加扰函数(题目中只有8轮),对输入块进行变换,生成伪随机输出。
输入块 (State)结构:
ChaCha20 的输入分为 16 个 32 位的无符号整数(总共 512 位):
常量 (16 个字节): 固定为
"expand 32-byte k"的 ASCII 编码。密钥 (32个字节): 256 位密钥。
计数器 (4个字节): 表示当前加密块的计数,防止重复密钥流。
随机数 / 随机值 (12个字节): 称为 Nonce,确保每次加密时的密钥流唯一

解密代码
1 | |
C++++
.NET的AOT程序,无法用dnspy,ilspy等工具去还原源码,用ida去看

动调可以理清基本流程

ai辅助分析


程序完整流程如下
1 | |
用frida去hook程序中的密钥扩展算法,S盒计算,RS码运算等内容
1 | |

NineApple
Swift开发的ios app

1 | |
app实现了一个手势输入的功能,九宫格节点编号
1 | |
将用户的手势输入转换为路径字符串
| 手势输入(节点编号) | 路径字符串 | 说明 |
|---|---|---|
| [0, 3, 6] | “147” | 左侧一列:节点0→3→6,转换为1→4→7 |
| [1, 4, 7, 8] | “2589” | 节点1→4→7→8,转换为2→5→8→9 |
| [0, 1, 2] | “123” | 顶部一行:节点0→1→2,转换为1→2→3 |
| [5, 4, 7, 8] | “6589” | 节点5→4→7→8,转换为6→5→8→9 |
| [0, 1, 3, 4, 6, 7, 8] | “1245789” | 7个节点的路径 |
将路径字符串在映射表中进行查询,得到对应字符,每一次手势输入得到一个字符,对多次输入的字符进行拼接
同时计算current_key的值,与最后的target_key进行验证,如果都相等,则拼接的字符串即为flag
1 | |
可以使用映射表中的路径字符串作为输入,去计算current_key,当计算结果与target_key对应时,输入字符串所对应的字符即为正确输入
映射表:
字典包含 39 个键值对
1 | |
解密脚本
1 | |
PWN
Gate-Way
题目分析
附件是Qualcom Hexagon架构的文件,静态编译
1 | |
ida需要装插件(n-o-o-n/idp_hexagon: Hexagon processor module for IDA Pro disassembler)才可以反汇编Hexagon架构文件
ida装完插件还是看不了伪代码,不过和mips风格有点像
本来想用qemu 开端口然后gdb-multiarch附加远程调试的,但一直连不上,我猜原因可能是gdb-multiarch无法识别Hexagon架构,不过看别的师傅的博客发现可以用strace跟踪调试
先./qemu-hexagon ./pwn看下大概逻辑
1 | |
有三个功能,管理、重置和退出,管理功能有reg、del、show
1 | |
reg的输入需要是ip:port|description格式,我们定位一下字符串,发现下面的函数

先后call了22120、21e60、20f30三个函数,strace跟一下

22120明显就是writev,20F30是逐字节read,而且是可以无限溢出的,结合ai发现21E60用于刷新缓冲区,把刚才writev的数据打印,20F30我们传入的参数是R0 = add(fp, #-0x68),栈上偏移0x68的位置,根据这个偏移我们就可以构造rop利用了

通过覆盖LR和FP,就可以实现栈迁移,再找gadgets控制寄存器执行syscall(59)即可
由于栈地址是不变的,所以不需要泄露地址,打远程的时候爆破一下栈地址就行了
结合ai找到的gadget链


r0=r16=memw(SP + 0x8),r6=r19=memw(SP + 0x4),这时候执行trap0就会调用execve(‘/bin/sh’)
利用思路
- 利用栈溢出覆盖LR、FP,并在栈上写入gadget
- 栈迁移到gadget,控制寄存器并执行syscall
- getshell!
成功调用execve

exp
1 | |
最后爆破出来的远程地址是0x4080fde8

bytezoo
题目分析
shellcode题,先mmap了两片内存,一个存shellcode,一个是shellcode执行时的栈空间,然后把第一片内存设为rx
限制条件是opcode每个对应字符出现的数量不能超过 其高四位和低四位之间的最小值,例如\x76出现的次数上限是min(7,6)=6,\xf0出现的次数上限是min(f,0)为0
这样限制了很多指令的调用,例如syscall(\x0f\x05)就不能出现

执行syscall时的内存

出题人很“贴心”的为我们提供了一个syscall
我们一开始思路是将syscall地址写入rbp,然后call rbp执行系统调用,构造执行mprotect->read
但构造成功后发现syscall之后没有ret,执行一次syscall之后就会程序就会报错
1 | |
然后思路改成向栈中写入伪造的sigframe,但没有syscall;ret还是无法连续执行系统调用


最后发现可以使用mov rbx,qword ptr fs:[-0x38] 将fs 基地址存入寄存器,这样我们就得到了libc地址,就可以随便调用函数了

执行mprotect–>read,写入orw即可(所以题目里故意写的\x0f\x05是不是只是为了挖坑的)
exp
1 | |

Misc
Welcome
开头为789c,应该为zlib压缩数据
转换代码:
1 | |
得到flag:LilacCTF{W3lc0M3_70_l1L4cc7F_g00D_LuCk}
Your GitHub, mine
目标是让 @lilacctf-tech 收到一封 X-GitHub-Sender: tynqf4hn8z-byte 的邮件,且该邮件是关于创建的issue的。Issue创建邮件不算。
GitHub的 @mention 机制:当你编辑issue body添加 @mention 时,GitHub会发送mention通知邮件给被提及的用户。虽然编辑操作的sender是编辑者,但服务器的验证逻辑可能只检查issue上是否有对 lilacctf-tech 的mention。
解题步骤:
- 加入GitHub Classroom获取仓库访问权限
- 使用nc创建issue:
nc 1.95.71.133 9999 选择 1. Create Issue 输入仓库名: lilacctf-puzzle-<username>
- 在GitHub网页上手动编辑issue的body,添加
@lilacctf-tech - 在issue下添加评论
@lilacctf-tech - 使用nc检查flag:
nc 1.95.71.133 9999 选择 2. Check Issue 输入仓库名: lilacctf-puzzle-<username> 输入Issue编号: <issue_number>
- 获得flag:
LilacCTF{D1sCov3r_Mor3_G17hU8_f347ur32}

Sky Is Ours
豆包问讯:

找一张官方图片确认下机翼,是青岛航空

- 看下文件时间 2025 4 10

从网上找青岛航空对应季节的航行计划表:https://www.ccaonline.cn/yunshu/yshot/1033369.html
2025 4 10是周四,排除非周四的航班,胶州湾定位+哈工大的哈尔滨猜测,试了几个,对了

1 | |
Questionnaire
1 | |
Crypto
myRSA
chall.py的逻辑:
1.加密部分
用三个大素数 $$p,q,r$$构造模数 $$n=pqr$$
其中 $$ p=pp^2+3pp+3$$ , $$ q=pp^2+5pp+7$$即 $$p$$ 和 $$q$$ 由同一个秘密小整数 $$pp$$ 生成
明文 FLAG 被加密为 $$c = m^e \ mod \ n \ (e=65537)$$,输出公钥 $$n $$和密文 $$c$$
2.预言机(Oracle)
用户可输入 $$x \in (80,100)$$且不能是完全平方数,若 $$x $$在模 $$ p,q,r$$下都是二次剩余,则返回其模 $$n$$ 的一个平方根,否则返回 🤐(表示无解)
参考:https://github.com/infobahnctf/CTF-2025/blob/main/crypto/madoka-rsa/solution.py的构造方式
构造一条过点 $$(1, y) $$的椭圆曲线。令 $$b=y^2−1 ⇒ E:y^2=x^3+b\ (\ mod\ n)$$,则点 $$G=(1,y)\in E(Z/nZ)$$
分解 $$n$$:计算 $$ P=n*G$$。由于 $$n$$ 是合数,在某个素因子$$P| n$$上,若 $$E(F_p)$$ 整除 $$n$$,则 $$P$$ 在该分量为无穷远点,导致射影坐标的 Z 分量满足 $$p | Z$$。于是 $$p=gcd(P_z,n)$$,成功提取一个素因子。
恢复完整因子分解: 题目中 $$n = p * q * r$$具有特殊结构:由 $$p$$ 计算 $$D=4p−3$$ ,验证其为完全平方数 。
令 $$ s=D$$ ,得 $$pp=−3+s2$$;则 $$q=pp^2+5pp+7,r=n//(pq)$$都可以求出,解RSA即可
1 | |

flag:LilacCTF{wHy_NoT_w4tch1ng_yOutub3_with_NPoYb4mbiOg}
nestDLP
• 题目分析
chall.py:8–chall.py:24里 OTP 的 padding 是固定重量:长度为 n 时,1 的个数恒为 $$n/2+1$$。指数是 $$ e = m \ xor \ padding$$,所以对所有样本都有 $$Hamming(m, e) = n/2+1$$。
chall.py:32–chall.py:40把指数用 $$g^e$$ 输出在商环 $$S = (Z/p^3)[x,y]/I $$中,表面上是多元 DLP。
突破点:把多元 DLP 降到整数模
关键想法是找出理想 $$I $$的一个解 $$(x_0,y_0)$$,这样就能把商环元素“取值”到 $$ Z/p^3$$:
- 先在 $$ F_p $$上消去 y 得到 x 的单变量多项式,分解后出现线性因子,得到 x0。
- 由 $$y^3 = -(x^5 + 37x - 13) $$得到 y0(因为 p ≡ 2 (mod 3),立方根唯一)。
- 用雅可比矩阵做 Hensel 提升,把 (x0,y0) 提升到模 $$p^3$$。
这样有同态: $$S → Z/p^3$$,且 $$ g(x_0,y_0) = g_0$$,每个输出 $$g^e $$变成整数 $$g_0^e (mod p^3)$$。
指数恢复:p‑adic log
在 $$Z/p^3 $$的单位群里,用 “先幂到 p-1 再取 p‑adic log” 的套路求指数:
- 设 $$log_p(u) = (u-1) - (u-1)^2/2 (mod \ p^3)$$
- 对 $$ u = g_0^e$$,有 $$log_p(u^{p-1}) ≡ e * log_p(g_0^{p-1}) ( mod \ p^3)$$
- 把两边除以 p,就在模 p^2 上解出 e:$$A = log_p(g_0^{(p-1)}) / p (mod \ p^2)$$、 $$e = ( log_p(u^{p-1}) / p ) * A^{-1} (mod \ p^2)$$
这里 $$e < 2^{576} < p^2$$,所以模 $$p^2 $$的结果就是完整的 e。最终得到 384 个 $$ e_i$$,位长为 576(即原始消息长度)。
由固定汉明距离还原明文:
设 m 为 576 位消息,已知对所有样本 $$Hamming(m, e_i) = w$$,其中 $$w = n/2+1 = 289$$。
把 m 的比特记为 $$b_j \in {0,1}$$,可改写为线性等式:
$$sum_j (b_j \ XOR\ e_{ij}) = w ⇔ sum_j \ a_{ij} * b_j = w - popcount(e_i)$$, 其中 $$a_{ij} = +1 (e_{ij}=0), -1 (e_{ij}=1)$$
这是一组 384 条等式、576 个二元变量的可行性问题。
直接 ILP 很慢,所以加上 CTF 常见的可打印 ASCII 约束(每个字节 0x20~0x7E),用 CP‑SAT 很快得到唯一解。
1 | |