RASP运行时防护实战:Frida检测与Hook检测的落地经验
2026/9/15 19:57:17 网站建设 项目流程

先说明一个背景:这篇是系列第六篇,前几篇聊过 DEX 加密、so 加固、资源保护、VMP 指令抽取这些在“启动期”和“静态层”做文章的手段。但干过安防的兄弟都知道,静态层做得再花,攻击者只要能把 Frida attach 上来,就能在运行时把逻辑改得面目全非。所以本篇聚焦 RASP 运行时安全防护,重点讲我实际在加固方案里落地过的 Frida 检测、Hook 检测和运行时环境校验,直接给你能用的原理、代码思路和排坑经验。

1. RASP 整体设计思路:为什么静态加固挡不住 Frida 和 Hook

1.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-loopgmain;最后它和外部工具通信还要走 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 的特征字符串,比如LIBFRIDAgum-js-loopgum-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 层完成。核心步骤是:

  1. 在 Java 层拿到关键方法的java.lang.reflect.MethodClass对象。
  2. 传给 Native 层,通过 JNI 的FromReflectedMethod拿到 ArtMethod 指针。
  3. 读取 ArtMethod 内部字段entry_point_from_quick_compiled_,这个字段存放的就是方法入口地址。
  4. 检查这个入口地址是否落在当前模块的合法代码段里。

这里给出一个简化版 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; }

实现isPtrInModuleisTrampolineCode需要解析 /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模拟器一般没有 SIM,IMEI 也经常是 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 层直接 open+read,绕过 Java hook,并定期换检测点
Xposed 包检测禁用包名、重打包检测 ClassLoader 加载的类、检测系统目录文件、检测注入 so
ArtMethod 入口校验使用 shadowhook 替换入口后同步改回做指令级快照,定时离线比对
完整性哈希patch 哈希校验函数将校验逻辑拆散到多个 Native 函数,服务端下发校验样本
模拟器检测修改 Build 属性、删 qemu 文件检测 CPU 指令差异、传感器事件、触屏事件真实度

我个人的实践体会是,RASP 一定要放在一个“平时不显眼、关键时刻起作用”的位置,而不是把所有检测逻辑都堆在一个文件里。攻击者拿到包之后第一件事就是脱壳、分析初始化流程,如果你的 RASP 代码写在明面上,他直接 hook 掉初始化开关就完了。可以把它拆成几个小 so,分别藏在不同初始化路径里,检测点之间互相校验心跳,这样就算其中一个被发现了,其他检测器还能撑住。

还有一个很值得做的扩展:把 Frida 检测作为一项能力放到云端配置中心,服务端可以随时下发“收紧检测”或“放宽检测”的指令,避免一次性把检测规则写死在包里。我自己在线上就是这么运营的,每次规则更新不用重新发包,对安全和产品体验的平衡非常有效。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询