XCTF-LilacCTF 2026-Writeup
XCTF-LilacCTF 2026-Writeup
官方题目网址:https://adworld.xctf.org.cn/match/guide?event_hash=7b528ca2-f4da-11f0-bf63-000c297261bb
这次就做出来5题(比一开始只会签到要好),以自己的拙见写一份wp
1.MISC-Welcome
签到题
题目描述:
1 | 789c0540b10980400c5ce92442067849153bc1f278ae1031ad95b87bc8bba6c611df6925ec46076bc955f2e0056ccc773c7f03fb580c81 |
这是一个十六进制字符串(hex)最后肯定要转成ASCII码:这里介绍两种做法,一种是用脚本就是下面这种做法:
1 | import binascii, zlib |
还有一种就是传统的cyberchef:(网址:https://cyberchef.org/)
这个十六进制有一个特征,就是文件头(zlib:文件头:789c),zlib众所周知是一个压缩包,所以我们要做的就只有两件:转hex和zilb解压,在cyberchef里就是,From Hex(从十六进制转换)转换成你平时在010 editor里看到的乱码(这其实才是zlib真正的内容)和Zlib inflate(zlib解压)即可获得flag。
flag为:
1 | LilacCTF{W3lc0M3_70_l1L4cc7F_g00D_LuCk} |
2.MISC-Questionnaire
我没想到问卷题也算分。
flag为:
1 | LilacCTF{7h4nk_U_f0r_p4rt1cip4t1n9_L1l4cCTF_2026} |
3.MISC-Sky Is Ours
题目描述:
1 | John likes to choose window seats on airplanes. He took this photo on a plane on April 10, 2025. What was his flight number? |
翻译一下:
1 | 约翰喜欢选飞机上的靠窗座位。他于 2025 年 4 月 10 日在飞机上拍下了这张照片。他乘坐的航班号是多少? |
题目图片贴一下:
放大的图更可以看出特征:一道经典的osint,依旧飞机题,但是明面上给的文字信息很少。
我来整理一下信息:
1.2025 年 4 月 10 日这是拍摄时间,这个时间还能补充,在你右键图片->属性,在详细信息里发现是11:20拍的,这是第一个信息。(当然exif信息是可以用exiftools扫出来,也可以用的线上,这里推荐两个:https://www.gaitubao.com/exif,
2.看这张图,飞机的侧翼是助我找到这个航班的关键(于我而言),根据这个侧翼的颜色分布及特征:白-蓝-白-深蓝-白-深蓝,加上搜索引擎搜索,(这里是豆包搜出来的),最后锁定是青岛航空,给你们看几张图
就说是不是一模一样吧,所以接下来就是根据航空公司查航班了,这里我用的网址是:https://www.flightera.net/,在**AIRLINE**里填**Qingdao Airlines**(你填进去会有提示的)或者1480,至于确定这架飞机的机型这个很玄学,直接用AI来表示吧:
1 | 判断 “飞越黄海、飞向山东半岛” 是靠这 2 个视觉 + 地理逻辑: |
难绷真的,然后根据这个起始地点和终点的大概位置,判断出机型:
1 | 最开始确定 B-8430,是基于青岛航空 2024 年及之前的旧执飞数据: |
算了聊天记录全贴吧:
1 | A:这个侧翼像哪个飞机 |
1 | B:这架青岛航空的飞机,它的名字是 **“耐冬花”**(或 “琴岛・耐冬花”)。 |
这里我确定了型号后我就去根据时间去查了(暂且相信她):
型号网址:https://www.flightera.net/zh/planes/B-8430
下面有历史航班点进具体年月,因为拍摄时间是11:20,所以我选择了从9点开始查:
可以看到符合大致时间的只有QW6097,这就是我们要找的航班。
flag为:
1 | LilacCTF{QW6097} |
4.ezPython
题目描述:
1 | Python is not as difficult as you think |
PyInstaller 一键解包 + “自修改”XXTEA 还原
结论
flag:
1 | LilacCTF{e@sy_Pyth0n_SMC!} |
验证(PowerShell):
1 | $flag = 'LilacCTF{e@sy_Pyth0n_SMC!}' |
输出会出现 Right, congratulations!。
- 定位:main.exe 是 PyInstaller Bootloader
把 main.exe 丢进 IDA,能看到典型 PyInstaller bootloader 逻辑:
- 读取可执行文件末尾 overlay(PKG)
- 解析 TOC(目录)
- 通过
PyMarshal_ReadObjectFromString反序列化脚本/模块 code object PyEval_EvalCode执行脚本(main/pyiboot01_bootstrap等)
因此 flag 逻辑不在 C 层,而在 PKG/PYZ 里的 Python 字节码模块。
- 解析 PyInstaller Cookie(注意是大端)
在文件末尾搜索 magic:MEI 0C 0B 0A 0B 0E,其后紧跟 88 字节 cookie。
该题 cookie 的关键点:整型字段是 大端(>I),否则会把 pkglen/toc_off/toc_len 解成离谱的大数。
cookie 字段(我们实际用到的):
pkglen:overlay(PKG) 总长度toc_off:TOC 在 overlay 内偏移toc_len:TOC 长度pylib:例如python39.dll
- 解 TOC 并提取文件
TOC 是一串 entry,entry 结构(同样大端):
entry_len(u32)data_pos(u32):数据相对 overlay 起始偏移clen(u32):压缩后长度ulen(u32):解压后长度flag(u8):1 表示 zlib 压缩type(u8):s/m/z/b等name:以\0结尾的字符串
本题提出来的关键条目:
main(types):主脚本(marshal code object)PYZ-00.pyz(typez):Python 模块包(里面有crypto、myalgo等)struct(typem):本题把_struct封装成一个 marshal 形式,方便脚本import struct
我这里把所有条目都提取到了:
其中:
- main 脚本做了什么
对 main.marshal 用 python3.9 的 marshal.loads 可以拿到 code object(注意:你本机 python3.11 直接 loads 会报 bad marshal data,因为版本不匹配)。
main 的关键常量:
- key bytes:
b'1111222233334444' - 校验目标(4 个 u32):
(761104570, 1033127419, 3729026053, 795718415) - 提示文本是 base64 / ascii85 混淆:
V2VsYzBtMyBUbyBUaGUgV29ybGQgb2YgTDFsYWMgPDM=→Welc0m3 To The World of L1lac <3UmlnaHQsIGNvbmdyYXR1bGF0aW9ucyE=→Right, congratulations!V3JvbmcgRmxhZyE=→Wrong Flag!:i(G#8T&KiF<F_)F\JToCggs;`(ascii85)用于“触发”后面的自修改逻辑(见下一节)
它的校验结构可以概括为:
- 要求输入形如
LilacCTF{...},并且{}中间长度固定为 16 字节(否则struct.unpack('<IIII', buf)会报错) - 把 16 字节内容按 little-endian 拆成 4 个 u32
- 调
myalgo.btea(v, 4, key)(看起来像 XXTEA/BTEA 加密) - 加密结果必须等于脚本里写死的那个 4-u32 tuple
- 关键坑:ascii85 解码会“改写”myalgo.MX
myalgo 一开始看起来像标准 BTEA/XXTEA,但如果你直接调用,会得到和主程序运行时不同的结果。
原因在 crypto.a85decode:它不仅仅是解 ascii85,它会做一次“自修改”:
- 在解码过程中动态构造/替换
myalgo.MX的字节码(等价于把 MX 函数换成另一个表达式) - 因此同样的明文/密钥,用
btea加密出来的密文会变
我们用 python3.9 运行时加载 PYZ-00.pyz 里的模块后做对比:
- 调
crypto.a85decode(':i(G#8T&KiF<F_)FJToCggs;’)之前,MX` 是一种实现 - 调用之后,
MX被替换成另一种位移组合:
1 | ((z << 3) ^ (y >> 5)) + ((y << 4) ^ (z >> 2)) |
并且 btea 内部使用的 DELTA 常量也不是标准 TEA 的 0x9E3779B9,而是:
1 | DELTA = 1163219540 = 0x45555254 |
- 还原 flag:对目标密文做 BTEA 解密
已知:
- 目标密文(4 个 u32):
res = [761104570, 1033127419, 3729026053, 795718415] - key(由
b'1111222233334444'以<IIII解释得到 4 个 u32) - 算法:BTEA/XXTEA 结构,但使用“被替换后的 MX”和自定义
DELTA=0x45555254
因为主程序的校验是:
1 | Encrypt(plain16) == res |
所以我们直接对 res 做解密得到 plain16,再拼回 flag:
1 | plain16 = b"e@sy_Pyth0n_SMC!" |
- 最终 flag
1 | LilacCTF{e@sy_Pyth0n_SMC!} |
5.C++++
题目描述:
1 | A simple .net app |
SentinelGuard(Twofish-like / AES S-box 表还原)
目标文件:
结论
flag / SECURITY TOKEN:
1 | LilacCTF{I_ju3t_w@nnA_b3_hapPy} |
验证(PowerShell):
1 | echo 'LilacCTF{I_ju3t_w@nnA_b3_hapPy}' | & .\SentinelGuard.exe |
会输出:
1 | [+] ACCESS GRANTED. WELCOME BACK. |
- 程序主流程定位(IDA)
从输出/交互行为(banner + “ENTER SECURITY TOKEN”)逆向定位到校验主函数:
sub_14007C6C0:打印、读入 token、变换、比较、输出结果
核心调用链:
sub_14007C6C0→sub_14007C490:写入 ctx 的两段 key buffersub_14007C6C0→sub_14007C4E0(ctx, inputString):把输入变换为比较用的字符串sub_14007C4E0:- 输入按 16 字节对齐
sub_14007C890做 key schedule- 每 16 字节调用
sub_14007CA40做 block 变换
比较对象是一个写死的 hex 字符串(32 字节密文的 hex 表示):
1 | A20492152735B4F6ECBAA359DB64417BDF277A73B085666034CF38E748D8FBD4 |
它长度为 64 hex 字符,对应 32 字节(两块 16 字节 block)。
- “强制成功”补丁为什么拿不到 flag
比较后会根据结果走成功/失败分支。可以把分支 patch 掉(强制打印 success),证明定位点正确,但程序在 success 后并不会额外打印 flag,因此仍需要还原正确 token。
(本题最终 token 就是 flag 本身。)
- 算法结构(Twofish-like)与关键子函数
整体结构类似 Twofish(16 轮、每轮通过 h/g 函数产生混淆扩散),但参数和表不是标准 Twofish:
sub_14007C890:生成 40 个子密钥(20 * 2)sub_14007CA40:16 轮 block transformsub_14007CC20:核心的 h/g 函数(字节查表 + MDS 混合)sub_14007CB60:RS-like remainder,用于生成 S-key
- 关键突破:q 表不是 Twofish 标准表,而是二进制内嵌表
初始化函数:
sub_14007D260会执行:memmove(dst+0x20, &unk_1401F9470, 0x200)
也就是拷贝 512 字节查表到运行时结构中。
从字节序列可识别出:
- 前 256 字节:AES S-box
- 后 256 字节:AES inverse S-box
因此不能用 Twofish 标准 q0/q1 permutation 去复现,必须直接使用 unk_1401F9470 的真实表。
- MDS 表生成与 RS remainder
5.1 MDS 相关
sub_14007D2E0 在启动时生成 4 组 256-entry 的 32-bit 表,内部使用了 GF(2^8) 上的变换:
- 多项式
0x11B - 条件异或常量
0x8D
脚本中用 _div2 / _div4 / _build_mds_tables 复刻这一过程。
5.2 RS remainder
sub_14007CB60 中可看到典型的 RS remainder 结构,关键常量:
0x11D0x8E
脚本里 _rs_rem 与 _rs_mds_encode 对齐该实现,用于推导 S-key。
- key schedule 的“非标准 Twofish 常量”
在 sub_14007C890 里,子密钥生成使用的常量来自 sub_14000101D() 指向的数据:
step = 0x0A0A0A0Abias = 0x0D0D0D0Drot = 0x0D(13)
对应关系(与标准 Twofish 的 rho=0x01010101 不同):
A = h(step*i, Me)B = rol8(h(step*i + bias, Mo))K[2i] = A + BK[2i+1] = rol(A + 2B, rot)
这也是直接套标准 Twofish 实现会失败的原因之一。
- 复现与解密(脚本)
脚本 solve_twofish_token.py 做了两步验证:
- 用程序内置密文解密出明文(得到 token)
- 将明文再加密回去,确认得到的密文与内置密文完全一致(自洽校验)
运行:
1 | python .\solve_twofish_token.py |
应看到:
pt_utf8 LilacCTF{I_ju3t_w@nnA_b3_hapPy}reenc_matches True
- 最终 flag
1 | LilacCTF{I_ju3t_w@nnA_b3_hapPy} |
附录:solve_twofish_token.py
1 | import struct |
所以flag为:
1 | LilacCTF{I_ju3t_w@nnA_b3_hapPy} |
