返回列表 发新帖

【多栈实战】某黑产软件全链路逆向实录 (中)

  [复制链接]

43

主题

648

回帖

6190

积分

硕士生

小佬

Rank: 6Rank: 6

金币
7087
好评
167
信誉
270

考神MT论坛新人MT论坛最佳新人

QQ
发表于 2026-6-6 12:52:09 | 显示全部楼层 | 阅读模式  来自 江西
本帖最后由 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 的工具。搜索后发现了 Blutterhttps://github.com/worawit/blutter

这里简单介绍一下,Blutter 能做什么?
1、扫描 libapp.so 的 Dart 常量池,提取所有类名、方法名、函数签名
2、理解 Dart AOT 的对象模型,生成类型关系映射
3、输出 ARM64 汇编和 Dart 伪代码的混合文件(比纯汇编可读得多)
4、生成完整的函数索引(pp.txt),直接告诉我们每个函数的名称和地址

安装和运行:

  1. <font size="3">git clone [url=https://github.com/worawit/blutter]https://github.com/worawit/blutter[/url]
  2. cd blutter
  3. # 从 APK 中提取 libapp.so
  4. unzip 某Flutter软件.apk lib/arm64-v8a/libapp.so
  5. # 运行 Blutter(大约需要 30 秒 ~ 2 分钟,取决于 libapp.so 大小)
  6. 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 调用 getApkPromoPayloadgetApkChannelCode。那么在 Dart 侧,一定有地方调用了这些方法。

打开 pp.txt ,搜索关键词:
  1. grep -i "promo\|channel\|NativeAppBridge" pp.txt
复制代码


命令行输出:
  1. package:xxx_flutter/src/runtime/native_app_bridge.dart
  2.   NativeAppBridge_getApkPromoPayload                   0x4a1b34
  3.   NativeAppBridge_getApkChannelCode                    0x4a1ba0
  4.   NativeAppBridge_getStableDeviceId                    0x4a1c0c
复制代码


找到了三处位置,接下来看谁调用了 getApkPromoPayload,继续在 pp.txt 中搜索 “PromoPayload”、“reportAppOpen”:
  1. grep -i "reportAppOpen\|AuthSession\|promoPayload" pp.txt
复制代码


命令行输出:
  1. package:xxx_flutter/src/core/network/xxx_api.dart
  2.   AuthSessionController__reportAppOpenNoInfoAsync       0x4c6d74
  3.   AuthRepository_reportAppOpenNoInfo                    0x5084f8
复制代码


这里我们分析得到了两个关键函数:
AuthSessionController._reportAppOpenNoInfoAsync (0x4c6d74) — 看起来是"无额外信息的应用打开上报"
AuthRepository.reportAppOpenNoInfo (0x5084f8) — 看起来是实际的 HTTP 请求发送

接下来继续搜索签名相关的函数,因为我们已经从抓包知道了 API 使用 x-sign:
  1. grep -i "sign\|signature\|xsign\|transport" pp.txt
复制代码


命令行输出:
  1. package:xxx/src/core/network/proto_transport.dart
  2.   ProtoTransport__calculateSignature                    0x54a5dc
  3.   ProtoTransport__buildProtoTransportUri                0x54ad98
  4.   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 中的偏移地址:
  1. _packagexxx_fluttersrccorenetworkxxx_apidart
  2.   AuthSessionController__reportAppOpenNoInfoAsync      0x4c6d74
  3.   AuthRepository_reportAppOpenNoInfo                   0x5084f8

  4. _packagexxx_fluttersrccorenetworkproto_transportdart
  5.   ProtoTransport__calculateSignature                   0x54a5dc
  6.   ProtoTransport__buildProtoTransportUri               0x54ad98
  7.   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 汇编文件中搜索这个地址:
  1. grep "0x54a608" blutter_output/asm/xxx_flutter/src/core/network/proto_transport.dart
复制代码

输出可能是:
// 0x54a608: ArrayLoad r1 = r7[0]   // 从数组取元素
这样就能知道这个 func_0x004d277c 实际上是 ArrayLoad。
以此类推...

让我们用上述方法,从头到尾、一段一段地解码这个函数:

第一段:读取类字段
  1. uVar1 = func_0x00557d1c(*(undefined8 *)(unaff_x27 + 0x1a8));
复制代码

分析过程:
参数 unaff_x27 是 AuthSessionController 类的类型表指针
偏移 0x1a8 是类中某个字段在内存中的偏移量
调用 func_0x00557d1c——用线索 1(单参数 + 类型表偏移)可以推断:这是 Dart 的实例字段读取操作
对应的 Dart 代码:
  1. final channelCode = this._channelCode;  // 读取 _channelCode 字段
复制代码


第二段:判空检查
  1. iVar2 = func_0x004e9644(uVar1, param_3);
  2. if (*(int32_t *)(iVar2 + 7) != 0) {
  3.     // ... 正常逻辑 ...
  4. } else {
  5.     // ... 跳转到 skip ...
  6. }
复制代码

分析过程:
func_0x004e9644 两个参数 = Dart 的 == 运算符或 identical() 检查
iVar2 + 7 检查 Dart 布尔值的内部表示(非零 = true)
结合 param_3 是空值标记,可以判断这是 channelCode == null 或 channelCode.isEmpty
对应的 Dart 代码:
  1. if (channelCode == null || channelCode.isEmpty) {
  2.     return "auth.app_open.skip";
  3. }
复制代码


第三段:时间戳 + SharedPreferences 读取
  1. // 获取当前时间
  2. iVar3 = func_0x00556da4();  // DateTime.now().millisecondsSinceEpoch

  3. // 读取 SharedPreferences
  4. iVar3 = func_0x00554f98();  // prefs.getString(...)
  5. iVar3 = (uint64_t)*(uint32_t *)(iVar3 + 7) + ...;

  6. // 时间差判断
  7. if (iVar3 - iVar5 < 3600000) {  // 3600000 = 1 小时
  8.     // 返回 skip
  9.     *(int32_t *)(iVar3 + 0x13) = 0x1c20;  // 0x1c20 = 7200 = 2 小时
  10.     goto code_r0x004c715c;  // return
  11. }
复制代码

分析过程:
func_0x00556da4 → 无参数 → 可能是获取当前时间
func_0x00554f98 → 读取 SharedPreferences
3600000 → 60×60×1000 = 1 小时(线索 2,魔数)
0x1c20 → 7200 秒 = 2 小时(另一个魔数,可能是备用时间窗口)
这里我们还可以从 libapp.so 的字符串池中找到 SharedPreferences 的 key:
  1. # 在 libapp.so 中搜索 "auth.app-open" 相关的字符串
  2. strings libapp.so | grep "auth.app-open"
  3. # 输出: xxx.auth.app-open-last-reported-at-mst
复制代码

对应的 Dart 代码:
  1. final now = DateTime.now().millisecondsSinceEpoch;
  2. final lastReported = prefs.getInt(
  3.     'xxx.auth.app-open-last-reported-at-mst'
  4. );
  5. if (lastReported != null && (now - lastReported) < 3600000) {
  6.     return "auth.app_open.skip";
  7. }
复制代码


第四段:调用 AuthRepository
  1. uVar6 = func_0x00508468(uVar6, *(undefined8 *)(in_x15 + -0xb8), uVar6);
复制代码

分析过程:
函数地址 0x5084f8 正好是 Blutter 中 AuthRepository_reportAppOpenNoInfo 的地址
所以这个调用就是 authRepository.reportAppOpenNoInfo(request)
前面的代码是构造 AppOpenRequest 对象

第五段:保存时间戳 + 返回成功
  1. // SharedPreferences.setInt(key, now)
  2. // ...
  3. // 返回 "auth.app_open.success" (字符串在 unaff_x27 + 0x13588)
  4. [i]对应的 Dart 代码:[/i]
  5. await prefs.setInt(
  6.     'xxx.auth.app-open-last-reported-at-mst', now
  7. );
  8. 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 逆向

我们发现代码没有被混淆,仍然是老套路,直接进行关键词搜索:
  1. grep -n "x-sign\|x-nonce\|x-timestamp" index-CgpnsbmD.js
复制代码


只有一行命中了所有三个头:



很好,我们直接找到了这个非常关键的 PP 函数,它调用了 6 个函数:kl、d4、Mv、Pv、Cc、qC。这是典型的“锚点函数”,找到它就找到了所有关键词的入口。

为了节省时间,我们直接使用 Python 正则定位每个函数定义,比如:
  1. import re
  2. with open("index-CgpnsbmD.js", "r") as f:
  3.     content = f.read()

  4. # 找 Pv 函数定义
  5. for m in re.finditer(r'function\s+Pv\(', content):
  6.     # 提取完整的函数体
  7.     brace_start = content.find('{', m.end())
  8.     # 括号匹配...
复制代码

由于 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 端一致?这时就要拿出最先抓到的数据包,对其进行正向还原:
  1. import hashlib

  2. SECRET = "6b5hR#7uVhJPZT1zAgA26fPSyx9Zh!Za"

  3. raw = "\n".join([
  4.     SECRET,                    # 密钥
  5.     "POST",                    # HTTP 方法
  6.     "/v1/auth/device",         # 去掉 /api 的路径
  7.     "ismobile=1",              # 查询参数
  8.     "1780146164",              # 时间戳
  9.     "fa37ed99e816c65a",        # 设备 ID
  10. ])

  11. sign = hashlib.sha256(raw.encode()).hexdigest()
  12. # 03acfa6585b533daae0ef075c083d4ff895401cd47048428cadfc2caaafd187d

  13. expected = "03acfa6585b533daae0ef075c083d4ff895401cd47048428cadfc2caaafd187d"
  14. print(f"Match: {sign == expected}")  # True!
复制代码


完美匹配! 网页端逆向出的密钥和算法与 APP 抓包的 x-sign 完全一致

这意味着:
1、网页端和 APP 端共享同一套签名基础设施
2、密钥 6b5hR#7uVhJPZT1zAgA26fPSyx9Zh!Za 对 APP 同样有效
3、签名算法为纯 SHA256,6 元素按上述顺序排列
4、时间戳需要减 5 秒


五、尾声

在完成上述所有分析后,我们用 Python 写了一个完整的模拟器,通过模拟最开始提到的发送邀请码的数据包,模拟新用户被邀请,核心逻辑如下:



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


运行效果:



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





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

我们总结一下本文的逆向分析流程:



再次感谢大家对我的支持!在下篇文章中,我们将会进行查漏补缺,将一些没说清楚的东西讲清楚,并带大家进行流媒体获取实战。

码字不易,点赞可有?
帖子内所有工具都已放在下面,可供大家练手,评论自取。
https://jiguro.lanzouw.com/iGpyL3r84wid

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
已有6人评分好评 金币 理由
清夜含隐空 + 1 + 1 很给力!
╰☆秋风oO + 1 + 1 学到了
青春向上 + 1 一个让论坛更改规则的男人!
合计 + 1 + 1
阿星破解 + 1 赞一个!
幸运儿8 + 1 + 1 厉害

查看全部评分 总评分:好评 +5  金币 +5 

回复

使用道具 举报

43

主题

648

回帖

6190

积分

硕士生

小佬

Rank: 6Rank: 6

金币
7087
好评
167
信誉
270

考神MT论坛新人MT论坛最佳新人

QQ
发表于 2026-6-6 12:52:10 | 显示全部楼层  来自 江西
本帖最后由 JiGuro 于 2026-6-6 16:30 编辑

感叹Bin佬的速度


也是成为全论坛第一篇最长单帖了

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
已有1人评分金币 理由
青春向上 + 1 大佬666。

查看全部评分 总评分:金币 +1 

回复

使用道具 举报

19

主题

2358

回帖

9210

积分

硕士生

Rank: 6Rank: 6

金币
6615
好评
3
信誉
102

考神MT论坛最佳新人MT论坛帅哥MT论坛新人

发表于 2026-6-6 12:52:58 来自手机  | 显示全部楼层  来自 河南
感谢分享技术
回复

使用道具 举报

23

主题

1572

回帖

5001

积分

硕士生

Rank: 6Rank: 6

金币
3823
好评
1
信誉
99
发表于 2026-6-6 12:57:04 来自手机  | 显示全部楼层  来自 四川
参考参考参考
回复

使用道具 举报

10

主题

1801

回帖

8299

积分

硕士生

Rank: 6Rank: 6

金币
3444
好评
0
信誉
106
发表于 2026-6-6 13:01:39 | 显示全部楼层  来自 美国
看看教程
回复

使用道具 举报

21

主题

2190

回帖

6788

积分

硕士生

Rank: 6Rank: 6

金币
4684
好评
3
信誉
101

MT论坛最佳新人MT论坛帅哥MT论坛新人

发表于 2026-6-6 13:03:09 来自手机  | 显示全部楼层  来自 马来西亚
看隐藏
回复

使用道具 举报

10

主题

3723

回帖

1万

积分

博士生

Rank: 7Rank: 7Rank: 7

金币
5054
好评
2
信誉
112

MT论坛最佳新人考神MT论坛帅哥

发表于 2026-6-6 13:04:19 来自手机  | 显示全部楼层  来自 福建
感谢分享
回复

使用道具 举报

10

主题

2464

回帖

6729

积分

硕士生

Rank: 6Rank: 6

金币
5419
好评
0
信誉
99
发表于 2026-6-6 13:05:49 来自手机  | 显示全部楼层  来自 江西
感谢分享
回复

使用道具 举报

55

主题

3636

回帖

1万

积分

博士生

Rank: 7Rank: 7Rank: 7

金币
901
好评
190
信誉
181

MT论坛新人MT论坛最佳新人MT论坛帅哥考神MT论坛活跃会员挂机大佬

发表于 2026-6-6 13:11:20 来自手机  | 显示全部楼层  来自 陕西
本帖最后由 青春向上 于 2026-6-6 13:25 编辑

大佬不仅技术nb,工具玩得6,还懂得社工,全才呀。
回复

使用道具 举报

7

主题

1939

回帖

8035

积分

硕士生

Rank: 6Rank: 6

金币
858
好评
5
信誉
125

MT论坛帅哥MT论坛最佳新人MT论坛新人考神

发表于 2026-6-6 13:16:31 来自手机  | 显示全部楼层  来自 北京
看看隐藏
回复

使用道具 举报

28

主题

957

回帖

4322

积分

大学生

Rank: 5Rank: 5

金币
2272
好评
2
信誉
90

考神

发表于 2026-6-6 13:17:01 来自手机  | 显示全部楼层  来自 山东
看看隐藏
回复

使用道具 举报

24

主题

988

回帖

3451

积分

大学生

Rank: 5Rank: 5

金币
1594
好评
40
信誉
100

考神MT论坛帅哥MT论坛最佳新人

发表于 2026-6-6 13:17:04 来自手机  | 显示全部楼层  来自 山西
感谢分享
回复

使用道具 举报

1

主题

1696

回帖

4475

积分

大学生

Rank: 5Rank: 5

金币
725
好评
0
信誉
100
发表于 2026-6-6 13:21:29 来自手机  | 显示全部楼层  来自 广西
看看
回复

使用道具 举报

19

主题

1916

回帖

8461

积分

硕士生

乱臣贼子

Rank: 6Rank: 6

金币
6470
好评
1
信誉
34

考神MT论坛最佳新人

发表于 2026-6-6 13:26:13 来自手机  | 显示全部楼层  来自 江西
看看你的隐藏
回复

使用道具 举报

48

主题

4245

回帖

1万

积分

博士生

Rank: 7Rank: 7Rank: 7

金币
78
好评
19
信誉
100
发表于 2026-6-6 13:32:56 来自手机  | 显示全部楼层  来自 天津
学习一下
回复

使用道具 举报

发表回复

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

快速回复 返回顶部 返回列表