-
-
[原创]实战某 Box APP全流程分析(检测绕过/登录分析/视频解锁/native加密/广告绕过)
-
发表于: 18小时前 335
-
tags:
- "#android"
- "#root_anti"
- "#start_anti"
- "#native"
- "#blowfish"
- "#AES"
BeautyBox 全流程分析
注:附件中有APK源文件与hook代码,需要自取
0. 分析结论摘要
基本判断
- APP 名称:BeautyBox
- 包名:com.secret.prettyhezi
- 版本:5.1.5 / 120
- 目标 SDK:29
- 主要载荷:Java 层存在明显混淆,Native 层加载
libali.so - 本次重点:重建样本基础信息、Manifest、启动链静态分析,以及
abc.c()的 Native 解密算法
核心发现
- 启动入口为
com.secret.prettyhezi.OuiCrGxF,Application为com.secret.prettyhezi.MainApplication。 - 启动链同时存在 Root、模拟器、Xposed 等多条会直接触发
m0()退出的环境检测分支;debuggable检测则更偏提示性质。 - 启动阶段除了环境检测外,还会进入广告放行、登录态恢复与页面跳转等关键分支。
libali.so通过JNI_OnLoad动态注册c.abc,承担关键字符串常量返回与编解码能力。abc.c(1)/c(2)/c(3)并非简单查表,而是“密文表 + 自定义预处理 + Blowfish 解密 + 自定义后处理”的完整 Native 常量保护方案。
按实际逆向推进顺序看
- 第一步先从
Manifest确认入口:MainApplication与OuiCrGxF。 - 第二步进入启动页
OuiCrGxF.onCreate(),定位多条启动检测与直接退出分支。 - 第三步结合
hook.js绕过 Java 层检测、广告拦截与 Nativeptrace反调试,让样本能够顺利进入后续流程。 - 第四步继续顺着启动页向下拆,确认广告放行、登录态恢复与页面跳转的衔接关系,并识别出
abc.c()与ea2/da2的 Native 介入点。 - 第五步分析登录页与自动登录链,确认请求统一汇入
j.t(mode=1),再由abc.ea5/da5负责请求与响应的 Native 编解码。 - 第六步分析视频详情与积分解锁链,确认
user/pverify/json -> rrvideo/unlock/json -> 用户态回写的完整业务流程,并用真实日志验证积分与经验变化。
推荐阅读顺序
- 如果按“完整流程分析”来读,建议优先看:
4. Manifest 分析 -> 5. 主流程分析 -> 6. Native 层分析。 - 先把第 5 节的启动、广告、登录、视频解锁主线串起来,再回看第 6 节,会更容易把
abc.c()、ea2/da2、ea5/da5分别对应到真实业务位置上。
1. 样本信息
1.1 APK 基本信息
| 项目 | 内容 |
|---|---|
| 文件路径 | com.secret.prettyhezi/BeautyBox_5.1.5.apk |
| APP 名称 | BeautyBox |
| 包名 | com.secret.prettyhezi |
| versionName | 5.1.5 |
| versionCode | 120 |
| compileSdkVersion | 29 |
| minSdkVersion | 21 |
| targetSdkVersion | 29 |
| 签名算法 | SHA256withRSA |
| 公钥算法 | 2048-bit RSA |
| debuggable | false |
| 分析日期 | 2026-04-27 |
1.2 Hash 信息
| 类型 | 值 |
|---|---|
| MD5 | C5E825297315F309107396FBFC5483C5 |
| SHA1 | 1D55E7D0DC405F9DF68795782A8AC0397286DC90 |
| SHA256 | 39161FFA49CB22D334E8899A2727467DE7CEE94AF74C953FCD9A1746F22DBCAC |
2. 分析环境
2.1 设备 / 运行环境
| 项目 | 内容 |
|---|---|
| 设备类型 | Redmi K40s |
| Android 版本 | 13 |
| CPU 架构 | arm64-v8a / armeabi-v7a |
| Root 状态 | 是 |
| Magisk | 已安装 |
| Frida | 16.7.19 |
| frida-server | 16.7.19 |
2.2 工具清单
| 工具 | 用途 |
|---|---|
| JADX / JADX MCP | Java 反编译、Manifest 分析 |
| IDA / IDA MCP | libali.so Native 分析 |
| adb | 安装、日志、设备交互 |
| Frida | 动态 Hook |
| MT 管理器 | APK 初步查看 |
3. 初始处理记录
3.1 初始观察
- 样本未见明显壳特征,可直接进入
jadx分析。 - 在已 Root 且装有 Magisk 的实验机上启动时出现白屏并退出,说明启动期存在明显环境检测。
- 从
jadx代码来看,OuiCrGxF.onCreate()是启动检测与配置调度的总入口。
3.2 原始文件与分析载荷
- 原始 APK:
com.secret.prettyhezi/BeautyBox_5.1.5.apk - Native 分析 IDB:
com.secret.prettyhezi/lib/arm64-v8a/libali.so.i64
3.3 本次实际推进顺序
- 先读
AndroidManifest.xml,确认MainApplication与OuiCrGxF是后续静态分析的主抓手。 - 再跟进
OuiCrGxF.onCreate(),因为样本一启动就白屏退出,优先要解释“为什么起不来”。 - 锁定
p0.d / p0.e / p0.a等启动检测后,结合hook.js逐项绕过 Java 检测,并在 Native 层替换ptrace,让样本能顺利跑通启动流程。 - 样本放行后,继续沿
OuiCrGxF拆出“广告放行 → 登录态恢复 → 页面跳转”这条启动主线。 - 在登录相关代码里观察到请求最终汇入
j.t(mode=1),再顺着Server.e.c/a -> abc.ea5/da5识别出 Native 编解码链。 - 最后进入视频详情与积分解锁场景,用动态日志验证
user/pverify/json、rrvideo/unlock/json、积分扣减、经验刷新与keyCurUser<uid>回写是否和静态分析一致。
3.4 初步判断
- 启动失败与环境检测高度相关,不像普通网络错误或资源加载失败。
MainApplication.<clinit>()会在 Java 层很早加载libali.so,说明 Native 逻辑不是边缘功能。
4. Manifest 分析
4.1 AndroidManifest.xml 关键字段
| 项目 | 内容 |
|---|---|
| package | com.secret.prettyhezi |
| application name | com.secret.prettyhezi.MainApplication |
| main activity | com.secret.prettyhezi.OuiCrGxF |
| allowBackup | false |
| largeHeap | true |
| usesCleartextTraffic | true |
| requestLegacyExternalStorage | true |
| exported activity 数量 | 1 |
4.2 权限分析
| 权限 | 风险等级 | 用途判断 | 是否合理 |
|---|---|---|---|
| android.permission.ACCESS_NETWORK_STATE | 低 | 网络状态检测 | 是 |
| android.permission.INTERNET | 低 | 网络通信 | 是 |
| android.permission.READ_EXTERNAL_STORAGE | 中 | 读取外部存储 | 是 |
| android.permission.WRITE_EXTERNAL_STORAGE | 中 | 写入外部存储 | 是 |
| android.permission.REQUEST_INSTALL_PACKAGES | 高 | 安装 APK/更新包 | 是 |
| android.permission.WAKE_LOCK | 低 | 保持设备唤醒 | 是 |
4.3 组件观察
- 主入口 Activity:
com.secret.prettyhezi.OuiCrGxF FileProvider:com.secret.prettyhezi.fileprovider- 从 Manifest 看不到显式
networkSecurityConfig,但usesCleartextTraffic="true"已说明允许明文流量
5. 主流程分析
打开 APP 先白屏退出,于是先去看 Manifest 和启动 Activity,定位到 OuiCrGxF.onCreate()。
图 5-1 样本启动后先进入 OuiCrGxF.onCreate(),因此后续环境检测、广告控制、登录恢复等主流程都可以从这个入口继续往下追。
5.1 启动阶段环境检测链
环境评分检测
com.secret.prettyhezi.OuiCrGxF.onCreate()
→ p0.d.b().d(this, null)
→ m0()
→ System.exit(0)样本在启动很早阶段就会做一轮“多特征累计打分”的环境检测,命中后直接退出,不会继续走后续初始化。
// com.secret.prettyhezi.OuiCrGxF.onCreate(android.os.Bundle)
protected void onCreate(Bundle bundle) {
super.onCreate(bundle);
com.secret.prettyhezi.View.s.c();
W0();
// 启动后第一批检测之一就是 p0.d 的环境评分
// 一旦返回 true,就直接调用 m0() 结束整个 APP
if (p0.d.b().d(this, null)) {
m0();
return;
}
...
}
// p0.d.b().d(this, null)
public boolean d(Context context, c cVar) {
String strA = a("gsm.version.baseband");
// 基带版本异常时先记 1 分
int i6 = (strA == null || strA.contains("1.0.0.0")) ? 1 : 0;
String strA2 = a("ro.build.flavor");
// build flavor 命中 vbox / sdk_gphone 这类模拟器特征时加分
if (strA2 != null && (strA2.contains("vbox") || strA2.contains("sdk_gphone"))) {
i6++;
}
String strA5 = a("ro.hardware");
if (strA5 == null) {
i6++;
} else if (strA5.toLowerCase().contains("ttvm") || strA5.toLowerCase().contains("nox")) {
// 命中夜神 / Nox 等特征时大幅加分
i6 += 10;
}
int size = ((SensorManager) context.getSystemService("sensor")).getSensorList(-1).size();
// 传感器数量过少也会作为可疑特征
if (size < 7) {
i6++;
}
int iC = c(p0.b.c().a("pm list package -3"));
// 用户安装应用数量太少时,也更像模拟器环境
if (iC < 5) {
i6++;
}
return i6 > 3;
}
函数作用:
OuiCrGxF.onCreate():启动页总入口,前半段就串入了环境检测。p0.d.d(Context, c):按多项系统属性与设备特征累计打分判断是否像模拟器/虚拟环境。m0():统一结束所有活动并执行System.exit(0)。
Xposed 检测
com.secret.prettyhezi.Yclh4J3zF.onCreate()
→ p0.e.b().f()
→ m0()
Xposed 检测在父类 onCreate() 中就已经执行,子类页面逻辑开始前就可能被拦截。
// com.secret.prettyhezi.Yclh4J3zF.onCreate(android.os.Bundle)
protected void onCreate(Bundle bundle) {
super.onCreate(bundle);
...
// 父类 onCreate 中就会先做 Xposed 检测
// 命中后直接结束,不继续后续初始化
if (p0.e.b().f()) {
m0();
}
...
}
// p0.e.b().f()
public boolean f() {
try {
throw new Exception("gg");
} catch (Exception e6) {
for (StackTraceElement stackTraceElement : e6.getStackTrace()) {
// 通过异常栈中是否出现 XposedBridge 来判断 Xposed
if (stackTraceElement.getClassName().contains("de.robv.android.xposed.XposedBridge")) {
return true;
}
}
return false;
}
}
debuggable 检测
com.secret.prettyhezi.OuiCrGxF.onCreate()
→ p0.e.b().a(this)
→ I("破解要小心哦~")
debuggable 检测更偏提示性质,不承担强制退出逻辑。
// 这里检查当前包是否处于 debuggable 状态
// 命中后只弹提示,不会立即退出
if (p0.e.b().a(this)) {
I("破解要小心哦~");
}
// p0.e.b().a(this)
public boolean a(Context context) {
return (context.getApplicationInfo().flags & 2) != 0;
}
第二套模拟器检测
这个检测藏在开头的父onCreate()中
图 5-2 除了前面的评分式检测外,样本在父类 onCreate() 中还额外埋了一套模拟器特征检测,命中后同样会直接退出。com.secret.prettyhezi.OuiCrGxF.onCreate()
→ p0.a.c(this)
→ m0()
除了打分式环境检测外,样本还有一套独立的模拟器特征检测链,命中后同样直接退出。
// 这是另一套模拟器特征检测
// 只要命中特征,就直接结束 APP
if (p0.a.c(this)) {
m0();
return;
}
// p0.a.c(this)
public static boolean c(Context context) {
ArrayList arrayList = new ArrayList();
try {
String strB = b(a(context));
// 先查静态特征,命不中再走备用检测
if (TextUtils.isEmpty(strB)) {
List listD = d(context);
if (listD.size() > 0) {
arrayList.add(listD.get(0));
}
} else {
arrayList.add(strB);
}
} catch (Exception e6) {
e6.printStackTrace();
}
return !arrayList.isEmpty();
}
Root 检测
com.secret.prettyhezi.OuiCrGxF.onCreate()
→ p0.e.b().d()
→ m0()
Root 检测直接参与流程控制,命中后也不会继续初始化。
// Root 检测命中后同样直接结束流程
if (p0.e.b().d()) {
m0();
}
// p0.e.b().d()
public boolean d() {
// Root 检测分两步:
// 1. 先查 ro.secure
// 2. 再查常见 su 路径
if (c() == 0) {
return true;
}
return e();
}
private int c() {
String strB = p0.b.c().b("ro.secure");
return (strB != null && "0".equals(strB)) ? 0 : 1;
}
private boolean e() {
String[] strArr = {"/sbin/su", "/system/bin/su", "/system/xbin/su", "/data/local/xbin/su",
"/data/local/bin/su", "/system/sd/xbin/su", "/system/bin/failsafe/su", "/data/local/su"};
for (int i6 = 0; i6 < 8; i6++) {
// 常见 su 路径只要存在,就认定设备已 Root
if (new File(strArr[i6]).exists()) {
return true;
}
}
return false;
}
Hook 代码:
// xposed 检测,直接返回 false
var xposed_hook = p0_e.f.overload();
xposed_hook.implementation = function() {
return false;
};
// 虚拟机检测,直接返回 false
var xuniqi_hook = p0_d.d.overload("android.content.Context", "p0.c");
xuniqi_hook.implementation = function(context, p0_c_instance) {
return false;
};
// debug 检测,直接返回 false
var debug_hook = p0_e.a.overload("android.content.Context");
debug_hook.implementation = function(context) {
return false;
};
// 模拟器检测,直接返回 false
var moniqi_hook = p0_a.c.overload("android.content.Context");
moniqi_hook.implementation = function(context) {
return false;
};
// root 检测,直接返回 false
var root_hook = p0_e.d.overload();
root_hook.implementation = function() {
return false;
};
函数作用 / 绕过意义:
p0.e.f / p0.d.d / p0.e.a / p0.a.c / p0.e.d:分别对应 Xposed、虚拟环境、debuggable、模拟器与 Root 检测,是启动放行前必须处理的 Java 层拦截点。
5.2 开屏广告控制链
广告创建与绕过
前面把启动检测处理掉之后,样本并不会立刻进入登录页或主页。配置返回成功后,程序会先进入 OuiCrGxF$q.run(),这里开始接管启动页后半段流程。
这一段里最容易误判的地方,是把广告逻辑看成一个独立功能点。实际上它更像一个“延迟放行器”。OuiCrGxF$q.run() 里先调用 com.secret.AD.h.i() 判断当前是否需要创建广告对象;如果返回 true,就通过 new com.secret.AD.h(OuiCrGxF.this, new d()) 把广告控制器挂到启动页上。
广告对象创建之后,并不会立刻决定页面跳转。com.secret.AD.h 的构造函数内部会马上调用 com.secret.AD.f(),开始倒计时;倒计时归零,或者用户手动点击跳过后,才会进入com.secret.AD.h(),最终回调到 OuiCrGxF$q$d.run(),再由它调用 OuiCrGxF.e1()。
这里真正需要抓住的不是广告展示本身,而是 OuiCrGxF.e1() 的角色。OuiCrGxF.e1() 才是整个启动页的统一放行口。它会同时检查两件事:一是广告是否已经结束,二是启动后续流程是否已经完成。只有当广告对象为空或 hVar.g() 返回 true,并且 f6972x 已经被置位之后,程序才会继续跳转到登录页或主页。
整个调用链:OuiCrGxF$q.run()→ com.secret.AD.h.i()→ new com.secret.AD.h(OuiCrGxF.this, new d())→ com.secret.AD.h.f()
→ 倒计时结束或点击跳过→ com.secret.AD.h.h()→ OuiCrGxF$q$d.run()→ OuiCrGxF.e1()→ hVar.g()
→ 跳转登录页或主页
函数作用:
q.run():启动页后续调度节点,在前置条件满足后决定是否进入广告创建流程。com.secret.AD.h.i():判断当前是否需要创建广告对象。new com.secret.AD.h(..., new d()):真正实例化广告控制器,并把“广告结束后的回调”挂到启动页。com.secret.AD.h.g():判断广告是否可视为已结束或无需继续等待。OuiCrGxF.e1():启动页统一放行口,决定最终跳转登录页还是主页面。
// OuiCrGxF$q.run()广告核心代码
if (com.secret.AD.h.i()) {
OuiCrGxF.this.f6971w = new com.secret.AD.h(OuiCrGxF.this, new d());
OuiCrGxF ouiCrGxF3 = OuiCrGxF.this;
ouiCrGxF3.f6965q.addView(ouiCrGxF3.f6971w, new RelativeLayout.LayoutParams(-1, -1));
}
// com.secret.AD.i()
public static boolean i() {
// 已有广告正在显示,或者本地没有广告配置时,都不创建广告
return (f6045i || g4.f.a().f().isEmpty()) ? false : true;
}
// com.secret.AD.g()
public boolean g() {
// 只有广告对象为空或倒计时结束,启动页才会继续放行
return this.f6021c == null || this.f6047g <= 0;
}
// com.secret.prettyhezi.OuiCrGxF.e1()
public void e1() {
if (this.f6966r || !this.f6972x) {
return;
}
com.secret.AD.h hVar = this.f6971w;
// 这里才是统一放行口:
// 没有广告对象,或者广告已经结束,才会继续跳转后续页面
if (hVar == null || hVar.g()) {
a0 a0VarR = MainApplication.f6868r.r();
if (a0VarR == null) {
cls = CfTs9fWO.class;
} else {
cls = ZIgnJ.class;
}
N0(cls);
finish();
}
}
Hook 代码:
// 跳过广告创建
var ad_hook = AD_h.i.overload();
ad_hook.implementation = function() {
return false;
};
// 让广告状态直接视为已结束
var ad_stop_time_hook = AD_h.g.overload();
ad_stop_time_hook.implementation = function() {
return true;
};
// 广告是否被初始化以及获取广告剩余时间,返回 true 绕过检测
var AD_h = Java.use("com.secret.AD.h");
var init = AD_h.$init.overload("com.secret.prettyhezi.Yclh4J3zF", "java.lang.Runnable");
init.implementation = function (ctx, r) {
console.log("广告被初始化");
var ret = init.call(this, ctx, r);
try {
var fields = AD_h.class.getDeclaredFields();
for (var i =0; i < fields.length; i++) {
console.log("field name: " + fields[i].getName() + " value: " + fields[i].get(this) + " type: " + fields[i].getType());
}
this.g.value = 0;
console.log("广告剩余时间:" + this.g.value);
} catch (e) {
console.log("dump fields failed: " + e);
}
return init.call(this, ctx, r);
};
5.3 登录分析
前面把启动检测和广告放行链理顺之后,样本才真正走到“要不要给你业务权限”这一步。这个阶段最值得跟的不是某个孤立接口,而是登录态到底怎么建立、怎么落盘、又怎么在下次启动时被重新捞回来。BeautyBox 在这里没有走简单明文请求,而是把登录页输入、设备态字段、Native 编解码和本地登录态缓存串成了一条完整链路,所以只要把这一段看明白,后面的自动登录恢复和视频解锁链就都能顺着接上。
手动登录请求构造与发送
CfTs9fWO.onCreate()→ this.f7644v.setOnClickListener(new c())→ CfTs9fWO$c.a(View)→ CfTs9fWO.B(new c.a())
→ 验证码回调c.a.b(String str, String str2)→ CfTs9fWO.W0(captchaKey, captchaPos)→ j.r(...)→ j.t(..., mode=1, ...)→ Server.e.c(...)→ abc.ea5(...)
图 5-3 登录按钮点击后并不是立即把账号密码直接发出去,而是先进入验证码回调,再由 W0() 统一组装 auth/json 请求。
真正顺着登录页往下跟时,会发现它并不是“点按钮就直接发一个账号密码过去”。登录按钮最终会落到 CfTs9fWO.W0(captchaKey, captchaPos),先把账号、密码、验证码标识和验证码坐标统一组装进 Registration.p,再交给 j.r() 和 j.t() 进入统一 POST 链。也就是说,登录页本身只负责把输入整理成一份标准请求对象,真正负责把这份对象送进网络层的,是后面的 j.t(..., mode=1)。
这一步之所以关键,是因为它正好把 Java 业务层和 Native 加密层连了起来。j.t() 在 mode=1 时不会把 JSON 明文直接发出去,而是先通过 Server.e.c() 进入 abc.ea5()。换句话说,登录链并不是“Java 里构造好参数就结束”,而是从这里开始正式进入 Native 字节级请求加密流程。
// CfTs9fWO.W0(captchaKey, captchaPos)
void W0(String str, String str2) {
String strTrim = this.f7642t.f8426c.getText().toString().trim();
String strTrim2 = this.f7643u.f8594b.getText().toString().trim();
J0();
// 这里把账号、密码、验证码 key、验证码坐标统一装进 Registration.p
// 然后发往 auth/json,正式进入登录请求链
com.secret.prettyhezi.j.r(
com.secret.prettyhezi.Server.v.f7323a + "auth/json",
new com.secret.prettyhezi.Registration.p(strTrim, strTrim2, str, str2),
false,
new g(this, strTrim, strTrim2)
);
}
// com.secret.prettyhezi.Registration.p
public class p extends k {
public String x5;
public String x7;
public String xi;
public String xj;
public p(String str, String str2, String str3, String str4) {
this("a", str, str2, str3, str4);
}
public p(String str, String str2, String str3, String str4, String str5) {
super(str);
// x5 = 账号
this.x5 = str2;
// x7 = 密码
this.x7 = str3;
// xi = 验证码 key
this.xi = str4;
// xj = 验证码点击坐标
this.xj = str5;
}
}
// j.r()
public static void r(String str, Object obj, boolean z5, e eVar) {
// j.r 先把对象转成 JSON
// 再继续下沉到 u / t 这条统一 POST 链
u(str, com.secret.prettyhezi.f.e(obj), z5, 1, eVar);
}
// j.t()
public static void t(String str, String str2, String str3, int i6, e eVar) {
RequestBody requestBodyCreate;
if (i6 > 0) {
try {
// mode=1 时,请求体不会明文发送
// 而是先交给 Server.e.c -> abc.ea5 做 Native 字节级加密
requestBodyCreate = RequestBody.create(f8646d, com.secret.prettyhezi.Server.e.c(str2, i6));
} catch (Exception e6) {
e6.printStackTrace();
requestBodyCreate = null;
}
} else {
requestBodyCreate = RequestBody.create(f8646d, str2);
}
...
}
函数作用:
CfTs9fWO.W0():登录页真正构造auth/json请求的位置。Registration.p:登录请求体对象,承载账号、密码、验证码 key 与点击坐标。j.r() / j.t():统一 POST 请求入口,mode=1时触发 Native 字节级加密。Server.e.c()/abc.ea5():把登录 JSON 包装成发送给服务端的加密字节流。
这里还要补一层很容易被忽略的关系:登录请求里拼接出来的 com.secret.prettyhezi.Server.v.f7323a + "auth/json",其中 f7323a 并不是 Java 层硬编码的明文域名,而是“本地缓存优先,Native 默认域名兜底”的运行时主站地址。Server.v 类加载时会先尝试读取本地 keyLastServer4;如果本地已经保存过上次成功使用的主站,就直接复用;如果没有,才退回 abc.c(1) 作为默认主站。
与之对应,f7324b 则来自 abc.c(2);而 Server.v.b() 这一段代码中,又确实存在 abc.c(2) 与 abc.c(3) 之间的切换逻辑。更稳妥的理解是:abc.c(1) / c(2) / c(3) 在 Java 业务层首先表现为 3 个内置站点常量,而不是所有业务都统一遵循“1 不通走 2、2 不通走 3”的通用主备链。就当前登录请求这条链来看,它明确拼接的是 f7323a + "auth/json",而 f7323a 的默认值正是 abc.c(1)。这样再回头看登录请求,就能看明白:APP 发送的并不是一个写死到 Java 代码里的 auth/json 完整 URL,而是“运行时主站 + 业务路径”的动态拼接结果,而这个主站本身又是由 Native 层 abc.c() 提供初始值的。
// com.secret.prettyhezi.Server.v
public static String f7323a = g4.f.a().i("keyLastServer4", abc.c(1));
public static String f7324b = abc.c(2);
public static String b() {
String strC = abc.c(2);
if (strC.equals(f7324b)) {
strC = abc.c(3);
}
f7324b = strC;
return strC;
}
结合 jadx 继承关系观察,可进一步对齐出 mode=1 登录请求体中的关键字段:
x5:账号x7:密码xi:验证码 keyxj:验证码点击坐标x1/x0/x2/x3/x4:来自父类请求对象的设备标识、固定类型、设备 ID、系统版本与厂商型号
图 5-4 从登录请求体可以直接对齐出账号、密码、验证码 key、点击坐标,以及设备标识等字段,说明登录请求在进入 Native 前已经在 Java 层完成了标准化组装。
登录成功后的用户态更新与本地落盘
j.t(..., mode=1, new g(...))
→ okhttp Callback.onResponse()
→ Server.e.a(resp.bytes(), 1)
→ abc.da5(...)
→ CfTs9fWO$g.g(String)
→ MainApplication.A(pVar.data)
→ MainApplication.x() / MainApplication.z(uid, token)
→ g4.f.a().l(account, password, token)
→ BrmpD.T0(...) 或 N0(ZIgnJ.class)
把请求发出去之后,登录链真正值得盯的不是“有没有返回 200”,而是“这次登录成功到底在客户端落下了哪些状态”。从 jadx 看,响应包先回到统一网络层,再经 Server.e.a() -> abc.da5() 解密成明文 JSON,最后才进入 CfTs9fWO$g.g(String) 这个登录成功回调。也就是说,前面手动输入的账号密码只是把请求送到服务端,真正决定下次能不能自动恢复、能不能直接进主页的,是这里这一串连续的本地状态更新动作。
这一段的结论很明确:MainApplication.A() 先把服务端返回的用户对象塞进当前进程内存,再通过 x() 和 z() 把 keyCurUser<uid>、keyCurUID、keyCurToken 写回本地;与此同时,刚才在登录页输入的账号和密码也会通过 g4.f.a().l(...) 一起落盘。这样下次启动时,APP 就不是“重新让你从零登录一次”,而是直接拿现成的本地状态去尝试恢复。
图 5-5 登录真正完成的标志不是页面跳转,而是用户对象、UID、Token、账号和密码都已经被写回内存或本地缓存。
// com.secret.prettyhezi.User.CfTs9fWO$g.g(java.lang.String)
class g extends t.g {
...
@Override // com.secret.prettyhezi.t.g
public void g(String str) {
p pVar = (p) com.secret.prettyhezi.f.d(str, p.class);
if (pVar.code != 200) {
MainApplication.f6868r.A(null);
f(pVar.err);
return;
}
// 登录成功后,先把返回的用户对象写入当前应用状态
UytRUHE.V0();
MainApplication.f6868r.A(pVar.data);
// 再把账号、密码和 token 时效信息持久化
g4.f.a().l(this.f7654b, this.f7655c, pVar.data.token);
NbNhY.T0(null);
...
}
}
// com.secret.prettyhezi.MainApplication.A(com.secret.prettyhezi.Server.a0) / x()
public void A(a0 a0Var) {
// 这里更新当前内存中的用户对象
this.f6887p = a0Var;
// 并立即触发 keyCurUser<uid> 的写回
x();
if (a0Var == null) {
z(0L, HttpUrl.FRAGMENT_ENCODE_SET);
this.f6885n = null;
this.f6884m = -1;
return;
}
// 登录成功后,这里同步刷新全局 UID 与 Bearer Token
z(a0Var.UserId(), a0Var.token.token);
...
}
public void x() {
a0 a0Var = this.f6887p;
if (a0Var == null) {
g4.f.a().q("keyCurUser" + f(), HttpUrl.FRAGMENT_ENCODE_SET);
return;
}
// 当前用户对象会以 JSON 形式写入 keyCurUser<uid>
String strE = f.e(a0Var);
g4.f.a().q("keyCurUser" + this.f6887p.UserId(), strE);
}
// com.secret.prettyhezi.MainApplication.z(long, java.lang.String) / g4.f.l(java.lang.String, java.lang.String, com.secret.prettyhezi.Server.a0$h)
public static void z(long j6, String str) {
if (str == null) {
str = HttpUrl.FRAGMENT_ENCODE_SET;
}
// keyCurUID / keyCurToken 是全局登录态恢复的核心落点
f6869s = j6;
f6870t = str;
g4.f.a().p("keyCurUID", f6869s);
g4.f.a().q("keyCurToken", f6870t);
}
public void l(String str, String str2, a0.h hVar) {
SharedPreferences.Editor editorEdit = this.f10716b.edit();
// 登录页输入的账号和密码会直接缓存,供启动期自动登录复用
editorEdit.putString(f10710e, str);
editorEdit.putString(f10711f, str2);
editorEdit.commit();
// 这里单独保存 token 与过期时间
s(hVar);
...
}
函数作用:
CfTs9fWO$g.g(String):登录成功回调,负责解析响应并驱动整套登录态更新。Server.e.a()/abc.da5():对mode=1的响应体做 Native 字节级解密。MainApplication.A():刷新当前用户对象,并同步调用x()/z()更新keyCurUser<uid>、keyCurUID、keyCurToken。g4.f.l():把账号、密码以及 token 时效信息持久化,供启动期自动登录复用。
如果只抓一句结论来概括这一段,那就是:登录真正完成的标志,不是界面跳了,而是 keyCurUser<uid>、keyCurUID、keyCurToken、keyAccount、keyPassword 这几类状态都已经被写下来了。后面所有“自动恢复登录”“启动后直进主页”的能力,都是建立在这里。
启动期自动登录恢复
OuiCrGxF.j1()
→ runOnUiThread(new n())
→ OuiCrGxF$n.run()
→ Device.a.i() != null ? d1() : l1()
→ d1() 中优先走 Server.v.w(token, b1()),失败再退回 o1()
→ o1() 中继续请求 auth/json
→ OuiCrGxF$f.c(String)
→ MainApplication.A(pVar.data) / g4.f.a().s(pVar.data.token)
→ f6972x = true
→ e1()
把登录落盘链看完之后,再回头看启动页里的恢复逻辑就会顺很多。样本并不是每次启动都老老实实把用户重新送回登录页,而是会先把上一次保存下来的状态都拿出来试一遍。这里最重要的自检点,是不要把它写成单线流程。OuiCrGxF.j1() 之后先进入 n.run(),然后根据 Device.a.i() 是否存在分成两路:设备标识已经准备好时走 d1(),否则先走 l1() 补设备态,再回到 d1()。
进入 d1() 之后也不是只有一条路。它会先看本地有没有 keyCurToken 对应的可用 token;如果有,就直接调用 Server.v.w(token, b1()) 走 token 恢复;如果 token 为空或失效,才退回 o1(),用 keyAccount/keyPassword 再发一次 auth/json。这也就解释了为什么前面登录成功时写下来的 keyAccount、keyPassword、keyCurToken 都那么关键,因为启动页后半段会把它们全部重新用起来。
还有一个很容易漏掉的点:d1() 末尾还有 if (this.f6974z == null) postDelayed(new g(), 400L) 这条放行路径。它说明启动页不会无限等待登录恢复回调;当恢复回调对象都还没建立起来时,程序会在 400ms 后主动把 f6972x 置位,再交给 e1() 统一决定后续跳转。所以这一段更准确的理解不是“启动必然先成功恢复登录再跳页面”,而是“启动页会尽量恢复登录,同时和广告放行口一起汇入 e1() 做最终决策”。
// com.secret.prettyhezi.OuiCrGxF.d1() / o1()
void d1() {
if (MainApplication.f6868r.b()) {
String strH = g4.f.a().h();
if (strH == null || strH.length() <= 0) {
// 本地 token 失效时,退回账号密码自动登录
o1();
} else {
// token 仍有效时,优先走 token 直接恢复
com.secret.prettyhezi.Server.v.w(strH, b1());
}
}
if (this.f6974z == null) {
this.f6965q.postDelayed(new g(), 400L);
}
}
void o1() {
String strF = g4.f.a().f();
String strG = g4.f.a().g();
if (strF.isEmpty() || strG.isEmpty()) {
return;
}
this.f6973y = true;
// 启动页自动登录同样走 auth/json,只是验证码 key 改为 abc.c(9)
com.secret.prettyhezi.j.r(
com.secret.prettyhezi.Server.v.f7323a + "auth/json",
new com.secret.prettyhezi.Registration.p(strF, strG, abc.c(9), "26,37"),
false,
b1()
);
}
// com.secret.prettyhezi.OuiCrGxF$n.run() / l1() / b1()
class n implements Runnable {
@Override
public void run() {
OuiCrGxF.this.f6970v = true;
// 已有设备标识就直接进入恢复逻辑,否则先补设备态
if (com.secret.prettyhezi.Device.a.i() != null) {
OuiCrGxF.this.d1();
} else {
OuiCrGxF.this.l1();
}
}
}
void l1() {
// 这里先请求 auth/json 拿到设备相关恢复材料
com.secret.prettyhezi.j.r(
com.secret.prettyhezi.Server.v.f7323a + "auth/json",
new com.secret.prettyhezi.Registration.g("i"),
false,
new p(this, new o())
);
}
j.e b1() {
// 自动恢复统一复用 OuiCrGxF.f 这个回调对象
if (this.f6974z == null) {
this.f6974z = new f();
}
return this.f6974z;
}
// com.secret.prettyhezi.OuiCrGxF$f.c(java.lang.String)
class f extends j.e {
@Override
public void c(String str) {
OuiCrGxF.this.f6965q.post(new a(str));
}
class a implements Runnable {
final String raw;
a(String str) {
this.raw = str;
}
@Override
public void run() {
com.secret.prettyhezi.Server.p pVar =
(com.secret.prettyhezi.Server.p) com.secret.prettyhezi.f.d(this.raw, com.secret.prettyhezi.Server.p.class);
if (pVar.code == 200) {
// token 恢复或账号密码自动登录成功后,都在这里重新写回用户态
MainApplication.f6868r.A(pVar.data);
g4.f.a().s(pVar.data.token);
NbNhY.T0(null);
} else if (!OuiCrGxF.this.f6973y) {
// token 恢复失败,再退回账号密码自动登录
OuiCrGxF.this.o1();
return;
} else {
MainApplication.f6868r.A(null);
}
OuiCrGxF.this.f6972x = true;
OuiCrGxF.this.e1();
}
}
}
函数作用:
j1() / n.run():启动页把登录恢复正式排上执行队列,并决定先走设备态恢复还是直接进入登录恢复。d1():启动期判断走 token 恢复还是账号密码自动登录。l1():设备标识尚未准备好时,先请求一轮auth/json补设备态,再回到恢复流程。o1():用keyAccount/keyPassword构造自动登录请求。OuiCrGxF$f.c():自动恢复统一回调,负责失败回退、成功写回与最终放行。
本节小结:
- 手动登录链的核心是
Registration.p -> j.t(mode=1) -> abc.ea5/da5 -> MainApplication.A(),也就是“输入整理 -> Native 加密 -> 响应解密 -> 用户态落盘”这一整条闭环。 - 启动恢复链的核心是先试
token,失败后再退回keyAccount/keyPassword重新请求auth/json,同时还带着设备态补齐与e1()放行协同逻辑。 - 登录态真正落地的位置有三类:
keyCurUser<uid>、keyCurUID/keyCurToken、keyAccount/keyPassword。理解了这几个落点,后面再看主页直达、视频解锁后的用户态回写,就不会再觉得它们是孤立现象。
5.4 视频解锁分析
视频详情定位与锁态判断
EkHbSOqG.onCreate()
→ p1()
→ j.m(Server.v.f7323a + b1(), new j(this))
→ EkHbSOqG$j.g(String)
→ VOGkBN.i1(String)
→ this.P = !vVar.data.st
真正去跑视频解锁链时,第一步不是先看 unlock/json,而是先看详情页到底怎么判断“当前视频是否还锁着”。从 EkHbSOqG.onCreate() 往下跟,页面初始化后会立刻调用 p1() 拉取详情,长视频这里最终会落到 VOGkBN.i1(String)。在这个回调里,程序把服务端返回的详情对象塞进 this.N,再根据 vVar.data.st 计算 this.P。换句话说,锁态并不是客户端本地拍脑袋判断的,而是详情接口返回后才真正确定。
如果用户此时点的是“试看”而不是“积分解锁”,程序还会进入 VOGkBN.L1(),额外请求一次 rrvideo/play/json?id=... 去拿试看播放链路;但这条链和后面的积分解锁不是同一个动作,写正文时最好把它单独当成“已锁内容下的试看分支”来看。
// com.secret.prettyhezi.EkHbSOqG.p1()
void p1() {
H0();
// 先请求 show/json,把当前资源详情拉回来
com.secret.prettyhezi.j.m(com.secret.prettyhezi.Server.v.f7323a + b1(), new j(this));
}
// com.secret.prettyhezi.VOGkBN.i1(java.lang.String)
@Override
protected void i1(String str) {
v vVar = (v) f.d(str, v.class);
if (vVar.code != 200) {
x0(vVar);
return;
}
// 当前长视频详情对象
this.N = vVar.detail();
// st=false 时,说明当前内容仍处于未解锁状态
this.P = !vVar.data.st;
J1();
}
支付密码校验与解锁请求
EkHbSOqG.k1()
→ q1(sc)
→ 确认弹窗 new m()
→ Device.a.b(activity, new a())
→ 已缓存支付密码则直接继续
→ 未缓存时请求 user/pverify/json
→ EkHbSOqG$m$a.a(String)
→ j.r(Server.v.f7323a + g1(), new Server.v.g(id), true, ...)
→ rrvideo/unlock/json
定位到详情页之后,真正的解锁动作并不是按钮一按就直接打 unlock/json。EkHbSOqG.k1() 会先根据积分价格弹出确认框;用户确认后进入 Device.a.b(...),这里专门负责收集支付密码。如果本地已经缓存了 6 位支付密码,就直接把它交给后续回调;如果没有缓存,或者当前账号要求重新校验,就先请求 user/pverify/json 验证密码。只有密码验证通过,才会继续真正的 rrvideo/unlock/json。
这一步很适合写成实战结论,因为它把“支付密码”和“视频解锁”两个看似相连的动作拆开了。前者先验证用户是否有资格继续提交,后者才是真正扣积分换播放权限的业务请求。两者虽然目的不同,但最后都会走进统一的 j.r() -> j.t(mode=1) -> Server.e.c() -> abc.ea5() 请求加密链,所以从 hook 日志上看,表现出来都是 mode=1 的受保护请求。
图 5-6 视频解锁前会先弹出支付密码输入框,因此后续的 user/pverify/json 和 unlock/json 实际上是两个连续但职责不同的步骤。
图 5-7 输入支付密码后,请求会先进入 user/pverify/json 做密码校验,校验通过后才继续后续解锁流程。
图 5-8 user/pverify/json 返回 code=200、data=true,说明当前支付密码校验成功,客户端随后才具备继续提交解锁请求的资格。
图 5-9 支付密码校验通过后,客户端继续构造 rrvideo/unlock/json,请求体中携带当前资源 id 与支付密码。
图 5-10 rrvideo/unlock/json 的响应中已经携带可播放资源路径,说明播放权限来自服务端真实下发,而不是本地伪造。
图 5-11 从 hook 日志可以看到 user/pverify/json 与 rrvideo/unlock/json 都继续走进了统一的 mode=1 受保护请求链。
// com.secret.prettyhezi.Device.a$f.a(java.lang.CharSequence, int)
@Override
public void a(CharSequence charSequence, int i6) {
String string = this.f6232a.getText().toString();
this.f6233b.j(this.f6234c);
a0 a0Var = this.f6235d;
if (a0Var == null || a0Var.bindstatus == 2) {
// 本地可直接用当前输入的支付密码继续后续解锁流程
a.f6219d = string;
this.f6236e.a(string);
} else {
// 否则先走 user/pverify/json 做密码校验
j.r(v.f7323a + "user/pverify/json", new v.f(string), true, new C0075a(this.f6233b, string));
}
}
// com.secret.prettyhezi.EkHbSOqG$m$a.a(java.lang.String)
@Override
public void a(String str) {
// 支付密码校验通过后,才真正发 unlock/json
com.secret.prettyhezi.j.r(
com.secret.prettyhezi.Server.v.f7323a + EkHbSOqG.this.g1(),
new v.g(EkHbSOqG.this.f6264s),
true,
new C0077a(EkHbSOqG.this)
);
}
// dynamic-log: user/pverify/json / rrvideo/unlock/json
[JS] 请求地址 : db8K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6I4M7g2)9J5k6i4c8S2L8%4q4I4x3e0j5K6i4K6u0W2j5$3!0E0i4K6u0r3N6i4y4W2M7W2)9J5c8Y4m8$3k6i4u0A6k6Y4W2Q4x3V1k6B7M7$3!0F1
[JS] 请求体 : {"p":"xxxx"}
[JS] 明文内容 : {"code":200,"data":true}
[JS] 请求地址 : 172K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6I4M7g2)9J5k6i4c8S2L8%4q4I4x3e0j5K6i4K6u0W2j5$3!0E0i4K6u0r3M7Y4u0$3K9h3c8W2L8#2)9J5c8Y4g2F1L8r3!0U0K9#2)9J5c8X3A6K6L8$3^5`.
[JS] 请求体 : {"id":1298xxx,"p":"xxxx"}
[JS] 明文内容 : {"code":200,"data":{"me":{"w":960,"h":540,"dur":763,"p":"type8/cdn2/640/fb/fb4e4fa213c4ec78ab6a35fa552712d41601aexx/master.m3u8","d":"type8/cdn2/640/fb/fb4e4fa213c4ec78ab6a35fa552712d41601aexx/master.m3u8"},"st":true}}
解锁后的用户态回写与等级刷新
VOGkBN.l1(String)
→ eVar.data.st == true
→ y1()
→ MainApplication.f6868r.r().integral.all -= this.f6265t.sc
→ MainApplication.f6868r.x()
→ ZIgnJ.N.f8280r.j() / ZIgnJ.N.f8280r.i()
→ C0226r.i()
→ j.m(Server.v.f7323a + "user/level/json", new j(...))
→ MainApplication.f6868r.r().level = xVar.data
→ MainApplication.f6868r.x()
解锁成功之后,程序也不是只把 m3u8 地址丢给播放器就结束了。VOGkBN.l1(String) 收到 unlock/json 的响应后,会先确认 st 为真,然后立刻调用 y1() 做本地用户态回写。这里直接把当前用户对象里的积分字段减掉本次消耗值,再调用 MainApplication.x() 把更新后的用户 JSON 回写到 keyCurUser<uid>。紧接着它还会刷新主页侧边栏相关组件,所以从实战日志上能看到后面又出现了等级刷新和用户缓存更新。
这里也要做一个关键自检:user/level/json 虽然经常出现在解锁后的日志里,但静态上它并不是 unlock/json 成功回调里的“下一行直接请求”。更准确的调用关系是:unlock/json 成功后,y1() 先改本地积分并刷新主页组件;之后 C0226r.i() 才去请求 user/level/json,把最新经验和等级重新拉回界面。所以正文里把它写成“解锁后的用户态刷新阶段继续触发 user/level/json”是准确的,写成“unlock/json 成功后立即直接调用 user/level/json”就会过度线性化。
积分变化了 
图 5-12 视频解锁成功后,积分会立即发生扣减,同时伴随经验刷新与用户缓存回写,说明这是一次完整的服务端业务闭环。
// com.secret.prettyhezi.VOGkBN.l1(java.lang.String)
@Override
protected void l1(String str) {
e eVar = (e) f.d(str, e.class);
if (eVar.code != 200) {
t.d(this, eVar.err);
return;
}
if (eVar.data.st) {
// 解锁成功后先更新当前用户积分与缓存
y1();
// 再把服务端返回的可播放资源写回详情对象
this.N.f7301me = eVar.data.f7864me;
this.P = false;
I1();
H1();
}
}
// com.secret.prettyhezi.EkHbSOqG.y1()
public void y1() {
// 本地先把当前用户积分减掉本次视频消耗值
MainApplication.f6868r.r().integral.all -= this.f6265t.sc;
// 然后立刻把新的用户对象写回 keyCurUser<uid>
MainApplication.f6868r.x();
// 刷新主页/侧边栏相关 UI
ZIgnJ.N.f8280r.j();
ZIgnJ.N.f8280r.i();
}
// com.secret.prettyhezi.C0226r.i() / C0226r$j.g(java.lang.String)
public void i() {
if (MainApplication.f6868r.r().grade == 0) {
return;
}
// 解锁后刷新侧边栏时,会再去拉一次 user/level/json
com.secret.prettyhezi.j.m(com.secret.prettyhezi.Server.v.f7323a + "user/level/json", new j(this.f8869b));
}
class j extends t.g {
@Override
public void g(String str) {
x xVar = (x) com.secret.prettyhezi.f.d(str, x.class);
if (xVar.code != 200) {
f(xVar.err);
return;
}
// 这里把最新经验与等级重新写回当前用户对象
MainApplication.f6868r.r().level = xVar.data;
MainApplication.f6868r.x();
C0226r.this.m();
}
}
// com.secret.prettyhezi.js.hook.js - score unlock request/response hooks
function isScoreUnlockRequest(url) {
if (!url) {
return false;
}
url = String(url);
return url.indexOf("/unlock/json") >= 0 && url.indexOf("/unlock/destory/json") < 0;
}
function logScoreUnlockRequest(method, url, mode, body, extra) {
latest_score_unlock_req = {
source: "score_unlock",
url: safeToText(url),
body: safeToText(body),
extra: safeToText(extra),
mode: mode,
time: (new Date()).toISOString()
};
logVideoRequest("积分解锁请求", method, url, mode, body, extra);
}
function logScoreUnlockResponse(req, mode, text) {
console.log("");
console.log("========== 积分解锁响应 ==========");
if (req !== null) {
logCn("请求来源", req.source);
logCn("请求地址", req.url);
logCn("请求体", req.body);
if (req.extra !== "") {
logCn("附加信息", req.extra);
}
logCn("记录时间", req.time);
}
logCn("响应内容", text);
console.log("");
}
var rHook = J.r.overload("java.lang.String", "java.lang.Object", "boolean", "com.secret.prettyhezi.j$e");
rHook.implementation = function (url, body, needAuth, cb) {
var bodyText = serializeRequestBody(body);
rememberRequest("j.r", url, bodyText, "是否需要认证=" + needAuth + ",回调对象=" + cb, 1);
if (isScoreUnlockRequest(url)) {
logScoreUnlockRequest("POST", url, 1, bodyText, "来源=j.r,是否需要认证=" + needAuth + ",回调对象=" + cb);
}
console.log("j.r 被调用");
console.log("请求地址 = " + url);
console.log("请求体 = " + bodyText);
return rHook.call(J, url, body, needAuth, cb);
};
var tHook = J.t.overload("java.lang.String","java.lang.String","java.lang.String","int","com.secret.prettyhezi.j$e");
tHook.implementation = function (str, str2, str3, i6, eVar) {
rememberRequest("j.t", str, str2, str3, i6);
if (isScoreUnlockRequest(str)) {
logScoreUnlockRequest("POST", str, i6, str2, "来源=j.t,附加参数=" + str3 + ",回调对象=" + eVar);
}
return tHook.call(J, str, str2, str3, i6, eVar);
};
var da5Hook = Abc.da5.overload("[B", "int");
da5Hook.implementation = function (bArr, mode) {
var ret = da5Hook.call(Abc, bArr, mode);
var outText = byteArrayToText(ret);
var req = getRequestCtx(mode);
var unlockReq = null;
if (req !== null && isScoreUnlockRequest(req.url)) {
unlockReq = req;
} else if (latest_score_unlock_req !== null) {
unlockReq = latest_score_unlock_req;
}
if (unlockReq !== null) {
logScoreUnlockResponse(unlockReq, mode, outText);
}
return ret;
};
结合真实日志可以把解锁后的用户态更新再落细一层:
user/pverify/json返回{"code":200,"data":true},说明支付密码校验通过后才会继续解锁。rrvideo/unlock/json返回st:true以及me.p / me.d播放路径,说明播放权限来自服务端下发,不是本地伪造。- 解锁前
integral.all = 126.5,解锁后integral.all = 124.5,本次实际扣除了2.0积分。 - 解锁后的用户态刷新阶段又出现
user/level/json,返回{"exp":44361,"level":"Level 4"},对应经验值从44359增加到44361。 - 更新后的用户对象最终写回
keyCurUser3801184,与前文MainApplication.x()的静态分析完全对上。
补充说明:
- 日志中某些
j.t打印把mode=1请求统一归类成“登录请求”,这是hook.js里打印模板的分类方式,不代表 APP 真的把视频解锁请求当成登录请求处理。
本节小结:
- 视频解锁链的核心是
show/json 锁态确认 -> user/pverify/json 支付密码校验 -> rrvideo/unlock/json 解锁 -> keyCurUser<uid> 回写 -> user/level/json 刷新等级。 - 登录链和视频解锁链虽然业务含义不同,但都共用
j.t(mode=1) -> abc.ea5/da5这条 Native 编解码主线。 - 从实战结果看,积分扣减、经验刷新和播放地址下发都来自真实服务端返回,因此这是一个完整的受保护业务闭环。
5.5 Java 到 Native 的连接链
Java 包装层到 c.abc
MainApplication.<clinit>()
→ System.loadLibrary("ali")
→ c.abc.*
→ libali.so
Java 层只保留轻量包装,真正关键的字符串常量返回、编解码和部分保护逻辑已经下沉到 libali.so。
// com.secret.prettyhezi.MainApplication.<clinit>()
static {
// Application 加载时先装入 libali.so
System.loadLibrary("ali");
}
// c.abc
public abstract class abc {
public static native void abe(Context context);
public static native void abl(int i6);
public static native String abm(String str);
public static native String ams(String str);
public static native String amu(String str);
public static native String c(int i6);
public static native String da2(String str);
public static native byte[] da5(byte[] bArr, int i6);
public static native String ea2(String str);
public static native byte[] ea5(byte[] bArr, int i6);
}
6. Native 层分析
前面第 5 节已经把启动、登录和视频解锁这些主流程跑通了,这一节就不再从业务入口往下追,而是反过来回答一个问题:前面那些关键节点里,到底有哪些地方是真的进了 Native。顺着这个思路去看,libali.so 的角色就会很清楚,它不是一个边缘小库,而是同时承担了“字符串常量保护”和“请求/缓存编解码”两类核心能力。
6.1 JNI 注册与入口定位
Java 加载 so 与 native 注册
MainApplication.<clinit>()
→ System.loadLibrary("ali")
→ JNI_OnLoad
→ RegisterNatives("c/abc", off_E1000, 6)
→ c.abc.*
如果只看 jadx,会觉得 c.abc 只是一个普通的 native 包装类;但把视角切到 IDA 以后,第一步就能确认 libali.so 在 JNI_OnLoad 里做了两件很关键的事:一是先调用 ptrace(PTRACE_TRACEME, ...) 做反调试占位,二是把 c/abc 上这组核心方法一次性注册进来。也就是说,后面 Java 层凡是走到 abc.ea5/da5/ea2/da2/c,本质上都是在进这套统一注册好的 Native 能力。

// com.secret.prettyhezi.MainApplication.<clinit>()
static {
// Application 类加载时先装入 libali.so
// 后续 c.abc 中的 native 方法都会在 JNI_OnLoad 中完成注册
System.loadLibrary("ali");
}
// libali.so:JNI_OnLoad
jint JNI_OnLoad(JavaVM *vm, void *reserved) {
// 先调用 ptrace(PTRACE_TRACEME, ...),这是一个很典型的反调试动作
ptrace(PTRACE_TRACEME, 0, 0, 0);
// 获取当前 JNI 环境,失败则直接返回错误
if ((*vm)->GetEnv(vm, (void **)&env, 65542)) {
return -1;
}
// 找到 Java 层的 c/abc 类
jclass cls = (*env)->FindClass(env, "c/abc");
// 把 off_E1000 中定义的 6 个 native 方法一次性注册到 c/abc
(*env)->RegisterNatives(env, cls, off_E1000, 6);
return 65542;
}
函数作用:
MainApplication.<clinit>():在 Application 类初始化时装载libali.so。JNI_OnLoad:完成 native 方法动态注册,同时提前执行ptrace(PTRACE_TRACEME, ...),带有明显反调试意图。RegisterNatives("c/abc", off_E1000, 6):把 Java 层c.abc的 6 个 native 方法一次性绑定到libali.so内部实现。
结合 IDA 可确认当前样本注册了以下 6 个 native 方法:
abe(Context)→0x42c34ea2(String)→0x43234da2(String)→0x43698da5(byte[], int)→0x43afcea5(byte[], int)→0x43f10c(int)→0x443d4(即后续重点分析的minax)
补充说明:
JADX中c.abc一共声明了 10 个native方法。- 但当前能从
JNI_OnLoad -> RegisterNatives(env, cls, off_E1000, 6)这条动态注册链直接确认的,是上面这 6 个方法。 - 因此本笔记里关于
JNI_OnLoad注册关系的确定性结论,只覆盖这 6 个已在off_E1000中明确出现的方法。
6.2 ea5/da5/ea2/da2 Native 编解码链
继续往下拆之后,会发现 libali.so 里其实至少有两组不能混写的 Native 能力。第一组就是这里的 ea5/da5/ea2/da2,它们服务的是“请求、响应、本地缓存”这些会在业务流程里频繁经过的数据;第二组才是后面 abc.c() 那条专门负责域名等常量保护的链。把这两组先分开,是整篇分析里最重要的自检点之一。 
登录/业务请求加密链 ea5
CfTs9fWO.W0(...) / j.r(...)
→ j.t(..., mode=1, ...)
→ Server.e.c(json, 1)
→ abc.ea5(byte[], 1)
→ JNI_OnLoad 注册 ea5
→ xyz9(0x43f10)
从第 5 节回头看这条链会非常顺,因为前面已经确认过登录请求、支付密码校验请求、视频解锁请求最后都会汇入 j.t(mode=1)。所以这里真正要验证的不是“它会不会进 Native”,而是“进了 Native 以后是哪一个函数在接手请求体”。顺着 Server.e.c() 往下看,可以很稳地落到 abc.ea5(),再从 JNI_OnLoad 注册表把它对到 0x43f10。这样就能把前面实战里看到的 mode=1 请求,和这里的 Native 请求加密实现准确对上。
换句话说,ea5 在整套样本里的角色不是“某个接口专用加密函数”,而是 Java 层把明文 JSON 交给 Native 之后的统一请求入口。只要正文里涉及受保护 POST 请求,这里都可以拿来当底层支撑。
// com.secret.prettyhezi.Server.e.c(java.lang.String, int)
public static byte[] c(String str, int i6) {
try {
// Java 字符串请求体在这里先转成 UTF-8 字节
// 然后交给 abc.ea5 进入 Native 请求加密流程
return abc.ea5(str.getBytes("UTF-8"), i6);
} catch (Exception unused) {
return null;
}
}
public static void t(String str, String str2, String str3, int i6, e eVar) {
RequestBody requestBodyCreate;
if (i6 > 0) {
try {
// mode>0 时,请求体不会直接明文发送
// 而是统一走 Server.e.c -> abc.ea5
requestBodyCreate = RequestBody.create(f8646d, com.secret.prettyhezi.Server.e.c(str2, i6));
} catch (Exception e6) {
e6.printStackTrace();
requestBodyCreate = null;
}
} else {
requestBodyCreate = RequestBody.create(f8646d, str2);
}
...
}
// libali.so:JNI_OnLoad
jint JNI_OnLoad(JavaVM *vm, void *reserved) {
...
// 动态注册表里可以直接确认 ea5 被绑到 0x43f10
(*env)->RegisterNatives(env, cls, off_E1000, 6);
return 65542;
}
函数作用:
Server.e.c():Java 请求体到 Native 字节加密的包装层。abc.ea5():JNI 导出方法,负责把请求数据送入xyz9。xyz9(0x43f10):ea5对应的 Native 实现。
登录/业务响应解密链 da5
OkHttp Callback.onResponse()
→ Server.e.a(resp.bytes(), mode)
→ abc.da5(byte[], mode)
→ JNI_OnLoad 注册 da5
→ xyz8(0x43afc)
→ 返回 UTF-8 明文 JSON
da5 就是前面 ea5 的回程链。这个点在第 5 节里其实已经通过登录成功、视频解锁日志间接验证过一次了,因为 Java 层后面拿到的始终是可直接 f.d(..., class) 反序列化的 JSON 明文。现在再回到 Native 层看,Server.e.a() 明确先调 abc.da5(),再按 UTF-8 组回 Java 字符串,所以可以把结论再收紧一步:业务层里能看到的那些响应明文,都是先经过 da5 才回到 Java 的。
这样一来,前面为什么能用 hook 稳定看到登录响应、支付密码校验响应、积分解锁响应,也就完全说通了。它们不是“网络层刚好返回了明文”,而是 Native 已经在 da5 这一层把字节流还原完了。
// com.secret.prettyhezi.Server.e.a(byte[], int)
public static String a(byte[] bArr, int i6) {
try {
// 响应字节会先经过 abc.da5 解密
// 然后再按 UTF-8 还原成 Java 字符串
return new String(abc.da5(bArr, i6), "UTF-8");
} catch (Exception unused) {
return null;
}
}
// com.secret.prettyhezi.j$d.onResponse(okhttp3.Call, okhttp3.Response)
class d implements Callback {
...
@Override // okhttp3.Callback
public void onResponse(Call call, Response response) {
j.f8644b = true;
if (response.code() == 200) {
try {
// mode>0 时,响应不会直接 body().string()
// 而是先走 Server.e.a -> abc.da5
this.f8655b.c(this.f8656c > 0
? com.secret.prettyhezi.Server.e.a(response.body().bytes(), this.f8656c)
: response.body().string());
} catch (Exception unused) {
this.f8655b.b(0);
}
} else {
...
}
}
}
函数作用:
Server.e.a():Java 响应字节到明文字符串的包装层。abc.da5():JNI 导出方法,负责把响应数据送入xyz8。xyz8(0x43afc):da5对应的 Native 实现。
配置持久化加密链 ea2
MainApplication.w(gVar)
→ f.e(gVar)
→ Server.e.d(json)
→ abc.ea2(String)
→ JNI_OnLoad 注册 ea2
→ xyz6(0x43234)
→ g4.f.a().q("keyCurConfigure", cipherText)
继续往下看,就会发现 libali.so 不是只保护网络包,连本地配置缓存也给了单独一条字符串级编码链。这里最直观的落点就是 MainApplication.w(gVar),服务端配置对象先被转成 JSON,再经 Server.e.d() -> abc.ea2() 处理后写进 keyCurConfigure。所以 ea2 更像“本地配置持久化专用包装器”,而不是前面 ea5 那种面向网络请求体的字节级入口。
这一点在写帖子时很有用,因为它能把“同样都是 Native 编码,为什么一个返回 byte[]、一个返回 String”这个疑问提前解释掉。不是算法名字不同,而是它们本来就在处理两种不同形态、不同场景的数据。
// com.secret.prettyhezi.MainApplication.w(com.secret.prettyhezi.Server.g)
public void w(com.secret.prettyhezi.Server.g gVar) {
this.f6888q = gVar;
if (gVar != null) {
// 配置对象先转 JSON
// 再交给 Server.e.d -> abc.ea2 做 Native 字符串级编码
g4.f.a().q(f6871u, com.secret.prettyhezi.Server.e.d(f.e(gVar)));
m();
}
}
public static String d(String str) {
try {
// 字符串级本地编码包装
return abc.ea2(str);
} catch (Exception unused) {
return null;
}
}
函数作用:
MainApplication.w():配置落盘入口。Server.e.d():Java 明文字符串到 Native 编码字符串的包装层。abc.ea2()/xyz6(0x43234):配置缓存使用的 Native 字符串级编码实现。
配置恢复解密链 da2
MainApplication.k()
→ g4.f.a().i("keyCurConfigure", "")
→ Server.e.b(cipherText)
→ abc.da2(String)
→ JNI_OnLoad 注册 da2
→ xyz3(0x43698)
→ f.d(json, Server.g.class)
da2 和 ea2 就是一组正反链。前面写配置时走 ea2,这里读配置时就会从 keyCurConfigure 把字符串取出来,再经 Server.e.b() -> abc.da2() 还原成明文 JSON,最后才恢复成 Server.g。所以如果从实战角度概括,这一组函数解决的不是“接口通信保护”,而是“APP 重启后还能把之前保存下来的配置重新读回来”。
// com.secret.prettyhezi.MainApplication.k()
public com.secret.prettyhezi.Server.g k() {
if (this.f6888q == null) {
String strI = g4.f.a().i(f6871u, HttpUrl.FRAGMENT_ENCODE_SET);
if (strI.length() > 0) {
try {
// 先用 Server.e.b -> abc.da2 解出明文 JSON
// 再反序列化成 Server.g
this.f6888q = (com.secret.prettyhezi.Server.g) f.d(
com.secret.prettyhezi.Server.e.b(strI),
com.secret.prettyhezi.Server.g.class
);
} catch (Exception unused) {
}
}
}
return this.f6888q;
}
public static String b(String str) {
try {
// 字符串级本地解码包装
return abc.da2(str);
} catch (Exception unused) {
return null;
}
}
函数作用:
MainApplication.k():配置恢复入口。Server.e.b():Native 编码字符串到明文 JSON 的包装层。abc.da2()/xyz3(0x43698):配置缓存使用的 Native 字符串级解码实现。
ea5/da5/ea2/da2 的算法归类
JNI_OnLoad
→ ea2/da2/da5/ea5
→ xyz6/xyz3/xyz8/xyz9
→ 命中字符串 "AES"
→ 命中同一组 key 字面量 "gncdGCPoNdM([[SEA"
把这四条链放在一起看,能得到一个比较稳的整体判断:它们属于同一组 AES 风格 Native 编解码族,服务的对象分别是“请求包、响应包、配置缓存”。这一步最重要的不是把模式名字硬猜死,而是先把边界划清楚。也就是前面登录、自动恢复、视频解锁里看到的那些受保护数据,底层大体都落在这组函数上;而后面马上要讲的 abc.c(),则是另一套专门保护内置字符串常量的 Blowfish 链。
// libali.so:xyz6 / xyz3 / xyz8 / xyz9
// 下面四个 Native 实现里都能稳定命中相同的算法与 key 相关字符串
// xyz6 -> ea2
// xyz3 -> da2
// xyz8 -> da5
// xyz9 -> ea5
"AES"
"gncdGCPoNdM([[SEA"
自检结论:
ea5/da5:请求与响应方向的字节级编解码链,主要挂在j.t(..., mode>0)上。ea2/da2:配置与缓存方向的字符串级编解码链,主要挂在MainApplication.w()/k()上。- 这四条链目前可稳定归类为 AES 相关 Native 实现,但仅凭当前静态证据不宜直接下结论到具体模式或填充方式。
abc.c()/minax/ Blowfish 仅负责常量字符串保护,不应与ea5/da5/ea2/da2混写。
6.3 abc.c() 调用链与功能定位
注意:
- 从这里开始,分析对象已经切换为
abc.c()的常量字符串保护链。 - 这一段的目标是解释域名等内置字符串如何从密文表恢复,不再讨论
ea5/da5/ea2/da2的请求或缓存编解码。
Java 域名常量到 native 字符串表
com.secret.prettyhezi.Server.v.<clinit>()
→ abc.c(1) / abc.c(2) / abc.c(3)
→ minax(0x443d4)
→ off_DA050[index]
→ 解密后返回 Java 字符串
如果说上一节是在回答“哪些业务数据要进 Native”,那这一节回答的就是“那些看不见的内置站点字符串到底藏在哪”。顺着 Server.v 往下跟,很容易先看到 abc.c(1) / c(2) / c(3);再把这个入口丢给 IDA,就能直接落到 minax(0x443d4)。这样一来,域名这件事就不再停留在“Java 里看不到明文”的层面,而是可以明确写成:Java 层只是按索引取值,真正的字符串恢复发生在 Native 里。
这里也顺手把前面已经校正过的一个关键点固定下来:abc.c(1) / c(2) / c(3) 在 Java 业务层首先表现为 3 个内置站点常量,而不是所有请求都统一按“1 不通走 2、2 不通走 3”的通用主备逻辑。当前能静态确认的,只是 f7323a 默认来自 abc.c(1),以及 Server.v.b() 里确实存在 abc.c(2) 和 abc.c(3) 的局部切换关系。
密文表
图 6-1 abc.c(index) 在 Native 层并不是简单返回常量,而是先从 off_DA050 这张密文字符串表中取出对应项,再继续后续解密流程。
// com.secret.prettyhezi.Server.v.<clinit>() / com.secret.prettyhezi.Server.v.b()
// 这里把 abc.c(1) 作为本地缓存域名 keyLastServer4 的默认值
public static String f7323a = g4.f.a().i("keyLastServer4", abc.c(1));
// 这里把 abc.c(2) 作为 f7324b 的初始站点值
public static String f7324b = abc.c(2);
public static String b() {
// 先重新取一次 abc.c(2)
String strC = abc.c(2);
// 在 Server.v.b() 这条逻辑里,如果当前仍等于 abc.c(2),就切到 abc.c(3)
if (strC.equals(f7324b)) {
strC = abc.c(3);
}
// 更新当前保存的 f7324b
f7324b = strC;
return strC;
}
// libali.so:minax
__int64 __fastcall minax(__int64 a1, __int64 a2, int a3)
{
// 先做一层环境检测/反调试相关判断
if ( (unsigned __int8)sub_45204(a1, qword_E21D8, qword_E21E0) )
*((_BYTE *)&word_3E + rand() % 98 + 1) = 103;
// 根据 a3 这个索引,从 off_DA050 中取出对应的密文字符串
sub_413B8((int)v12, (char *)*(&off_DA050 + a3));
// 对这段密文字符串执行完整的 Native 解密流程
sub_46CB4(&v14, v12);
...
// 最终把解密后的结果封装成 Java 字符串返回
return v10;
}
函数作用:
Server.v.<clinit>():在 Java 层初始化主域名和备用域名。Server.v.b():在f7324b这条逻辑上,先取abc.c(2);若当前值仍与之相同,则切换到abc.c(3)。minax(int index):根据索引从off_DA050中取出一段密文字符串,完成整套 Native 解密流程,再封装成 Java 字符串返回。sub_413B8():把off_DA050[index]的 C 字符串包装成内部std::string风格结构。sub_46CB4():负责后续真正的解密与明文化处理。
结合 IDA 实际恢复出的结果如下:
abc.c(1)→eadK9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8U0p5%4x3W2)9J5k6e0R3H3i4K6u0W2y4e0N6Q4x3X3f1I4x3q4)9J5c8R3`.`.abc.c(2)→611K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6I4M7i4c8S2L8$3u0S2L8#2)9J5k6i4b7I4x3U0y4&6i4K6u0W2j5$3!0E0i4K6u0r3abc.c(3)→54bK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1j5I4y4U0y4Q4x3X3g2@1j5h3!0K6K9h3&6S2x3e0j5K6i4K6u0W2j5$3!0E0i4K6u0r3
6.4 abc.c() 的 native 解密算法流程
abc.c() 整体解密链
abc.c(index)
→ minax
→ sub_46CB4
→ sub_469C0
→ 非标准 hex 解码
→ sub_49824
→ sub_49D9C
→ sub_49494
→ sub_48F68
→ 返回明文 URL
真正把 minax 往下拆开以后,这条链最容易误判的地方就是把它想成“密文做个 hex 解码,再套一层 Blowfish 就结束”。实际不是这样。abc.c() 这一套 Native 逻辑更接近三段式:先做自定义预处理,再做 Blowfish 分组解密,最后还有一段自定义后处理。只有整条链走完,最后才会落到可直接在 Java 层使用的明文 URL。
abc.c() 完整执行流程总览
abc.c(index)
→ JNI c(int) 进入 minax(a1, a2, index)
→ sub_413B8((int)v12, (char *)*(&off_DA050 + index))
→ 从 off_DA050[index] 取出密文字符串并包装成输入对象 v12
→ sub_46CB4(&v14, v12)
→ 从输入对象里拆出真实密文缓冲区指针 v10
→ sub_469C0(v10, v9, len, cap)
→ 非标准 hex 预处理
→ sub_49824(ctx, off_E1098, 7) 初始化 Blowfish 上下文
→ sub_49D9C(ctx, v10, v10, len/2) 按 8 字节分组循环解密
→ sub_49494(ctx, left, right) 执行单块 Blowfish 解密
→ sub_48F68(v10, v29, a2, a4) 做最终字节置换与异或
→ sub_413B8(a2, v9) 把解密结果重新包装成输出字符串对象
→ minax 把结果封装成 Java String
→ 返回明文 URL
从写帖子角度说,这一段最大的价值不是把每个函数名都背下来,而是把“为什么一开始直接写脚本往往解不出来”这件事说明白。原因就在这里:abc.c() 的返回值不是在某一个单点函数里“顺手就解出来”的,而是必须完整经过“取密文对象 -> 拆真实缓冲区 -> 预处理 -> Blowfish 解密 -> 后处理 -> 重新封装字符串对象”这一整条链。少看任一步,拿到的都只是中间态。
图 6-2 minax 是 abc.c() 的 Native 总入口,它负责按索引取密文、调用解密总控函数,并最终把结果重新封装成 Java String 返回。
图 6-3 从 sub_46CB4 到 sub_469C0 再到后续 Blowfish 与后处理函数,可以确认 abc.c() 走的是一整条完整的解密链,而不是单步明文返回。
// libali.so:minax
__int64 __fastcall minax(__int64 a1, __int64 a2, int a3)
{
// a3 就是 Java 层传入的 index,用来决定取哪一条密文常量
sub_413B8((int)v12, (char *)*(&off_DA050 + a3));
// v12 是输入密文对象,v14 是输出结果对象
sub_46CB4(&v14, v12);
// 下面把 native 结果对象再转成 Java String 返回给上层
v8 = (*(__int64 (__fastcall **)(__int64, _QWORD))(*(_QWORD *)a1 + 1408LL))(a1, v7);
...
(*(void (__fastcall **)(__int64, __int64, _QWORD, _QWORD, char *))(*(_QWORD *)a1 + 1664LL))(a1, v8, 0, v7, v9);
return sub_41258(a1, qword_E2190, qword_E2198, v8, qword_E21A0);
}
函数作用:
abc.c(index):Java 层入口,只负责把索引传进 JNI。minax():Native 总入口,负责取密文、调解密总控、再把结果转回 Java 字符串。sub_413B8():把原始 C 字符串或结果缓冲区包装成内部字符串对象。sub_46CB4():连接“字符串对象层”和“真实算法层”的中间包装函数。sub_469C0():真正执行算法总控。sub_49824() + sub_49D9C() + sub_49494():构成 Blowfish 解密主体。sub_48F68():把 Blowfish 中间结果整理成最终可读明文。
// libali.so:sub_46CB4
void __usercall sub_46CB4(unsigned __int8 *a1@<X0>, unsigned __int64 *a2@<X8>)
{
// 这里的 a1 不是普通 char*,而是“输入密文字符串对象”
// 先从这个字符串对象里取出真实字符缓冲区地址,再交给 sub_469C0
sub_469C0(v10, v9, (unsigned int)v5, v7);
// 这里的 a2 不是输入参数,而是输出字符串对象的接收地址
// 算法总控函数返回后,把结果重新包装成字符串对象写回 a2
sub_413B8(a2, v9);
operator delete[](v9);
}
// libali.so:sub_469C0
void __fastcall sub_469C0(unsigned __int8 *a1, __int64 a2, int a3, unsigned int a4)
{
...
// 这里不是标准 hex 解码,而是它自定义的“两字符拼一字节”逻辑
*v21++ = v25 | (16 * (v26 + v23));
...
// 用 off_E1098 指向的 7 字节 key 初始化 Blowfish 上下文
sub_49824(v27, off_E1098, 7);
// 按 8 字节分组对数据做 Blowfish 解密
sub_49D9C(v27, v10, v10, (unsigned int)(v28 >> 1));
// 对 Blowfish 输出继续做字节置换和异或,得到最终明文
sub_48F68(v10, v29, a2, a4);
}
函数作用:
sub_46CB4():作为minax的主解密包装层,输入是密文字符串对象,输出是解密结果字符串对象。sub_469C0():核心总控函数,依次完成“预处理 → 分组解密 → 后处理”。sub_49824():初始化 Blowfish 风格上下文,装载 P-array/S-box,并使用 key 做 key schedule。sub_49D9C():按 8 字节分组循环处理数据,每块都调用sub_49494()。sub_49494():具体的 Blowfish 单块解密例程。sub_48F68():在 Blowfish 解密结果基础上继续做 16 字节级别的字节置换和异或,得到最终明文。
参数流向与反编译阅读陷阱
这一段就是前面反复校对过很多次的地方。sub_46CB4(&v14, v12) 乍一看,确实很像“把 &v14 传进去处理,再把结果写到 v12”;但只要结合 X0/X8 的寄存器传参与函数内部行为去看,真实语义正好反过来。这类地方特别适合在帖子里单独提醒一下,因为很多人第一次看 ARM64 反编译都会在这里被变量顺序带偏。
参数流向补充说明:
minax里先执行sub_413B8((int)v12, (char *)*(&off_DA050 + a3));,所以v12先被包装成“密文字符串对象”。- 随后的
sub_46CB4(&v14, v12)不应按“文字顺序”理解成a1=&v14, a2=v12,因为sub_46CB4的反编译原型是a1@<X0>, a2@<X8>。 - 真实语义应理解为:
v12对应输入密文对象,&v14对应输出结果对象,即“把v12里的密文解密后写入v14”。 sub_46CB4内部再从输入对象中拆出真实字符地址到v10,随后调用sub_469C0(v10, v9, ...),因此真正进入算法总控函数的密文数据指针是v10。
非标准 hex 预处理
sub_469C0()
→ 两字符转一字节
→ 低 4 位取前字符
→ 高 4 位取后字符
→ 得到真实密文字节流
把参数流向看清以后,sub_469C0 的第一段预处理就容易理解了。这里不是直接调标准库做 hex -> bytes,而是自己按“两字符拼一字节”的方式手搓转换逻辑;并且字符映射也不是现成函数,而是通过条件分支自己算出来。所以密文虽然表面长得像十六进制字符串,但直接拿普通 bytes.fromhex() 去解,结果一定会错。
// libali.so:sub_469C0
// 先取当前的两个字符
v22 = a1[v12];
v23 = a1[v12 + 1];
...
// 前一个字符先转成 0~15,后面会放到低 4 位
if (v22 <= 0x40)
v24 = -48;
else
v24 = -55;
v25 = v24 + v22;
// 后一个字符也转成 0~15,后面会放到高 4 位
if (v23 <= 0x40)
v26 = 0;
else
v26 = 9;
// 真正拼字节时,高低 4 位顺序和标准 hex 正好反过来
*v21++ = v25 | (16 * (v26 + v23));
函数作用:
- 前一个字符参与低 4 位。
- 后一个字符参与高 4 位。
- 因此它的效果不是标准
68 -> 0x68,而是68 -> 0x86。 - 这一点是手工复现算法时最容易出错的位置之一。
Blowfish key schedule 与分组解密
sub_469C0()
→ sub_49824(ctx, off_E1098, 7)
→ sub_49D9C(ctx, buf, buf, len)
→ sub_49494(ctx, left, right)
等预处理结束以后,整条链才真正进入 Blowfish 主体。这里可以很清楚地拆成三层:sub_49824(..., off_E1098, 7) 负责把 key 装进上下文并完成 key schedule,sub_49D9C 负责按 8 字节一组循环处理密文块,每一组再下沉到 sub_49494 做单块 Blowfish 解密。也正因为它是标准的分组循环,所以前面如果只把整段密文粗暴交给某个现成 Blowfish 库,往往还会继续踩到 key 和后处理两层坑。
// libali.so:sub_469C0 / sub_49824
// 用运行时 key 初始化 Blowfish 上下文
sub_49824(v27, off_E1098, 7);
// 按 8 字节一组做分组解密
sub_49D9C(v27, v10, v10, (unsigned int)(v28 >> 1));
// libali.so:sub_49D9C
__int64 __fastcall sub_49D9C(__int64 result, _BYTE *a2, _BYTE *a3, unsigned int a4)
{
...
// 每次都把当前 8 字节块拆成左右两个 32 bit 交给 sub_49494
result = sub_49494(v7, v10, v10 + 4);
...
}
函数作用:
sub_49824():使用长度为 7 的 key 初始化 Blowfish 上下文。sub_49D9C():把输入按 8 字节分组逐块处理,不存在 CBC 那样的前后块链式依赖。sub_49494():使用倒序 P-array 的轮函数,表现为 Blowfish 的解密方向。
关键结论:
- 算法核心:
Blowfish - 分组大小:
8 字节 - 工作方式:等价于
ECB - IV:不存在
Blowfish 解密后的自定义后处理
sub_469C0()
→ sub_49D9C(...)
→ sub_49494(...)
→ sub_48F68(v10, v29, a2, a4)
→ 最终明文
sub_48F68 是整条链里另一个特别容易漏掉的点,因为它说明“Blowfish 解完”还不是最终答案。样本在 Blowfish 输出后,又额外追加了一层自定义后处理;只有把这一步也补上,前面那段中间数据才会真正变成 Java 层可直接使用的明文字符串。
// libali.so:sub_469C0 / sub_48F68
// 先按 8 字节分组做 Blowfish 解密
sub_49D9C(v27, v10, v10, (unsigned int)(v28 >> 1));
// 再把 Blowfish 的输出继续送入 sub_48F68 做最终明文化处理
sub_48F68(v10, v29, a2, a4);
函数作用:
sub_49D9C()/sub_49494():负责 Blowfish 本体的分组解密过程。sub_48F68():不再属于 Blowfish 轮函数或 key schedule,而是对 Blowfish 输出再做一层 16 字节粒度的字节置换与固定异或。- 这也解释了为什么“只把密文按 Blowfish-ECB 解密”仍然得不到最终 URL,因为后面还差
sub_48F68这一步后处理。
补充结论:
- Blowfish 本体对应的是
sub_49824 + sub_49D9C + sub_49494。 sub_48F68更适合归类为post-process / finalizer,而不是 Blowfish 算法的一部分。
运行时 key 初始化
sub_40934() / sub_46440()
→ 解码字符串 BC35D8F2602146DT9032AE
→ 写入运行时缓冲区
→ off_E1098 = buffer
→ sub_49824(..., off_E1098, 7)
把算法主体走通以后,最后还剩一个最值得收口的判断:off_E1098 到底是不是固定写死的 key。结合 sub_40934 / sub_46440 和最终能成功还原出来的 abc.c() 结果来看,更稳妥的结论是:off_E1098 本身只是“当前 key 指针”,不是永远固定等于 yx1c7db。在 sub_49824(..., off_E1098, 7) 真正执行之前,它大概率已经被运行时初始化逻辑改写过了。
目前静态证据更强支持 sub_40934 参与了这次 key 初始化,因为它会把 "BC35D8F2602146DT9032AE" 这段数据处理后写给 off_E1098;sub_46440 虽然看起来是同类路径,但当前只能确认它具备类似能力,不能直接写成“运行时也一定执行了”。
// libali.so:sub_40934
size_t sub_40934()
{
...
// 初始化结束后,把 off_E1098 改指向新生成的 key 缓冲区
off_E1098 = (char *)&unk_E2290;
return result;
}
// libali.so:sub_46440
size_t __fastcall sub_46440(char *a1)
{
...
// 另一条路径中同样会把 off_E1098 更新为运行时缓冲区地址
off_E1098 = a1;
return result;
}
函数作用:
sub_40934():会把字符串BC35D8F2602146DT9032AE按同样的非标准 hex 规则解码到缓冲区,再让off_E1098指向该缓冲区。sub_46440():在另一条路径中也会做相同风格的off_E1098更新。- 目前能严格确认的是:
sub_49824(..., off_E1098, 7)读取到的已经是运行时 key,而不是单纯的yx1c7db。 - 目前更稳妥的静态判断是:
sub_40934存在类似.init_array的数据引用,较强支持其参与了装库期或初始化期的 key 改写;sub_46440是否也在这条实际路径上执行,当前没有足够调用链证据。
结合 IDA 实际恢复,可得到当前样本运行时 key 的前 7 字节为:
cb 53 8d 2f 06 12 64- 十六进制写法:
cb538d2f061264
6.5 Native 层分析方法与结论
本次算法恢复的方法路径
Java 常量使用点
→ 锁定 abc.c(int)
→ JNI_OnLoad 确认 native 目标函数
→ 顺着 minax -> sub_46CB4 -> sub_469C0 拆链
→ 识别 sub_49494 为 Blowfish 解密
→ 识别 sub_48F68/sub_48D58 为互逆后处理
→ 通过闭环验证恢复明文
分析方法总结:
- 先从 Java 层
Server.v中abc.c(1)/c(2)/c(3)的使用位置入手,确认其返回值就是域名类字符串。 - 再通过
JNI_OnLoad -> RegisterNatives锁定abc.c(int)对应 native 实现minax(0x443d4)。 - 沿
minax -> sub_46CB4 -> sub_469C0追踪,确认其结构是“取密文表 → 解密 → 返回字符串”。 - 通过
sub_49824中 18 项 P-array、4 组 S-box、循环异或 key 的特征,识别其中间算法为 Blowfish。 - 通过对比
sub_48F68与sub_48D58,确认二者分别对应后处理与其逆过程。 - 最后用“正向加密再逆向解密”的闭环测试,验证
sub_49494的方向、key 取值和前后处理映射都正确。 - 在 key 初始化时机判断上,结论分为两层:一层是从解密结果反推
off_E1098在abc.c()前必然已被改写;另一层是静态上更支持sub_40934参与了该初始化,而不是直接断言sub_40934 / sub_46440两条路径都已执行。
最终结论:
abc.c()的 Native 层本质上是一套“密文字符串表 + 自定义预处理 + Blowfish 解密 + 自定义后处理”的常量保护方案。- 其目的主要是隐藏域名、备用域名和认证相关字符串,避免直接在 Java 层明文暴露。
- 该样本真正的关键点不是单独识别 Blowfish,而是识别出:
- 密文不是标准 hex;
- key 不是表面看到的
yx1c7db; - Blowfish 解完后还需要再走
sub_48F68才能得到最终明文。
逆向抓手总结:
- Java 层入口优先看
OuiCrGxF,因为它串起了检测、广告放行、登录态恢复和页面跳转。 - 网络层优先看
j.r/j.t,因为登录链和多数业务链都会在这里汇合。 - Native 层优先看
JNI_OnLoad,因为它能快速区分“请求/缓存编解码”与“常量字符串保护”两套能力。 - 常量恢复优先看
abc.c(),请求与缓存编解码则优先看ea5/da5/ea2/da2。
粗体文本