Bugku 3122 谷歌小恐龙题解:纯离线逆向 Vault 的 AES-GCM 加密资源(附 Python 解密器)

这题表面是个「小恐龙游戏」的 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 字节的加密包,开头正好是 ASCII VLT1;
  • 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 失败尝试与坑点

  1. 「玩通关」是死路 :Random 的种子由明文派生,想预测障碍物就得先知道明文;而明文就在同一个包里,直接解密比玩游戏快得多。
  2. 假 flag 陷阱 :GamePanel 里有 fakeVictoryEncoded = "YnVna3V7Q0Y5M0ExNzA5QzFFNDY0OTBCRjEwRERCNDE3QzUxN0Z9",Base64 解出来是 bugku{...} 形式,通关画面显示的就是它 ------ 提交必错,真 flag 只在 assets/cache.bin 里。
  3. jarDigest 细节错一个就全错 :必须按条目名升序;每个条目先写 name、再写一个 0x00、再写 content;要排除目录项和 cache.bin 自身;MANIFEST.MF 等其他文件都要算进去。少写 0x00 或排序不一致,key 就不对,GCM tag 校验直接失败。
  4. 压缩格式用错 :Inflater(true) 是 raw deflate,Python 必须 zlib.decompress(pt, -15);用默认 zlib.decompress(pt) 去解会报 incorrect header check。
  5. magic 的字节序 :Java ByteBuffer.getInt() 默认大端,0x564C5431 对应文件前 4 字节 56 4C 54 31(ASCII VLT1),可以直接 xxd 对照。
  6. 明文长度不规整 :明文是 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 本身,因此:

  1. jarDigest 的计算过程完全在客户端类里,反编译即可复刻;
  2. AES-GCM 只提供完整性 (tag 校验),不解决保密性------密钥就在同一台机器上;
  3. Inflater(true) 只是把明文压小一点,与安全无关,但格式细节容易踩坑;
  4. 随机种子由明文派生是典型的「假绑定」:它只让游戏依赖明文,并不能保护明文。

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. 参考

相关推荐
DevOpenClub2 小时前
职教高考控制线 API:招生类别枚举、筛选与空分数含义
java·数据库·高考
露天赏雪3 小时前
第3篇:Sentinel熔断降级实战:大模型接口抖动时的服务兜底方案
java·开发语言·人工智能·spring boot·spring·servlet·sentinel
天天被压力3 小时前
【A股分钟级高频数据完全手册 #06】北交所分钟级数据接口:京市股票Pro版最新分时
java·人工智能·python
天天被压力4 小时前
【A股分钟级高频数据完全手册 #05】指数历史分钟数据接口:Pro版沪深指数分时回放
java·人工智能·python
captain3764 小时前
▲统一功能——DispatcherServlet源码分析
java·spring·java-ee·log4j·maven
金銀銅鐵4 小时前
JDK 21 中 List.of 的实现细节:从 List12 到 ListN
java·后端·ai编程
yeccheng6 小时前
MySQL 索引超详解:从底层 B+ 树到面试高频考点
java·开发语言·jvm·mysql·面试
这个昵称不容易6 小时前
SpringBoot 读书笔记共享平台部署实战:97 个 Java 文件 / 113 个接口 / 10 张表
java·springboot·毕设
金銀銅鐵8 小时前
一个 Java 文件实现《孫子兵法·始計篇》中英对照阅读器
java·后端·ai编程