做Android逆向和动态分析的老哥应该都遇到过这个场景:拿到一个目标App,本地搭好了Frida环境,结果一查设备没Root;好不容易找到一台可Root的设备,又碰上厂商不让解BL锁。于是很多人第一反应就是重打包APK,把frida-gadget.so手动塞进目标包,再回签安装。但这条路走起来是真够折腾,签名校验、自校验、更新迭代,每一个坑都能卡你半天。今天这篇就来聊聊另一种思路:在Android 9的AOSP源码层面做文章,把Frida-Gadget直接做成系统级组件,让目标应用每次启动都自动加载,全程不依赖Root权限,也不用碰APK本身的完整性。
这套方案的核心价值在于“持久化”和“免重打包”,适合需要在自有设备或授权测试环境里长期做Hook分析的场景。不管你是做逆向研究、自动化测试,还是想给某些App做辅助能力增强,只要愿意编译一次系统镜像,后面所有目标应用都可以像普通应用一样正常安装、正常更新,而Hook能力始终藏在系统底层,不会因为App升级而失效。下面我会把从思路选型到AOSP修改、SELinux策略、刷机验证的完整流程拆开讲。
1. 为什么非要把Hook做成“免Root持久化”
1.1 重打包方案折腾在哪
先聊聊大家最熟悉的Frida重打包流程。正常操作是用jadx反编译目标APK,然后把libfrida-gadget.so按对应ABI放进lib目录,再在smali入口调用System.loadLibrary("frida-gadget"),最后用apktool回编译、签名、安装。听起来不算复杂,但实际做起来处处是坑。
首先是签名校验问题。Android 7.0之后默认启用V2签名,有些App还会用V2+V3甚至更高级的签名方案。你重打包之后,只要签名和原包不一致,系统在安装阶段就会拒绝安装;如果目标应用在代码里做了Java层签名校验,即使你侥幸装上去,打开App也会闪退。为了过掉签名校验,你还得去定位校验逻辑、动态Patch,这等于额外给自己加了一堆逆向工作量。
其次是更新成本。重打包方案最难受的地方在于目标App每次发版,你都要重新下载新APK、重新注入、重新过校验。如果你同时监控好几个App,那这个维护成本会迅速失控。尤其现在很多应用走的是多渠道分包、热更新、插件化,哪怕主包没变,插件一更新也可能把原有的Hook逻辑冲掉。
还有一类App做了so层的完整性校验或者反调试检测。重打包之后,包内文件hash都变了,应用启动时先校验自身文件完整性,发现被改动就直接退出。这个场景下,重打包基本是死路一条,除非你花大量精力把校验逻辑全部逆清除。
1.2 系统级注入的意义
换到系统级注入的思路以后,前面这些麻烦基本都能绕开。目标APK从头到尾没有被修改过,它被安装到设备上的形式、签名、文件hash都保持原样,应用自身也感知不到包体有变化。Hook能力来自系统固件层面:我们在AOSP编译阶段就把frida-gadget.so预置进系统镜像,再在进程启动路径上加入一段加载逻辑,让目标应用在fork出来之后、真正执行App代码之前,先把自己的Hook环境准备好。
这样做的好处很明显。第一是持久化,系统镜像刷一次,只要不换系统,注入能力就一直在,App怎么升级都不影响;第二是隐藏性好,包体干净、文件系统干净,Java层和so层都很难通过常规自校验发现异常;第三是批量覆盖方便,一个系统镜像可以带白名单,把想Hook的应用全列进去,新装一个目标应用也能自动被覆盖。
1.3 方案适用的场景与边界
这套方案也有明确的适用边界。它需要你能拿到设备Bootloader权限来刷自定义系统镜像,或者你本来就在维护一块定制硬件,可以直接烧录自己编译的固件。也就是说,它解决的不是“任何一台量产手机上免RootHook”的问题,而是“在你可以掌控系统镜像的设备上,彻底摆脱重打包和Root依赖”的问题。
常见的使用场景包括:自己做逆向研究的备用机、公司内部自动化测试设备池、企业内部应用的合规性检测设备、以及各种行业定制终端的二次开发。需要特别说清楚的是,这类能力只应该在自有设备或获得授权的测试环境里使用,不要拿去做破解收费服务、绕过认证或者处理别人的私有数据。技术本身是中性的,但用法的边界自己要守住。
2. Android 9上的方案选型与注入点分析
2.1 Android 9作为试验场的优势
可能你会问,现在Android 14都出来了,为什么还选Android 9?其实这里没有追新的需求,反而是Android 9这个版本在源码定制和刷机验证上优势很明显。
第一是编译成本适中。AOSP编译需要很强的机器配置,Android 9的源码树相对后面几个大版本更轻量,用一台16核32G内存的机器加Docker里的Ubuntu环境,跑一次完整构建大概一两个小时就能完成。Android 10之后源码树越来越大,Android 12、13对磁盘和内存的要求更是水涨船高,很多个人开发者的机器跑一次要四五个小时起步。
第二是SELinux策略相对好调。Android 9的SELinux还是Enforcing为主流,但avc日志的排查路径已经很成熟,而且untrusted_app的权限约束没有后续版本那么严格。给系统库加一条允许规则的影响面比较容易评估,不容易出现一改就牵动一堆其他域的问题。
第三是市场上存量很多。很多跑在Android 9上的设备是定制终端、车载中控、扫码枪、旧旗舰机,这些设备在安全测试和功能增强上仍然有真实需求。你在Android 9上调通的方案,很多都能直接平移到同厂商的Android 10/11机型。
2.2 四个注入点的优劣对比
AOSP的进程启动链路里,能够承载系统级注入的点基本有四个,我直接列一张对比表说明。
| 注入点 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Zygote进程 | 在Zygote启动及fork子进程前后挂载加载逻辑,所有应用进程都经过这里 | 覆盖最全,从进程源头动手 | 必须很小心,否则系统里所有App都会受影响 |
| app_process | 修改app_process入口,通过启动参数或系统属性判断是否加载 | 代码位置集中,改动直观 | 拿到的上下文有限,需要配合系统属性或uid判断目标 |
| system_server | 在系统服务进程里注入,可影响系统级服务调用 | 适合Hook系统API | 不适合直接覆盖普通应用 |
| PMS包管理服务 | 在安装/启动阶段拦截,动态修改类加载路径 | 可以精确到包名 | 实现复杂,容易影响启动速度和稳定性 |
从代码改动量最小的角度出发,我会优先考虑app_process或者Zygote,而不是去动PMS这种业务逻辑很重的服务。PMS方案看起来精确,但它需要你在安装流程里动态拼装ClassLoader路径,还要处理各种ABI、多用户、分身的边界情况,很容易改出隐患。
2.3 我选定的注入路线
实际上,最终我采用的是“app_process启动参数判断为主,Zygote侧加载为辅”的组合路线。app_process是Android所有应用进程启动时必经的原生入口,它在main函数里会解析启动参数并区分是要启动zygote还是runtime进程。
我在app_process的main函数开始位置加一段启动逻辑:先检查系统属性persist.frida.targets是否为空,如果不为空,再判断当前进程是否属于白名单App。由于app_process阶段还拿不到包名,我把判断条件换成了uid区间对比——先从/data/system/packages.list读取目标应用uid,再在加载逻辑里比对当前进程uid。这样既能精确覆盖目标进程,又不会让系统里其他应用都被Gadget挂载。
至于为什么不直接改Zygote,是因为Zygote会fork所有应用进程,在Zygote侧做hook等于让所有App都带上了Gadget,行为和性能影响都不可控。app_process+L实uid判断的粒度更适合做定向注入。
3. AOSP源码修改详解:让Gadget跟着App启动
3.1 把Gadget塞进系统镜像
第一步是让frida-gadget.so出现在系统镜像里,同时把它的配置文件和Hook脚本也一起打包进固件,这样系统刷完后一切就绪,不需要手动推文件。
我在device/vendor对应的产品mk文件里,用PRODUCT_COPY_FILES把Gadget动态库和配置复制到目标分区。需要注意区分ABI,64位设备要复制到system/lib64,32位设备复制到system/lib。为了让不同架构都能用,最好两个路径都放一份。
# device/<vendor>/<board>/device.mk PRODUCT_COPY_FILES += \ vendor/frida/android-9/lib/arm64/libfrida-gadget.so:system/lib64/libfrida-gadget.so \ vendor/frida/android-9/lib/arm/libfrida-gadget.so:system/lib/libfrida-gadget.so \ vendor/frida/frida-gadget.config:system/etc/frida-gadget.configGadget配置文件我放在system/etc下面,一是因为系统分区对普通应用进程默认可读,二是不用额外处理data分区的SELinux权限。配置文件内容大概长这样:
{ "interaction": { "type": "script", "path": "/system/etc/frida-hook.js", "on_change": "reload" } }这里interaction.type用script而不是listen,意义后面会细说。Hook脚本路径也直接指向system/etc,避免出现/data目录权限不可读的问题。
3.2 修改app_process注入加载逻辑
接下来是核心代码修改。AOSP里对应的文件是frameworks/base/cmds/app_process/app_main.cpp,Android 9的具体代码在不同厂商CAF源码里可能略有差异,但核心结构一致。我在main函数读入启动参数之后、执行zygote启动之前插入了一段加载逻辑。
// frameworks/base/cmds/app_process/app_main.cpp // 在 main() 中增加一个预处理函数(示意代码) static void maybeLoadFridaGadget() { // 1. 读取系统属性,只有配置了目标uid的场合才继续 std::string targets = android::base::GetProperty("persist.frida.uid", ""); if (targets.empty()) { return; } // 2. 当前进程uid需要和配置的目标uid对上 uid_t current_uid = getuid(); if (std::to_string(current_uid) != targets) { return; } // 3. 按ABI选择正确的gadget路径 std::string lib_path = sizeof(void*) == 8 ? "/system/lib64/libfrida-gadget.so" : "/system/lib/libfrida-gadget.so"; // 4. dlopen触发gadget加载,配置里的script会自动执行 void* handle = dlopen(lib_path.c_str(), RTLD_NOW); if (handle == nullptr) { ALOGE("failed to load frida gadget: %s", dlerror()); } }这里有几个工程细节要注意。第一是getuid()拿到的其实是运行该进程的Linux uid,Android里每个应用有独立uid,通过packages.list能查到,但对于分享uid的应用和多用户场景要额外处理。第二是dlopen之后Gadget会自己读配置文件并启动脚本,Android 9上的Gadget版本建议选14.x以上,兼容性和稳定性都更好。
另外启动参数里要保证app_process以非zygote模式被调用,不然getuid()拿到的是root或者system的uid,判断会直接漏掉目标应用。实际调测时我用的是/system/bin/app_process /system/bin --application这样的启动路径,确保走的是普通应用进程分支。
3.3 用脚本模式实现免连接持久化Hook
配置里把Gadget的interaction类型设为script,这是实现“持久化”非常关键的一步。Frida-Gadget的默认交互方式是listen,也就是App启动后Gadget会在某个端口上监听,等待外部frida客户端连接上来后手动下发Hook脚本。这在传统注入场景里没什么问题,但要配合服务器做自动化批量Hook,或者目标设备没法临时连接电脑时,就很不友好。
换成script模式后,Gadget在加载时会自动去读指定路径的js脚本,然后直接在进程内执行,整个过程不需要任何外部Frida客户端参与。这意味着只要系统镜像里带着Hook脚本,目标应用每次启动,Hook逻辑都会自动生效,完全做到无人值守。
示例脚本不需要太复杂,起一个基本作用就行:
// /system/etc/frida-hook.js Java.perform(function () { var targetClass = Java.use("com.example.target.MainActivity"); targetClass.secretFunc.implementation = function () { console.log("[hook] secretFunc called"); return this.secretFunc(); }; });在实际落地时,你可以把各种类方法Hook、参数修改、返回值篡改都写进这个脚本。因为路径在system/etc,普通应用对它只有读权限,不用担心脚本被目标应用篡改,这也是放在系统分区的另一层安全考虑。
3.4 SELinux策略:最容易翻车的环节
这部分我单独强调一下,因为绝大多数第一次尝试这套方案的人,都是栽在SELinux上。Gadget编译好、镜像也刷进去了、代码改得也没问题,但目标App启动后,logcat里就是干干净净没有任何加载日志,dmesg里却刷满了avc denied记录。
原因是selinux会拦掉目标应用对system分区so文件的读取,以及尝试监听socket等操作。Gadget虽然放在system分区的可读位置,但一个普通App进程默认没有权限dlopen一个不属于它域的so文件。
解决思路是给untrusted_app域增加对应的allow规则。我这里给出一个示例,实际修改要结合你的设备sepolicy目录结构来调整:
# device/<vendor>/<board>/sepolicy/untrusted_app.te allow untrusted_app frida_gadget_file:file { read execute open getattr map }; allow untrusted_app self:tcp_socket { create bind listen accept read write };同时在file_contexts里给Gadget的so文件打上标签:
/system/lib64/libfrida-gadget\.so u:object_r:frida_gadget_file:s0 /system/lib/libfrida-gadget\.so u:object_r:frida_gadget_file:s0改完策略之后,编译时候强制刷新对应的sepolicy文件,不然旧的binary policy里还是缺失这些规则。验证时用adb shell dmesg | grep avc过滤denied日志,能看到frida相关的denied说明规则还没放全。
3.5 编译刷机验证流程
完整验证流程我按下面几步走。
第一步,准备AOSP编译环境。Android 9建议用Ubuntu 18.04,或者像我一样在Docker里跑一个ubuntu 18.04容器,磁盘分配至少200G,内存建议16G以上。初始化AOSP环境后,先source build/envsetup.sh,再执行lunch选择对应的产品配置,比如aosp_arm64-userdebug。userdebug版本保留了adb root能力,对调试很多问题都有帮助。
第二步,执行完整编译make -j16。如果只是想验证方案,不用全量make,可以先编systemimage和bootimage,但修改app_process之后还是需要完整生成system镜像的依赖。编译完成后镜像产物在out/target/product/ /下面。
第三步,刷机。先解锁设备的Bootloader,然后fastboot flash boot boot.img,fastboot flash system system.img,再fastboot reboot。这里注意别漏刷vendor,如果设备有vendor分区,务必保证vendor和system是配套的,不然开机可能进不去系统。
第四步,配置目标应用。开机后先安装目标APK,然后在adb shell里执行setprop persist.frida.uid 10xxx,把目标应用的uid填进去。uid可以通过dumpsys package <包名> | grep userId查到。最后强制停止目标应用再重新拉起,观察logcat里是否有我们的加载日志。
如果一切正常,应用启动后hook脚本的效果应该立刻体现出来,比如某个Toast被替换、某个函数调用被记录,或者在logcat里看到了console.log的输出。
4. 常见问题与排查技巧实录
4.1 目标App启动即闪退
闪退是这类方案最常见的失败形态,原因通常是Gadget与系统库的兼容性,或者脚本本身报错。
先检查so文件是不是针对目标架构编译。很多Gadget从Frida官网下载的预编译包分x86、arm、arm64几种,如果你在64位设备上只放了32位的so,dlopen时必然失败。我建议在PRODUCT_COPY_FILES里同时放lib和lib64两份,但加载逻辑里用sizeof(void*)判断位数选择对应路径,这能挡住一半的闪退问题。
接下来检查Gadget版本。Gadget并不是越新越好,它需要和目标设备的Android系统libc版本兼容。Android 9设备用15.x的Gadget基本稳定,如果你的设备是Android 9的定制ROM,用18.x可能反而出现无法解析依赖的情况。遇到这种问题,我会先adb shell进到设备里手动执行一次dlopen,看dlerror具体报什么错。
有一个常见误区是把listen模式配置和script模式配置混着写。如果config里同时出现listen和script,Gadget可能按默认顺序处理,导致加载流程和你预期不一致。保持配置干净,只留一种交互方式,能减少很多奇怪问题。
4.2 端口连不上或脚本没生效
如果你改了配置想让Gadget监听端口,然后用frida客户端连接,连接不上时先检查端口监听状态。在设备上执行adb shell netstat -tlnp | grep 12345,没有输出就说明Gadget没起来,有输出但连不上,多半是SELinux拦截了来自network namespace的连接。
这里我提供一个排查思路:gadget监听默认绑定127.0.0.1,如果不做adb forward,局域网内别的机器是连不上的。开发阶段我用adb forward tcp:12345 tcp:12345把端口映射到电脑,再用frida连接。如果目标是自动化无人值守,强烈建议用script模式代替listen模式,省掉端口连接这层麻烦。
脚本没有生效的情况,我遇到过两种。一种是脚本路径拼错,Gadget静默失败不会主动报警;另一种是脚本里的Java.perform还没等到art runtime就绪就执行了。针对第二种,在脚本外层加一个setTimeout或者用Java.performNow,都能缓解一部分时机问题。更稳妥的做法是在目标Activity的onCreate里打点,确认脚本确实已经被加载,再剥离开是不是Hook目标类名写错。
4.3 SELinux denial日志排查
SELinux相关的排查算是这套方案里最难的一环,因为它不像代码错误那样报错明显。典型的症状是目标应用正常启动,receiver和activity都没问题,但你就是看不到任何Gadget日志。
排查方法是先确认Gadget有没有真的被dlopen成功。我在加载代码里加了ALOGE日志,正常情况logcat里会看到failed to load或者成功的标记。如果连失败日志都没有,大概率连so文件的读取都被拦了。
然后执行adb shell dmesg | grep avc | grep frida看denied记录。常见的denied有两类:一类是untrusted_app对frida_gadget_file的execute权限缺失;另一类是Gadget运行后访问网络接口或者/data子目录的权限缺失。每次补完规则都建议重新编译并重新刷机,不要只改文件上下文然后用setenforce 0来验证,那样掩盖了问题本身,下次revert后照样翻车。
4.4 多开应用或厂商ROM的干扰
如果目标应用开了多开分身,它的uid和你从packages.list查到的uid可能不一样,而且不同厂商分身实现机制差异很大。有的分身应用会以新uid运行,有的会复用原uid但带额外参数。在这种情况下,纯uid判断已经不够用了。
我在后续版本里加了一层补充判断:除了比对uid,还要检查进程的附加组或者启动参数中是否包含特定字符串。这个需要用/proc/self/cmdline去读取进程名,再和包名列表做匹配。但注意app_process阶段进程名可能还没完全设置好,实际判断时我会把条件放宽到“进程名包含目标包名的一段前缀”,牺牲一点精确度换覆盖完整度。
厂商ROM的干扰主要来自各种优化功能,比如后台清理、省电模式、APP预加载。某些国产ROM会在App启动路径上插入预创建进程,导致app_process被提前拉起但最终是空进程,uid判断依然命中,但应用真正的业务进程没被注入到。这种情况我的经验是不要只盯着启动那一刻,把Gadget加载逻辑从app_process挪到更晚的Application.attachBaseContext阶段,用反射触发可能更稳,不过这又回到了Java层注入的复杂度上。
5. 实战复盘:稳定性、性能与后续扩展
5.1 性能影响与优化建议
有人会担心系统级注入会不会拖慢整台设备。从我实测的情况看,只注入目标白名单进程的话,系统整体性能基本无感。Gadget被加载进目标App进程后,会带来额外的CPU和内存开销,但单个App多出几十MB内存、运行时有几个后台work线程,在测试设备上完全能接受。
主要开销集中在应用冷启动阶段。Gadget初始化、脚本编译、类解析都要时间,实测冷启动时间比正常情况下多几百毫秒到一秒多,具体取决于目标进程的复杂度和脚本大小。如果你的Hook脚本只是简单Hook两三个方法,那几乎无感知;如果脚本里做了大量Java.use和反射操作,冷启动变慢会比较明显。
优化方向有两个。第一是脚本轻量化,把无关的Hook逻辑全去掉,只保留核心检测或者核心篡改;第二是getuid判断尽量前置,让非目标进程在属性读取阶段就被快速返回,不要走到dlopen那一步。
5.2 从单目标到多目标配置
如果你需要同时Hook多个目标App,只需把uid判断从单值扩展成一组白名单。我在系统属性里维护persist.frida.targets,格式用逗号分隔多个uid。
std::string targets = android::base::GetProperty("persist.frida.targets", ""); if (targets.empty()) return; std::string current_uid = std::to_string(getuid()); if (targets.find(current_uid) == std::string::npos) return;这样改完之后,配置一个新的目标App只需要重启一次系统,不需要重新编译镜像。复杂团队协作时,可以先让测试设备刷入支持多目标的镜像,之后所有目标App的增删都通过改属性完成,非常灵活。
5.3 还能往哪个方向长
这套方案的扩展空间其实比想象中大。比如你可以给Gadget做一个控制接口,在脚本模式之外再叠加一个自定义的Unix socket通道,用来动态注册新的Hook逻辑,把“持久化启动”和“运行时热调”结合起来。
还可以考虑从app_process注入升级为在zygote加上native bridge机制,通过配置实时开关注入,避免改属性之后必须重启才能生效的麻烦。不过改动量会大不少,稳定性也需要更长的验证周期,我建议先把app_process方案跑通,再考虑往Zygote方向演进。
再比如在Gadget的配置里,可以设置on_change: "reload",这个功能很好用。它会让Gadget持续监控脚本文件的变化,如果检测到文件更新就自动重载,不需要重启App就能生效。日常调试时我把Hook脚本直接放在系统分区,在电脑上sudo mount改写不方便,就退而求其次在/data下面建一个软链接,配合SELinux的调整,也能实现免重启改脚本的体验。
最后再分享一个实操中的体会:整套方案调试最花时间的不是编译AOSP,也不是改SELinux,反而是那些看似不起眼的细节——比如Gadget的so文件名里不要带特殊字符,系统属性名不要和厂商自带属性冲突,以及编译镜像前记得清掉out目录里的旧产物。这些细节每一条我都填过坑,列出来是希望后来的人能少走几步弯路。做系统级注入本身就是个需要耐心反复验证的活,只要肯沉下来把每一个报错都追到底,最终刷出真正可用的免Root持久化Hook环境,整个过程还是相当有成就感的。