tags:
注:附件中有APK源文件与hook代码,需要自取
打开 APP 先白屏退出,于是先去看 Manifest 和启动 Activity,定位到 OuiCrGxF.onCreate()。

图 5-1 样本启动后先进入 OuiCrGxF.onCreate(),因此后续环境检测、广告控制、登录恢复等主流程都可以从这个入口继续往下追。
com.secret.prettyhezi.OuiCrGxF.onCreate()
→ p0.d.b().d(this, null)
→ m0()
→ System.exit(0)
样本在启动很早阶段就会做一轮“多特征累计打分”的环境检测,命中后直接退出,不会继续走后续初始化。
函数作用:
com.secret.prettyhezi.Yclh4J3zF.onCreate()
→ p0.e.b().f()
→ m0()
Xposed 检测在父类 onCreate() 中就已经执行,子类页面逻辑开始前就可能被拦截。
com.secret.prettyhezi.OuiCrGxF.onCreate()
→ p0.e.b().a(this)
→ I("破解要小心哦~")
debuggable 检测更偏提示性质,不承担强制退出逻辑。
这个检测藏在开头的父onCreate()中

图 5-2 除了前面的评分式检测外,样本在父类 onCreate() 中还额外埋了一套模拟器特征检测,命中后同样会直接退出。
com.secret.prettyhezi.OuiCrGxF.onCreate()
→ p0.a.c(this)
→ m0()
除了打分式环境检测外,样本还有一套独立的模拟器特征检测链,命中后同样直接退出。
com.secret.prettyhezi.OuiCrGxF.onCreate()
→ p0.e.b().d()
→ m0()
Root 检测直接参与流程控制,命中后也不会继续初始化。
函数作用 / 绕过意义:
前面把启动检测处理掉之后,样本并不会立刻进入登录页或主页。配置返回成功后,程序会先进入 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()
→ 跳转登录页或主页
函数作用:
Hook 代码:
前面把启动检测和广告放行链理顺之后,样本才真正走到“要不要给你业务权限”这一步。
这个阶段最值得跟的不是某个孤立接口,而是登录态到底怎么建立、怎么落盘、又怎么在下次启动时被重新捞回来。
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 字节级请求加密流程。
函数作用:
这里还要补一层很容易被忽略的关系:登录请求里拼接出来的 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() 提供初始值的。
结合 jadx 继承关系观察,可进一步对齐出 mode=1 登录请求体中的关键字段:
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、账号和密码都已经被写回内存或本地缓存。
函数作用:
如果只抓一句结论来概括这一段,那就是:登录真正完成的标志,不是界面跳了,而是 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() 做最终决策”。
函数作用:
本节小结:
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=... 去拿试看播放链路;但这条链和后面的积分解锁不是同一个动作,写正文时最好把它单独当成“已锁内容下的试看分支”来看。
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 的响应中已经携带可播放资源路径,说明播放权限来自服务端真实下发,而不是本地伪造。
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 视频解锁成功后,积分会立即发生扣减,同时伴随经验刷新与用户缓存回写,说明这是一次完整的服务端业务闭环。
结合真实日志可以把解锁后的用户态更新再落细一层:
补充说明:
本节小结:
MainApplication.<clinit>()
→ System.loadLibrary("ali")
→ c.abc.*
→ libali.so
Java 层只保留轻量包装,真正关键的字符串常量返回、编解码和部分保护逻辑已经下沉到 libali.so。
前面第 5 节已经把启动、登录和视频解锁这些主流程跑通了,这一节就不再从业务入口往下追,而是反过来回答一个问题:前面那些关键节点里,到底有哪些地方是真的进了 Native。顺着这个思路去看,libali.so 的角色就会很清楚,它不是一个边缘小库,而是同时承担了“字符串常量保护”和“请求/缓存编解码”两类核心能力。
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 能力。

函数作用:
结合 IDA 可确认当前样本注册了以下 6 个 native 方法:
补充说明:
继续往下拆之后,会发现 libali.so 里其实至少有两组不能混写的 Native 能力。第一组就是这里的 ea5/da5/ea2/da2,它们服务的是“请求、响应、本地缓存”这些会在业务流程里频繁经过的数据;第二组才是后面 abc.c() 那条专门负责域名等常量保护的链。把这两组先分开,是整篇分析里最重要的自检点之一。

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 请求,这里都可以拿来当底层支撑。
函数作用:
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 这一层把字节流还原完了。
函数作用:
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”这个疑问提前解释掉。不是算法名字不同,而是它们本来就在处理两种不同形态、不同场景的数据。
函数作用:
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 重启后还能把之前保存下来的配置重新读回来”。
函数作用:
JNI_OnLoad
→ ea2/da2/da5/ea5
→ xyz6/xyz3/xyz8/xyz9
→ 命中字符串 "AES"
→ 命中同一组 key 字面量 "gncdGCPoNdM([[SEA"
把这四条链放在一起看,能得到一个比较稳的整体判断:它们属于同一组 AES 风格 Native 编解码族,服务的对象分别是“请求包、响应包、配置缓存”。这一步最重要的不是把模式名字硬猜死,而是先把边界划清楚。也就是前面登录、自动恢复、视频解锁里看到的那些受保护数据,底层大体都落在这组函数上;而后面马上要讲的 abc.c(),则是另一套专门保护内置字符串常量的 Blowfish 链。
自检结论:
注意:
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 这张密文字符串表中取出对应项,再继续后续解密流程。
函数作用:
结合 IDA 实际恢复出的结果如下:
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(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() 走的是一整条完整的解密链,而不是单步明文返回。
函数作用:
函数作用:
这一段就是前面反复校对过很多次的地方。sub_46CB4(&v14, v12) 乍一看,确实很像“把 &v14 传进去处理,再把结果写到 v12”;但只要结合 X0/X8 的寄存器传参与函数内部行为去看,真实语义正好反过来。这类地方特别适合在帖子里单独提醒一下,因为很多人第一次看 ARM64 反编译都会在这里被变量顺序带偏。
参数流向补充说明:
sub_469C0()
→ 两字符转一字节
→ 低 4 位取前字符
→ 高 4 位取后字符
→ 得到真实密文字节流
把参数流向看清以后,sub_469C0 的第一段预处理就容易理解了。这里不是直接调标准库做 hex -> bytes,而是自己按“两字符拼一字节”的方式手搓转换逻辑;并且字符映射也不是现成函数,而是通过条件分支自己算出来。所以密文虽然表面长得像十六进制字符串,但直接拿普通 bytes.fromhex() 去解,结果一定会错。
函数作用:
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 和后处理两层坑。
函数作用:
关键结论:
sub_469C0()
→ sub_49D9C(...)
→ sub_49494(...)
→ sub_48F68(v10, v29, a2, a4)
→ 最终明文
sub_48F68 是整条链里另一个特别容易漏掉的点,因为它说明“Blowfish 解完”还不是最终答案。样本在 Blowfish 输出后,又额外追加了一层自定义后处理;只有把这一步也补上,前面那段中间数据才会真正变成 Java 层可直接使用的明文字符串。
函数作用:
补充结论:
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 虽然看起来是同类路径,但当前只能确认它具备类似能力,不能直接写成“运行时也一定执行了”。
函数作用:
结合 IDA 实际恢复,可得到当前样本运行时 key 的前 7 字节为:
Java 常量使用点
→ 锁定 abc.c(int)
→ JNI_OnLoad 确认 native 目标函数
→ 顺着 minax -> sub_46CB4 -> sub_469C0 拆链
→ 识别 sub_49494 为 Blowfish 解密
→ 识别 sub_48F68/sub_48D58 为互逆后处理
→ 通过闭环验证恢复明文
分析方法总结:
最终结论:
逆向抓手总结:
推荐一下比较好用的 Frida 工具:Frida-Hookers
| 项目 |
内容 |
| 文件路径 |
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 |
| 类型 |
值 |
| MD5 |
C5E825297315F309107396FBFC5483C5 |
| SHA1 |
1D55E7D0DC405F9DF68795782A8AC0397286DC90 |
| SHA256 |
39161FFA49CB22D334E8899A2727467DE7CEE94AF74C953FCD9A1746F22DBCAC |
| 项目 |
内容 |
| 设备类型 |
Redmi K40s |
| Android 版本 |
13 |
| CPU 架构 |
arm64-v8a / armeabi-v7a |
| Root 状态 |
是 |
| Magisk |
已安装 |
| Frida |
16.7.19 |
| frida-server |
16.7.19 |
| 工具 |
用途 |
| JADX / JADX MCP |
Java 反编译、Manifest 分析 |
| IDA / IDA MCP |
libali.so Native 分析 |
| adb |
安装、日志、设备交互 |
| Frida |
动态 Hook |
| MT 管理器 |
APK 初步查看 |
| 项目 |
内容 |
| 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 |
| 权限 |
风险等级 |
用途判断 |
是否合理 |
| 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 |
低 |
保持设备唤醒 |
是 |
protected void onCreate(Bundle bundle) {
super.onCreate(bundle);
com.secret.prettyhezi.View.s.c();
W0();
if (p0.d.b().d(this, null)) {
m0();
return;
}
...
}
public boolean d(Context context, c cVar) {
String strA = a("gsm.version.baseband");
int i6 = (strA == null || strA.contains("1.0.0.0")) ? 1 : 0;
String strA2 = a("ro.build.flavor");
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")) {
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;
}
protected void onCreate(Bundle bundle) {
super.onCreate(bundle);
...
if (p0.e.b().f()) {
m0();
}
...
}
public boolean f() {
try {
throw new Exception("gg");
} catch (Exception e6) {
for (StackTraceElement stackTraceElement : e6.getStackTrace()) {
if (stackTraceElement.getClassName().contains("de.robv.android.xposed.XposedBridge")) {
return true;
}
}
return false;
}
}
if (p0.e.b().a(this)) {
I("破解要小心哦~");
}
public boolean a(Context context) {
return (context.getApplicationInfo().flags & 2) != 0;
}
if (p0.a.c(this)) {
m0();
return;
}
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();
}
if (p0.e.b().d()) {
m0();
}
public boolean d() {
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++) {
if (new File(strArr[i6]).exists()) {
return true;
}
}
return false;
}
var xposed_hook = p0_e.f.overload();
xposed_hook.implementation = function() {
return false;
};
var xuniqi_hook = p0_d.d.overload("android.content.Context", "p0.c");
xuniqi_hook.implementation = function(context, p0_c_instance) {
return false;
};
var debug_hook = p0_e.a.overload("android.content.Context");
debug_hook.implementation = function(context) {
return false;
};
var moniqi_hook = p0_a.c.overload("android.content.Context");
moniqi_hook.implementation = function(context) {
return false;
};
var root_hook = p0_e.d.overload();
root_hook.implementation = function() {
return false;
};
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));
}