为保证结论稳健且可迁移,文中所有实验均在以下环境中完成:
- LineageOS 21 / Nexus 5X
- Magisk 29.0.0,LSPosed
- Zygisk Frida Gadget开源模块(sucsand)
- Frida Server 16.5.2
合规声明:本文仅用于安全研究与对抗评估,旨在帮助甲方团队识别自身加固薄弱点、完善自检与回归策略;不针对具体业务落地攻击,不提供可直接用于对第三方应用的利用脚本。
本文将系统性地拆解一个使用DexProtector加固的应用如何检测并阻止Frida,并分享一套完整的绕过思路与实操步骤。你将学习到以下核心技能:
- 入口卡位:不死盯
System.loadLibrary,而是改从 __loader_android_dlopen_ext 抓“真实装载面”,提早拿到证据和时序。
- 匿名段定位与转储:通过
JNI_OnLoad → 函数指针 → 匿名可执行段 这条线索,结合 /proc/<pid>/maps 定位内存段,并用Frida直接dump。
- 最小化修复与类型库引入:在IDA中手动补充区段、引入
android_arm64 / gnulnx_arm64 类型库,理顺JNI动态注册链条。
- 校验链路拆解:识别 xxHash / SHA256 / HMAC 等算法的落点,采用“等式化替换 + 调用点定位”策略进行最小侵入的绕过。
- 二分法定位:从“可卸载点”开始逐段排除,将“必崩区间”缩小到少量函数,再精确打补丁。
- 入口完整性绕过:遇到对“当前段基址”的校验,复制一份干净的text段,将参数基址替换为干净副本来绕过检测。
- 线程面处理:顺着
/proc/self/maps 的反向引用追溯到 pthread_create,定位并禁用监控线程。
在整个过程中,保持工程化习惯:每一步都预留“可回头验证”的观测点,避免“一刀切”,以降低误伤和回归压力。
1. 分析过程
1.1 样本获取与安装
- 样本:Hyatt 6.8.0.apkm
- 目标:安装相同版本,确保App可安装、可启动,并能完整复现检测与崩溃过程。
- 安装方法:通过 APKMirror Installer 或 MT 管理器安装即可。
1.2 设备与运行环境
该基线用于复现与对比。不同 SoC / API level / ART 实现可能导致“加载顺序、符号可见性、maps 标记”等差异。
- 设备:Nexus 5X(刷入LineageOS 21)。
- Root环境:Magisk 29.0.0、LSPosed、Zygisk Frida Gadget 模块(sucsand)。
- Frida:frida-server 16.5.2。
- PC环境:使用集成了逆向常用工具的r0env Kali虚拟机,以简化环境配置。
运行现象:
- 不运行
frida-server 时,应用可进入主页,但会提示需要升级。
- 一旦尝试使用Frida进行
attach 或 spawn,目标进程会迅速崩溃。
- App本身不运行Frida时不会崩溃,说明壳可能没有有效的Root检测,或未检测到Magisk/LSPosed。


2. Java层入口识别
使用JADX对APK进行分析,通过 AndroidManifest.xml 与 Application 类定位主壳入口与native库加载点。
2.1 AndroidManifest.xml分析
- 定位
application 节点,其 android:name 属性即为主壳入口点:com.Hyatt.hyt.ProtectedTopHyattApplication。
- 查找带有
LAUNCHER 类别的 Activity,确认入口Activity为 SplashActivity。
2.2 Application类分析
查看 ProtectedTopHyattApplication 类,在 attachBaseContext 等方法中发现了加载 so 库的操作(如 System.loadLibrary(“dpboot”))以及大量 native 方法声明。这表明Java层主要起引导作用,核心逻辑已下沉到native层。
3. Native层入口点定位
如果直接尝试Hook System.loadLibrary 函数,会导致进程崩溃。因此需要寻找更早的时机。更底层的加载函数是linker中的 __loader_android_dlopen_ext,这是一个全局符号,可以直接获取。
3.1 Hook dlopen 定位加载时机
- 启动
frida-server 并指定非默认端口(如14725),以规避部分端口检测。
- 执行Frida脚本Hook
__loader_android_dlopen_ext。
function hook_dlopen() {
var android_dlopen_ext = Module.findExportByName(null, “__loader_android_dlopen_ext“)
Interceptor.attach(android_dlopen_ext, {
onEnter: function (args) {
var pathptr = args[0];
console.log(”path is => “, pathptr.readCString())
},
onLeave: function () {
console.log(”结束“)
}
})
}
hook_dlopen()
分析日志:日志显示依次加载了 libalice.so、libdpboot.so、libdexprotector.so。崩溃发生在这些库“加载完成之后”,因此将入口库聚焦于 libdexprotector.so。检测逻辑很可能不在 .init_array,后续应重点分析 JNI_OnLoad 或动态注册链。
3.2 尝试Hook JNI_OnLoad
Hook libdexprotector.so 的 JNI_OnLoad 函数,发现它能顺利执行并返回,但进程依然崩溃。这说明检测可能发生在 JNI_OnLoad 注册的JNI函数被外部调用时。
var libdexprotector = Process.findModuleByName(“libdexprotector.so“)
Interceptor.attach(libdexprotector.findExportByName(”JNI_OnLoad“), {
onEnter: function (args) { console.log(”JNI_OnLoad onEnter”) },
onLeave: function(ret){ console.log(”JNI_OnLoad 结束”) }
})
4. 定位检测函数至匿名内存段
检测函数可能位于其他native函数中。使用IDA分析 libdexprotector.so 的 JNI_OnLoad,发现它只是将 JavaVM* 指针传递给了一个函数指针 off_C838。
4.1 追踪函数指针
使用Frida读取 off_C838 指针的值,并检查其所属模块。
// ... 在 dlopen 的 onLeave 中判断 libdexprotector.so 加载后
var libdexprotector = Process.findModuleByName(“libdexprotector.so“)
console.log(”off_C838 is => “, libdexprotector.base.add(0xC838).readPointer())
console.log(”off_C838 module is => “, Process.findModuleByAddress(libdexprotector.base.add(0xC838).readPointer()))
结果:指针地址不属于任何已知模块(返回 null)。进一步查询该地址所在的内存段:
console.log(“off_C838 mem is => ”, JSON.stringify(Process.findRangeByAddress(libdexprotector.base.add(0xC838).readPointer())))
// 输出示例:{"base":"0x7e25afc000","size":507904,"protection":"r-x"}
通过 cat /proc/<pid>/maps 交叉验证,确认该地址位于一段 [anon:...] 匿名可执行内存映射中。这表明DexProtector将关键逻辑放入了运行时生成的匿名映射段,分析必须转向内存态dump与反汇编。
4.2 Dump匿名内存段
获取内存段基址、大小和进程PID后,使用Frida脚本将该段内存dump到本地文件(例如 libanon.so),以供后续静态分析。
结论:分析需基于内存dump,进行单段反汇编。
5. IDA手工修复匿名内存SO
- 加载dump文件:将dump下来的
libanon.so 拖入IDA,由于没有ELF头,需手动选择处理器类型为 ARM Little-endian [ARM]。
- 导入类型库:视图 → 打开子视图 → 类型库,右键加载
android_arm64 和 gnulnx_arm64 类型库,以识别JNIEnv、jclass等类型,以及 RegisterNatives、FindClass 等API。
- 计算真实函数偏移:通过
函数指针地址 - 内存段基址 得到 off_C838 对应函数在dump文件中的偏移(例如 0x4E984),在IDA中跳转至此进行分析。
- 修复无效内存访问:反编译时可能遇到访问
[0x8A800] 等红字错误,这是因为dump文件中没有数据段。在IDA的“区段”视图中,手动添加区段(如名为 rodata),覆盖这些地址范围,即可消除错误。
6. JNIEnv恢复与动态注册链梳理
将 sub_4E984 的参数类型修正为 JavaVM* 后,代码可读性大幅提升。分析发现其调用了 sub_4EAA0、sub_4EBE0、sub_4EFC4 等函数。
重点跟进 sub_4EAA0,将其参数类型修正为 JNIEnv* 后,发现了明显的动态注册逻辑(RegisterNatives)。注册的函数列表中包含 sub_4F0F4(像是初始化函数)和 sub_4F254(一个非常冗长、包含复杂业务逻辑的函数),后者成为后续分析的重点。
7. 关键函数 sub_4F254 的初步探测
直接Hook sub_4F254,观察其返回值,并尝试修改返回值以测试是否能够绕过检测。
Interceptor.attach(libanon.base.add(0x4F254),{
onEnter: function(){ console.log(“onEnter 0x4F254 ”) },
onLeave: function(retval){
console.log(“onLeave 0x4F254 “, retval.toInt32())
// 尝试修改返回值: retval.replace(0)
}
})
修改返回值为0后,App依然崩溃,说明检测逻辑嵌套在该函数内部的校验路径中,必须深入分析其内部的完整性或哈希校验分支。
8. 定位哈希校验算法(xxHash与HMAC-SHA256)
分析 sub_4F254,发现关键校验代码:
if ( v34 == sub_161E8(unk_82948, unk_82990 - unk_82948, &v75) )
sub_14D24(byte_872AC, 64, v32, 32, v33);
sub_161E8:通过分析其指令特征(如大量 vaddq_s64、veorq_s8 等NEON指令)和询问GPT,判断其为类似 xxHash 的非加密哈希算法,用于CRC校验。
sub_14D24:其内部调用了 sub_304A8 和 sub_30B14,这两个函数中包含ARMv8内联的SHA256指令(如 SHA256SU0),可以判定 sub_14D24 是HMAC-SHA256的入口。
9. 等式化替换策略绕过CRC校验
目标是让 sub_161E8 的返回值与预期的内存值相等,从而“通过”校验。首先需要找到所有调用 sub_161E8 进行校验的位置。
Interceptor.attach(libanon.add(0x161E8),{
onEnter:function(args){
this.lr = this.context.lr.sub(libanon) // 保存调用地址偏移
},
onLeave:function(ret){
// 定义不同调用点需要相等的目标内存地址
var hash_crc = {“0x4f5a4”: 0x8A810, “0x4f6cc”: 0x8A810, “0x5c494”: 0x8AB20}
var targetAddr = hash_crc[this.lr.toString()];
if(targetAddr){
ret.replace(libanon.add(targetAddr).readU64()); // 替换返回值
}
}
})
注意:lr 寄存器(存有返回地址)需在 onEnter 阶段保存,因为 onLeave 时其可能已被Hook框架修改。
此策略成功让程序走进了 sub_14D24(HMAC-SHA256)的逻辑,但程序仍然崩溃,说明后续还有校验或副通道检测。
10. 字符串解密与 /proc/self/maps 检测点
在分析其他函数(如 sub_50130)时,发现了字符串解密函数(sub_55650, sub_40814)以及对 /proc/self/maps 的读取操作。Hook字符串函数后,日志显示应用确实在检测maps。
通过调用栈回溯(利用 lr),定位到读取maps的检测函数 sub_61974。Hook发现其返回值为786(表示异常),尝试修改返回值为0后,崩溃日志变多,说明maps检测只是整个检测体系的一部分。
11. 二分排除法缩小“必崩区间”
为了精确打击,采用“二分排除法”定位导致崩溃的核心代码区间。
- 切换至Zygisk Frida Gadget:使用sucsand模块,以绕过部分基于ptrace的检测,并为二分法创造条件。
- 逐步卸载Hook:从入口函数
sub_4F254 开始,逐步卸载后续Hook的代码块,观察App是否崩溃。
- 卸载
sub_4F254 后不崩溃? → 崩溃点在它之后。
- 卸载
sub_593FC 后不崩溃? → 崩溃点在 sub_4F254 到 sub_593FC 之间。
- 依次在中间点测试,如卸载
sub_3EA0C 后不崩溃,但卸载 sub_15128 后崩溃,则“必崩区间”被缩小到 sub_3EA0C 到 sub_15128 之间。
通过此方法,可以将问题范围从数百个函数急剧缩小到少数几个,极大提升分析效率。
12. 绕过入口完整性校验(基址检测)
在对缩小的区间进行分析时,发现 sub_304A8(SHA256相关函数)的入口参数中包含了待校验内存的基址,并且该校验可能针对当前匿名段的基址。
绕过方案:在匿名段加载后,立即将其text段内容拷贝一份到新分配的“干净”内存中。当检测函数校验基址时,将参数中的基址替换为这份干净副本的地址。
var origin = Memory.alloc(size);
origin.writeByteArray(libanon.base.readByteArray(size));
Interceptor.attach(libanon.base.add(0x304A8), {
onEnter: function (args) {
if (args[1].toString() === libanon.base.toString()) { // 如果检测当前段
args[1] = origin; // 替换为干净副本基址
}
}
});
实施此操作后,应用成功绕过检测,进入主界面。但日志显示仍有大量线程在刷maps检测信息。
13. 定位并禁用监控线程
根据之前发现的 /proc/self/maps 调用栈,反向追踪到线程创建函数 sub_7A230(类似 pthread_create)。
- 在IDA中通过交叉引用(X键)查找所有调用
sub_7A230 的地方,获取其传入的线程入口函数地址(如 0x5A708, 0x5BE28 等)。
- 在运行时,使用Frida向这些线程入口地址写入
RET 指令,使新创建的线程立即返回,从而无法执行监控逻辑。
function retFunc(funcAddress) {
Memory.protect(funcAddress, 4, ‘rwx’);
var writer = new Arm64Writer(funcAddress);
writer.putRet(); // 写入返回指令
writer.flush();
writer.dispose();
}
retFunc(libanon.base.add(0x5A708));
retFunc(libanon.base.add(0x5BE28));
// ... 禁用其他监控线程入口
执行后,maps检测日志停止刷屏,实现相对“干净”的绕过。
14. 结论回顾
- 入口选择:从
__loader_android_dlopen_ext 切入比 System.loadLibrary 更能把握早期时机。
- 主战场:DexProtector的关键逻辑常驻于运行时生成的匿名可执行段,需掌握内存dump与“仅text段”反汇编的技巧。
- 对抗核心:识别并处理多层校验链(如xxHash、HMAC-SHA256),采用等式替换等精准打击策略。
- 分析方法:善用二分排除法系统性缩小问题范围,定位到具体函数后再做补丁,而非盲目禁用。
- 对抗全面性:除了主校验逻辑,还需处理独立的监控线程。
15. 限制与风险
- 环境敏感性:结论受ROM、内核、ART版本影响较大,不同环境下的内存布局、linker行为可能不同。
- 时效性:部分绕过技巧(如特定偏移、匿名段特征)可能仅对当前样本或版本有效。
- 工具链影响:Zygisk Frida Gadget 与标准 frida-server 在时序和可见性上存在差异。
本文涉及的工具与代码已整理,可通过相关渠道获取以供学习交流。