
做移动端安全加固这几年越到后面越会意识到一件事DEX加壳、SO加固解决的是“安装包被静态分析”的问题但App真正跑起来之后内存里全是明文代码攻击者拿Frida一挂、Xposed一Hook前面做的壳就像一层纸。这也是为什么现在主流加固方案都会把**RASPRuntime Application Self-Protection运行时应用自我保护**作为最后一环补进去。这篇文章是加固原理系列的第六篇重点聊RASP在Android端怎么落地。我会从设计思路开始把Frida检测、Hook检测、反调试、误报治理、性能控制这些工程化问题逐个拆开讲尽量给出可以直接抄作业的代码骨架和排查路径。适合正在做App安全方案、或者对加固原理感兴趣但不想只看PPT的读者。1. RASP 在 Android 加固体系中的定位与设计思路1.1 静态加固解决不了的问题传统的APK加固做的事情基本可以概括为把DEX加密藏起来把SO做VMP或者指令抽取让攻击者拿到安装包后不容易直接看逻辑。这套思路在“静态分析”阶段确实有效但有一个天生短板——App一旦启动虚拟机要把DEX解密后装载native代码也要被映射到内存执行这时候代码必须以完整形态出现。攻击者不需要拿到你的加固包只需要在运行时把内存里的数据捞走、把关键函数Hook掉、或者直接动态调试。多年前很流行的做法是先静态逆一遍壳的启动流程找到解密后的DEX路径再dump现在更多人直接用Frida脚本在运行时做各种操作连静态逆壳都省了。静态加固面对这种攻击方式几乎没有还手之力。RASP的价值就在于把安全能力从“安装包”挪到“运行时”。它跑在App进程内部能感知到进程里发生了什么异常然后根据预置策略决定是上报、警告、还是直接终止进程。说得直白一点静态加固是防守大门RASP是巡逻的保安。1.2 RASP 需要覆盖的攻击面要设计一套可落地的RASP方案第一步是明确要防什么。Android运行时攻击大概分这几类动态调试通过ptrace或者JDWP协议附加到目标进程单步跟踪、下断点、查看寄存器。代码注入Frida、Substrate这类框架通过ptrace或zygote注入的方式把agent模块映射进目标进程。Java层HookXposed、Frida的Java Hook能力直接修改ART方法入口拦截和篡改业务方法。Native层HookGOT表Hook、Inline Hook、通过修改so代码段指令改变函数行为。模拟器与多开环境用于批量自动化、协议重放对金融和业务风控类App威胁很大。这五类之间不是完全独立的。比如Frida既能做Java Hook也能做Native Hook还自带调试能力。所以在设计检测模块时不要按“某个工具”去做特征库而应该按异常行为维度去抽象有没有陌生so被映射进来、有没有调试器附加、关键函数有没有被改指令、有没有异常的JNI调用链。1.3 设计原则检测、响应、伪装RASP工程实现的三个关键词是检测、响应、伪装。检测很好理解就是通过多维度特征判断当前进程是否处于被攻击状态。响应则分温和派和激进派温和派只做风险上报让服务端记录激进派直接自杀或者返回假数据。伪装是指检测逻辑不能太容易被发现比如不要在Java层暴露一个叫checkFrida()的方法否则攻击者直接Hook这个方法让你永远返回false。我个人的经验是第一版RASP建议先做“检测上报”不要一上来就杀掉进程。先把误报率压到可接受范围再灰度开启阻断。否则一次误杀业务损失远比攻击损失大。2. Frida 检测的核心原理与实战落地2.1 从 Frida 的工作链路反推检测点Frida大概是目前最让加固厂商头疼的工具因为它太灵活了。要检测Frida最好的方式不是背特征而是理解它的注入链路攻击者在PC端运行frida命令手机端需要先启动一个frida-server通常运行在root环境。frida-server通过ptrace附加目标进程把一段agent逻辑注入进去。注入后的进程里会出现一个新的so例如早期版本常见的frida-agent.so。agent会创建若干线程其中比较典型的是gum-js-loop、gmain、gdbus等。agent与frida-server之间通过本地socket通信默认端口通常是27042实际用的时候经常自定义。这就给了我们至少四个检测维度文件与内存映射、进程与线程、端口与socket、以及ptrace附加状态。单一维度很容易被绕过但多维度交叉判断攻击成本就会明显上升。2.2 多维度检测清单文件、内存、端口、线程下面这张表是我在做检测模块时常用的参考维度。注意每个维度都不能作为唯一判断依据最好是组合打分检测维度常见特征实现方式备注内存映射进程maps中出现frida相关路径的so读/proc/self/mapsagent文件名可能随机化需要辅助判断文件系统/data/local/tmp下存在frida-server等可执行文件遍历目录或访问特定路径需要处理权限Android高版本有SELinux限制线程名gum-js-loop、gmain、gdbus等遍历/proc/self/task/*/comm攻击者可以改名但很少会改本地端口27042、27043等端口处于监听状态解析/proc/net/tcp自定义端口后失效只做辅助ptrace状态/proc/self/status中的TracerPid不为0读取status文件对反调试同样有效反射调用高频调用java.lang.Class.getDeclaredMethod需要插桩或hook ART工程成本高适合进阶方案这里有个容易被忽略的点Frida有spawn和attach两种模式。spawn模式是在App启动前就注入很多只在MainActivity里做检测的方案会漏。所以检测逻辑要尽量早执行最好放到SO加载后的构造函数中或者放在Application.attachBaseContext之前能调用的native层。2.3 JNI 检测代码落地示例网上有很多Frida检测代码但很多只是扫描一下/proc/self/maps里有没有frida字符串。单纯这么做很容易被反制因为只需要把agent改名就能绕过。下面这个示例会稍微完整一点同时检查maps、TracerPid和线程名。#include stdio.h #include string.h #include unistd.h #include dirent.h // 检查TracerPid非0说明正在被调试 static int check_tracer_pid() { FILE *fp fopen(/proc/self/status, r); if (!fp) return 0; char line[256]; int tracerPid 0; while (fgets(line, sizeof(line), fp)) { if (strncmp(line, TracerPid:, 10) 0) { sscanf(line 10, %d, tracerPid); break; } } fclose(fp); return tracerPid; } // 检查maps中是否有可疑映射 static int check_maps() { FILE *fp fopen(/proc/self/maps, r); if (!fp) return 0; char line[1024]; int found 0; // 注意生产代码不要直接写明文特征字符串建议拆开拼接或加密 const char *p1 frida; const char *p2 gum-js-loop; const char *p3 linjector; while (fgets(line, sizeof(line), fp)) { if (strstr(line, p1) || strstr(line, p2) || strstr(line, p3)) { found 1; break; } } fclose(fp); return found; } // 检查线程名遍历 /proc/self/task 目录 static int check_threads() { DIR *dir opendir(/proc/self/task); if (!dir) return 0; struct dirent *entry; int found 0; while ((entry readdir(dir)) ! NULL) { if (entry-d_name[0] .) continue; char comm_path[128]; char comm[64] {0}; snprintf(comm_path, sizeof(comm_path), /proc/self/task/%s/comm, entry-d_name); FILE *fp fopen(comm_path, r); if (fp) { if (fgets(comm, sizeof(comm), fp)) { if (strstr(comm, gum-js-loop) || strstr(comm, gmain) || strstr(comm, gdbus)) { found 1; fclose(fp); break; } } fclose(fp); } } closedir(dir); return found; } int native_check_environment() { int risk 0; if (check_tracer_pid() 0) risk 10; if (check_maps()) risk 20; if (check_threads()) risk 30; return risk; }上面三个函数分别对应三个检测维度最后统一打分。工程上不要直接返回布尔值因为后续你要做灰度、白名单、按风险级别处理。另一个建议是在Native层检测时做“轻量混淆”。比如把frida这个字符串拆成char a[] {f,r,i,d,a}再拼接否则检测so本身也会成为静态扫描的靶子。2.4 应对绕过别让检测逻辑成为固定靶子Frida检测最大的敌人不是Frida本身而是“你只有一种打法”。攻击者只要知道你用A方案就有一百种方式绕过去。常见的思路包括改agent文件名、改线程名、用自定义端口、甚至直接Hook你的检测函数。所以工程上要做的几件事检测函数不下放Java层全部放到native层别让攻击者一眼找到入口。检测时机随机化不要固定每3秒检测一次可以在业务事件触发后随机延时再检测。检测结果不直接用于业务逻辑比如不要写if (isFrida true) return;这种明文判断攻击者直接改跳转就完了。更好的做法是把检测结果加密后上报由服务端判定风险本地只做“静静地看着你”的模式。混淆和反调试结合检测代码所在的so要做完整性校验避免被动态patch。当然这永远是一个猫鼠游戏。RASP的定位不是让攻击者100%进不来而是让他每换一种姿势都要重新破解一次成本足够高之后很多攻击者就会放弃。3. Hook 检测与运行时完整性防护3.1 Hook 框架的分类与对应检测思路Hook检测比单纯检测Frida难度高一个级别因为Hook的本质是“修改了运行时的行为”而不是简单地注入一个特征文件。Java层Hook主要靠Xposed、Frida的Java方法替换能力实现。原理是修改ART中方法的ArtMethod结构或者在方法入口插入跳转指令。检测Java Hook最直接的方式是拿到关键方法的ArtMethod结构对比它的入口是否指向了预期地址。但这项工作在Android高版本上很依赖系统内部结构ROM一改就崩工程成本很高。Native层Hook更常见的是两种GOT/PLT Hook和Inline Hook。GOT Hook修改的是so导入函数的地址把原本指向libc.so中某个函数的指针改成攻击者函数的地址Inline Hook则是直接修改so代码段在函数开头写入跳转指令。对应检测思路也不太一样Hook类型原理检测思路GOT/PLT Hook修改导入函数地址表解析ELF的.rel.plt/.rela.plt段对比函数地址是否在合法模块范围内Inline Hook修改函数入口指令对关键so代码段做Hash发现变化即认为被篡改Java层Hook修改ArtMethod实现获取ArtMethod入口地址与已注册JNI函数表对比指令抽取执行期恢复指令配合更底层的ELF加载校验3.2 关键函数与代码段的指纹校验Inline Hook的检测是最常被问到的。理论其实很简单一段so加载进内存后如果没有被篡改它的代码段内容应该和磁盘原始文件一致。攻击者做Inline Hook必然要修改代码段指令这就导致内存中的代码和磁盘文件对不上。所以一个实用的检测方案是读取自身so在磁盘上的文件解析ELF头获取.text段在内存中的偏移和大小然后与/proc/self/maps对应内存区域做逐字节Hash对比。// 伪代码用于体现思路不依赖具体ELF解析库 int check_so_integrity(const char *so_path) { // 1. 从磁盘读取ELF文件定位.text段 // 2. 通过/proc/self/maps找到so映射基址 // 3. 计算 memory_base text_offset 处 text_size 字节的SHA256 // 4. 与磁盘文件.text段SHA256比较 // 5. 不一致则说明代码段被Inline Hook return 0; }但这里有个细节要注意现代Android上/proc/self/maps中可能同时存在多个so基址映射而且有的系统会把代码段按page拆分。加上部分厂商ROM会对so做段页对齐调整直接做全段Hash很危险容易误报。更稳妥的做法是对关键函数的前16字节做Hash而不是对整个.text段做Hash。因为Inline Hook要改跳转指令必然要改函数入口的字节16字节足够覆盖多数跳转指令的长度。我做过一轮验证整个.text段Hash的误报率确实高集中在华为、小米部分机型上原因是一些ROM会在启动阶段给so打热补丁。改成关键函数入口Hash后误报率大幅下降。3.3 从“被篡改”到“自毁”响应策略选择检测到Hook之后怎么处理这里分享几个层次的经验。第一层是静默上报。把风险类型、所在so名称、函数偏移、检测时间上报到服务端不打断用户操作。好处是不会影响业务坏处是攻击者可以明显感知到没有对抗放心大胆继续搞。第二层是返回假数据。比如检测到关键登录函数被Hook不去终止进程而是给攻击者返回一个错误密钥、错误用户状态。攻击者看到的是“正常运行”但拿到的全是错的。这种方式对自动化脚本和协议攻击效果很明显。第三层才是主动自杀。在native层直接调用exit()或者触发崩溃。但自杀策略必须配合“延迟随机化”不要检测到就立刻杀否则攻击者可以通过二分法定位到具体是哪个检测点触发的。选择哪一档取决于业务容忍度。金融、支付类App可以激进一点普通工具类App还是先上报更重要。我踩过最深的坑是在一个日活几百万的工具App上直接开了自杀策略结果某厂商ROM误报线上崩溃率直接翻倍。后来改成服务端下发配置默认只上报确认误报可控后才开启部分用户的自杀策略。4. 反调试与其他运行时攻击面4.1 反调试基础TracerPid 与 ptrace 自守护Android上最常见的调试方式是ptrace附加。Linux内核规定一个进程同一时刻只能被一个ptrace跟踪者附加。利用这个特性可以让被保护进程的native层先ptrace自己这样调试器再附加就会失败。更基础也更通用的检测就是读/proc/self/status里的TracerPid。正常运行情况下这个值是0一旦被调试器附加就会变成调试器的PID。在检测函数里加上这个判断成本非常低。static int check_tracer_pid() { FILE *fp fopen(/proc/self/status, r); if (!fp) return 0; char line[256]; int tracerPid 0; while (fgets(line, sizeof(line), fp)) { if (strncmp(line, TracerPid:, 10) 0) { sscanf(line 10, %d, tracerPid); break; } } fclose(fp); return tracerPid; }不过TracerPid检测有一个明显的弱点攻击者如果先ptrace了目标进程那么他可以通过读目标进程的status拿到数据然后自己伪造返回结果。这就是为什么反调试检测不能单独用要跟其他检测逻辑互相配合。4.2 双进程守护的设计与局限双进程守护是早期很流行的反调试方案核心思路是利用ptrace“同时只能被一个进程附加”的限制。简单来说App启动native层后fork一个子进程子进程负责ptrace父进程。这样调试器再尝试附加父进程时会失败因为父进程已经被子进程ptrace了。子进程同时也可以监控父进程的运行状态一旦父进程被杀死子进程可以尝试拉起或者做清理。很多老牌加固方案的“自杀/保护”逻辑都是基于这个模型。但这个方案在Android高版本上越来越难做。原因有几个高版本对SELinux控制更严格普通App不能随便ptrace自己fork出来的进程权限管理更严。双进程会让系统更早地杀掉后台App因为系统认为有两个进程在跑内存开销更大。攻击者只要先杀掉子进程再ptrace父进程双进程守护就失效了。所以我的建议是Android 8.0以上不要过度依赖双进程守护把重点放在多维度检测和动态响应上。双进程可以保留但它只是其中一层防御不是全部。4.3 其他运行时攻击面模拟器、注入、内存 Dump除了Frida和HookRASP通常还会顺带处理模拟器、注入、内存dump这些攻击面。模拟器检测在风控场景里用得很多常见特征包括Build.FINGERPRINT包含generic/sdk等关键字、CPU指令集特征、传感器列表异常、TelephonyManager返回空IMEI等。RASP层面做模拟器检测不需要太复杂判定为高风险后上报即可。内存dump攻击是指攻击者在App运行到关键页面时通过/proc/pid/mem或者进程attach的方式把内存中的解密数据dump下来。这很难完全防御能做的是尽量缩短密钥等敏感信息在内存中的存活时间用完立刻清零。RASP可以配合做的是检测异常的内存映射权限比如某个非so区域同时具有读写执行权限RWX这种情况在正规App里非常少见一旦出现就有可能是动态生成的shellcode。内核级的检测在Android上很难做到通用因为不同厂商对内核的改动太大。把这些所谓“其他攻击面”的控制目标定为“发现异常并上报”比试图阻止更实际。5. 误报与性能问题实际工程化的限度5.1 误报率是 RASP 的生命线做RASP方案最难的不是写出检测代码而是让检测代码在几万种Android机型上不误报。很多安全团队做POC时跑得很开心一上灰度就出事故原因就是只在自己几台测试机上看效果。常见的误报来源有几个误报来源原因例子系统库特征厂商ROM集成调试工具或安全组件某些厂商ROM的liboemcrypto.so会触发so完整性告警其他App注入输入法、自动化测试工具、广告SDK输入法为了记录热词会向目标进程注入一段共享内存SELinux策略差异不同Android版本访问/proc受限在Android 10上读取某些字段返回空误判为异常加固SDK冲突同时使用两家加固方案时native层互相检测双壳情况下看到对方的so特征会误报解决误报最重要的手段就是灰度。RASP检测结果在上报阶段不要阻断先跑一段时间积累数据。线上出现误报后通过服务端下发白名单把特定机型、特定so指纹、特定线程名加进忽略列表。我见过最离谱的一次误报是某款手机自带的安全中心App会在目标App进入后台时注入一个so做所谓“安全检测”导致我们的so完整性校验误报。最后排查了很久确认是系统级注入不是攻击。这种场景如果直接开启自杀策略线上会直接崩掉。5.2 性能预算与触发时机RASP检测是有性能开销的。比如扫描/proc/self/maps文件小还好如果进程加载的so很多文件可能上百KB再比如对so做SHA256在高频场景下也不便宜。性能控制有几个原则不要在业务主线程做检测所有检测放到独立的native线程优先级调到后台级别。不要在关键路径上同步等待检测结果检测结果只会影响后续业务分支不影响当前调用。检测频率要可配置启动阶段可以多检测几次运行稳定后逐步降到几分钟一次。完整Hash计算要抽样不要每次把所有so都Hash一遍只对受保护的核心模块做校验。做一个粗略的估算完整扫描一遍maps并逐个做特征比对在主流中端机型上只需要几毫秒到十几毫秒完全可以接受。但如果每秒钟都跑一次耗电和CPU占用就会上升。所以频率一般建议启动后3秒内做第一轮之后5到10分钟一轮比较合适。5.3 动态配置灰度、白名单与热更新RASP的检测规则不能写死在App里否则每次调整都要发版等你发完版攻击者早绕过去了。成熟的方案是服务端下发一份风险配置包含各检测维度的开关、阈值、阻断策略。配置采用异步拉取不要阻塞启动流程。检测结果先上报服务端服务端通过后端策略决定是放行、告警还是封禁。白名单也由服务端下发避免客户手机上的正常工具被误杀。这里有一个容易被忽略的坑配置下发接口本身也可能被攻击者利用。攻击者可以抓包拿到配置然后伪造一个“全部关闭”的配置下发到客户端。所以响应策略不能完全依赖配置客户端本地要有一套最低安全基线即使配置被篡改关键检测项依然会运行。6. 常见问题排查与调试实录6.1 常见问题速查表整理一张速查表帮助排查RASP模块接入时的典型问题现象可能原因排查思路检测不到FridaFrida是以spawn模式注入检测时机太晚把检测提前到so加载阶段不要只在Activity里做启动崩溃so的构造函数里调用了Android APInative层不要在init_array里依赖JNIEnv异步化误报高厂商ROM自带注入或so补丁收集线上特征服务端下发白名单性能卡顿maps扫描和Hash计算频率过高降低频率只对核心so做抽样Hash被绕过检测函数本身被Hook增加so完整性校验native层做反调试日志混乱多个检测模块重复打点统一检测入口用独立tag输出6.2 检测无效时的排查路径如果你按照前面的代码接入了RASP但测试发现Frida还是能正常挂载不要慌按下面步骤排查先用一个最简化的检测包只包含TracerPid检测看Frida挂载时能不能检测到。如果连这个都检测不到可能是so根本没加载成功检查System.loadLibrary路径。确认检测代码的运行线程。如果被主线程阻塞或提前被异常打断检测结果可能永远不会上报。把检测结果写到本地文件在测试机上观察。某些ROM对/proc/self/maps的读取有SELinux限制返回内容为空导致特征检查全部失效。用Frida实际操作一遍同时在logcat里打上检测点的日志看哪些维度触发了哪些没触发。逐个维度排查不要一把抓。印象很深刻的一次是我们把检测放在Application里结果某个型号的手机上Application的static块执行时间特别晚Frida早就注入完了检测才刚开始。后来把检测逻辑放到单独的so里在JNI_OnLoad里就执行第一轮检查才解决问题。6.3 误杀问题的定位与修复实录误杀的排查比检测不到更难因为你要区分是“真的被攻击”还是“系统/其他SDK的行为”。我们的方法比较笨但也最有效先在线上开启“只上报不阻断”模式把每一条风险记录都打点上报包含机型、系统版本、检测维度、命中的特征值。然后根据上报数据做聚类分析。如果某类风险集中在同一个机型或同一个系统版本大概率是兼容性问题如果风险分散且命中的特征值高度统一才可能是真实攻击。有一次我们发现某品牌手机上报了大量“so完整性校验不通过”的记录进一步分析发现命中的so是厂商自己的注入库而且只在特定系统版本上出现。后来我们在这类机型的白名单里加了该so的hash误杀直接归零。所以白名单不是安全性的妥协而是工程化的必经之路。真正有效的RASP是在“不误伤正常用户”和“提升攻击成本”之间找到平衡点。结尾一点个人体会做RASP这几年我最大的体会是它更像一个持续对抗的运营体系而不是一个写完就能一劳永逸的功能。检测特征要跟着攻击手法迭代响应策略要跟着业务容忍度调整白名单要跟着机型和系统版本更新。每次加新的检测项我都会先在几十台不同品牌的真机上跑一轮再上小流量灰度确认没有明显误报后才全量。如果你刚开始在App里接入RASP建议从最简单的TracerPid检测和maps特征扫描开始先在线上跑通“检测点上报”这条链路再慢慢加响应策略和Hook检测。每一步稳一点后面返工的成本会小很多。