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 -> 用户态回写的完整业务流程,并用真实日志验证积分与经验变化。
1、样本信息
1.1 APK 基本信息

1.2 Hash 信息

2、分析环境
2.1 设备 / 运行环境

2.2 工具清单

3、初始处理记录
3.1 初始观察
样本未见明显壳特征,可直接进入 jadx分析。在已 Root 且装有 Magisk 的实验机上启动时出现白屏并退出,说明启动期存在明显环境检测。 从 jadx代码来看,OuiCrGxF.onCreate()是启动检测与配置调度的总入口。
3.2 原始文件与分析载荷
原始 APK: com.secret.prettyhezi/BeautyBox_5.1.5.apkNative 分析 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 关键字段
4.2 权限分析
4.3 组件观察
主入口 Activity: com.secret.prettyhezi.OuiCrGxFFileProvider: 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 booleand(Context context, c cVar) {StringstrA = a("gsm.version.baseband");// 基带版本异常时先记 1 分inti6 = (strA == null || strA.contains("1.0.0.0")) ? 1 : 0;StringstrA2 = a("ro.build.flavor");// build flavor 命中 vbox / sdk_gphone 这类模拟器特征时加分if (strA2 != null && (strA2.contains("vbox") || strA2.contains("sdk_gphone"))) { i6++; }StringstrA5 = a("ro.hardware");if (strA5 == null) { i6++; } else if (strA5.toLowerCase().contains("ttvm") || strA5.toLowerCase().contains("nox")) {// 命中夜神 / Nox 等特征时大幅加分 i6 += 10; }intsize = ((SensorManager) context.getSystemService("sensor")).getSensorList(-1).size();// 传感器数量过少也会作为可疑特征if (size < 7) { i6++; }intiC = 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 来判断 Xposedif (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()
除了打分式环境检测外,样本还有一套独立的模拟器特征检测链,命中后同样直接退出。
// 这是另一套模拟器特征检测// 只要命中特征,就直接结束 APPif (p0.a.c(this)) { m0();return;}// p0.a.c(this)public static booleanc(Context context) {ArrayListarrayList =new ArrayList();try {StringstrB = b(a(context));// 先查静态特征,命不中再走备用检测if (TextUtils.isEmpty(strB)) {ListlistD = 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 路径只要存在,就认定设备已 Rootif (new File(strArr[i6]).exists()) {return true; } }return false;}Hook 代码:
// xposed 检测,直接返回 falsevar xposed_hook = p0_e.f.overload();xposed_hook.implementation = function() {return false;};// 虚拟机检测,直接返回 falsevar xuniqi_hook = p0_d.d.overload("android.content.Context", "p0.c");xuniqi_hook.implementation = function(context, p0_c_instance) {return false;};// debug 检测,直接返回 falsevar debug_hook = p0_e.a.overload("android.content.Context");debug_hook.implementation = function(context) {return false;};// 模拟器检测,直接返回 falsevar moniqi_hook = p0_a.c.overload("android.content.Context");moniqi_hook.implementation = function(context) {return false;};// root 检测,直接返回 falsevar 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());OuiCrGxFouiCrGxF3 = 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.ppublic 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 = 验证码 keythis.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.vpublic 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验证码 key xj验证码点击坐标 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.gpublic 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 { @Overridepublic 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 {@Overridepublic void c(String str) { OuiCrGxF.this.f6965q.post(new a(str)); }class a implements Runnable {final String raw; a(String str) {this.raw = str; }@Overridepublic 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)@Overrideprotected 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 从 hook 日志可以看到 user/pverify/json 与 rrvideo/unlock/json 都继续走进了统一的 mode=1 受保护请求链。
图 5-9 user/pverify/json 返回 code=200、data=true,说明当前支付密码校验成功,客户端随后才具备继续提交解锁请求的资格。
图 5-10 支付密码校验通过后,客户端继续构造 rrvideo/unlock/json,请求体中携带当前资源 id 与支付密码。
图 5-11 rrvideo/unlock/json 的响应中已经携带可播放资源路径,说明播放权限来自服务端真实下发,而不是本地伪造。
// com.secret.prettyhezi.Device.a$f.a(java.lang.CharSequence, int)@Overridepublic 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)@Overridepublic 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] 请求地址 : https://qq.taoqq163.com/user/pverify/json[JS] 请求体 : {"p":"xxxx"}[JS] 明文内容 : {"code":200,"data":true}[JS] 请求地址 : https://qq.taoqq163.com/rrvideo/unlock/json[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)@Overrideprotected 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 {@Overridepublic void g(String str) {xxVar = (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 hooksfunction 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.abcpublic 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_OnLoadjint 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_OnLoadjint 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.Callbackpublic void onResponse(Call call, Response response) { j.f8644b = true;if (response.code() == 200) {try {// mode>0 时,响应不会直接 body().string()// 而是先走 Server.e.a -> abc.da5this.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.gk() {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.gthis.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)→ http://172.80.57.10/abc.c(2)→ https://qqtaobao.t123y.com/abc.c(3)→ https://163.taosina163.com/
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_46CB4void __usercall sub_46CB4(unsigned __int8 *a1@<X0>, unsigned __int64 *a2@<X8>){// 这里的 a1 不是普通 char*,而是“输入密文字符串对象”// 先从这个字符串对象里取出真实字符缓冲区地址,再交给 sub_469C0sub_469C0(v10, v9, (unsignedint)v5, v7);// 这里的 a2 不是输入参数,而是输出字符串对象的接收地址// 算法总控函数返回后,把结果重新包装成字符串对象写回 a2sub_413B8(a2, v9);operator delete[](v9);}// libali.so:sub_469C0void __fastcall sub_469C0(unsigned __int8 *a1, __int64 a2, int a3, unsignedint 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, (unsignedint)(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, unsignedint 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 字节工作方式:等价于 ECBIV:不存在
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_40934size_tsub_40934(){ ...// 初始化结束后,把 off_E1098 改指向新生成的 key 缓冲区 off_E1098 = (char *)&unk_E2290;return result;}// libali.so:sub_46440size_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。
推荐一下比较好用的 Frida 工具:Frida-Hookers

看雪ID:Mengz3
https://bbs.kanxue.com/user-home-962430.htm

# 往期推荐
基于 Seccomp-notify 的 Binder 事务级无 Hook 代理机制研究


球分享

球点赞

球在看

点击阅读原文查看更多
夜雨聆风