硕士生
小佬
 
- 金币
- 7087
- 好评
- 167
- 信誉
- 270
  

|
本帖最后由 JiGuro 于 2026-6-7 18:37 编辑
【多栈实战】
某黑产软件全链路逆向实录 (中) ——
Flutter / Web 逆向伪造邀请请求会员全流程
开篇严正声明:本文仅用于学习逆向工程与网络安全相关技术,未掺杂任何不良目的。文中的软件已对其名字、图标及相关敏感信息做了模糊处理,本人也不会提供软件原包样品,内容仅作学习交流使用!同时,本人并未对该软件样本进行任何分享,下载仅做技术研究,均在个人设备上和虚拟设备中进行分析,并已在分析完后删除!
让各位久等了,继上一篇文章之后,中篇也终于被我写出来了。如果说上篇偏理论知识的话,那么这一篇主要就是以真正的逆向为主了。文章超长警告!建议各位备好花生、瓜子,慢慢细品,还是挺精彩的
如果还有没看过上一篇文章的,可以先去把上篇文章看看,这样看中篇就没有那么费劲了:
【多栈实战】某黑产软件全链路逆向实录 (上) (https://bbs.binmt.cc/thread-167215-1-1.html)
看前提醒:这系列文章较以前文章技术难度有一定提升,全是干货,希望各位能够耐心看完,感谢大家的支持与理解!
一、初探
现在进入正题,我们先梳理一下我们在上篇文章中干了啥:
在上篇中,我们追踪了一个黑产 App 的"邀请得 VIP"机制,即通过邀请链接下载的 APK 与原版 ZIP 内容逐字节相同,但整体哈希不同,最终定位到差异藏在 APK Signing Block V2 的自定义 ID-Value 对中 —— 服务器将邀请码注入该区块,App 启动时读取自身 APK 解析上报,实现免登录精准拉新奖励。
那么既然我们已经理清了该黑产软件用户拉新下放会员的逻辑,那么我们就应该立刻意识到,这个机制是可以被利用的
既然软件读取了安装包内嵌的邀请码,服务器也成功发放给邀请用户奖励,那么软件肯定与服务器之间有某种联系,而这种联系就是请求包。
上篇文章还没带大家详细了解该软件,现在补充这一部分内容:

文件名 某黑产软件_2.0.4.apk
包名 com.xxx.xxx
版本 2.0.4 (versionCode: 2000004)
最低 SDK 26 (Android 8.0)
目标 SDK 34 (Android 14)
技术栈 Flutter (1 个 Dex, 2834 个类,被严重混淆)
入口 Activity com.xxx.xxx_flutter.MainActivity
在逆向前我们先可以初步推测,软件会从 Java 层读取内嵌在 APK Signing Block 中的标识 ID-Value Pairs,再将这个值进行传递。那么该值会传递到哪里呢?虽然上篇文章里我们并未提这方面信息,但是我们很容易能想到其实就是传递给了 Dart 层,由 Native 库对获取到的信息进行进一步处理和加工。绘制成流程图大概是这样:

逆向的第一步总是抓包。我们先不着急对 Flutter 库进行逆向,先按照我们的想法,对该软件进行抓包。
这次抓包并不能像以前一样使用 小黄鸟 (HttpCanary) + 模块 (JustTrustMe) 进行抓包。这其实是一个坑,具体有以下两个原因:
1. 无视系统代理配置
Flutter 应用使用 Dart 的 dart:io 进行网络请求,默认不会读取 Android 的系统代理设置。这意味着,即使你开启了小黄鸟并设置了系统代理,Flutter 应用依然会选择直接连接目标服务器,流量根本不会经过小黄鸟的本地代理服务器,因此工具端看不到任何请求。
2. 独立的证书信任机制(只信系统证书)
与原生 Android 应用不同,Flutter 在底层硬编码了证书校验逻辑,只信任系统证书目录( /system/etc/security/cacerts ),而完全忽略"用户证书"(User CA)。
所以,对于这个应用,我们需要引入更高级的工具:Reqable
其实这款抓包工具就是小黄鸟的高级版本,最重要的地方是,Reqable 不仅可以通过安装系统证书的方式,绕过 Flutter 的校验,同时还可以和 Windows 端联合,这使我们抓包的效率提高了很多。不过,这种方式的缺点是需要有一台 Root 的设备。我们这里用安卓模拟器代替。
配置过程不多说,总之我们抓取到了一系列的数据包:
发现关键的 POST 请求:
POST /api/v1/auth/device?ismobile=1 HTTP/1.1
user-agent: Dart/3.11 (dart:io)
x-mobile-request: 1
accept-encoding: gzip
x-simulated-cookie: session_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJI...
x-protobuf-body-encoding: base64
content-type: text/plain;charset=UTF-8
x-cookie-mode: simulated
x-session-token: eyJ0eXAiOiJKV1QiLCJhbGci...
x-protobuf-response-encoding: base64
accept: application/x-protobuf
x-nonce: 7c461b734988a90640d2d609e9b0e7ac
content-length: 108
x-timestamp: 1780146164
x-device-id: fa37ed99e816c65a
host: xxx.asia
x-sign: 03acfa6585b533daae0ef075c083d4ff895401cd47048428cadfc2caaafd187d
Body (base64):
ChBmYTM3ZWQ5OWU4MTZjNjVhEgdhbmRyb2lkGhBSZWRtaSAyMzA3OFJLRDVDIg0yLjAuNCsyMDAwMDA0KgdkZWZhdWx0Mgg3MTk3RDkxNg==
Request (protobuf):
EgJvaxrpBAqpAgoHMTc5MzM4OBIP5ZyG5ram5bCP6IKJ5Li4Gr0BL2..,

我们对其请求进行观察:
四个自定义头 x-nonce, x-timestamp, x-device-id, x-sign,有经验的同学一眼就可以看出这是典型的签名校验系统,用于保护请求。
把 Body 做 base64 解码,得到 protobuf 消息:
Field 1 (string) = "fa37ed99e816c65a" # device_id
Field 2 (string) = "android" # os
Field 3 (string) = "Redmi 23078RKD5C" # device_model
Field 4 (string) = "2.0.4+2000004" # app_version
Field 5 (string) = "default" # channel
Field 6 (string) = "7197D916" # 邀请码
F6 = “7197D916” —— 这就是 Java 层从签名中读取到的邀请码。
把 Request 做 base64 解码,得到 protobuf 消息:
Field 1 (string) = "1793388" # 用户ID
Field 2 (string) = "圆润小肉丸" # 用户昵称
Field 3 (string) = "/api/s3-proxy/mediaapps/images/icon/2026/02/02/xxx.png?=..." # 用户头像
Field 4 (string) = "我想听你亲口说出那个愿望。" # 用户个性签名
Field 5 (string) = "eyJ0eXAiOiJKV1QiLCJhbG..." # 用户 JWT
...
把 x-session-token 做 base64 解码,得到以下消息:
{
"sid": "7756f5fe-f0ce-46d0-879d-b3b27b456ba9", // Session ID
"uid": 1793388, // User ID
"iss": "mediaapps", // 签发者 identity
"aud": "user", // 受众
"exp": 1780165104, // 过期时间
"iat": 1780143504, // 签发时间
"jti": "abb0055f-1d04-4a40-89d5-35b4fd929f91" // JWT ID
}
根据请求体和响应体的信息,我们可以推断出,该数据包就是接收设备信息和邀请码,并自动生成新用户的关键请求。
至此,我们已经锁定了我们需要伪造的数据包,理论上,只要伪造该数据包,将邀请码换成我们自己的邀请码,再通过一系列随机生成用户的手段,就能够轻而易举地获取无限 VIP 
但是,现在说这些还太早。虽然我们判断出是签名校验,此时我们还不知道这些请求头中的字符串具体代表什么含义,也不知道它们以何种方式生成,如果盲目照搬,我已经替大家试过了,服务器会直接返回签名错误:

所以我们现在确定要逆向的是数据包签名方向,因为相比于签名,其他请求头基本上都可以直接根据含义进行伪造。
二、Flutter 逆向
如果要知道这些信息,那么就得通过逆向,现在我们进入逆向环节
我们已经确认这个 APP 是 Flutter 应用。Flutter 的 Dart 代码不放在 DEX 里,而是被 AOT(Ahead-of-Time)编译成 ARM64 原生代码,存储在 lib/arm64-v8a/libapp.so 中。
先用 UN管理器 打开软件,虽然软件没有经过高强度混淆,但如果一旦转伪C,就只能看到数以千计的 FUN_xxxxxxxx 函数,完全看不懂调用约定,没有可识别的导入导出。UN管理器 能反编译出指令流,但无法恢复语义,这让人很头疼
这时候我们需要一个专门针对 Flutter AOT 的工具。搜索后发现了 Blutter (https://github.com/worawit/blutter)
这里简单介绍一下,Blutter 能做什么?
1、扫描 libapp.so 的 Dart 常量池,提取所有类名、方法名、函数签名
2、理解 Dart AOT 的对象模型,生成类型关系映射
3、输出 ARM64 汇编和 Dart 伪代码的混合文件(比纯汇编可读得多)
4、生成完整的函数索引(pp.txt),直接告诉我们每个函数的名称和地址
安装和运行:
- <font size="3">git clone [url=https://github.com/worawit/blutter]https://github.com/worawit/blutter[/url]
- cd blutter
- # 从 APK 中提取 libapp.so
- unzip 某Flutter软件.apk lib/arm64-v8a/libapp.so
- # 运行 Blutter(大约需要 30 秒 ~ 2 分钟,取决于 libapp.so 大小)
- python3 blutter.py lib/arm64-v8a/libapp.so ./blutter_output</font>
复制代码
成功解包!Blutter 的输出目录结构如下:
blutter_output/
├── asm/xxx_flutter/ // 最重要的目录
│ └── src/
│ ├── runtime/
│ │ └── native_app_bridge.dart
│ ├── features/
│ │ └── ...
│ └── core/network/
│ ├── xxx_api.dart
│ └── proto_transport.dart
├── pp.txt // 函数索引
├── objs.txt // 对象/类定义
└── blutter_frida.js // Frida Hook 脚本
这里注意,不要一开始就去读汇编文件!要不然你肯定看不懂,先打开 pp.txt ,了解函数结构。
那么先看哪个位置呢?
那就又要回到 Java 层。我们知道,Flutter 中 Dart 和 Java 通信的标准方式是 MethodChannel。Dart 侧通过 MethodChannel('xxx/native_app_bridge') 调用 Java 侧的方法。
我们在 MT 中对混淆类 LR2/F;(R2.F)进行反编译,得到其 onMethodCall 方法:

根据上述代码,我们可以分析出 APP 通过 MethodChannel 调用 getApkPromoPayload 和 getApkChannelCode。那么在 Dart 侧,一定有地方调用了这些方法。
打开 pp.txt ,搜索关键词:
- grep -i "promo\|channel\|NativeAppBridge" pp.txt
复制代码
命令行输出:
- package:xxx_flutter/src/runtime/native_app_bridge.dart
- NativeAppBridge_getApkPromoPayload 0x4a1b34
- NativeAppBridge_getApkChannelCode 0x4a1ba0
- NativeAppBridge_getStableDeviceId 0x4a1c0c
复制代码
找到了三处位置,接下来看谁调用了 getApkPromoPayload,继续在 pp.txt 中搜索 “PromoPayload”、“reportAppOpen”:
- grep -i "reportAppOpen\|AuthSession\|promoPayload" pp.txt
复制代码
命令行输出:
- package:xxx_flutter/src/core/network/xxx_api.dart
- AuthSessionController__reportAppOpenNoInfoAsync 0x4c6d74
- AuthRepository_reportAppOpenNoInfo 0x5084f8
复制代码
这里我们分析得到了两个关键函数:
AuthSessionController._reportAppOpenNoInfoAsync (0x4c6d74) — 看起来是"无额外信息的应用打开上报"
AuthRepository.reportAppOpenNoInfo (0x5084f8) — 看起来是实际的 HTTP 请求发送
接下来继续搜索签名相关的函数,因为我们已经从抓包知道了 API 使用 x-sign:
- grep -i "sign\|signature\|xsign\|transport" pp.txt
复制代码
命令行输出:
- package:xxx/src/core/network/proto_transport.dart
- ProtoTransport__calculateSignature 0x54a5dc
- ProtoTransport__buildProtoTransportUri 0x54ad98
- ProtoTransport__buildSimulatedCookie 0x54bc00
复制代码
我们又发现一个关键函数: ProtoTransport._calculateSignature (0x54a5dc)。我们可以大胆猜测,这就是计算 x-sign 的地方!
现在我们有了一条清晰的函数链:

我们已经找到了关键函数,接下来我们要做的就是打开对应的 Blutter 汇编文件。比如 asm/xxx_flutter/src/runtime/native_app_bridge.dart :

在这个文件中,我们找到了完整的 MethodChannel 方法列表:getApkPromoPayload、getApkChannelCode、getStableDeviceId、getVersionName、getVersionCode。
这和我们在 Java 侧 R2.F.onMethodCall 中看到的 switch-case 分支完全对应。
我们继续追踪数据流,Blutter 帮我们还原了 PromoPayload 的解码逻辑。在同目录的其他 Dart 文件中:

至此,Dart 侧的邀请码读取链路完全清晰:

Blutter 的另一个关键产出是函数地址映射。在 pp.txt 中,每个函数旁边都有它在 libapp.so 中的偏移地址:
- _packagexxx_fluttersrccorenetworkxxx_apidart
- AuthSessionController__reportAppOpenNoInfoAsync 0x4c6d74
- AuthRepository_reportAppOpenNoInfo 0x5084f8
- _packagexxx_fluttersrccorenetworkproto_transportdart
- ProtoTransport__calculateSignature 0x54a5dc
- ProtoTransport__buildProtoTransportUri 0x54ad98
- ProtoTransport__buildSimulatedCookie 0x54bc00
复制代码
这些地址是我们接下来在 UN管理器 中进行深度反编译的导航坐标。没有 Blutter,我们根本无法知道应该反编译哪个函数。这里要夸一下 Blutter 的作者,实在太牛逼了
现在打开 UN ,等待分析完成,跳转到 0x4c6d74 ,然后直接转伪C,我们可以看到一串看起来像天书的 ARM64 汇编代码。

看起来全是 func_0x00xxxxx,不过别慌,Dart AOT 的反编译结果都是这样的。UN管理器 不认识 Dart 运行时的内部函数,所以给它们都起了临时的名字。
这里我要点出一个关键思路,看类似这样的伪C代码时,千万不能硬看或者想理解代码含义,而是通过参数特征和常量来推断它们的作用。如果实在看不懂,直接丢给AI也行。
对于该 APP 以及其他 Flutter 软件的逆向,我总结出了三点,可以帮助我们理解:
1、参数模式
Dart 运行时的很多函数有固定的调用模式。比如:
func_0x00557d1c(unaff_x27 + 0x1a8) — 一个参数,传入类类型表 + 偏移量,可推断其为字段读取器
func_0x004e9644(uVar1, param_3) — 两个参数,返回布尔值,可推断其为相等/判空检查
func_0x00ca33a8() — 无参数,返回新对象,可推断其为构造函数/分配器
以此类推...
2、魔数常量
找到代码中的特殊数值。比如 3600000 这个数很眼熟,因为:
3600000 毫秒 = 60 × 60 × 1000 = 1 小时
这必然是某个时间间隔常量
再看 0x1c20 — 这个数:
0x1c20 = 7200 秒 = 2 小时
结合上下文,这可能是"防重复上报"的时间窗口。
以此类推...
3、伪C 和 Blutter 交叉验证
这是最有力的方法。在伪C中看到一个函数的地址(如 0x54a608),回到 Blutter 汇编文件中搜索这个地址:
- grep "0x54a608" blutter_output/asm/xxx_flutter/src/core/network/proto_transport.dart
复制代码
输出可能是:
// 0x54a608: ArrayLoad r1 = r7[0] // 从数组取元素
这样就能知道这个 func_0x004d277c 实际上是 ArrayLoad。
以此类推...
让我们用上述方法,从头到尾、一段一段地解码这个函数:
第一段:读取类字段
- uVar1 = func_0x00557d1c(*(undefined8 *)(unaff_x27 + 0x1a8));
复制代码
分析过程:
参数 unaff_x27 是 AuthSessionController 类的类型表指针
偏移 0x1a8 是类中某个字段在内存中的偏移量
调用 func_0x00557d1c——用线索 1(单参数 + 类型表偏移)可以推断:这是 Dart 的实例字段读取操作
对应的 Dart 代码:
- final channelCode = this._channelCode; // 读取 _channelCode 字段
复制代码
第二段:判空检查
- iVar2 = func_0x004e9644(uVar1, param_3);
- if (*(int32_t *)(iVar2 + 7) != 0) {
- // ... 正常逻辑 ...
- } else {
- // ... 跳转到 skip ...
- }
复制代码
分析过程:
func_0x004e9644 两个参数 = Dart 的 == 运算符或 identical() 检查
iVar2 + 7 检查 Dart 布尔值的内部表示(非零 = true)
结合 param_3 是空值标记,可以判断这是 channelCode == null 或 channelCode.isEmpty
对应的 Dart 代码:
- if (channelCode == null || channelCode.isEmpty) {
- return "auth.app_open.skip";
- }
复制代码
第三段:时间戳 + SharedPreferences 读取
- // 获取当前时间
- iVar3 = func_0x00556da4(); // DateTime.now().millisecondsSinceEpoch
- // 读取 SharedPreferences
- iVar3 = func_0x00554f98(); // prefs.getString(...)
- iVar3 = (uint64_t)*(uint32_t *)(iVar3 + 7) + ...;
- // 时间差判断
- if (iVar3 - iVar5 < 3600000) { // 3600000 = 1 小时
- // 返回 skip
- *(int32_t *)(iVar3 + 0x13) = 0x1c20; // 0x1c20 = 7200 = 2 小时
- goto code_r0x004c715c; // return
- }
复制代码
分析过程:
func_0x00556da4 → 无参数 → 可能是获取当前时间
func_0x00554f98 → 读取 SharedPreferences
3600000 → 60×60×1000 = 1 小时(线索 2,魔数)
0x1c20 → 7200 秒 = 2 小时(另一个魔数,可能是备用时间窗口)
这里我们还可以从 libapp.so 的字符串池中找到 SharedPreferences 的 key:
- # 在 libapp.so 中搜索 "auth.app-open" 相关的字符串
- strings libapp.so | grep "auth.app-open"
- # 输出: xxx.auth.app-open-last-reported-at-mst
复制代码
对应的 Dart 代码:
- final now = DateTime.now().millisecondsSinceEpoch;
- final lastReported = prefs.getInt(
- 'xxx.auth.app-open-last-reported-at-mst'
- );
- if (lastReported != null && (now - lastReported) < 3600000) {
- return "auth.app_open.skip";
- }
复制代码
第四段:调用 AuthRepository
- uVar6 = func_0x00508468(uVar6, *(undefined8 *)(in_x15 + -0xb8), uVar6);
复制代码
分析过程:
函数地址 0x5084f8 正好是 Blutter 中 AuthRepository_reportAppOpenNoInfo 的地址
所以这个调用就是 authRepository.reportAppOpenNoInfo(request)
前面的代码是构造 AppOpenRequest 对象
第五段:保存时间戳 + 返回成功
- // SharedPreferences.setInt(key, now)
- // ...
- // 返回 "auth.app_open.success" (字符串在 unaff_x27 + 0x13588)
- [i]对应的 Dart 代码:[/i]
- await prefs.setInt(
- 'xxx.auth.app-open-last-reported-at-mst', now
- );
- return "auth.app_open.success";
复制代码
综合上述五段,我们可以还原出完整的 Dart 代码:

你学废了吗?
现在,我们可以用同样的方法分析 AuthRepository.reportAppOpenNoInfo 这个函数。
UN 反编译:

通过 Blutter 和 伪C 的交叉引用,我们认出 unaff_x27 + 0x2e40 和 unaff_x27 + 0x2e50 是类常量池中的字符串——分别是 "POST" 和 "/auth/device"。
然后,我们逆向 _calculateSignature ,即签名核心,这是最关键的函数。
UN 反编译:

从 Blutter 的输出中,我们确认了 ProtoTransport 类的完整结构:
文件: src/core/network/proto_transport.dart
类 ProtoTransport:
├── _uriSummary() ← URI 摘要(日志用)
├── _buildProtoTransportUri() ← 构建请求 URI + 签名
├── _buildSimulatedCookie() ← 构建模拟 cookie
├── _calculateSignature() ← 计算 x-sign
├── _decodeResponseBytes() ← 解码 protobuf 响应
└── protoTransportProvider ← Riverpod 依赖注入
虽然 UN 的反编译结果充满了 func_0x00xxxxx,但通过和 Blutter 汇编的逐行交叉验证,我们能确信:
1、函数内部构造了一个 6 元素字符串列表
2、用 join("\n") 拼接
3、然后对拼接结果做 SHA256 哈希
4、返回 64 字符的十六进制字符串
为了进一步佐证我们的猜想,最后让我们大致还原 _buildProtoTransportUri ,这个函数组装了最终的 HTTP 请求:

大功告成!
此时我们可以综合 Blutter、伪C 和抓包数据,还原出完整 HTTP 请求结构,并弄清楚每一个请求头到底代表着什么 (省略无关请求头):
POST /api/v1/auth/device?ismobile=1 HTTP/1.1
Host: xxx.asia
Content-Type: text/plain;charset=UTF-8
User-Agent: Dart/3.11 (dart:io)
# ProtoTransport 自定义签名头(由 _buildProtoTransportUri 添加):
x-protobuf-body-encoding: base64
x-nonce: {16字节随机数,hex}
x-timestamp: {Unix 秒级时间戳 - 5}
x-device-id: {16 位 hex 设备 ID}
x-sign: {SHA256 (6元素) , 64 位 hex,关键中的关键}
x-mobile-request: 1
Accept: application/x-protobuf
# 会话管理(由 _buildSimulatedCookie 构建):
x-simulated-cookie: session_token={JWT}; _st={JWT}; device_id={devid}
x-session-token: {JWT}
x-cookie-mode: simulated
不过不能报喜不报忧。虽然有这些信息,但此时我们仍然不知道 _calculateSignature 中 6 个元素的精确排列顺序(虽然大致推测出了哪 6 类值)和密钥 ( _buildProtoTransportUri 传递的 _secret ) 是什么(在 6 元素之一中,但不知道具体值)。信息是单向的,我们有 SHA256(secret + data) = sign,但无法从 sign 反推出 secret。
唯一的方法只有 Frida 运行时 Hook,进行动态分析,这样才能找出关键的 Key 。
但问题是,我擅长静态分析,对于动态分析还是不太熟悉,最 cd 的是我的模拟器,Frida 一旦附加软件进程,模拟器就可能随时崩溃 (不知道啥原因)
我相信,绝大部分人如果到了这一步,大多都想着死磕 Frida 或者直接放弃吧?其实我当时也有这种念想
三、转机
但是当我的鼠标停留在之前抓包信息的那一刻,我突然萌生了一个想法。让我们重新观察之前的数据包中的请求头 x-session-token 的解码信息:
{
"sid": "7756f5fe-f0ce-46d0-879d-b3b27b456ba9", // Session ID
"uid": 1793388, // User ID
"iss": "mediaapps", // 签发者 identity
"aud": "user", // 受众
"exp": 1780165104, // 过期时间
"iat": 1780143504, // 签发时间
"jti": "abb0055f-1d04-4a40-89d5-35b4fd929f91" // JWT ID
}
再次观察上列数据。不知各位是否注意到,在 JWT 中,存在这样一个参数:
"iss": "mediaapps"
对于熟悉 JWT 的同学来说,这个字段并不陌生。iss(Issuer)表示令牌签发方。最开始我并没有太在意它,因为很多系统都会随便填一个字符串进去。但是现在,我们是否可以理解 iss 参数标记的是客户端实现?或者说,服务器实际上服务着多个客户端?
因为当前我是直接通过 APP 进行抓包的,所以参数传递的是 mediaapps ,那么是否还意味着该黑产团伙运营的并不只是一个 APP ,甚至还有网页?
想到这里,我们可以重新观察之前掌握的线索:
1、邀请码并非直接在 APP 内完成传播,用户点击邀请码后,会先跳转到一个网页页面 (这一点在上一篇中已经提到过),说明运营方本身就维护着 Web 服务。
2、抓包发现,大量媒体内容来自 CDN。而 CDN 本身天然适合多端共同访问。
3、JWT 本身就是跨平台身份体系中最常见的方案。如果服务器只服务安卓客户端,完全可以直接采用更简单的 Session 机制。如果多端共同使用同一套账号系统,那么使用 JWT 就非常合理了。
把这些线索放在一起后,我们得到一个猜想:服务器后面很可能不只有一个 APP,它也许还存在网页端!
这时有同学会疑问,APP 端和网页端又有什么区别呢?
事实上,我们可以大胆猜测,如果 APP 和网页端使用同一个身份系统,它们的签名算法很可能是一样的。那我们就可以绕开繁杂的 Dart 层和动态调试,直攻 Web 层,这可就到了我的舒适区了。
不过,虽然想法很好,但仍有一个十分现实的问题摆在眼前:APP 端的图片、视频请求均来自 API 接口,翻遍整个软件也无法找到网页入口,无法获取任何有关 Web 页面的信息。既然连网站的网址都弄不到,那又何谈 Web 逆向呢?
这时,我突然看到 APP 的个人页面有一个客服入口,在客服入口中又存在一个 “群组” 按钮。我尝试点击按钮,发现它跳转到了一个 Telegram 群组邀请。
我尝试加入该群组,发现群组管理员和用户正在热烈讨论。看来,这就是该黑产 APP 的反馈交流群组了。这时,我灵机一动,掏出了网络安全中的最大杀器 —— 社会工程学

在一段时间的交谈后,我以 “能不能在电脑上看” 为由,成功地从管理员口中套出了网站地址,依旧托管在 AWS 上:
https://xxx.cloudfront.net/video/
这里再叠层保命甲:本人这样做仅为后续的逆向分析和剧情需要,询问的问题均为伪造,本人与这些犯罪分子并无任何关联!
就这样,我们终于拿到了网页信息。我们尝试打开网页,发现 Web 布局和 APP 中非常相似,这进一步印证了我们的猜想。
我们随便打开一个视频页面,按键盘 F12 键打开开发者模式:

随便点击一个数据包,可以看到,该数据包是一个请求视频的请求 (省略无关信息):
GET <a href="https://xxx.cloudfront.net/api/videos/30405/xxx" target="_blank">https://xxx.cloudfront.net/api/videos/30405/xxx</a>
x-device-id: a2d665098a9d466cbedf28725b3d575d
x-timestamp: 1780187863
x-nonce: 2af57fe82bee66bf2a0dc190505ad02e
x-sign: 94a8b15d7a8b3e05fbe519774570bbbd0051a10d512fb257da27f8226aacf382

我们直接发现了与 APP 端完全相同的四个自定义头! 虽然域名不同(CloudFront vs API 域名),但 JWT issuer 相同、头部命名相同、值格式相同。
我们基本可以断定,Web 端与 APP 端应该使用的就是完全相同的签名格式,那么生成方法估计也是相同的。
抱着这种心理,我们点击该数据包的发起程序记录进行查看,发现是由一个名叫 index-CgpnsbmD.js 的脚本发起的:

我们立即下载该脚本,并对其进行逆向分析。
四、Web 逆向
我们发现代码没有被混淆,仍然是老套路,直接进行关键词搜索:
- grep -n "x-sign\|x-nonce\|x-timestamp" index-CgpnsbmD.js
复制代码
只有一行命中了所有三个头:

很好,我们直接找到了这个非常关键的 PP 函数,它调用了 6 个函数:kl、d4、Mv、Pv、Cc、qC。这是典型的“锚点函数”,找到它就找到了所有关键词的入口。
为了节省时间,我们直接使用 Python 正则定位每个函数定义,比如:
- import re
- with open("index-CgpnsbmD.js", "r") as f:
- content = f.read()
- # 找 Pv 函数定义
- for m in re.finditer(r'function\s+Pv\(', content):
- # 提取完整的函数体
- brace_start = content.find('{', m.end())
- # 括号匹配...
复制代码
由于 Pv() 函数就是获取密钥的关键函数,所以我们首先逆向它,完整定义如下:

没想到,我们直接发现了密钥明文!
密钥:6b5hR#7uVhJPZT1zAgA26fPSyx9Zh!Za
32 个字符,混合大小写字母、数字和特殊字符。
现在,按照上面的方法,我们逆向一些辅助函数,让我们的后期还原更加精准,并补全我们之前在 Dart 层还未逆向的部分。
第一个要逆向的依赖是 kl(),它生成了 x-device-id :

然后我们逆向时间戳生成 d4() 函数,它的实现非常简单,一行代码:

这里为了保证签名验证有效,需要将当前时间戳减去5秒,这里其实是一个坑点。在 Dart 层,我们误以为这获取的就是当前时间戳。如果我们不深入逆向,可能就会导致问题。
我们终于干掉了第一头拦路虎,不过还有一个问题急需解决,就是 Dart 层中其余元素的排列方式,还有其元素的对应关系,我们可以通过逆向 qC 函数来获取完整签名算法:
终于,我们获取了一切信息!
我们可以看到,其实前文所述的 6 个元素分别对应的就是 密钥、字符串"Post"、字符串"/v1/auth/device"、字符串"ismobile=1"、时间戳、设备ID ,再直接将他们按照上述排序一起进行 SHA256 计算。
这里有一个误区,很多人看到密钥,可能就认为这个密钥一定要对明文进行加密,事实却不是这样。在这次逆向中,无论从 Dart 的代码中还是 JavaScript 中,我们都能发现,密钥实际上作为一个固定值,被直接塞进了元素组中,然后一起进行 SHA256 ,这其实一定程度上打破了我们对密钥的固有认知。
最后,对 Dart 和 Web 层的所有推断是否属实?Web 端的密钥和元素排列是否和 APP 端一致?这时就要拿出最先抓到的数据包,对其进行正向还原:
- import hashlib
- SECRET = "6b5hR#7uVhJPZT1zAgA26fPSyx9Zh!Za"
- raw = "\n".join([
- SECRET, # 密钥
- "POST", # HTTP 方法
- "/v1/auth/device", # 去掉 /api 的路径
- "ismobile=1", # 查询参数
- "1780146164", # 时间戳
- "fa37ed99e816c65a", # 设备 ID
- ])
- sign = hashlib.sha256(raw.encode()).hexdigest()
- # 03acfa6585b533daae0ef075c083d4ff895401cd47048428cadfc2caaafd187d
- expected = "03acfa6585b533daae0ef075c083d4ff895401cd47048428cadfc2caaafd187d"
- print(f"Match: {sign == expected}") # True!
复制代码
完美匹配! 网页端逆向出的密钥和算法与 APP 抓包的 x-sign 完全一致
这意味着:
1、网页端和 APP 端共享同一套签名基础设施
2、密钥 6b5hR#7uVhJPZT1zAgA26fPSyx9Zh!Za 对 APP 同样有效
3、签名算法为纯 SHA256,6 元素按上述顺序排列
4、时间戳需要减 5 秒
五、尾声
在完成上述所有分析后,我们用 Python 写了一个完整的模拟器,通过模拟最开始提到的发送邀请码的数据包,模拟新用户被邀请,核心逻辑如下:

使用方法:
- PS C:\Users\Administrator\Desktop\Code\Work\scripts> python invite_sim.py
- 用法:
- python invite_sim.py <8位邀请码> # 单次注册(详细输出)
- python invite_sim.py <8位邀请码> --batch <N> # 批量注册N个用户
复制代码
运行效果:

可以看到,我们现在可以利用我们写出的 Python 注册机批量注册新账号,服务器会认为这些账号都是通过邀请码对应的老账号邀请的,然后给老账号发放 VIP 奖励,一下没忍住,干了一个多月会员 :


至此,本文的所有分析过程圆满结束。
能够耐心读到这里的,你也一定是大佬!
我们总结一下本文的逆向分析流程:

再次感谢大家对我的支持!在下篇文章中,我们将会进行查漏补缺,将一些没说清楚的东西讲清楚,并带大家进行流媒体获取实战。
码字不易,点赞可有?
帖子内所有工具都已放在下面,可供大家练手,评论自取。
https://jiguro.lanzouw.com/iGpyL3r84wid |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
x
-
查看全部评分
总评分:好评 +5
金币 +5
|