猿人学 App 第七题「flutter」离机复现:Blutter 反编译 Dart AOT,还原 HMAC 双盐签名

本文最后更新于:2026年9月2日 中午

[猿人学 App 逆向系列]第七题「衣带渐宽终不悔【flutter】」。取数逻辑在 Flutter 的 Dart AOT 快照(libapp.so) 里,用 Flutter 自带 BoringSSL 走 HTTPS。
本系列坚持离机约束:不驱动 App、不靠抓包当答案。抓包只是定位算法、拿对拍样本的手段;真正的目标是把签名算法从 Dart AOT 里读出来,用纯 Python 自算签名、自发请求。
本文完整走一遍:抓包定位 → 断定不是裸 SHA256 → 上 Blutter 反编译 Dart AOT → 读出 HMAC-SHA256 + 两个内嵌盐 → 纯 Python 离机复现

⚠️ 猿人学闯关 App 是面向逆向练习的 CTF 式靶场;本文仅用于授权范围内的学习研究,请遵守相关服务条款与法律。

本文目录

  • 请求模型:抓包只为定位
  • 为什么 Flutter 抓包难(以及怎么看到明文)
  • 卡住的地方:盐找不全,裸 SHA256 全错
  • 上 Blutter:把 Dart AOT 快照反编译出来
  • 读汇编:一行行还原签名构造
  • 纯 Python 离机复现 + 对拍
  • 踩坑记:Blutter 编译那些事

一、请求模型:抓包只为定位

先说结论式的请求模型(后面会证明每一部分):

1
2
3
4
POST https://www.python-spider.com/api/app7
User-Agent: Dart/2.19 (dart:io)
Content-Type: application/json; charset=utf-8
{"sign":"c651ff1d…9377c95f", "content":"1:1788313273229"}
  • content = "页码:毫秒时间戳"
  • sign 是 64 位小写 hex(一眼 SHA256/HMAC-SHA256 家族);
  • 没有 cookie、没有 token——这题的鉴权全压在 sign 上。

抓包在这里的定位很明确:只用来确认”请求长什么样”、以及拿到一批 (content, sign)作为后面离线还原算法的对拍样本。抓包不是答案。

二、为什么 Flutter 抓包难(以及怎么看到明文)

Flutter 的联网走静态链接进 libflutter.so 的 BoringSSL:不吃系统代理、自带根证书、符号全剥离。常规”设代理 + 装 CA”抓不到。

拿明文的办法(仅用于取样)是 reFlutter:它按 SnapshotHash 匹配引擎版本,换上预编译好的、关掉 SSL pinning 的同版本引擎。这里有个实战坑——直接装 reFlutter 重打包的 APK 会崩:

1
java.lang.IllegalArgumentException: Missing android.support.FILE_PROVIDER_PATHS meta-data

这是 apktool 重打包丢了 meta-data,不是 app 自检。破法是只 root 替换 libflutter.so 进原版 app(签名、manifest、其它 so 全不动),再配 adb reverse + mitmdump --mode reverse 把明文导出来。

这套组合拳能看到明文,于是我们拿到了几十条 (content, sign) 样本。但这只是起点——离机复现要求能自己算出 sign

三、卡住的地方:盐找不全,裸 SHA256 全错

有了样本,第一反应是穷举常见构造。libapp.so 里静态搜字符串,能扒到一个很像盐的 16 字符串:

1
nmZO8*USEkF9fIoG

然后拿它往死里试:

1
2
3
4
5
sha256(content + salt)      # ✗
sha256(salt + content) # ✗
sha256(salt + content + salt) # ✗
hmac_sha256(salt, content) # ✗
md5 混合 / 双重哈希 / UTF-16# 全 ✗

没有一个对得上样本,服务器也一律 403。 内存扫描也扑空——哈希输入是个瞬时 Uint8List,用完即焚,扫不到。

问题很清楚:光靠”猜构造 + 静态字符串”不够nmZO8*USEkF9fIoG 只是拼图的一块,构造本身(是不是 HMAC?key 是谁?)必须从 Dart 代码里读出来。而 Dart AOT 是编译成机器码的快照,得上专门的反编译工具。

四、上 Blutter:把 Dart AOT 快照反编译出来

Blutter 是针对 Dart AOT 快照的反编译器。它的原理决定了它必须用与目标完全一致的 Dart 版本:它会拉对应版本的 Dart SDK 源码、编译出同版本的 libdart,再用它去解析 libapp.so 的对象池与函数。

目标版本从 UA 和快照信息都能确认是 Dart 2.19.1。跑起来:

1
python3 blutter.py <包含 libapp.so/libflutter.so 的目录> out

产物结构:

1
2
3
4
5
6
7
out/
├── asm/ # 按 package 组织的 Dart 反编译汇编(带注释)
│ ├── crypto/src/{sha256,hmac,hash,...}.dart
│ └── flutter_module/page/challenge_seven_page.dart ← 第七题就在这
├── objs.txt # 对象池
├── pp.txt
└── blutter_frida.js # 自动生成的 frida 脚本

一眼就看到 crypto/src/hmac.dart 被编进来了——大概率是 HMAC,不是裸 SHA256。这解释了第三节为什么全错。

五、读汇编:一行行还原签名构造

打开 challenge_seven_page.dart,先扫字符串常量和 crypto 调用:

1
2
3
4
5
6
7
"9R^tF2xFmt0A0vm."       ← 之前完全没见过的第二个盐!
"nmZO8*USEkF9fIoG" ← 老熟人
":" ← content 的分隔符
"sign" / "content" ← 请求 body 的 key
"https://www.python-spider.com/api/app7"
...
Hmac::Hmac / Hmac::convert / Digest::toString ← 确认是 HMAC

关键是两个 16 字符盐都出现了。跟着数据流读汇编(节选、去掉栈检查噪声):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
; ① 用 Utf8Codec.encode("9R^tF2xFmt0A0vm.") —— 这是 HMAC 的 key
r30 = "9R^tF2xFmt0A0vm."
r0 = encode() ; key_bytes -> 存 fp[-0xa8]

; ② 拼 content = page + ":" + millis
r0 = <page>.toString()
r16 = ":" ; _StringBase::+ ; "page:"
...
DateTime._getCurrentMicros / 1000 -> millis
r0 = <millis>.toString() ; _StringBase::+ ; "page:millis" -> 存 fp[-0xb8] ← 这就是 content

; ③ message = content + "nmZO8*USEkF9fIoG"
r16 = "nmZO8*USEkF9fIoG" ; _StringBase::+
r0 = encode() ; msg_bytes -> 存 fp[-0xc8]

; ④ HMAC(key).convert(message).toString()
r0 = Hmac() ; AllocateHmacStub
Hmac::Hmac(key_bytes) ; key = fp[-0xa8] = utf8("9R^tF2xFmt0A0vm.")
Hmac::convert(msg_bytes) ; msg = fp[-0xc8] = utf8(content + "nmZO8*USEkF9fIoG")
Digest::toString() ; -> 64 位 hex = sign

; ⑤ 组 body: {"sign": <sign>, "content": <content=fp[-0xb8]>}

_StringBase::+ 的收发者/参数顺序,可以用抓到的样本反推:样本里 content = "1:1788313273229" 正好是 page + ":" + millis,与汇编里”先 page 再 : 再 millis”的拼接顺序一致,顺序就锁死了。

还原出的算法:

1
2
3
4
content = f"{page}:{ms}"
key = "9R^tF2xFmt0A0vm." # 盐一 = HMAC 密钥
msg = content + "nmZO8*USEkF9fIoG" # 盐二 = 拼在 message 尾部
sign = HMAC_SHA256(key, msg).hexdigest()

之前全错的两个原因一次性解释清楚:(1) 是 HMAC 不是裸 SHA256;(2) 缺了 key 盐 9R^tF2xFmt0A0vm.,而且 nmZO8… 是拼在 message 尾部、不是当 key。

六、纯 Python 离机复现 + 对拍

先拿抓到的样本对拍(离线、不发请求):

1
2
3
4
5
6
7
8
9
10
11
import hmac, hashlib
KEY = b"9R^tF2xFmt0A0vm."
SALT = "nmZO8*USEkF9fIoG"
samples = [
("1:1788313273229", "c651ff1d7f9e7036a101022198a5f103140d85e0268df30199d6f1c19377c95f"),
("2:1788313335093", "d33fd6c1404a72f419422bf8cd2e5e2e9df7b3500fe17dce1ca26e79cbada590"),
("3:1788313337654", "c916d1079dd2eab83fc05e9248a5eaaabee6f5bae17ad3a698fbd03d288fabae"),
]
for c, exp in samples:
got = hmac.new(KEY, (c + SALT).encode(), hashlib.sha256).hexdigest()
print(got == exp) # True / True / True

三条全 True。 算法确定,接下来自己算签名、自己发请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import hmac, hashlib, time, requests

URL, KEY, SALT = "https://www.python-spider.com/api/app7", b"9R^tF2xFmt0A0vm.", "nmZO8*USEkF9fIoG"

def fetch(sess, page):
ms = int(time.time() * 1000)
content = f"{page}:{ms}"
sign = hmac.new(KEY, (content + SALT).encode(), hashlib.sha256).hexdigest()
r = sess.post(URL, json={"sign": sign, "content": content},
headers={"User-Agent": "Dart/2.19 (dart:io)"}, timeout=15)
return [int(i["value"].strip()) for i in r.json()["data"]]

sess = requests.Session()
total = sum(sum(fetch(sess, p)) for p in range(1, 101))
print("ANSWER =", total)

跑通:page 1 返回 [6745, 5106, 3952, 5372, 6686, 4825, 2995, 1297, 7037, 3118],与抓包里第一页逐个一致;1-100 页 1000 个数求和得到答案。

这才是离机复现:签名是自己按还原的算法算的,请求是自己发的,服务器 200 正常返回,全程不再需要 App、不再需要抓包。

七、踩坑记:Blutter 编译那些事

Blutter 要现编 Dart VM,在一台 15G 内存的 WSL 上连踩三坑,记一下:

  1. OOM 被 SIGKILL:ninja 默认按核数并行(这里 22 核),Dart VM 有几个重 TU 单个吃 1~2G,峰值直接爆内存、进程 [killed]。把 subprocess.run([NINJA_CMD]) 改成 [NINJA_CMD, '-j', '4'],4×2G 稳在 8G 内。
  2. WSL 出站 TCP 假死:clone 到一半 github TLS 连不上,但宿主机能上网——典型 WSL2 NAT 网络掉线。wsl --shutdown 重启网络栈即恢复(DNS/ping 正常但 443 全挂时基本就是它)。
  3. 缺 capstone:Dart VM 编完后,blutter 工具本身 cmake 报 None of the required 'capstone' foundapt install libcapstone-dev pkg-config 补上即可,重跑会跳过已编好的 Dart VM 直接反编译。

小结

第七题的完整链路:

  1. 抓包只做定位:reFlutter 关 pinning + 只换 libflutter.so 进原版 app + adb reverse + mitm reverse,拿到请求模型和 (content, sign) 样本;
  2. 静态穷举失败说明构造必须从代码读,不能猜;
  3. Blutter 反编译 Dart AOT,在 challenge_seven_page.dart 里读出真身:HMAC_SHA256(key="9R^tF2xFmt0A0vm.", content + "nmZO8*USEkF9fIoG");
  4. 两个盐都在 libapp.so 里——一个当 HMAC key、一个拼在 message 尾部,少任何一个、或把 HMAC 当成裸 SHA256,都对不上;
  5. 纯 Python 自算签名、自发请求,样本对拍全 True,离机复现完成。

Flutter 的”难”在于取数逻辑被编成了 Dart AOT 机器码;抓包能看到明文却看不到算法,真正的钥匙是把 AOT 快照反编译回来。这也是本系列一直强调的:能抓包 ≠ 完成逆向,把算法读出来、能自己算,才算真的还原。


猿人学 App 第七题「flutter」离机复现:Blutter 反编译 Dart AOT,还原 HMAC 双盐签名
https://kingjem.github.io/2026/09/02/猿人学App第七题离机复现-Blutter反编译DartAOT还原HMAC签名/
作者
Ruhai
发布于
2026年9月2日
许可协议