阿里 CTF 2026 逆向部分 WP
逆向三道题出了两道,另一道纯恶心人来的
发现晚上出 flag 概率比白天高 =w=
pixelflow
初步分析
找到 global-metadata.dat 和 GameAssembly.dll 用 il2cppdumper 解包
但是在 Assembly-CSharp.dll 没看到什么东西
于是开始分析 GameAssembly.dll,参考 普通PC端IL2Cpp Unity游戏逆向方法体代码记录
反编译结果分析:
Controller___Check_b__18_0_d__MoveNext 是输入校验和提取字节,仅当输入文本 以长度为 7 的固定前缀开头,并 以长度为 1 的固定后缀结尾 才继续,截取中间部分:Substring(7, len-8),要求长度恰好 16
Controller___Check_b__18_1_d__MoveNext 新建 Texture2D(16,1, format=63),用 Color32 写入:R=byte[i], G=0, B=0, A=255,SetPixels32 + Apply,然后 Graphics.CopyTexture 到 TexF,说明 K0 输出的 16 字节正是 TexF 的 R 通道
Controller___Check_b__18_2_d__MoveNext 新建 ComputeBuffer(16,4),命名 Buffer0,绑定到 Shader0 的 Buffer0,绑定 TexF 到 Shader0 的 TexF,设置 K2 常量(4 个 float),调用 Dispatch(Shader0, K0, 1,1,1),说明 K0 是加密的线程组数
Controller___Check_b__18_3_d__MoveNext 清空 Buffer0,Dispatch(Shader0, K1, 1,1,1),若输入无效(bytes 为空),直接把 Buffer0[0]=1(失败标志)
Controller__Check_d__18__MoveNext 取输入并转 16 字节上传到 TexF,连续运行 3 次 runEncryptAsyn,运行 runCheckAsync,若输入无效:直接置 Buffer0[0]=1
纹理数据
用 AssetRipper 找纹理数据,在 sharedassets0.assets 里有个 ComputeShader 类的 Shader,Yaml 中找到 K0 K1 K2 的 code,先存成 .bxdc 文件然后用 HLSLDecompiler 反编译成 hlsl 文件
K0.hlsl
1 | |
K1.hlsl
1 | |
K2.hlsl
1 | |
主要逻辑
K1 的核心逻辑:
- 读取 u0[x](来自 K0 输出纹理)
- 计算 u0[x] + x(按 8-bit 截断)
- 读取 t0[x](来自 ciTex 纹理)
- 若不相等则失败
因此有:
$$t0[x] = (u0[x] + x) ; mod; 256$$
从而目标字节为:
$$target[x] = (t0[x] - x) ; mod; 256$$
K0 的 VM 指令序列(来自 coTex.png)等价于对 16 字节循环一次的变换,关键点:初始化寄存器:x1[1]=42, x1[2]=0;
对每个索引 i(0…15):
- x = u0[i]
- x = x ^ a(a 为累加器,初始 42)
- x = ROL8(x, i & 7)
- x = (x * 7) mod 256
- x = (x + i) mod 256
- out[i] = x
- a = (a + x) mod 256
正向模拟 K0 VM
1 | |
主流程里 runEncryptAsync 连续执行 3 次,所以最终供 K1 校验的 u0 是 u0 = K0(K0(K0(input)))
因此需要对 target 反向执行 K0 的逆变换三次,得到原始输入字节
- x = (out[i] - i) mod 256
- x = x * 7^{-1} mod 256,其中 $7^{-1} \equiv 183 \ mod\ 256$
- x = ROR8(x, i & 7)
- x = x ^ a
- a = (a + out[i]) mod 256
将这个逆过程对 target 重复 3 次
EXP
1 | |
flag 为 alictf{5haderVM_Rep3at!}
Thief
流量包分析
分析流量包,里面有三个数据流,从 Wireshark 导出那 3 个 TCP 流的原始数据


用 010 看会发现每条消息的开头都是 0x89 "ali",结构为 magic(4) + partIdx(4) + rsaLen(4) + rsaBlob(rsaLen) + fileCount(4) + [nameLen + nameXor + offset]* + payload,其中 nameXor 每字节异或 233 可以得到真实文件名,offset 指向 payload 内各 LZRR 块起始位置
三条流内容:
1 | |
webp 文件分析
Thief\unknown\app\src\main\res\mipmap-hdpi 这个目录下有很多 .webp 文件,并且其中有的名字还带有混淆,应该属于是比较关键的内容
发现 .webp 开头是 CA FE BA BE,实际上是 Java class,对其进行反编译:
i.l.L 负责扫描 user.dir 上级目录并批量打包,每 3 个文件一组
i.l.l1I 内嵌 base64 数据段,解码得到两份算法表(记为 algo2/algo3)
i.l.l1I 负责打包:文件内容先经 i.l.Il1 压缩为 LZRR 格式;文件名 UTF‑8 后与 233 异或;offset 为每个文件压缩块的拼接偏移
i.l.Il1 产生的头部是大端 "LZRR" + ver(0x0201) + flag + in_len + crc
- flag=13:直接 LZ 比特流;flag=15:先 RLE(0xFF 作为转义与游程标记),再进入 LZ 比特流。
- LZ 采用 1bit 标志区分字面量/匹配,offset 8/16 位可变,length 为 3+ 编码值
从 l1I 的 base64 常量解出两段二进制表,其中一段用于 Runner.encrypt 的 algo2,另一段用于 algo3,通过 TryDecrypt.java 调用 Runner.encrypt 可验证哪一个表对应哪种行为
TryDecrypt.java
1 | |
试验发现 algo3 与 seed 无关,任意 seed 输出一致,因此可视为对 payload 的固定变换;algo2 对 seed 的影响是线性 XOR,seed 64bit 每一位对应一个可叠加的差分输出
对于 part1 和 part3,用 algo3 解密对应 payload 即 Runner.encrypt(algo3, payload, seed) 直接得到 LZRR 压缩流, 依据 offsets 或按 LZRR 魔数切块逐一解压来还原 .java 与 Image1Part*.java
LZRR + RLE 解压
1 | |
恢复 part1/part3 的 .java 与图片段:
1 | |
恢复 part2 的 .java 与图片段
对于 part2,先用 seed=0 得到 dec_part2_algo2_zeros.bin,再对 seed 64 位逐位置 1,生成 64 个基向量输出 dec_part2_algo2_seedb_{si}_{bit}.bin,利用 LZRR 头 8 字节已知模式 LZRR 0201 flag 建立 GF(2) 线性方程组
1 | |
解得 seed 为 a91b1bb4e8978bda,然后合成真实 payload 并解压得到 .java 与 Image1Part*.java
1 | |
图片合成
最后从 Image1Part*.java 抽取 base64 ,生成 part1..6.jpg,再横向拼接成 combined.jpg
1 | |

OCR 识别一下
1 | |
flag 为 alictf{5a8e0fb1-d3f5-4b13-8424-164faab9bbd2}