首页
社区
课程
招聘
[原创]实战某 Box APP全流程分析(检测绕过/登录分析/视频解锁/native加密/广告绕过)
发表于: 2026-4-28 19:30 6940

[原创]实战某 Box APP全流程分析(检测绕过/登录分析/视频解锁/native加密/广告绕过)

2026-4-28 19:30
6940

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>keyCurUIDkeyCurToken 写回本地;与此同时,刚才在登录页输入的账号和密码也会通过 g4.f.a().l(...) 一起落盘。这样下次启动时,APP 就不是“重新让你从零登录一次”,而是直接拿现成的本地状态去尝试恢复。 图 5-5 登录真正完成的标志不是页面跳转,而是用户对象、UID、Token、账号和密码都已经被写回内存或本地缓存。

函数作用:

如果只抓一句结论来概括这一段,那就是:登录真正完成的标志,不是界面跳了,而是 keyCurUser<uid>keyCurUIDkeyCurTokenkeyAccountkeyPassword 这几类状态都已经被写下来了。后面所有“自动恢复登录”“启动后直进主页”的能力,都是建立在这里。

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。这也就解释了为什么前面登录成功时写下来的 keyAccountkeyPasswordkeyCurToken 都那么关键,因为启动页后半段会把它们全部重新用起来。

还有一个很容易漏掉的点: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/jsonEkHbSOqG.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.soJNI_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)

da2ea2 就是一组正反链。前面写配置时走 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_E1098sub_46440 虽然看起来是同类路径,但当前只能确认它具备类似能力,不能直接写成“运行时也一定执行了”。

函数作用:

结合 IDA 实际恢复,可得到当前样本运行时 key 的前 7 字节为:

Java 常量使用点

→ 锁定 abc.c(int)

JNI_OnLoad 确认 native 目标函数

→ 顺着 minax -> sub_46CB4 -> sub_469C0 拆链

→ 识别 sub_49494 为 Blowfish 解密

→ 识别 sub_48F68/sub_48D58 为互逆后处理

→ 通过闭环验证恢复明文

分析方法总结:

最终结论:

逆向抓手总结:

粗体文本

项目 内容
文件路径 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 保持设备唤醒
// 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;
}
// 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 状态
// 命中后只弹提示,不会立即退出
if (p0.e.b().a(this)) {
    I("破解要小心哦~");
}

// p0.e.b().a(this)
public boolean a(Context context) {
    return (context.getApplicationInfo().flags & 2) != 0;
}
// 这是另一套模拟器特征检测
// 只要命中特征,就直接结束 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 检测命中后同样直接结束流程
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;
}
// 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;
};
// 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));
}

传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

最后于 2026-4-29 15:25 被Mengz3编辑 ,原因:
上传的附件:
收藏
免费 2
支持
分享
最新回复 (5)
雪    币: 6410
活跃值: (6885)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
先收藏起来慢慢学,感谢分享。
2026-4-28 23:08
1
雪    币: 6410
活跃值: (6885)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3

重复了,删除。

最后于 2026-4-28 23:10 被院士编辑 ,原因: 重复了。
2026-4-28 23:09
0
雪    币: 6410
活跃值: (6885)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4

重复了,删除。

最后于 2026-4-28 23:11 被院士编辑 ,原因: 重复回帖,删除。
2026-4-28 23:09
0
雪    币: 104
活跃值: (8407)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5
tql
2026-4-29 10:50
0
雪    币: 232
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
6
66666很全面
1天前
0
游客
登录 | 注册 方可回帖
返回