2026年第二届湾区杯网络安全大赛 WriteUp
2026年第二届湾区杯网络安全大赛 WriteUp
Reverse
Pixel Oracle
题目信息
题目提供 pixel_oracle.apk。程序界面包含一个 5×5 棋盘、上下左右移动按钮和 Flag 输入框。
校验器不会直接比较明文 Flag,而是依次检查移动路线长度、棋盘边界、路线的 FNV-1a 哈希、棋盘分数回放、JNI native gate,并使用 native gate 派生的 xorshift32 密钥流解密字节数组。
附件外层 ZIP 的 SHA-256 为:
1 | a96931bdba1bc56fc43bac8b1d35baf4ea77319f90f6b3ed69f48a7a60bcc422 |
解压得到的 APK SHA-256 为:
1 | b80c5d7247b22ccda5bb1790a87b8e7a252256ab9e08af44758400a79718ea1b |
APK 中包含 classes.dex,以及 x86 和 x86_64 两种架构的 libpixgate.so。整个过程只进行静态解包、反编译和脚本模拟,没有在宿主机安装或执行 APK 和 native 库。
解题思路
先使用 JADX 反编译 classes.dex。MainActivity.verify() 显示路线必须恰好包含 11 个 U、D、L、R 字符,起点为 (0,4),每步必须位于 5×5 棋盘内。
路线还需要同时满足两个约束:
1 | FNV-1a(path) = 0xb97de93a |
每次移动后的分数更新规则为:
1 | score = ((score * 131) ^ (BOARD[y][x] + move * (index + 7))) & 0x7fffffff; |
初始分数是 20823。由于合法路线只有 11 步,直接深度优先枚举棋盘内路线,并在枚举过程中同步维护 FNV-1a 和 score,即可恢复正确路径。
随后分析 x86_64 的 libpixgate.so。ELF 没有加壳,保留了以下符号:
1 | Java_com_ddl4_easy_MainActivity_ng 0x1330 |
JNI 函数的等价逻辑为:
1 | uint32_t native_gate(uint32_t path_hash, uint32_t score) { |
最后使用 native gate、score 和路径哈希生成 xorshift32 初始状态。每个密文字节都先更新一次 xorshift32,再取状态低字节参与异或,因此更新顺序不能颠倒。
技术实施
FNV-1a 使用标准 32 位初值和质数:
1 | 初值:0x811c9dc5 |
恢复出的路线为:
1 | UURRDRURDDL |
逐步回放结果如下:
1 | step=01 move=U pos=(0,3) cell= 68 score=0x00299d12 |
该路线的 FNV-1a 为 0xb97de93a,最终 score 为 0x33191d74。代入 JNI 逻辑得到 native gate 0x4dbcda7a,与 Java 层的预期常量完全一致。
xorshift32 的 seed 为:
1 | seed = native_gate ^ score ^ path_hash ^ 0x9e3779b9 |
每轮更新和字节解密规则为:
1 | state ^= state << 13 |
完整解题脚本
1 | #!/usr/bin/env python3 |
验证输出
1 | route = UURRDRURDDL |
路径哈希、score、native gate 和最终 DJB2-XOR 均与 APK 内的目标常量一致。平台提交后确认 Flag 正确。
问题与解决
该题的 Flag 不以明文形式存在,单纯搜索字符串无法得到结果。真正的关键是先恢复移动路线,因为路径哈希和分数共同决定 native gate,而 native gate 又参与 xorshift32 seed 的构造。
Java 的 int 运算具有 32 位溢出语义,Python 实现时必须在 FNV-1a、旋转、xorshift32 和 DJB2 的每个关键阶段使用 & 0xffffffff 截断。score 还额外使用 & 0x7fffffff 清除符号位。如果忽略这些截断,后续 native gate 和解密结果都会错误。
知识点总结
本题组合了 Android DEX 反编译、JNI ELF 静态分析、棋盘状态回放、FNV-1a、32 位整数溢出、循环左移和 xorshift32。校验链存在明确的数据依赖:路线决定路径哈希与 score,二者决定 native gate,最终共同决定解密 seed。因此需要完整还原整条链,而不能只分析最后的密文字节数组。
参考资料
- Fowler–Noll–Vo hash function
- George Marsaglia, Xorshift RNGs
- Android JNI native method naming convention
最终 Flag
1 | flag{pix_oracle_moves_5279} |
Crypto
TinyNTRU
题目信息
题目提供 TinyNTRU-like 公钥加密方案的源码、公钥和密文,目标是恢复私钥多项式并解密 Flag。附件 ZIP 的 SHA-256 需以题目目录中的原始证据为准;解题过程未向外部站点上传附件或明文。
解题思路
方案工作在环 R = Z[x]/(x^127 - 1) 上,参数为 q = 12289、p = 3。私钥满足 f = 1 + 3F,其中 F 只有 7 个 ±1 系数;g 也只有 7 个非零系数,公钥关系为:
1 | h = f^(-1) · g mod q |
因此可以利用:
1 | f · h = g mod q |
恢复私钥。由于 f 和 g 极度稀疏,且系数集合被限制在 {−3, 0, 1, 3},采用三坐标约束的 meet-in-the-middle 枚举比通用格攻击更直接且确定性更强。枚举 f 中 4 个 +3 位置和 3 个 −3 位置,在 g 的零坐标上匹配卷积约束,命中后完整卷积验证 f · h ≡ g (mod q)。
技术实施
恢复出的私钥位置为:
1 | f[0] = 1 |
对应系数形状为:
1 | f ∈ {−3, 0, 1, 3},除 f[0] 外有 7 个非零系数 |
源码、公钥和密文位于:
1 | D:\CaptureTheFlag\Analysis\Writeup\CTF\湾区杯\Crypto\TinyNTRU\source\ |
离线恢复和解密命令:
1 | & 'C:\Program Files (x86)\Dev-Cpp\MinGW64\bin\g++.exe' -O3 -std=c++11 recover_sparse.cpp -o recover_sparse.exe |
解密脚本的核心校验如下,脚本同时验证公钥关系、系数形状、Flag 格式和重新编码往返一致性:
1 | params = NTRUParams(127, 3, 12289, 7, 7, 6, 24) |
恢复程序第一次和第二次运行均确定性命中 coords=0,1,2,退出码为 0,输出一致。解密得到 2 个密文块,共 42 字节明文。明文 SHA-256 为:
1 | 02ae75274d129533f14db58a5de9db6cf7e243e9974b9ab7bafa51e138b9ead4 |
验证输出摘要:
1 | verify_f_times_h_eq_g=true |
问题与解决
直接把公钥当作普通整数或只做字符串搜索无法恢复明文。关键在于识别出 NTRU 公钥关系以及私钥的极端稀疏约束,再利用卷积零坐标构造确定性匹配。Python 解密时必须遵守源码的多项式环模运算、三进制块编码和字节序,否则即使私钥恢复正确,也会得到错误明文。
知识点总结
本题涉及 NTRU 公钥关系、环上多项式卷积、稀疏短向量恢复、meet-in-the-middle 枚举、模逆、三进制多项式编码和可逆性验证。恢复私钥后,应同时进行代数校验、系数形状校验、明文格式校验和重新编码往返校验,不能只依赖 flag{...} 格式判断。
参考资料
- NTRU 公钥加密基本构造
- 多项式环
Z[x]/(x^N - 1)上的卷积运算 - Meet-in-the-middle 搜索
- 模运算与多项式可逆性
最终 Flag
1 | flag{27dec48f-f9c7-4bad-9a1b-f7aa77badc5f} |

