
这题表面是个「小恐龙游戏」的 Web 场景,实际是一道 Java 逆向 + 密码学复刻 题:flag 不在服务端接口里,而是被加密塞进 game.jar 的 assets/cache.bin,密钥又由 JAR 自身内容派生。只要反编译 Vault.class 并把算法用 Python 复刻出来,拿当次实例的 JAR 离线跑一遍就能得到 flag ------ 不需要装 JRE、不需要真的玩游戏,更不用通关。
1. 题目信息
| 项目 | 内容 |
|---|---|
| 题目 ID | 3122(cid) |
| 题目名称 | 谷歌小恐龙 |
| 分类 | Reverse / Web(Java JAR 逆向) |
| 分值 | 30 分 |
| 题面 | flag{...} |
| 平台 | https://ctf.bugku.com/challenges/detail/id/3122.html |
| 附件 | 场景页 /download/game.jar(90,866 字节) |
2. 环境与附件
场景页只有一个下载按钮,/api、/submit、/flag 之类的接口全部 404,页面也没有业务 JS。下载得到 game.jar 后先做静态侦察:
bash
$ unzip -l game.jar | tail -35
Archive: game.jar
Length Date Time Name
------- ---------- ----- ----
99 2026-xx-xx xx:xx META-INF/MANIFEST.MF
2629 2026-xx-xx xx:xx com/sanjiu/dino/Main.class
21424 2026-xx-xx xx:xx com/sanjiu/dino/GamePanel.class
7556 2026-xx-xx xx:xx com/sanjiu/dino/Vault.class
...(GamePanel 的 8 个内部类、SpriteInfo/Obstacle/Player 等)
24786 2026-xx-xx xx:xx game/dino/sounds/hit.wav
12304 2026-xx-xx xx:xx game/dino/sounds/press.wav
50004 2026-xx-xx xx:xx game/dino/sounds/score.wav
73 2026-xx-xx xx:xx assets/cache.bin
关键点:
MANIFEST.MF里Main-Class: com.sanjiu.dino.Main,是 Java 17 的 Swing 小游戏;- 全部素材之外,只有
assets/cache.bin是 73 字节的加密包,开头正好是 ASCIIVLT1; GamePanel里有this.random = new Random(Vault.seed())------ 障碍物随机种子由明文派生,说明「想通关必须先知道明文」,而明文本身就在同一个加密包里。
工具链:jadx(反编译)、Python 3 + pycryptodome,无需 JRE。
3. 解题步骤
3.1 反编译 Vault,还原加密格式
bash
$ jadx -d jadx_out game.jar
INFO - output directory: jadx_out
$ sed -n '28,90p' jadx_out/sources/com/sanjiu/dino/Vault.java
Vault.java 的核心逻辑(jadx 还原后):
java
static final String packName = "assets/cache.bin";
private static final int magic = 1447842865; // 0x564C5431 = "VLT1"
private static final byte[] salt = {-12, 51, 68, 9, -89, 23, ...}; // 32 字节
private static byte[] key(Path path) { // 密钥派生
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(jarDigest(path)); // ① JAR 内容摘要
md.update(salt); // ② 拼固定盐
byte[] d = md.digest();
return Arrays.copyOf(d, 16); // ③ 取前 16 字节
}
private static byte[] jarDigest(Path path) { // JAR 内容摘要
// 过滤:非目录、且不是 assets/cache.bin 自身
// 排序:按条目名升序(字典序)
// 逐个喂:name.getBytes(UTF_8) || 0x00 || content
}
static String open(Path path) {
byte[] blob = readBlob(path); // 读 cache.bin
ByteBuffer bb = ByteBuffer.wrap(blob);
if (bb.getInt() != magic) throw ...; // 大端 "VLT1"
byte[] iv = new byte[12]; bb.get(iv); // 12 字节 GCM nonce
byte[] ct = new byte[bb.remaining()]; bb.get(ct);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(2, new SecretKeySpec(key(path), "AES"), new GCMParameterSpec(128, iv));
return new String(unpack(c.doFinal(ct)), UTF_8); // Inflater(true) 解压
}
unpack() 用的是 new Inflater(true),也就是 raw deflate (等价于 Python 的 zlib.decompress(data, -15)),不是带 zlib/gzip 头的格式。
3.2 用 Python 复刻算法
核心就四步:算 jarDigest → 派生 16 字节 key → 按 VLT1 | nonce | ciphertext 解 AES-GCM → raw deflate 解压。
python
SALT = bytes([(x + 256) % 256 for x in [-12, 51, 68, 9, -89, 23, 110, -18, 42, -77, 95, 5, -41, 66,
19, -96, 11, -54, 87, 28, -12, 90, 104, -31, 37, -19, 63, 72, -6, 14, 121, -88]])
def jar_digest(jar_path):
z = zipfile.ZipFile(jar_path)
names = sorted(n for n in z.namelist() if not n.endswith('/') and n != 'assets/cache.bin')
h = hashlib.sha256()
for n in names:
h.update(n.encode('utf-8')); h.update(b'\x00'); h.update(z.read(n))
return h.digest()
def decrypt(jar_path):
z = zipfile.ZipFile(jar_path)
blob = z.read('assets/cache.bin')
assert struct.unpack('>I', blob[:4])[0] == 1447842865 # "VLT1"
key = hashlib.sha256(jar_digest(jar_path) + SALT).digest()[:16]
pt = AES.new(key, AES.MODE_GCM, nonce=blob[4:16], mac_len=16) \
.decrypt_and_verify(blob[16:-16], blob[-16:]) # GCM tag 校验
return zlib.decompress(pt, -15).decode('utf-8') # raw deflate
3.3 实测输出(当次实例)
text
entries_total = 28 (目录项 + cache.bin 排除后 27)
jarDigest = a93f6675...8b260df2
salt = f4334409...fa0e79a8 (32B)
key = 1a3916bf13c0c90c28857ca7ad672b0f
blob_len = 73 head = 564c5431 ("VLT1")
nonce = 406f8a074372d11944ffb900 (12B)
cipher_len = 57 (GCM 密文 41 + tag 16)
plain = flag{b55cffb834a4d6ec087d7051aceaba49}}
plain_len = 39 (多出一个右花括号)
明文末尾是 两个右花括号,剥掉多余的那个,提交标准形式即可:
text
flag{b55cffb834a4d6ec087d7051aceaba49}
不同实例的
cache.bin明文可能不同:早期样本解出来是 30 字符的游戏种子串(看起来像干扰),当次实例直接就是flag{...}明文。算法完全一样,以当次下载的 JAR 为准重跑即可。

3.4 失败尝试与坑点
- 「玩通关」是死路 :
Random的种子由明文派生,想预测障碍物就得先知道明文;而明文就在同一个包里,直接解密比玩游戏快得多。 - 假 flag 陷阱 :
GamePanel里有fakeVictoryEncoded = "YnVna3V7Q0Y5M0ExNzA5QzFFNDY0OTBCRjEwRERCNDE3QzUxN0Z9",Base64 解出来是bugku{...}形式,通关画面显示的就是它 ------ 提交必错,真 flag 只在assets/cache.bin里。 - jarDigest 细节错一个就全错 :必须按条目名升序;每个条目先写
name、再写一个0x00、再写content;要排除目录项和cache.bin自身;MANIFEST.MF等其他文件都要算进去。少写0x00或排序不一致,key 就不对,GCM tag 校验直接失败。 - 压缩格式用错 :
Inflater(true)是 raw deflate,Python 必须zlib.decompress(pt, -15);用默认zlib.decompress(pt)去解会报incorrect header check。 - magic 的字节序 :Java
ByteBuffer.getInt()默认大端,0x564C5431对应文件前 4 字节56 4C 54 31(ASCIIVLT1),可以直接xxd对照。 - 明文长度不规整 :明文是 39 字节而不是 38,多出来的
}要手动剥掉再提交。
4. 可复现脚本
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""谷歌小恐龙 ------ 离线解密 assets/cache.bin(Vault 格式,无需运行游戏)
用法: python unvault.py <game.jar>
"""
import hashlib, struct, sys, zipfile, zlib
from Crypto.Cipher import AES
SALT = bytes([(x + 256) % 256 for x in [-12, 51, 68, 9, -89, 23, 110, -18, 42, -77, 95, 5, -41, 66,
19, -96, 11, -54, 87, 28, -12, 90, 104, -31, 37, -19, 63, 72, -6, 14, 121, -88]])
MAGIC = 1447842865
PACK = 'assets/cache.bin'
def jar_digest(jar_path):
z = zipfile.ZipFile(jar_path)
names = sorted(n for n in z.namelist() if not n.endswith('/') and n != PACK)
h = hashlib.sha256()
for n in names:
h.update(n.encode('utf-8'))
h.update(b'\x00')
h.update(z.read(n))
return h.digest()
def decrypt(jar_path):
z = zipfile.ZipFile(jar_path)
blob = z.read(PACK)
if len(blob) < 16 or struct.unpack('>I', blob[:4])[0] != MAGIC:
raise ValueError('bad magic / blob too short')
key = hashlib.sha256(jar_digest(jar_path) + SALT).digest()[:16]
nonce, body = blob[4:16], blob[16:]
pt = AES.new(key, AES.MODE_GCM, nonce=nonce, mac_len=16).decrypt_and_verify(body[:-16], body[-16:])
return zlib.decompress(pt, -15).decode('utf-8', 'replace')
if __name__ == '__main__':
jar = sys.argv[1] if len(sys.argv) > 1 else 'game.jar'
txt = decrypt(jar)
print('plaintext:', repr(txt))
print('candidate:', txt.rstrip('}') + '}')
运行:
bash
python unvault.py game_now.jar
# plaintext: 'flag{b55cffb834a4d6ec087d7051aceaba49}}'
5. 原理总结
这题的核心是「客户端自包含加密」:JAR 既是密文容器,又是密钥材料来源。设计者以为「密钥由 JAR 内容派生、每个实例不同」就安全了,但攻击者拿到的就是 JAR 本身,因此:
jarDigest的计算过程完全在客户端类里,反编译即可复刻;- AES-GCM 只提供完整性 (tag 校验),不解决保密性------密钥就在同一台机器上;
Inflater(true)只是把明文压小一点,与安全无关,但格式细节容易踩坑;- 随机种子由明文派生是典型的「假绑定」:它只让游戏依赖明文,并不能保护明文。
6. 防御 / 加固视角
- 不要在前端/客户端存放 flag 或密钥材料:JAR、APK、前端 JS 都是攻击者可完整读取的;动态 flag 应由服务端在会话内下发,并做权限与频率控制。
- 服务端校验成绩:游戏类题目如果真要让用户「玩」,应由服务端校验输入/成绩,而不是让客户端算出结果。
- 混淆只能提高成本:ProGuard/R8、字符串加密、把关键逻辑放 native 都能拖慢分析,但拿到 JAR 的人最终总能复刻算法,不能当作安全边界。
- 完整性保护:对 JAR 做签名校验、启动时校验自身摘要,可提高篡改/二次打包成本;但本题的「摘要派生密钥」只是把校验变成了密钥派生,攻击者照样能算。
- 密钥管理:如果确实需要客户端加密,应使用服务端下发的一次性会话密钥(并配合 TLS 证书绑定/attestation),而不是从公开文件内容派生固定密钥。
7. 图形附件(Archify)
| 图 | 文件 | 说明 |
|---|---|---|
| Vault 加密资源与离线解密链路 | 3122-arch.html(交互图,CSDN 已省略 HTML 版) |
JAR → jarDigest → key → AES-GCM → inflate → flag 的组件关系 |
| 离线解密时序 | 3122-seq.html(交互图,CSDN 已省略 HTML 版) |
场景页、JAR、jadx、Python 解密器、AES-GCM 的交互顺序 |
静态校验:两张图 archify check 均 ok=true;archify finalize --quality showcase 的 sequence 图 validate/deliver/check/browser-check 全部 pass;architecture 图 validate/deliver/check 通过,browser-check 在本机 Windows 上卡在 Chromium 临时 profile 清理(EBUSY),已在本地如实记录。
8. 参考
- 题目页:https://ctf.bugku.com/challenges/detail/id/3122.html
- jadx:https://github.com/skylot/jadx
- Java
Inflater(true)语义:JDKjava.util.zip.Inflater文档(nowrap 模式对应 raw deflate) - 附件与脚本:
cases/3122/game_now.jar、cases/3122/vault_solve.py、cases/3122/jadx_out/