Android逆向实战:Hook dlopen绕过Frida检测机制
1. 项目概述为什么我们要关注 dlopen 的 Hook在移动安全逆向和动态分析领域绕过应用的保护机制是一个永恒的话题。很多应用尤其是那些对安全性有较高要求的金融、游戏类应用会采用各种手段来检测和阻止动态分析工具的运行比如检测 Frida、Xposed 等框架的存在。其中一个非常经典且有效的检测点就是dlopen函数。dlopen是 Linux/Android 系统动态链接器的核心函数负责在运行时加载共享库.so 文件。当 Frida 注入到目标进程时其核心组件frida-agent.so就需要通过dlopen被加载到进程内存中。因此应用的保护机制常常会 Hook 系统的dlopen调用检查即将加载的库文件名或路径中是否包含 “frida” 等敏感字符串一旦发现就拒绝加载或直接终止进程导致我们的 Hook 操作功亏一篑。所以这个项目的核心目标就非常明确了逆向分析这种基于dlopen的保护机制并利用 Frida 自身去 Hookdlopen从而绕过检测实现成功注入。这本质上是一场“矛与盾”的博弈也是深入理解 Android Native 层安全攻防的绝佳实践。无论你是想加固自己的应用还是作为安全研究员去挑战更坚固的防线掌握这套思路都至关重要。2. 保护机制原理与 dlopen 深度解析要绕过必须先理解。我们得先搞清楚应用是如何通过dlopen来检测 Frida 的。2.1 dlopen 函数的工作流程在 Linux/Android 的 ELF 动态链接模型中dlopen函数的典型声明如下void *dlopen(const char *filename, int flags);filename: 要加载的共享库的路径。如果为 NULL则返回主程序的句柄。flags: 加载标志如RTLD_LAZY延迟绑定或RTLD_NOW立即绑定。返回值成功时返回一个句柄void*失败时返回 NULL。当进程调用dlopen(“/data/local/tmp/libexample.so”, RTLD_NOW)时动态链接器通常是/system/bin/linker或/system/bin/linker64会执行一系列操作解析路径、打开文件、映射到内存、处理重定位、执行初始化函数.init_array或JNI_OnLoad最后将句柄返回给调用者。2.2 常见的检测与保护实现方式应用的保护代码通常位于一个先于业务逻辑加载的“壳”或安全模块中会采用以下几种方式利用dlopen直接 Hookdlopen函数通过修改 PLT/GOT 表或内联钩子Inline Hook将dlopen的调用重定向到自定义函数。在这个自定义函数里检查filename参数。void* my_dlopen(const char* filename, int flags) { if (filename ! NULL strstr(filename, “frida”) ! NULL) { LOGD(“检测到 Frida 库: %s”, filename); // 可以选择返回 NULL或加载一个无害的假库或直接崩溃 return NULL; } // 调用真正的 dlopen return orig_dlopen(filename, flags); }检查已加载模块保护代码会遍历/proc/self/maps或调用dl_iterate_phdr函数检查当前进程内存映射中是否已经存在名称包含 “frida-agent”、“gadget” 等字样的库。监控文件系统检查/data/local/tmp等常见目录下是否存在 Frida Server 或相关库文件。这些保护的核心逻辑都依赖于对dlopen调用链的监控或篡改。因此我们的对抗思路就是在保护代码生效之前抢先一步控制dlopen的执行流。3. 逆向分析定位保护代码的 Hook 点在动手编写绕过脚本之前我们需要先做一番侦察确定目标应用的保护代码具体 Hook 了哪里。3.1 静态分析寻找线索使用逆向工具如 IDA Pro, Ghidra, radare2分析目标应用的 Native 库通常是libxxx.so或libsecurity.so。搜索字符串在字符串列表中搜索 “frida”、“gadget”、“agent”、“xposed” 等关键词找到相关的日志或判断逻辑。分析导入表查看.so文件的导入表关注dlopen、dlsym、dl_iterate_phdr、fopen用于读/proc/self/maps等函数。如果这些函数被导入说明代码中可能直接调用了它们。定位初始化函数保护逻辑通常会在库被加载时立即执行。因此要重点关注JNI_OnLoad、.init、.init_array段中的函数。在这些函数里很可能藏着 Hook 的安装代码。3.2 动态调试验证猜想静态分析可能不够直观我们需要用 Frida 进行动态验证前提是应用没有完全封死 Frida或者我们可以找到其检测的盲区。枚举模块和导入/导出// 枚举所有已加载的模块 Process.enumerateModules({ onMatch: function(module){ console.log(module.name “ “ module.base); }, onComplete: function(){} }); // 查看特定模块的导入函数 var libtarget Module.findBaseAddress(“libtarget.so”); if (libtarget) { Module.enumerateImports(“libtarget.so”, { onMatch: function(imp){ if (imp.name.indexOf(“open”) ! -1) console.log(imp); }, onComplete: function(){} }); }通过这个脚本可以确认目标库是否导入了dlopen。Hook 并监控dlopen调用// Hook libc 中的 dlopen var dlopen Module.findExportByName(“libc.so”, “dlopen”); if (dlopen) { Interceptor.attach(dlopen, { onEnter: function(args) { var path args[0].readCString(); this.path path; console.log([dlopen] 尝试加载: ${path}); // 打印调用栈看是谁调用的 console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join(‘\n‘)); }, onLeave: function(retval) { console.log([dlopen] 返回: ${retval}); } }); }运行这个脚本观察当 Frida 尝试注入时是否有来自目标保护库的dlopen调用以及其参数和返回值。如果发现对包含 “frida” 路径的调用返回了 NULL那么基本可以确定保护生效了。注意有些保护会 Hooklibdl.so中的dlopen而不是libc.so中的。Android 系统中dlopen的实际实现通常在libdl.so。因此最稳妥的方式是同时 Hooklibdl.so的dlopen和__loader_dlopenlinker 的内部函数。4. 绕过方案设计与 Frida Hook 实现经过分析我们知道了保护代码在哪里守株待兔。现在我们的目标就是“偷梁换柱”让真正的dlopen在我们控制的逻辑下运行。4.1 核心绕过思路我们的策略是使用 Frida 的Interceptor在保护代码的 Hook 生效之前抢先 Hook 系统原始的dlopen函数。在我们的 Hook 回调中对参数进行检查和过滤。如果发现是 Frida 相关的库文件路径我们就将其“伪装”成一个无害的路径或者直接允许其加载然后将“伪装”后的参数传递给原始函数。这里有一个关键点我们必须找到最底层、最原始的那个dlopen函数。因为保护代码可能也 Hook 了它如果我们 Hook 的层级不够低仍然会落到保护代码的陷阱里。4.2 寻找原始的 dlopen在 Android 中dlopen的调用链可能是应用代码-libxxx.so中的自定义dlopen-libdl.so中的dlopen-linker(linker64)中的__loader_dlopen因此优先级是Hook linker 中的函数 Hook libdl.so 中的函数 Hook libc.so 中的符号。尝试 Hook linker这是最根本的。linker 是动态链接器本身。var linker Process.findModuleByName(“linker64”) || Process.findModuleByName(“linker”); var loader_dlopen linker ? Module.findExportByName(linker.name, “__loader_dlopen”) : null; if (loader_dlopen) { // 这是最理想的 Hook 点 var target_func loader_dlopen; }如果找不到__loader_dlopen可以尝试枚举 linker 的所有导出函数寻找包含 “dlopen” 的。备用方案Hook libdl.sovar target_func Module.findExportByName(“libdl.so”, “dlopen”); if (!target_func) { // 某些 Android 版本符号可能在 libc 中 target_func Module.findExportByName(“libc.so”, “dlopen”); }4.3 实现绕过 Hook 脚本找到目标函数后我们就可以编写核心的绕过脚本了。// bypass_dlopen_check.js Java.perform(function () { console.log(“[*] 开始 dlopen 保护绕过...”); // 1. 优先寻找 linker 中的原始函数 var linker Process.findModuleByName(“linker64”) || Process.findModuleByName(“linker”); var targetFunc null; var funcName “”; if (linker) { // 尝试几个常见的内部符号 var candidates [“__loader_dlopen”, “dlopen”, “__dl__ZL10dlopen_extPKciPK17android_dlextinfo”]; for (var i 0; i candidates.length; i) { var addr Module.findExportByName(linker.name, candidates[i]); if (addr) { targetFunc addr; funcName candidates[i]; console.log([] 在 ${linker.name} 中找到目标函数: ${funcName} ${targetFunc}); break; } } } // 2. 如果 linker 没找到回退到 libdl.so if (!targetFunc) { targetFunc Module.findExportByName(“libdl.so”, “dlopen”); funcName “dlopen (libdl.so)”; if (targetFunc) { console.log([] 在 libdl.so 中找到目标函数: ${funcName} ${targetFunc}); } } // 3. 最后的回退方案libc.so if (!targetFunc) { targetFunc Module.findExportByName(“libc.so”, “dlopen”); funcName “dlopen (libc.so)”; if (targetFunc) { console.log([] 在 libc.so 中找到目标函数: ${funcName} ${targetFunc}); } else { console.log(“[-] 错误未找到 dlopen 函数”); return; } } // 4. 实施 Hook Interceptor.attach(targetFunc, { onEnter: function (args) { var pathPtr args[0]; this.originalPath null; this.shouldBypass false; if (!pathPtr.isNull()) { var path pathPtr.readCString(); this.originalPath path; // 定义需要绕过的关键词列表 var dangerKeywords [“frida”, “gadget”, “agent”, “libfrida”, “/data/local/tmp/”]; // 根据实际情况调整 for (var i 0; i dangerKeywords.length; i) { if (path path.toLowerCase().indexOf(dangerKeywords[i].toLowerCase()) ! -1) { console.log([!] 检测到可疑路径: ${path}); // 关键绕过操作将路径替换为一个无害的、已存在的系统库路径 // 例如替换成 libc.so或者一个不存在的路径让 dlopen 失败但不会触发检测 var fakePath “/system/lib/libc.so”; // 或者 “/data/fake.so” // 注意我们需要修改内存中的参数。这里使用 Memory.allocCString 分配新字符串。 var newPathPtr Memory.allocCString(fakePath); args[0] newPathPtr; this.fakePathPtr newPathPtr; // 保存指针便于清理如果需要 this.shouldBypass true; console.log([] 已替换为: ${fakePath}); break; } } if (!this.shouldBypass) { // console.log([dlopen] 正常加载: ${path}); // 正常路径可以注释掉日志减少输出 } } }, onLeave: function (retval) { // 如果需要可以在这里根据 this.shouldBypass 标志位对返回值做进一步处理。 // 例如如果替换路径导致加载失败返回NULL我们可以尝试其他方法。 if (this.shouldBypass) { console.log([] 绕过操作完成原始路径 ‘${this.originalPath}‘ 的调用已伪装。); // 注意我们替换了路径所以 retval 是加载 fakePath 的结果。 // 如果 fakePath 加载成功比如是 libc.so返回的是一个有效句柄但这可能不是 Frida 想要的。 // 更复杂的方案是不替换路径而是修改保护函数的判断逻辑这需要 Hook 保护代码本身。 } // 清理分配的内存可选因为进程退出会回收 // if (this.fakePathPtr) { /* 通常不需要手动释放 */ } } }); console.log(“[*] dlopen Hook 安装完成保护绕过已激活。”); });4.4 方案进阶更隐蔽的绕过方式上述脚本直接修改了dlopen的路径参数方法直接但可能有些粗暴。更高级的保护可能会检查加载的库内容而不仅仅是路径。因此我们可以考虑其他策略Hook 保护函数本身如果我们通过逆向分析找到了保护代码中那个负责字符串比较strstr的函数可以直接 Hook 它让它对 “frida” 等关键词的检查永远返回 false或 NULL。// 假设我们找到了保护模块中的 anti_frida_check 函数 var antiCheckFunc Module.findExportByName(“libsecurity.so”, “anti_frida_check”); Interceptor.attach(antiCheckFunc, { onEnter: function(args) { this.pathArg args[0]; }, onLeave: function(retval) { // 强制让检测函数返回 0 (表示未检测到) retval.replace(ptr(0)); console.log([] 篡改了检测函数对路径 ${this.pathArg.readCString()} 的返回值); } });内存补丁直接修改保护代码中关键判断指令的字节码。例如将BNE跳转改为B无条件跳转或NOP空操作。这需要精确的偏移地址和对 ARM/ARM64 指令集的了解。// 在特定地址写入 NOP 指令 (ARM 32位下为 0xBF00, 64位下为 0xD503201F) var patchAddr Module.findBaseAddress(“libsecurity.so”).add(0x1234); Memory.patchCode(patchAddr, 4, code { let writer new Arm64Writer(code, { pc: patchAddr }); writer.putInstruction(0xD503201F); // NOP writer.flush(); });5. 实操流程与现场问题排查理论有了脚本写了现在让我们实际跑一遍并记录下可能遇到的问题。5.1 完整操作步骤环境准备Android 设备Root 或 可调试的模拟器。Frida 环境PC 端安装frida-tools(pip install frida-tools)设备端运行对应架构的frida-server。目标应用一个带有dlopen检测保护的应用可以自己写一个 demo或者找一些有保护的 APK 进行练习。启动与注入# 1. 在设备上启动 frida-server adb shell su /data/local/tmp/frida-server # 2. 在 PC 上先以 spawn 模式启动应用并注入我们的绕过脚本 frida -U -f com.example.targetapp -l bypass_dlopen_check.js --no-pause使用--no-pause是为了让应用在注入后立即继续运行这对于需要在应用初始化阶段就绕过保护的情况很重要。验证绕过是否成功 注入脚本后观察控制台输出。如果看到类似“[] dlopen Hook 安装完成”和“[] 已替换为: ...”的日志说明 Hook 安装成功。 接着我们可以再运行一个正常的 Frida 脚本去检测 Frida 是否正常工作例如枚举进程模块frida -U com.example.targetapp -l enumerate_modules.js如果这个脚本能成功执行并列出模块包括frida-agent-64.so说明绕过成功。5.2 常见问题与排查技巧实录问题1脚本注入失败提示“Failed to spawn: unable to connect to remote frida-server”排查检查frida-server是否在设备上运行 (ps | grep frida)。检查 PC 和设- 备是否在同一网络或 USB 连接是否正常 (adb devices)。尝试关闭 PC 和设备端的防火墙。使用frida-ps -U看是否能列出设备进程。问题2Hook 安装成功但保护依然触发应用崩溃或退出排查Hook 点不对保护可能 Hook 了更底层的函数如android_dlopen_ext或 linker 内部的open函数。需要扩大搜索范围。路径检测方式多样保护代码可能不是简单用strstr而是用strstr的变体、正则表达式或者先解析路径的basename。调整脚本中的dangerKeywords列表尝试更多变体如“-agent”、“gum-js”等。时序问题保护代码可能在.init_array或JNI_OnLoad中同步执行检测而此时 Frida 的 JavaScript 运行时可能还未完全初始化导致我们的 Hook 跑晚了。尝试用 Frida 的EarlyInstrumentation如果支持或者将脚本编译成dex并通过-D参数以 Dex 模式提前注入。反调试/反注入应用可能有其他保护如ptrace反调试、检查TracerPid等。需要综合应对。问题3替换路径后Frida 自身功能不正常分析我们把加载frida-agent.so的请求重定向到了libc.soFrida 的核心引擎自然无法正常工作。这是一个“杀敌一千自损八百”的方法。解决这种直接替换路径的方案更适合用于“欺骗”保护检测让保护以为没有加载 Frida但前提是 Frida 已经通过其他方式加载进来了。更常见的场景是先通过某种方式如修改ro.debuggable、使用 Magisk 模块让 Frida 在应用启动早期就完成注入然后我们的脚本再去绕过后续的dlopen检测。或者采用前述的“Hook 保护函数”或“内存补丁”方案不干扰dlopen的正常加载过程。问题4如何找到保护代码中具体的检测函数动态分析在 Hook 了dlopen并发现拦截后打印调用栈 (Thread.backtrace)。调用栈中在libc.so或libdl.so之上的那个来自目标保护库的函数很可能就是检测函数。记下它的地址在 IDA 中查看。字符串交叉引用在 IDA 中找到“frida”这个字符串查看哪些代码引用了它 (X键)。追踪这些引用函数。Frida Stalker对于复杂的混淆代码可以使用 Frida 的Stalker跟踪代码执行流观察执行到哪个基本块时发生了崩溃或返回从而定位关键判断点。6. 对抗升级与防御思路探讨安全是螺旋上升的。当我们掌握了绕过dlopen检测的方法后防御方也会升级。防御方可能的升级策略静态链接关键函数将字符串比较等关键函数内联或静态链接避免通过 PLT/GOT 表被轻易 Hook。完整性校验对自身的检测代码段进行哈希校验防止被内存补丁。多线程异步检测不在初始化时同步检测而是启动后台线程周期性遍历/proc/self/maps或调用dl_iterate_phdr。使用 Syscall直接使用syscall指令调用openat、read等底层函数来读取文件或内存绕过 libc 的 Hook。环境检测结合其他特征如检查LD_PRELOAD环境变量、检测进程名、端口扫描等进行综合判断。作为攻击方的应对思考对抗内存校验可以 Hook 校验函数直接返回正确的哈希值。对抗 Syscall在内核层面进行 Hook需要 root 权限或者使用更底层的调试接口如ptrace拦截系统调用。全链路对抗这不再是一个单点技术问题而是一个系统工程。需要综合使用静态分析、动态调试、二进制修补、环境伪装等多种手段。绕过dlopen保护只是一个具体的战场。真正的价值在于通过这个案例理解 Android Native 层 Hook 与反 Hook 的基本原理、动态链接的过程以及攻防双方的思维模式。在实际操作中几乎没有一成不变的方案你需要根据目标应用的具体实现灵活组合静态分析与动态调试技术不断试验和调整你的 Hook 脚本。记住最重要的不是脚本本身而是你分析问题、定位关键点、并设计出针对性解决方案的能力。每一次失败和崩溃日志都是通往最终成功的路标。

相关新闻