RASP运行时防护实战:Frida检测与Hook检测的落地经验
先说明一个背景这篇是系列第六篇前几篇聊过 DEX 加密、so 加固、资源保护、VMP 指令抽取这些在“启动期”和“静态层”做文章的手段。但干过安防的兄弟都知道静态层做得再花攻击者只要能把 Frida attach 上来就能在运行时把逻辑改得面目全非。所以本篇聚焦 RASP 运行时安全防护重点讲我实际在加固方案里落地过的 Frida 检测、Hook 检测和运行时环境校验直接给你能用的原理、代码思路和排坑经验。1. RASP 整体设计思路为什么静态加固挡不住 Frida 和 Hook1.1 攻击者的突破点不在文件而在进程内存很多刚入行的同学有个误区以为 APK 加壳、DEX 加密之后攻击者就看不到业务逻辑了。实际情况完全不是这样。攻击者把 APK 装到设备上、跑起来之后App 的代码和数据一定会在进程内存里“活”过来。Frida 做的就是一件事在运行时往目标进程里注入一个 agent通过这个 agent 读写内存、调用任意 Java 方法、hook 任意 Native 函数。内存只要被读出来函数只要被拦截静态层的壳再硬也都白搭。所以我在加固设计里一直坚持一个理念静态层和运行时要分层做。静态层解决“文件看不出逻辑”运行时层解决“进程里动不了手脚”。RASP 就是运行时层的一个关键成员它不关心 APK 文件长什么样只关心当前进程环境是否干净、关键函数是否还执行着自己该执行的代码。1.2 RASP 和传统反调试、签名校验的区别传统加固方案里也有反调试、签名校验这些模块但它们的思路偏“一次性”启动时检查一下环境没问题就放行后面就不再管了。这个思路容易被绕过因为攻击者可以在启动检查通过之后再把 Frida attach 上来或者干脆把检查函数 hook 掉。RASP 的做法不一样它的核心词是“持续”和“内察”。它会周期性或事件性地检查线程列表、检查 /proc/self/maps、校验方法入口指令、检测系统调用时序一旦发现环境异常就按策略响应。我通常把 RASP 布置成一个独立的 Native 模块不依赖 Java 层逻辑这样即使 Java 层被 hook 得乱七八糟检测端还能独立运行。1.3 落地时先定好检测范围别一上来就追求“全”在我的项目经验里RASP 最忌讳的就是贪多。检测维度一多误报率立刻上来线上用户被误杀那损失比被打穿还惨。我一般把检测分成三个等级第一优先级必须做Frida 存在性检测、Xposed 存在性检测、调试状态检测。这几个特征明显、误报率低属于“看到就有鬼”的级别。第二优先级推荐做模拟器检测、方法入口指令校验、ptrace 反调试。这些能挡住一批自动化分析工具但要注意特征适配。第三优先级谨慎做行为时序分析、调用堆栈深度检测。这类误报率高我通常只在企业级定制包里开普通 App 不开。2. Frida 检测实现从端口扫描到内存指纹的多维度方案2.1 Frida 的工作方式直接决定检测面Frida 检测为什么有很多种维度因为 Frida 本身的工作方式就留下了多种痕迹。简单拆一下Frida 要工作首先得有一个 frida-server 在设备上跑通过 socket 监听 27042 端口然后它要注入你的进程注入后进程里会出现 frida-agent 的 so 文件映射接着它的 runtime 会创建几个特殊线程比如gum-js-loop、gmain最后它和外部工具通信还要走 D-Bus 协议。所以检测也可以走对应维度查端口、查 /proc/self/maps、查线程名、查 D-Bus 特征。但这里要特别注意单独任何一项都能被绕过组合起来用才有实际意义。2.2 动手写一个能上线的 Frida 检测器我实际工程里通常 Java 层和 Native 层各做一组检测。Java 层的好处是开发快、便于加日志Native 层的好处是不容易被 Java 层 hook 绕过。先给一个 Java 层可用的多维度检测示例思路直接可以抄。public class FridaDetector { // 检测 frida-server 默认端口 private static boolean checkDefaultPort() { try (Socket socket new Socket(127.0.0.1, 27042)) { // 能连上基本就可以认定为 frida-server return true; } catch (IOException e) { return false; } } // 扫描 /data/local/tmp 下是否有 frida 相关文件 private static boolean checkTmpFiles() { try { File tmpDir new File(/data/local/tmp); File[] files tmpDir.listFiles(); if (files ! null) { for (File file : files) { if (file.getName().toLowerCase().contains(frida)) { return true; } } } } catch (Exception ignored) {} return false; } // 检查当前进程的线程名是否存在 frida 特征 private static boolean checkFridaThreads() { try { File tasksDir new File(/proc/self/task); File[] tasks tasksDir.listFiles(); if (tasks null) return false; for (File task : tasks) { String commPath task.getAbsolutePath() /comm; String comm readFile(commPath); if (comm null) continue; if (comm.contains(gum-js-loop) || comm.contains(gmain) || comm.contains(pool-frida)) { return true; } } } catch (Exception ignored) {} return false; } // 检查 /proc/self/maps 中的 so 映射 private static boolean checkLoadedLibs() { String maps readFile(/proc/self/maps); return maps ! null maps.toLowerCase().contains(frida); } // 综合判定 public static boolean isFridaPresent() { int score 0; if (checkDefaultPort()) score 2; if (checkTmpFiles()) score 1; if (checkFridaThreads()) score 3; if (checkLoadedLibs()) score 3; // 分数 3 认为存在 Frida return score 3; } private static String readFile(String path) { try { File file new File(path); if (!file.exists() || !file.canRead()) return null; byte[] buffer new byte[(int) file.length()]; try (FileInputStream fis new FileInputStream(file)) { int len fis.read(buffer); return len 0 ? new String(buffer, 0, len, StandardCharsets.UTF_8) : ; } } catch (Exception e) { return null; } } }这里有一个细节值得说一下checkDefaultPort 里我只做了 TCP 连接测试严格来说应该走一遍 D-Bus AUTH 协议握手因为有的攻击者会用一个普通 TCP 服务占住 27042 来反检测。但在实战中能连上 27042 这个动作本身已经能过滤掉绝大多数脚本小子性价比很高。2.3 Native 层内存特征扫描Java 层做完之后我还会在 Native 层再做一遍更隐蔽的检查。原理是遍历 /proc/self/maps拿到当前进程可读可写的内存段然后在这个段里搜索 Frida 的特征字符串比如LIBFRIDA、gum-js-loop、gum-util这些。这块比较敏感我只给出核心思路代码。#include stdio.h #include string.h #include stdlib.h #include fcntl.h #include unistd.h static int scan_maps_for_frida_signatures() { FILE* fp fopen(/proc/self/maps, r); if (!fp) return 0; char line[512]; int hit 0; while (fgets(line, sizeof(line), fp)) { if (strstr(line, frida) || strstr(line, gum-js) || strstr(line, re.frida)) { // 文件路径或注释段出现特征 hit 1; break; } // 提取内存范围 unsigned long start, end; char perms[8]; if (sscanf(line, %lx-%lx %7s, start, end, perms) 3) { if (perms[0] r perms[2] x) { // 对可读可执行段做特征扫描实际工程中还需要过滤合法区间 if (scan_region_for_signature((void*)start, end - start, LIBFRIDA, 8)) { hit 1; break; } } } } fclose(fp); return hit; }说句实话内存特征扫描在真机上偶尔会误报因为有的系统库也包含类似的子串。我在线上方案里不会单独靠它下结论而是和线程名、maps 文件路径组合成一个加权评分。这个经验很重要不要相信单个检测点重要的是“多个特征同时命中”这件事本身。2.4 Frida 检测的常见绕过与对抗攻击者不是吃素的常见的绕过方式有改 frida-server 端口、把 frida-agent 改名放进系统目录、用 hluajit 之类的方案把 Frida 痕迹抹掉。我的策略是端口检测只作为辅助信号不作为判定依据。/proc/self/maps 检测不只看文件名还要看 so 的导出符号frida-agent 里一定有 gum 相关的导出符号这是很难改的。线程名检测要叠加“线程数量异常”的判断比如普通 App 长期保持 20 个线程以内突然多出 gmain、gum-js-loop 这类线程优先判异常。更激进的做法是主动探测 D-Bus 的AUTH协议这个在 Native 层用 socket 连本地端口后发一段固定的 D-Bus 认证数据看回包特征。3. Hook 行为检测如何发现方法入口被动手脚3.1 先理解 ART 下方法执行的基本原理Hook 检测比 Frida 检测更复杂因为 Hook 框架不一定会留下“文件、线程、端口”这些明显痕迹。常见的 Xposed、EdXposed、LSPosed 以及一些自研 Hook 框架走的都是同一个底层逻辑修改目标方法的 ArtMethod 结构把入口指令改成一段跳转代码跳到框架自己的处理函数里。所以检测思路也就清晰了拿到一个关键方法的 ArtMethod 入口地址判断这个地址是不是落在合理的可执行代码段范围内。如果发现某个关键方法的入口被改到了某个非常规 so 的地址区间或者入口指令变成了跳板指令比如ldr x16, #...br x16这种固定模式基本可以认为是被 hook 了。3.2 实操ArtMethod 入口校验的设计这个方案在工程上要处理几个细节不同 Android 版本 ArtMethod 结构不一样所以纯 Java 层不好做建议通过 JNI 在 Native 层完成。核心步骤是在 Java 层拿到关键方法的java.lang.reflect.Method或Class对象。传给 Native 层通过 JNI 的FromReflectedMethod拿到 ArtMethod 指针。读取 ArtMethod 内部字段entry_point_from_quick_compiled_这个字段存放的就是方法入口地址。检查这个入口地址是否落在当前模块的合法代码段里。这里给出一个简化版 Native 检测思路真要用到线上还需要针对不同 Android 版本做结构体偏移适配建议直接用art::ArtMethod类的源码或者逆向对标。JNIEXPORT jboolean JNICALL Java_com_example_nativecheck_ArtMethodChecker_isMethodHooked( JNIEnv* env, jobject thiz, jobject method) { // 1. 通过 FromReflectedMethod 获取 ArtMethod 指针 // 针对 Android 8.0 及以上ArtMethod 是紧凑结构有 entry_point_from_quick_compiled_ void* art_method env-FromReflectedMethod(method); if (art_method NULL) return JNI_FALSE; // 2. 读取 entry_point_from_quick_compiled_ 字段 // 偏移量需要根据目标 Android 版本动态适配这里示意为 20 // 真实版本上建议通过遍历内存结构或 hook art 导出来确认 void** entry_ptr (void**)((unsigned char*)art_method 20); void* entry *entry_ptr; // 3. 判断入口地址是否在 libart.so 的合法代码段内 if (isPtrInModule(entry, libart.so)) { return JNI_FALSE; // 入口正常 } // 4. 检查是否为常见的 hook 跳板指令 if (isTrampolineCode(entry)) { return JNI_TRUE; // 被 hook } return JNI_FALSE; }实现isPtrInModule和isTrampolineCode需要解析 /proc/self/maps 并读取指令字节整体工作量中等。我在项目里主要用它保护登录、支付、VIP 校验这几个核心方法。3.3 检测 Xposed 与动态代理除了 ArtMethod 入口校验我还会做一些低成本高价值的检测。Xposed 框架的特征比较固定它会在系统进程中注入libxposed_art.so并且相关包名de.robv.android.xposed.installer会出现在已安装列表里。Java 层可以这样快速判断public static boolean checkXposed() { // 1. 检测已安装的 Xposed Installer 应用 if (checkPackageInstalled(de.robv.android.xposed.installer) || checkPackageInstalled(com.topjohnwu.magisk)) { return true; } // 2. 检测 ClassLoader 中是否加载了 XposedBridge try { Class.forName(de.robv.android.xposed.XposedBridge); return true; } catch (ClassNotFoundException e) { // 未检测到继续下一步 } return false; } private static boolean checkPackageInstalled(String packageName) { try { ApplicationCompat app getApp(); PackageManager pm app.getPackageManager(); pm.getPackageInfo(packageName, 0); return true; } catch (PackageManager.NameNotFoundException e) { return false; } }不过这个检测点要注意Magisk root 检测经常和 Xposed 检测混在一起。我的做法是把 root 和框架分开判断root 用户不一定是攻击者但 Xposed 注入到系统进程里基本就是恶意行为。Create 了 Xposed 检测之后还要处理检测代码本身被 hook 的问题——攻击者完全可以把checkPackageInstalled这个函数 hook 掉永远返回 false。所以我通常会让 Native 层去遍历/data/data/目录和读取package.xml而不是依赖 Java 层 API。动态代理也是常见的 hook 手段。如果某个业务接口被人用Proxy.newProxyInstance包了一层那这个代理对象的方法调用就会走 InvocationHandler在业务侧很难察觉。检测方式相对简单在自己的关键接口实例上调用Proxy.getInvocationHandler(obj)如果返回值不为 null就说明这个对象是被代理过的。这套逻辑适合用来保护 SDK 内部的核心 callback。3.4 Hook 检测的踩坑记录我最初在线上 8.0 设备上做 ArtMethod 入口校验时误报率很高排查发现是厂商系统 ROM 修改了libart.so导致isPtrInModule判断失败。后来加了一个白名单机制先在生产环境收集一个“正常设备入口地址分布表”针对厂商特殊 ROM 做兼容再在服务端动态下发开关才算把误报压下来。这里面有一个很关键的工程经验RASP 检测规则一定要配置化、服务端可下发不要把规则写死。4. 运行时攻击检测反调试、模拟器、完整性校验4.1 ptrace 反调试的正确用法反调试是 RASP 里最基础也最容易被忽略的一块。Android 上最经典的反调试思路是主动调用ptrace(PTRACE_TRACEME, 0, 0, 0)让自己成为“已经被跟踪”的进程这样调试器或者 Frida 再来 attach 就会失败。这个机制的原理很简单同一个进程同一时间只能被一个 ptrace 跟踪者 attach。实际操作中有一个坑ptrace在 Android 10 以后受到 Yama 和 SELinux 的限制调用时机不对会被直接杀掉。我的做法是在 Native init 函数里尽早调用同时检测返回值。调用失败不一定是被调试了也可能是权限问题所以要结合/proc/self/status里的TracerPid来判断。static int check_debugger() { // 读取 /proc/self/status 中的 TracerPid FILE* fp fopen(/proc/self/status, r); if (!fp) return 0; char line[256]; int tracer_pid 0; while (fgets(line, sizeof(line), fp)) { if (strncmp(line, TracerPid:, 10) 0) { sscanf(line, TracerPid: %d, tracer_pid); break; } } fclose(fp); return tracer_pid ! 0; }TracerPid 非 0 就说明有进程正在跟踪当前进程这是个非常直接的调试信号。但攻击者可以 hookfgets或者这个 Native 函数来伪造返回所以我在线上是把它和 ptrace 自调试配合使用两者都异常才判定为调试。4.2 模拟器与异常环境检测模拟器检测的目的是阻断自动化批量攻击。市面上常见的模拟器雷电、MuMu、夜神、BlueStacks以及 Android Studio 自带模拟器都有相对固定的特征。我把常用检测点整理成下面的表格读者可以直接对着抄检测维度关键检查点说明Build 属性Build.FINGERPRINT是否包含generic/emulator/sdk_gphone模拟器的指纹往往是 AOSP 默认值的变体硬件特征Build.HARDWARE是否包含goldfish/ranchu/intelARM 模拟器在 x86 主机上的特征很难完全抹掉传感器是否存在加速度计、陀螺仪以及传感器列表数量真机至少有加速度计模拟器经常为空或仅有虚拟传感器文件特征/dev/qemu_pipe、/dev/goldfish_pipe、/system/lib/qemu_*QEMU 模拟器特有设备节点CPU 指令检测是否 ARM 设备跑在 x86 库环境下通过读取/proc/cpuinfo对比 Build 信息SIM 卡TelephonyManager的 IMSI / MEID / PhoneType模拟器一般没有 SIMIMEI 也经常是 000000000000000需要特别提醒模拟器检测误报重灾区是 blueStacks 的兼容模式它的 Build 属性可能被改得很像真机但文件特征逃不掉。我在线上策略里是“文件特征命中 Build 特征命中”双条件同时满足才判模拟器单条件只做记录不上报。4.3 运行时完整性校验除了环境检测RASP 还要防“你自己的代码被改”。攻击者可以改 APK 里的 classes.dex、改资源文件、改 so 文件再重新打包。传统签名校验在启动时做一次容易被 patch。运行时完整性的思路是周期性校验关键文件而不是只校验一次。实际操作中我会把 classes.dex 的原版 SHA256 值放在服务端客户端启动后异步上报当前包的哈希由服务端比对另外在 Native 层对核心 so 的.text段做哈希和出厂时嵌在 Native 里的白名单比对。这里有个细节很多 App 会做多渠道打包V2/V3 签名后的 APK 哈希是变化的不能直接拿 release 包哈希来校验。正确做法是校验未签名产物或者只校验classes.dex和关键 so 文件的哈希而不是整个 APK。5. RASP 工程落地初始化时机、性能开销、误报控制、威胁上报5.1 初始化时机直接决定检测可靠度RASP 模块的初始化位置非常关键必须在所有用户代码执行之前完成。我在项目里的方案是提供一个独立的 so在JNI_OnLoad里完成 Native 侧检测器初始化在Application.attachBaseContext里再补一个 Java 侧初始化。原因是 attachBaseContext 早于业务代码Frida 脚本通常要等 App 跑起来后才 attach这个阶段是“空窗期”必须先把自己放进去。如果工程里用的不是 Application 而是 ContentProvider 自动初始化那要确保 provider 的onCreate里的执行顺序足够靠前。还有一个细节不要在初始化时执行耗时太长的检测否则影响冷启动体验。我会把轻量检测放在启动关键路径把内存枚举类重检测放到子线程延迟 2 到 5 秒执行。5.2 性能开销怎么压RASP 检测做多了CPU 和耗电都上去了用户会骂。我的经验是给每一项检测确定一个“频率预算”。比如 Frida 端口检测每 10 秒做一次线程名扫描每 5 秒做一次内存特征扫描 60 秒才做一次方法入口校验只在关键方法调用时触发。检测结果要做缓存比如/proc/self/maps读取出来之后保存一行一行的结果避免频繁读文件。另外一个很实用的技巧是采样只在登录、支付、VIP 认证这类关键时刻做重检测平时只跑轻量检测。这样既保证核心场景安全又不会拖累日常使用。5.3 检测到风险之后怎么办检测到 Frida、Hook 或者模拟器处理策略分三种记录、提示、终止。我的默认策略是“记录 轻度干扰”先往服务端上报一条带异常特征类型、App 版本、系统版本、设备指纹的日志同时在前台弹一个容易被安全工程师发现但不影响普通用户的提示。只有在确认威胁极高比如关键方法被 hook Frida 特征同时命中时才执行进程自杀或者业务降级。关于“自杀”有一个大坑不要在检测代码的同一个线程里自杀因为有反检测 hook 可能拦截exit系列调用。建议的做法是 fork 一个子进程来执行 kill或者在 Native 层直接调用_exit并在回调里清理状态。5.4 误报治理是 RASP 的生命线误报多App 会被应用市场下架用户也会流失。我在上线 RASP 模块前会做一个灰度数据看板观察每台设备的异常命中率。正常设备的异常命中率应该长期为 0如果有某个主流 ROM 持续出现命中那基本就是误报。记住一个原则检测结果都要带证据比如命中端口检测时记录下 socket 连接结果命中线程检测时记录线程名这样排查的时候才能快速定位是规则问题还是环境问题。6. 常见绕过手法与排查技巧速查表最后整理一张速查表方便团队协作和后期排查检测项常见绕过手法排查与对抗思路27042 端口修改 frida-server 默认端口不单独依赖端口叠加 D-Bus 协议 AUTH 探测和端口扫描/proc/self/maps 特征修改 agent 文件名、加载到系统路径扫描 so 导出符号、扫描内存中的 gum 特征字符串线程名修改 frida 源码里的线程命名结合线程数量、内存区间、D-Bus 连接多维度打分TracerPid 检测hook fgets、伪造 status 文件Native 层直接 openread绕过 Java hook并定期换检测点Xposed 包检测禁用包名、重打包检测 ClassLoader 加载的类、检测系统目录文件、检测注入 soArtMethod 入口校验使用 shadowhook 替换入口后同步改回做指令级快照定时离线比对完整性哈希patch 哈希校验函数将校验逻辑拆散到多个 Native 函数服务端下发校验样本模拟器检测修改 Build 属性、删 qemu 文件检测 CPU 指令差异、传感器事件、触屏事件真实度我个人的实践体会是RASP 一定要放在一个“平时不显眼、关键时刻起作用”的位置而不是把所有检测逻辑都堆在一个文件里。攻击者拿到包之后第一件事就是脱壳、分析初始化流程如果你的 RASP 代码写在明面上他直接 hook 掉初始化开关就完了。可以把它拆成几个小 so分别藏在不同初始化路径里检测点之间互相校验心跳这样就算其中一个被发现了其他检测器还能撑住。还有一个很值得做的扩展把 Frida 检测作为一项能力放到云端配置中心服务端可以随时下发“收紧检测”或“放宽检测”的指令避免一次性把检测规则写死在包里。我自己在线上就是这么运营的每次规则更新不用重新发包对安全和产品体验的平衡非常有效。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →