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
2
3
4
5
import binascii, zlib
hexdata = "789c0540b10980400c5ce92442067849153bc1f278ae1031ad95b87bc8bba6c611df6925ec46076bc955f2e0056ccc773c7f03fb580c81"
raw = binascii.unhexlify(hexdata)
out = zlib.decompress(raw)
print(out.decode('utf-8'))

还有一种就是传统的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
2
3
John likes to choose window seats on airplanes. He took this photo on a plane on April 10, 2025. What was his flight number?
The final flag is LilacCTF{flight number}.
Note: The flight number should be the actual operating flight number, in all capital letters.

翻译一下:

1
2
3
约翰喜欢选飞机上的靠窗座位。他于 2025 年 4 月 10 日在飞机上拍下了这张照片。他乘坐的航班号是多少?
最终的 flag 格式为 LilacCTF {航班号}。
注:航班号需填写实际执飞的航班号,且全部使用大写字母。

题目图片贴一下:

放大的图更可以看出特征:一道经典的osint,依旧飞机题,但是明面上给的文字信息很少。

我来整理一下信息:

1.2025 年 4 月 10 日这是拍摄时间,这个时间还能补充,在你右键图片->属性,在详细信息里发现是11:20拍的,这是第一个信息。(当然exif信息是可以用exiftools扫出来,也可以用的线上,这里推荐两个:https://www.gaitubao.com/exif,

https://exif.tuchong.com/)

2.看这张图,飞机的侧翼是助我找到这个航班的关键(于我而言),根据这个侧翼的颜色分布及特征:白-蓝-白-深蓝-白-深蓝,加上搜索引擎搜索,(这里是豆包搜出来的),最后锁定是青岛航空,给你们看几张图

就说是不是一模一样吧,所以接下来就是根据航空公司查航班了,这里我用的网址是:https://www.flightera.net/,在**AIRLINE**里填**Qingdao Airlines**(你填进去会有提示的)或者1480,至于确定这架飞机的机型这个很玄学,直接用AI来表示吧:

1
2
3
判断 “飞越黄海、飞向山东半岛” 是靠这 2 个视觉 + 地理逻辑:
地貌特征:照片里能看到大片开阔海域,旁边是 “半岛状陆地”—— 而山东半岛是中国唯一伸入黄海的大型半岛,这个地貌是黄海 + 山东半岛的典型组合;
飞行方向:飞机翼尖在画面上方,陆地(山东半岛)处于画面右侧,说明航班是向西飞行(翼尖朝向是飞机前进方向的侧方),而山东半岛位于黄海西侧,因此是 “飞向山东半岛”。

难绷真的,然后根据这个起始地点和终点的大概位置,判断出机型:

1
2
3
最开始确定 B-8430,是基于青岛航空 2024 年及之前的旧执飞数据:
当时青岛航空的国际航线(比如之前推测的 QW9902),固定使用 “耐冬花” 涂装的 A320 执飞,而该系列中专门匹配日韩航线的注册号就是 B-8430(这是当时公开的航班 - 注册号绑定信息);
B-8430 的机型是 A320,和照片里的翼尖涂装(青岛航空 A320)完全对应,所以当时结合 “国际航线 + 涂装 + 旧数据” 锁定了它。

算了聊天记录全贴吧:

1
2
3
4
5
6
7
8
9
A:这个侧翼像哪个飞机
B:这是 ** 青岛航空的空客 A320(或 A320neo)** 客机的翼尖小翼。
它的标志性特征是黑白相间的条纹鲨鳍小翼,这正是青岛航空的涂装风格。结合照片中跨越海域和城市地貌的航线特征,也符合青岛航空的典型飞行场景。
要不要我帮你整理一份常见航空公司翼尖小翼识别清单,方便你以后快速辨认?
A:飞机是对的,继续锁定
B:航空公司确认:翼尖的黑白条纹鲨鳍小翼,明确是青岛航空(IATA 代码 QW)的空客 A320 系列,这一点你已经确认是对的。
时间与位置匹配:照片拍摄于 2025 年 4 月 10 日 11:20(北京时间),航班正飞越黄海,远处是山东半岛。
(这之后我把那个查询网站的搜索页面图片给她了)
A:飞机名字叫什么
1
2
B:这架青岛航空的飞机,它的名字是 **“耐冬花”**(或 “琴岛・耐冬花”)。
青岛航空的所有空客 A320 系列飞机,都以青岛市花 “耐冬花” 作为统一的命名主题。从你提供的注册号列表来看,照片里的飞机很可能是注册号为B-8430的这架,它执行的正是青岛往返首尔的 QW9901/QW9902 航班,与照片的时间、航线完全吻合。

这里我确定了型号后我就去根据时间去查了(暂且相信她):

型号网址:https://www.flightera.net/zh/planes/B-8430

下面有历史航班点进具体年月,因为拍摄时间是11:20,所以我选择了从9点开始查:

可以看到符合大致时间的只有QW6097,这就是我们要找的航班。

flag为:

1
LilacCTF{QW6097}

4.ezPython

题目描述:

1
2
3
Python is not as difficult as you think

flag format: LilacCTF{...}

PyInstaller 一键解包 + “自修改”XXTEA 还原

结论

flag:

1
LilacCTF{e@sy_Pyth0n_SMC!}

验证(PowerShell):

1
2
$flag = 'LilacCTF{e@sy_Pyth0n_SMC!}'
$flag | & "C:\Users\Lenovo\Desktop\逆向工作目录\main.exe"

输出会出现 Right, congratulations!


  1. 定位: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 字节码模块。


  1. 解析 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

  1. 解 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(type s):主脚本(marshal code object)
  • PYZ-00.pyz(type z):Python 模块包(里面有 cryptomyalgo 等)
  • struct(type m):本题把 _struct 封装成一个 marshal 形式,方便脚本 import struct

我这里把所有条目都提取到了:

其中:


  1. 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 <3
    • UmlnaHQsIGNvbmdyYXR1bGF0aW9ucyE=Right, congratulations!
    • V3JvbmcgRmxhZyE=Wrong Flag!
    • :i(G#8T&KiF<F_)F\JToCggs;`(ascii85)用于“触发”后面的自修改逻辑(见下一节)

它的校验结构可以概括为:

  1. 要求输入形如 LilacCTF{...},并且 {} 中间长度固定为 16 字节(否则 struct.unpack('<IIII', buf) 会报错)
  2. 把 16 字节内容按 little-endian 拆成 4 个 u32
  3. myalgo.btea(v, 4, key)(看起来像 XXTEA/BTEA 加密)
  4. 加密结果必须等于脚本里写死的那个 4-u32 tuple

  1. 关键坑: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

  1. 还原 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
2
plain16 = b"e@sy_Pyth0n_SMC!"
flag = "LilacCTF{" + plain16.decode("ascii") + "}"

  1. 最终 flag
1
LilacCTF{e@sy_Pyth0n_SMC!}

5.C++++

题目描述:

1
2
A simple .net app
flag format: LilacCTF{...}

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.

  1. 程序主流程定位(IDA)

从输出/交互行为(banner + “ENTER SECURITY TOKEN”)逆向定位到校验主函数:

  • sub_14007C6C0:打印、读入 token、变换、比较、输出结果

核心调用链:

  • sub_14007C6C0sub_14007C490:写入 ctx 的两段 key buffer
  • sub_14007C6C0sub_14007C4E0(ctx, inputString):把输入变换为比较用的字符串
  • sub_14007C4E0
    • 输入按 16 字节对齐
    • sub_14007C890 做 key schedule
    • 每 16 字节调用 sub_14007CA40 做 block 变换

比较对象是一个写死的 hex 字符串(32 字节密文的 hex 表示):

1
A20492152735B4F6ECBAA359DB64417BDF277A73B085666034CF38E748D8FBD4

它长度为 64 hex 字符,对应 32 字节(两块 16 字节 block)。


  1. “强制成功”补丁为什么拿不到 flag

比较后会根据结果走成功/失败分支。可以把分支 patch 掉(强制打印 success),证明定位点正确,但程序在 success 后并不会额外打印 flag,因此仍需要还原正确 token。

(本题最终 token 就是 flag 本身。)


  1. 算法结构(Twofish-like)与关键子函数

整体结构类似 Twofish(16 轮、每轮通过 h/g 函数产生混淆扩散),但参数和表不是标准 Twofish:

  • sub_14007C890:生成 40 个子密钥(20 * 2)
  • sub_14007CA40:16 轮 block transform
  • sub_14007CC20:核心的 h/g 函数(字节查表 + MDS 混合)
  • sub_14007CB60:RS-like remainder,用于生成 S-key

  1. 关键突破: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 的真实表。


  1. 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 结构,关键常量:

  • 0x11D
  • 0x8E

脚本里 _rs_rem_rs_mds_encode 对齐该实现,用于推导 S-key。


  1. key schedule 的“非标准 Twofish 常量”

sub_14007C890 里,子密钥生成使用的常量来自 sub_14000101D() 指向的数据:

  • step = 0x0A0A0A0A
  • bias = 0x0D0D0D0D
  • rot = 0x0D(13)

对应关系(与标准 Twofish 的 rho=0x01010101 不同):

  • A = h(step*i, Me)
  • B = rol8(h(step*i + bias, Mo))
  • K[2i] = A + B
  • K[2i+1] = rol(A + 2B, rot)

这也是直接套标准 Twofish 实现会失败的原因之一。


  1. 复现与解密(脚本)

脚本 solve_twofish_token.py 做了两步验证:

  1. 用程序内置密文解密出明文(得到 token)
  2. 将明文再加密回去,确认得到的密文与内置密文完全一致(自洽校验)

运行:

1
python .\solve_twofish_token.py

应看到:

  • pt_utf8 LilacCTF{I_ju3t_w@nnA_b3_hapPy}
  • reenc_matches True

  1. 最终 flag
1
LilacCTF{I_ju3t_w@nnA_b3_hapPy}

附录:solve_twofish_token.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
import struct


def _rol32(x: int, n: int) -> int:
n &= 31
return ((x << n) & 0xFFFFFFFF) | ((x & 0xFFFFFFFF) >> (32 - n))


def _ror32(x: int, n: int) -> int:
n &= 31
return ((x & 0xFFFFFFFF) >> n) | ((x << (32 - n)) & 0xFFFFFFFF)


def _ror4(x: int, n: int) -> int:
n &= 3
x &= 0xF
return ((x >> n) | ((x << (4 - n)) & 0xF)) & 0xF


_Q_BYTES = bytes.fromhex(
"63 7c 77 7b f2 6b 6f c5 30 01 67 2b fe d7 ab 76"
" ca 82 c9 7d fa 59 47 f0 ad d4 a2 af 9c a4 72 c0"
" b7 fd 93 26 36 3f f7 cc 34 a5 e5 f1 71 d8 31 15"
" 04 c7 23 c3 18 96 05 9a 07 12 80 e2 eb 27 b2 75"
" 09 83 2c 1a 1b 6e 5a a0 52 3b d6 b3 29 e3 2f 84"
" 53 d1 00 ed 20 fc b1 5b 6a cb be 39 4a 4c 58 cf"
" d0 ef aa fb 43 4d 33 85 45 f9 02 7f 50 3c 9f a8"
" 51 a3 40 8f 92 9d 38 f5 bc b6 da 21 10 ff f3 d2"
" cd 0c 13 ec 5f 97 44 17 c4 a7 7e 3d 64 5d 19 73"
" 60 81 4f dc 22 2a 90 88 46 ee b8 14 de 5e 0b db"
" e0 32 3a 0a 49 06 24 5c c2 d3 ac 62 91 95 e4 79"
" e7 c8 37 6d 8d d5 4e a9 6c 56 f4 ea 65 7a ae 08"
" ba 78 25 2e 1c a6 b4 c6 e8 dd 74 1f 4b bd 8b 8a"
" 70 3e b5 66 48 03 f6 0e 61 35 57 b9 86 c1 1d 9e"
" e1 f8 98 11 69 d9 8e 94 9b 1e 87 e9 ce 55 28 df"
" 8c a1 89 0d bf e6 42 68 41 99 2d 0f b0 54 bb 16"
" 52 09 6a d5 30 36 a5 38 bf 40 a3 9e 81 f3 d7 fb"
" 7c e3 39 82 9b 2f ff 87 34 8e 43 44 c4 de e9 cb"
" 54 7b 94 32 a6 c2 23 3d ee 4c 95 0b 42 fa c3 4e"
" 08 2e a1 66 28 d9 24 b2 76 5b a2 49 6d 8b d1 25"
" 72 f8 f6 64 86 68 98 16 d4 a4 5c cc 5d 65 b6 92"
" 6c 70 48 50 fd ed b9 da 5e 15 46 57 a7 8d 9d 84"
" 90 d8 ab 00 8c bc d3 0a f7 e4 58 05 b8 b3 45 06"
" d0 2c 1e 8f ca 3f 0f 02 c1 af bd 03 01 13 8a 6b"
" 3a 91 11 41 4f 67 dc ea 97 f2 cf ce f0 b4 e6 73"
" 96 ac 74 22 e7 ad 35 85 e2 f9 37 e8 1c 75 df 6e"
" 47 f1 1a 71 1d 29 c5 89 6f b7 62 0e aa 18 be 1b"
" fc 56 3e 4b c6 d2 79 20 9a db c0 fe 78 cd 5a f4"
" 1f dd a8 33 88 07 c7 31 b1 12 10 59 27 80 ec 5f"
" 60 51 7f a9 19 b5 4a 0d 2d e5 7a 9f 93 c9 9c ef"
" a0 e0 3b 4d ae 2a f5 b0 c8 eb bb 3c 83 53 99 61"
" 17 2b 04 7e ba 77 d6 26 e1 69 14 63 55 21 0c 7d"
)
Q0 = list(_Q_BYTES[:256])
Q1 = list(_Q_BYTES[256:])


def _div2(v: int) -> int:
v &= 0xFF
return ((v >> 1) ^ (0x8D if (v & 1) else 0)) & 0xFF


def _div4(v: int) -> int:
v &= 0xFF
return ((v >> 2) ^ (0x8D if (v & 2) else 0) ^ (0x46 if (v & 1) else 0)) & 0xFF


def _build_mds_tables(q_base):
m0 = [0] * 256
m1 = [0] * 256
m2 = [0] * 256
m3 = [0] * 256
for x in range(256):
v9 = q_base[x] & 0xFF
v10 = (v9 ^ _div4(v9)) & 0xFF
v12 = (_div4(v9) ^ v9 ^ _div2(v9)) & 0xFF
m0[x] = v10 | (v9 << 8) | (v12 << 16) | (v9 << 24)
m1[x] = v9 | (v12 << 8) | (v10 << 16) | (v12 << 24)
m2[x] = v12 | (v10 << 8) | (v9 << 16) | (v10 << 24)
m3[x] = m0[x]
return m0, m1, m2, m3


MDS0, MDS1, MDS2, MDS3 = _build_mds_tables(Q0)


def _rs_rem(x: int) -> int:
b3 = (x >> 24) & 0xFF
g2 = (b3 << 1) & 0x1FF
if b3 & 0x80:
g2 ^= 0x11D
g2 &= 0xFF
g3 = ((b3 >> 1) & 0x7F) ^ g2
if b3 & 1:
g3 ^= 0x8E
g3 &= 0xFF
b0 = (x >> 0) & 0xFF
b1 = (x >> 8) & 0xFF
b2 = (x >> 16) & 0xFF
return ((b0 ^ g3) | ((b1 ^ g2) << 8) | ((b2 ^ g3) << 16) | (b3 << 24)) & 0xFFFFFFFF


def _rs_mds_encode(even_word: int, odd_word: int) -> int:
r = 0
for w in (odd_word & 0xFFFFFFFF, even_word & 0xFFFFFFFF):
r ^= w
for _ in range(4):
r = _rs_rem(r)
return r & 0xFFFFFFFF


def _h_word(x: int, l0: int, l1: int) -> int:
b0 = x & 0xFF
b1 = (x >> 8) & 0xFF
b2 = (x >> 16) & 0xFF
b3 = (x >> 24) & 0xFF

y0 = (Q1[Q0[b0] ^ (l1 & 0xFF)] ^ (l0 & 0xFF)) & 0xFF
y1 = (Q1[Q1[b1] ^ ((l1 >> 8) & 0xFF)] ^ ((l0 >> 8) & 0xFF)) & 0xFF
y2 = (Q0[Q0[b2] ^ ((l1 >> 16) & 0xFF)] ^ ((l0 >> 16) & 0xFF)) & 0xFF
y3 = (Q0[Q1[b3] ^ ((l1 >> 24) & 0xFF)] ^ ((l0 >> 24) & 0xFF)) & 0xFF

return (MDS0[y0] ^ MDS1[y1] ^ MDS2[y2] ^ MDS3[y3]) & 0xFFFFFFFF


def _key_schedule_128(key: bytes) -> tuple[tuple[int, int], list[int]]:
if len(key) != 16:
raise ValueError("key must be 16 bytes")

kw = [int.from_bytes(key[i : i + 4], "little") for i in range(0, 16, 4)]
me = [kw[0], kw[2]]
mo = [kw[1], kw[3]]

s1 = _rs_mds_encode(me[0], mo[0])
s0 = _rs_mds_encode(me[1], mo[1])
s_key = (s0, s1)

step = 0x0A0A0A0A
bias = 0x0D0D0D0D
rot = 0x0D

k = [0] * 40
for i in range(20):
a = _h_word((step * i) & 0xFFFFFFFF, me[0], me[1])
b = _rol32(_h_word((step * i + bias) & 0xFFFFFFFF, mo[0], mo[1]), 8)
k[2 * i] = (a + b) & 0xFFFFFFFF
k[2 * i + 1] = _rol32((a + 2 * b) & 0xFFFFFFFF, rot)
return s_key, k


def _encrypt_block(block: bytes, s_key: tuple[int, int], k: list[int]) -> bytes:
s0, s1, s2, s3 = struct.unpack("<4I", block)
s0 ^= k[0]
s1 ^= k[1]
s2 ^= k[2]
s3 ^= k[3]

for r in range(16):
t0 = _h_word(s0, s_key[0], s_key[1])
t1 = _h_word(_rol32(s1, 8), s_key[0], s_key[1])

s3 = _rol32(s3, 5)
s2 = _rol32((((k[8 + 2 * r] + t0 + t1) & 0xFFFFFFFF) ^ s2) & 0xFFFFFFFF, 27)
s3 ^= (k[8 + 2 * r + 1] + t0 + 2 * t1) & 0xFFFFFFFF

if r != 15:
s0, s2 = s2, s0
s1, s3 = s3, s1

s0 ^= k[4]
s1 ^= k[5]
s2 ^= k[6]
s3 ^= k[7]
return struct.pack("<4I", s0, s1, s2, s3)


def _decrypt_block(block: bytes, s_key: tuple[int, int], k: list[int]) -> bytes:
s0, s1, s2, s3 = struct.unpack("<4I", block)
s0 ^= k[4]
s1 ^= k[5]
s2 ^= k[6]
s3 ^= k[7]

for r in range(15, -1, -1):
if r != 15:
s0, s2 = s2, s0
s1, s3 = s3, s1

t0 = _h_word(s0, s_key[0], s_key[1])
t1 = _h_word(_rol32(s1, 8), s_key[0], s_key[1])

s2 = _ror32(s2, 27)
s2 ^= (k[8 + 2 * r] + t0 + t1) & 0xFFFFFFFF
s3 ^= (k[8 + 2 * r + 1] + t0 + 2 * t1) & 0xFFFFFFFF
s3 = _ror32(s3, 5)

s0 ^= k[0]
s1 ^= k[1]
s2 ^= k[2]
s3 ^= k[3]
return struct.pack("<4I", s0, s1, s2, s3)


def main():
key = bytes([b ^ 0x41 for b in [0x16, 0x0E, 0x0F, 0x05, 0x04, 0x13, 0x07, 0x14, 0x0D, 0x67, 0x67, 0x11, 0x04, 0x00, 0x02, 0x04]])
ct = bytes.fromhex("A20492152735B4F6ECBAA359DB64417BDF277A73B085666034CF38E748D8FBD4")
s_key, subkeys = _key_schedule_128(key)

pt = _decrypt_block(ct[:16], s_key, subkeys) + _decrypt_block(ct[16:], s_key, subkeys)
pt_stripped = pt.rstrip(b"\x00")
print("pt_hex", pt.hex())
print("pt_hex_stripped", pt_stripped.hex())
try:
print("pt_utf8", pt_stripped.decode("utf-8"))
except UnicodeDecodeError:
pass
try:
print("pt_utf16le", pt_stripped.decode("utf-16le"))
except UnicodeDecodeError:
pass

padded = pt_stripped + b"\x00" * ((16 - (len(pt_stripped) % 16)) % 16)
if len(padded) == 0:
padded = b"\x00" * 16
check = _encrypt_block(padded[:16], s_key, subkeys) + _encrypt_block(padded[16:32], s_key, subkeys)
print("reenc_matches", check == ct)


if __name__ == "__main__":
main()

所以flag为:

1
LilacCTF{I_ju3t_w@nnA_b3_hapPy}