吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 460|回复: 6
上一主题 下一主题
收起左侧

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

[复制链接]
跳转到指定楼层
楼主
JiGuro 发表于 2026-6-6 11:13 回帖奖励
本帖最后由 JiGuro 于 2026-6-6 11:26 编辑

【多栈实战】
某黑产软件全链路逆向实录 (中) ——
Flutter / Web 逆向伪造邀请请求会员全流程


开篇严正声明:本文仅用于学习逆向工程与网络安全相关技术,未掺杂任何不良目的。文中的软件已对其名字、图标及相关敏感信息做了模糊处理,本人也不会提供软件原包样品,内容仅作学习交流使用!同时,本人并未对该软件样本进行任何分享,下载仅做技术研究,均在个人设备上和虚拟设备中进行分析,并已在分析完后删除!


让各位久等了,继上一篇文章之后,中篇也终于被我写出来了。如果说上篇偏理论知识的话,那么这一篇主要就是以真正的逆向为主了。建议各位备好花生、瓜子,慢慢细品,还是挺精彩的
如果还有没看过上一篇文章的,可以先去把上篇文章看看,这样看中篇就没有那么费劲了:
【多栈实战】某黑产软件全链路逆向实录 (上) (https://www.52pojie.cn/thread-2110455-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),直接告诉我们每个函数的名称和地址

安装和运行:
[Shell] 纯文本查看 复制代码
git clone https://github.com/worawit/blutter
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


成功解包!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 方法:
[Java] 纯文本查看 复制代码
MethodCallHandler {
    private Object target;  // MainActivity 实例
    
    @Override
    public void onMethodCall(MethodCall call, Result result) {
        String method = call.method;
        MainActivity activity = (MainActivity) target;
        
        switch (method) {
            case "getApkPromoPayload":
                // 读取 p 字段 (邀请码)
                String promo = SigningBlockReader.read(activity, "p");
                result.success(promo);
                break;
                
            case "getApkChannelCode":
                // 读取 channel 字段 (固定 "default")
                String channel = SigningBlockReader.read(activity, "channel");
                result.success(channel);
                break;
                
            case "getStableDeviceId":
                // 读取 Android ID
                String androidId = Settings.Secure.getString(
                    activity.getContentResolver(), "android_id");
                result.success(androidId);
                break;
...

        }
    }
}

根据上述代码,我们可以分析出 APP 通过 MethodChannel 调用 getApkPromoPayloadgetApkChannelCode。那么在 Dart 侧,一定有地方调用了这些方法。

打开 pp.txt ,搜索关键词:
[Shell] 纯文本查看 复制代码
grep -i "promo\|channel\|NativeAppBridge" pp.txt

命令行输出:
[Shell] 纯文本查看 复制代码
package:xxx_flutter/src/runtime/native_app_bridge.dart
  NativeAppBridge_getApkPromoPayload                   0x4a1b34
  NativeAppBridge_getApkChannelCode                    0x4a1ba0
  NativeAppBridge_getStableDeviceId                    0x4a1c0c

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

命令行输出:
[Shell] 纯文本查看 复制代码
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:
[Shell] 纯文本查看 复制代码
grep -i "sign\|signature\|xsign\|transport" pp.txt

命令行输出:
[Shell] 纯文本查看 复制代码
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 :
[C] 纯文本查看 复制代码
class NativeAppBridge {
  static const _channel = MethodChannel('xxx/native_app_bridge');
  
  // 读取 APK Signing Block 中的 "p" 字段,即邀请码
  // 对应 Java 侧 R2.F.onMethodCall "getApkPromoPayload"
  static Future<String> getApkPromoPayload() async =>
      await _channel.invokeMethod('getApkPromoPayload');
  
  // 读取 APK Signing Block 中的 "channel" 字段
  // 对应 Java 侧 R2.F.onMethodCall "getApkChannelCode"
  static Future<String> getApkChannelCode() async =>
      await _channel.invokeMethod('getApkChannelCode');
  
  // 获取 Android ID
  static Future<String> getStableDeviceId() async =>
      await _channel.invokeMethod('getStableDeviceId');
  
  // 版本信息
  static Future<String> getVersionName() async =>
      await _channel.invokeMethod('getVersionName');
  
  static Future<int> getVersionCode() async =>
      await _channel.invokeMethod('getVersionCode');
}

在这个文件中,我们找到了完整的 MethodChannel 方法列表:getApkPromoPayload、getApkChannelCode、getStableDeviceId、getVersionName、getVersionCode
这和我们在 Java 侧 R2.F.onMethodCall 中看到的 switch-case 分支完全对应。

我们继续追踪数据流,Blutter 帮我们还原了 PromoPayload 的解码逻辑。在同目录的其他 Dart 文件中:
[C] 纯文本查看 复制代码
// Blutter 提取的函数 decodePromoPayload
// 这个函数把 Java 侧返回的 base64 字符串解码为结构化数据
class PromoPayload {
  final int v;        // 版本号
  final String i;     // 邀请码("7197D916")
  
  factory PromoPayload.decode(String base64Str) {
    // Base64 解码
    final json = jsonDecode(utf8.decode(base64.decode(base64Str)));
    
    // 提取字段
    return PromoPayload(v: json['v'], i: json['i']);
  }
}

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



Blutter 的另一个关键产出是函数地址映射。在 pp.txt 中,每个函数旁边都有它在 libapp.so 中的偏移地址:
[Shell] 纯文本查看 复制代码
_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 汇编文件中搜索这个地址:
[Shell] 纯文本查看 复制代码
grep "0x54a608" blutter_output/asm/xxx_flutter/src/core/network/proto_transport.dart

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

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

第一段:读取类字段
[C] 纯文本查看 复制代码
uVar1 = func_0x00557d1c(*(undefined8 *)(unaff_x27 + 0x1a8));

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


第二段:判空检查
[C] 纯文本查看 复制代码
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 代码:
[C] 纯文本查看 复制代码
if (channelCode == null || channelCode.isEmpty) {
    return "auth.app_open.skip";
}


第三段:时间戳 + SharedPreferences 读取
[C] 纯文本查看 复制代码
// 获取当前时间
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:
[Shell] 纯文本查看 复制代码
# 在 libapp.so 中搜索 "auth.app-open" 相关的字符串
strings libapp.so | grep "auth.app-open"
# 输出: xxx.auth.app-open-last-reported-at-mst

对应的 Dart 代码:
[C] 纯文本查看 复制代码
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
[C] 纯文本查看 复制代码
uVar6 = func_0x00508468(uVar6, *(undefined8 *)(in_x15 + -0xb8), uVar6);

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

第五段:保存时间戳 + 返回成功
[C] 纯文本查看 复制代码
// 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 代码:
[C] 纯文本查看 复制代码
/// AuthSessionController._reportAppOpenNoInfoAsync
/// 源文件: xxx_api.dart
/// 
/// 函数名解码:
///   report (上报)
///   AppOpen (应用打开事件)
///   NoInfo (无额外信息(纯邀请码上报))
///   Async (异步函数)
Future<String> _reportAppOpenNoInfoAsync() async {
    // 读取 channelCode
    // 伪C: func_0x00557d1c(unaff_x27 + 0x1a8)
    // Blutter: 0x1a8 = AuthSessionController._channelCode 字段偏移
    final channelCode = this._channelCode;  // "default"
    
    // 判空
    // 伪C: func_0x004e9644 == 或 identical 检查
    if (channelCode == null || channelCode.isEmpty) {
        return "auth.app_open.skip";
    }
    
    // 检查 1 小时防重复
    // 伪C: func_0x00556da4 = DateTime.now()
    // 伪C: func_0x00554f98 = SharedPreferences.getString
    // 魔数: 3600000 = 60×60×1000 = 1 小时
    final now = DateTime.now().millisecondsSinceEpoch;
    final prefs = await SharedPreferences.getInstance();
    final lastReported = prefs.getInt(
        'xxx.auth.app-open-last-reported-at-mst'
    );
    
    if (lastReported != null && (now - lastReported) < 3600000) {
        return "auth.app_open.skip";
    }
    
    // 构造 protobuf 请求并发送
    // 包含 channelCode + promoPayload + deviceId + ...
    final request = AppOpenRequest(
        channelCode: channelCode,
        promoPayload: _promoPayload,  // 从 Signing Block 读取的邀请码
        deviceId: _deviceId,
    );
    
    // 伪C: func_0x00508468 (地址 = 0x5084f8 AuthRepository.reportAppOpenNoInfo)
    await authRepository.reportAppOpenNoInfo(request);
    
    // 保存上报时间
    await prefs.setInt(
        'xxx.auth.app-open-last-reported-at-mst', now
    );
    
    return "auth.app_open.success";
}

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

UN 反编译:
[C] 纯文本查看 复制代码
void AuthRepositoryreportAppOpenNoInfo
    (undefined8 param_1, int64_t param_2, undefined8 param_3)
{
    // 伪C 确认的关键常量:
    //   unaff_x27 + 0x2e40 = "POST"
    //   unaff_x27 + 0x2e50 = "/auth/device"
    // Blutter 确认: param_2 = protobuf (序列化的请求体)
    //               param_3 = (元数据/session 信息)
    
    puVar1[4] = *(undefined8 *)(unaff_x27 + 0x2e40);  // "POST"
    puVar1[2] = param_3;                                 // 请求体
    *puVar1 = *(undefined8 *)(unaff_x27 + 0x2e50);       // "/auth/device"
    
    // 调用 HTTP 客户端
    fcn.005084f8();
}

通过 Blutter 和 伪C 的交叉引用,我们认出 unaff_x27 + 0x2e40 和 unaff_x27 + 0x2e50 是类常量池中的字符串——分别是 "POST" 和 "/auth/device"。

然后,我们逆向 _calculateSignature ,即签名核心,这是最关键的函数。

UN 反编译:
[C] 纯文本查看 复制代码
// _packagexxx_fluttersrccorenetworkproto_transportdart
//   ProtoTransport_calculateSignatureNoinfo
//
// 8 个参数看起来很多,但 ARM64 调用约定中,
// x0-x7 传递参数,所以实际上是 8 个参数:

void ProtoTransport_calculateSignatureNoinfo
    (undefined8 param_1,   // x0 = this(忽略)
     undefined8 param_2,   // x1 = (字符串)
     undefined8 param_3,   // x2 = (字符串)
     undefined8 param_4,   // x3 = (整数值(会被 toString))
     undefined8 param_5,   // x4 = 
     undefined8 param_6,   // x5 = (字符串)
     undefined8 param_7,   // x6 = (字符串)
     int64_t param_8)      // x7 = (数组指针,关键)
{
    // 从数组中提取 URL,去掉 /api 前缀
    // Blutter 汇编交叉引用:
    //   0x54a608: ArrayLoad r1 = r7[0] 取 array[0]
    //   0x54a614: Load "^/api" RegExp 模式字符串
    //   0x54a638: RegExp("^/api")
    //   0x54a64c: replaceFirst(RegExp, "")
    uVar1 = func_0x004d277c();      // ArrayLoad(r7, 0)
    uVar1 = func_0x004f5620(uVar1,  // RegExp.replaceFirst
            *(undefined8 *)(unaff_x27 + 0x6510));  // "^/api" 模式
    
    // 读取数组中的 extra 值
    // param_8 + 0x1b = array[3] (类 C 结构体偏移)
    iVar6 = *(uint32_t *)(param_8 + 0x1b);
    if (iVar6 == null) {
        iVar6 = "";  // null 空字符串
    }
    
    // 构造 6 元素列表,join("\n"),SHA256
    // Blutter 汇编确认:
    //   0x54a700: AllocateArray(12 = 6×2)
    //   0x54a710-0x54a754: 依次填充 6 个字段
    //   [0] = param_4 toString (时间戳)
    //   [1] = param_3 (query/body)
    //   [2] = url stripped (路径(去掉 /api))
    //   [3] = extra (device_id/空)
    //   [4] = boxed_int (另一个整型)
    //   [5] = param_2 (secret/方法名)
    //
    //   后续操作:
    //   0x54a758: Load "\n"
    //   0x54a764: _GrowableList::join("\n")
    //   0x54a774: Utf8Encoder::convert  
    //   0x54a780: sha256.convert
    //   0x54a788: Digest::toString
    uVar1 = (**(code **)(...))();  // SHA256(join("\n")) → hex string
}

从 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 请求:

[C] 纯文本查看 复制代码
/// 构建 ProtoTransport 请求 URI 和签名头
static Uri _buildProtoTransportUri(
    int method,            // HTTP 方法 (GET=0, POST=1, ...)
    String path,           // 请求路径 ("/auth/device")
    Uint8List body,        // protobuf 请求体
    Uint8List? nonce,      // 随机 nonce
    Uint8List? sessionToken, // JWT 会话令牌
) {
    // 构建基础 URL
    var uri = Uri.parse(baseUrl + path);
    
    // 如果是 auth 相关路径,添加 _st= 参数
    if (path.startsWith('/auth/')) {
        uri = uri.replace(queryParameters: {
            ...uri.queryParameters,
            '_st': sessionToken,
        });
    }
    
    // 调用 _calculateSignature 计算 x-sign
    //    参数: secret, method, path, query, timestamp, deviceId
    final sign = _calculateSignature(
        _secret,        // 之前的 6 元素中的某个元素
        method.toString(),
        path,
        uri.query,
        timestamp,
        deviceId,
    );
    
    // 添加签名头
    headers['x-sign'] = sign;
    headers['x-nonce'] = nonce;
    headers['x-timestamp'] = timestamp;
    headers['x-device-id'] = deviceId;
    
    return uri;
}

大功告成!
此时我们可以综合 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 https://xxx.cloudfront.net/api/videos/30405/xxx

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 逆向

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

只有一行命中了所有三个头:
[JavaScript] 纯文本查看 复制代码
function PP(e, t) {
    const r = kl(),              // 设备 ID
          n = d4(),              // 时间戳
          i = Mv(),              // nonce
          s = {
            "X-Device-Id": r,
            "X-Timestamp": String(n),
            "X-Nonce": i
          },
          o = Pv();              // 获取密钥,关键!
    if (!o) return s;
    const l = Cc(t);             // 解析 URL
    const c = l.search.startsWith("?") ? l.search.slice(1) : "",
          u = qC({
            secret: o,
            method: e,
            path: l.pathname,
            query: c,
            timestamp: n,
            deviceId: r
          });
    s["X-Sign"] = u;
    return s;
}

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

为了节省时间,我们直接使用 Python 正则定位每个函数定义,比如:
[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() 函数就是获取密钥的关键函数,所以我们首先逆向它,完整定义如下:

[JavaScript] 纯文本查看 复制代码
function Pv() {
    var r;
    // 硬编码默认密钥
    const t = "6b5hR#7uVhJPZT1zAgA26fPSyx9Zh!Za".trim();
    if (t) return t;  // 永远返回这个值
    
    // localStorage 动态覆盖(紧急更换用)
    try {
        const n = (r = localStorage.getItem("xxx.signSecret")) == null
                  ? void 0
                  : r.trim();
        return n || null;
    } catch {
        return null;
    }
}

没想到,我们直接发现了密钥明文!
密钥:6b5hR#7uVhJPZT1zAgA26fPSyx9Zh!Za
32 个字符,混合大小写字母、数字和特殊字符。

现在,按照上面的方法,我们逆向一些辅助函数,让我们的后期还原更加精准,并补全我们之前在 Dart 层还未逆向的部分。
第一个要逆向的依赖是 kl(),它生成了 x-device-id
[JavaScript] 纯文本查看 复制代码
const The = Ou(mm);  // The = SHA1 哈希函数
const vS = "xxx.device-id";  // localStorage 存储键名
const xS = "device_id";  // cookie 存储键名

/**
 * 校验设备 ID 格式为 32 位小写十六进制
 */
function rj(e) {
    return /^[0-9a-f]{32}\$/.test(e);
}

/**
 * 如果 crypto.randomUUID 不可用时的回退方案:
 * 用 SHA1(当前时间戳 + "|" + 随机数) 生成设备 ID
 */
function khe(e) {
    return The(e).toString();   // SHA1 哈希后转字符串
}

/**
 * 从 cookie 中读取指定键名的值
 */
function nj(e) {
    const t = document.cookie.match(new RegExp("(^| )" + e + "=([^;]+)"));
    return t ? t[2] : null;
}

/**
 * 将键值对写入 cookie,有效期 365 天
 */
function ij(e, t) {
    const r = new Date();
    r.setTime(r.getTime() + 365 * 24 * 60 * 60 * 1000);
    const n = "expires=" + r.toUTCString();
    document.cookie = e + "=" + t + ";" + n + ";path=/";
}

/**
 * 设备 ID 生成主函数
 *
 * 优先级链(从上到下):
 *   1. NativeApp 原生接口(App 内嵌 WebView)
 *   2. Cookie "device_id" 字段
 *   3. localStorage "xxx.device-id"
 *   4. 全新生成 (crypto.randomUUID,SHA1 回退)
 *
 * 读取后还会做 cookie 和 localStorage 同步,确保两边一致。
 */
function kl() {
    var t;

    // NativeApp 原生接口
    if (En() && (t = window.NativeApp) != null && t.getDeviceId) {
        const r = window.NativeApp.getDeviceId();
        if (r) return r;
    }

    // 从 cookie 读取
    let e = nj(xS);  // cookie key = "device_id"

    // 如果 cookie 不存在或格式不对,从 localStorage 读取
    if ((!e || !rj(e)) && (e = localStorage.getItem(vS)), e && rj(e)) {
        // 同步回 cookie(确保两边一致)
        if (nj(xS) !== e) ij(xS, e);
        // 同步回 localStorage
        if (localStorage.getItem(vS) !== e) localStorage.setItem(vS, e);
        return e;
    }

    // 兜底,生成新的设备 ID
    try {
        e = crypto.randomUUID().replace(/-/g, "");  // 32 位 hex
    } catch {
        // 如果 crypto.randomUUID 不可用(低版本浏览器)
        e = khe(`\${Date.now()}|\${Math.random()}`);  // SHA1 回退
    }

    // 写入 cookie 和 localStorage
    ij(xS, e);
    localStorage.setItem(vS, e);
    return e;
}

然后我们逆向时间戳生成 d4() 函数,它的实现非常简单,一行代码:
[JavaScript] 纯文本查看 复制代码
/**
 * 时间戳偏移量:5 秒
 * 
 * dRe = 5,               // 时间偏移量(秒),用于 x-timestamp 的计算
 * fRe = "key_000001.key" // 其他无关常量
 */
const dRe = 5;

/**
 * 生成 x-timestamp 值
 *
 * 为什么减 5 秒?
 * 这是为了避免客户端时间略微快于服务器时间导致的签名验证失败。
 * 服务器在验证时会允许一定的时间窗口偏移(通常 ±300 秒),
 * 客户端主动减去 5 秒是一种常见的保守策略。
 *
 * Math.max(1, ...) 确保时间戳不会为 0 或负数。
 */
function d4() {
    return Math.max(1, Math.floor(Date.now() / 1000) - dRe);
}

这里为了保证签名验证有效,需要将当前时间戳减去5秒,这里其实是一个坑点。在 Dart 层,我们误以为这获取的就是当前时间戳。如果我们不深入逆向,可能就会导致问题。

我们终于干掉了第一头拦路虎,不过还有一个问题急需解决,就是 Dart 层中其余元素的排列方式,还有其元素的对应关系,我们可以通过逆向 qC 函数来获取完整签名算法:
[JavaScript] 纯文本查看 复制代码
function qC(e) {
    // HTTP 方法转大写
    const t = e.method.toUpperCase();     // "GET" / "POST"
    
    // 空值兜底
    const r = e.query ?? "";              // 查询字符串 or 空
    
    // 路径去前缀
    const n = e.path.replace(/^\/api/, "");  // "/api/v1/auth/device" → "/v1/auth/device"
    
    // 6 元素用换行符拼接
    const i = [
        e.secret,         // "6b5hR#7uVhJPZT1zAgA26fPSyx9Zh!Za"
        t,                // "POST"
        n,                // "/v1/auth/device"
        r,                // "ismobile=1"
        String(e.timestamp), // "1780146164"
        e.deviceId        // "fa37ed99e816c65a"
    ].join("\n");
    // 拼接结果:
    // 6b5hR#7uVhJPZT1zAgA26fPSyx9Zh!Za
    // POST
    // /v1/auth/device
    // ismobile=1
    // 1780146164
    // fa37ed99e816c65a
    
    // SHA256,小写 hex
    return Tme(i).toString();
}

终于,我们获取了一切信息!
我们可以看到,其实前文所述的 6 个元素分别对应的就是 密钥、字符串"Post"、字符串"/v1/auth/device"、字符串"ismobile=1"、时间戳、设备ID ,再直接将他们按照上述排序一起进行 SHA256 计算。
这里有一个误区,很多人看到密钥,可能就认为这个密钥一定要对明文进行加密,事实却不是这样。在这次逆向中,无论从 Dart 的代码中还是 JavaScript 中,我们都能发现,密钥实际上作为一个固定值,被直接塞进了元素组中,然后一起进行 SHA256 ,这其实一定程度上打破了我们对密钥的固有认知。

最后,对 Dart 和 Web 层的所有推断是否属实?Web 端的密钥和元素排列是否和 APP 端一致?这时就要拿出最先抓到的数据包,对其进行正向还原:
[Python] 纯文本查看 复制代码
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 写了一个完整的模拟器,通过模拟最开始提到的发送邀请码的数据包,模拟新用户被邀请,核心逻辑如下:
[Python] 纯文本查看 复制代码
import hashlib, base64, secrets, time, requests

API_BASE = "https://xxx.asia/api/v1"
SIGN_SECRET = "6b5hR#7uVhJPZT1zAgA26fPSyx9Zh!Za"

# protobuf 编码
def varint(v):
    buf = b""
    while v > 0x7F:
        buf += bytes([(v & 0x7F) | 0x80])
        v >>= 7
    buf += bytes([v & 0x7F])
    return buf

def field(n, val):
    tag = (n << 3) | 2
    return varint(tag) + varint(len(val)) + val

def build_device_request(device_id, model, ver, channel, promo):
    p = field(1, device_id.encode())   # device_id
    p += field(2, b"android")          # os
    p += field(3, model.encode())      # device_model
    p += field(4, ver.encode())        # app_version
    p += field(5, channel.encode())    # channel
    p += field(6, promo.encode())      # promo_code = 邀请码
    return p

# x-sign 签名计算
def compute_sign(method, path, query, timestamp, device_id):
    stripped = path.replace("/api", "", 1)  # 去掉 /api 前缀
    raw = "\n".join([
        SIGN_SECRET, method.upper(), stripped,
        query or "", str(timestamp), device_id
    ])
    return hashlib.sha256(raw.encode()).hexdigest()

# 主流程
def register(promo_code):
    device_id = secrets.token_hex(8)
    models = ["Redmi K70", "Xiaomi 14", "OPPO Find X7", "vivo X100", "Pixel 9"]
    
    body = build_device_request(device_id, secrets.choice(models),
                                "2.0.4+2000004", "default", promo_code)
    body_b64 = base64.b64encode(body).decode()
    
    ts = max(1, int(time.time()) - 5)  # 时间戳 -5 秒
    nonce = secrets.token_bytes(16)
    
    sign = compute_sign("POST", "/api/v1/auth/device",
                        "ismobile=1", ts, device_id)
    
    headers = {
        "Content-Type": "text/plain;charset=UTF-8",
        "x-protobuf-body-encoding": "base64",
        "x-nonce": nonce.hex(),
        "x-timestamp": str(ts),
        "x-device-id": device_id,
        "x-sign": sign,
        "x-mobile-request": "1",
        "Accept": "application/x-protobuf",
    }
    
    resp = requests.post(
        f"{API_BASE}/auth/device?ismobile=1",
        data=body_b64, headers=headers
    )
    return resp.json()  # 包含 x-session-token

使用方法:
[Shell] 纯文本查看 复制代码
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

免费评分

参与人数 5威望 +2 吾爱币 +105 热心值 +4 收起 理由
Barih + 1 + 1 谢谢@Thanks!
SVIP9大会员 + 2 + 1 我很赞同!
beatone + 1 谢谢@Thanks!
ChiZhu + 1 + 1 用心讨论,共获提升!
正己 + 2 + 100 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
正己 发表于 2026-6-6 13:21
期待师傅后续更多佳作
3#
 楼主| JiGuro 发表于 2026-6-6 13:26 |楼主
4#
htx202502 发表于 2026-6-6 13:36
5#
makmak79 发表于 2026-6-6 14:06
厉害厉害,太强了
6#
Barih 发表于 2026-6-6 16:16
好文。另外模拟器奔溃我也遇到过,好像是因为x86架构运行arm64 app导致的。用真机一切正常。
7#
 楼主| JiGuro 发表于 2026-6-6 16:40 |楼主
Barih 发表于 2026-6-6 16:16
好文。另外模拟器奔溃我也遇到过,好像是因为x86架构运行arm64 app导致的。用真机一切正常。

感觉不像,我早就知道我的模拟器是x86的,这软件也支持x86,如果单跑没有任何问题,但一旦上Frida就闪退(Frida也是x86版本)
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-6-6 20:14

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

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