ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

免Root持久化Hook:将Frida-Gadget嵌入AOSP系统镜像

免Root持久化Hook:将Frida-Gadget嵌入AOSP系统镜像 做逆向、做自动化测试的兄弟大概都被重打包折腾过目标App加了签名校验重打包之后秒闪退就算侥幸装上了进程一重启又要重新挂Frida项目根本没法持续跑。我最近在Android 9上搞了一套免Root的Frida-Gadget持久化Hook方案思路是把Gadget直接编进AOSP系统镜像通过改Zygote源码做到按包名自动注入。今天把源码修改、SELinux策略、编译踩坑都整理出来给想走这条路的同学省点时间。这套方案的核心价值在于它不需要目标手机上额外获取Root权限也不破坏目标APK的任何证书和文件结构只要设备能刷进一个定制系统镜像之后每一次开机、每一个目标进程启动Hook脚本都会自动加载并执行。换个直白点的说法——把Hook能力直接“烧”进操作系统里而不是每次临时去附加。1. 项目概述告别重打包把Hook直接焊进系统里1.1 重打包到底哪里让人抓狂我最早做App动态分析时走的也是传统路线APK拖下来、反编译、塞Dex/So、重新签名、安装。这种方案的痛点在目标App稍微上点强度之后会集中爆发。首先是签名校验。现在稍微有点安全意识的App几乎都会做签名校验有的是本地校验有的还会把签名摘要上报服务端比对。你重打包之后签名一定和官方不一致本地校验直接闪退服务端校验更麻烦光改本地代码没有用还要把抓包、降级、伪造服务端返回全流程走一遍。其次是Dex加壳和Vdex/Odex优化。只要目标App上了加固重打包这类基于静态修改的基本可以说废了一大半。你得先脱壳脱完壳还得把修复后的Dex重新塞进APK这个流程一遇到混淆、反调试、资源校验就非常折磨人。最后是更新频率问题。目标App一个月发两三个版本是常态每更新一次上面的重打包流程全部重来一遍。我在一个长期盯着的测试项目上耗了两个月后来实在受不了了才下定决心走系统镜像这条路。1.2 免Root的另一种含义系统镜像内置能力很多同学看到“免Root”第一反应是不用Root就能Hook是不是有什么黑魔法其实这里的逻辑没那么玄。普通App想在自己进程内加载一个so库本来就不需要Root权限。真正需要Root的是“附加到别人进程里做内存操作”这件事。而Frida-Gadget的模式恰好不同它是以库的形式直接加载进目标进程一旦加载成功它就在目标进程内部执行天然绕过了跨进程附加对特殊权限的要求。传统做法里要把Gadget加载进目标进程最常见的两种方式都要依赖Root一个是用frida-server配合注入脚本另一个是修改App本身让它启动时加载Gadget。前者要Root后者要重打包。我们做系统镜像的方法相当于是把“修改App本身”这一步提前到了系统源码里——你不需要碰APK系统在fork出目标App进程时直接把Gadget给你塞进去。所以这里的“免Root”是站在最终使用者的角度说的测试机上不需要开放Root权限给应用层使用不需要装Magisk模块不需要维护frida-server。但编译定制镜像的过程肯定需要你有刷机和解锁Bootloader的能力这一点必须在项目开始前评估清楚。1.3 适合谁看以及需要什么基础这篇文章最适合以下三类人第一类是安全测试团队或渗透测试工程师需要在一台固定测试机上长期分析某几个目标App的加密算法、通信协议或者内部接口。第二类是自动化测试或灰度监控方向的开发想让一批目标App在每次启动时自动加载埋点Hook收集运行数据。第三类是ROM定制开发者本来就在做系统定制顺手把动态分析能力集成进去减少后续维护成本。需要的基础能力也不高懂一点Android的基本概念进程、Zygote、SELinux会用repo和make编译AOSP剩下的就是照着改代码、刷机、调脚本。Frida脚本本身用JavaScript写会做逆向的多少都接触过这部分门槛最低。2. 整体设计三个注入点为什么选中了Zygote2.1 候选方案横向对比我在动手之前先后评估过四个注入点每个方案都有明显的优点和坑列个表看一下会比较直观注入点实现方式优点主要问题app_process入口修改app_main.cpp进程启动早期dlopen改动极小代码量最少所有进程都会加载Gadget内存和特征暴露面太大Zygote自己也被Hook系统全局行为不稳定Zygote native层修改NativeForkAndSpecializefork之后dlopen加载时机早可以按包名判断fork之后调用dlopen不是完全安全涉及动态库内部锁状态风险SELinux和ABI处理更复杂Zygote Java层修改Zygote.java的handleChildProc按包名System.load改动直观、风险低、可按包名控制加载时机比native层稍晚对大部分目标App无感知Framework层注入在AMS或ActivityThread回调里加载最可控能精确到Activity级别改动面太大维护成本高不如前几个方案通用最后我选了“Zygote Java层”作为正式方案。理由很直白它改动最小、最容易排查问题、还能按包名控制加载范围。对于一个长期维护的定制系统来说简单和稳定永远比花哨重要。2.2 Zygote Java层注入改动最小、最稳的做法Android系统里几乎所有App进程都是由Zygote进程fork出来的。Zygote会先孵化出system_server然后再根据AMS的请求通过forkAndSpecialize孵化普通应用进程。你要是能在Zygote fork出子进程之后、子进程真正启动应用代码之前偷偷加载一个so那就等于在“起跑线”上抢跑了。Java层的注入点就在Zygote.java的handleChildProc里这个方法是子进程fork完成后的Java处理入口。在这里判断一下当前进程的包名niceName如果落在我们的Hook名单里就调用System.load加载Gadget。System.load本质上也是调dlopen但它跑在Java线程上下文里比直接在native fork后调用dlopen要安全得多至少不会踩到“fork之后动态库内部锁状态不一致”这种深坑。而且Java层可以直接读取系统属性、做字符串处理后续要改成动态配置扩展起来非常方便。2.3 Frida-Gadget的运行模式与脚本设计Gadget的配置有两种典型模式一种是“listen”模式进程加载后会监听本地端口等待frida客户端连接适合交互式调试。另一种是“script”模式加载后直接从配置文件指定的路径读取一段JS脚本并立即执行完全不需要外部连接适合持久化场景。我在持久化方案里用的是第二种模式而且把listen设成了none。这样Gadget在目标进程里就是一个“闷头干活”的内部模块不暴露任何网络端口也不依赖外部工具才能运行。JS脚本可以写好一次之后长期自动执行。一个典型的配置文件长这样{ interaction: { type: script, path: /data/local/tmp/frida-hook.js, on_change: reload }, listen: none, log: { level: info, file: /data/local/tmp/frida-gadget.log } }这里有几个细节需要特别注意配置文件路径、脚本路径和日志路径默认都涉及/data/local/tmp但普通App进程的SELinux域untrusted_app默认是访问不了这个目录的。所以只把Gadget放进系统镜像还不够还必须给SELinux开好口子这块后面会专门讲。3. Android 9的AOSP源码修改实战3.1 编译环境准备和AOSP源码下载我选的是Android 9的最后一个稳定分支android-9.0.0_r53设备用的Pixel 3crosshatch。选Android 9而不是更高版本原因是这个版本既保留了比较完整的AOSP构建体系又没有Android 10之后那么多system-as-root、动态分区、apex之类的改动对只想集成一个Gadget的场景来说坑最少。编译环境我建议至少16G内存、400G磁盘空间能上32G内存最好。第一次全量编译时间大概在几小时到十几小时之间取决于机器核心数和磁盘速度。推荐在Docker里直接开一个Ubuntu 18.04容器干净隔离避免把宿主环境搞乱docker run -it --privileged \ -v ~/aosp:/aosp \ ubuntu:18.04 \ bash进入容器后安装基础依赖Android 9必须用OpenJDK 8apt-get update apt-get install -y \ git-core gnupg flex bison build-essential zip curl zlib1g-dev \ gcc-multilib g-multilib libc6-dev-i386 lib32ncurses5-dev \ x11proto-core-dev libx11-dev lib32z1-dev libgl1-mesa-dev \ libxml2-utils xsltproc unzip fontconfig openjdk-8-jdk \ python ccache源码同步用的是repo命令本身不复杂但要注意第一次同步务必加--no-tags能少拉不少东西mkdir -p /aosp cd /aosp repo init -u https://android.googlesource.com/platform/manifest -b android-9.0.0_r53 repo sync -c --no-tags -j8同步完成后初始化编译环境并选目标source build/envsetup.sh lunch aosp_crosshatch-userdebug这里先选userdebug版本开发调试阶段比较方便可以通过adb root快速验证。等整个流程跑通、确认稳定之后再编译user版本作为正式测试镜像。3.2 第一步把Gadget放进system.imgGadget的so文件要打进系统镜像最规范的做法是把整个文件放到AOSP源码树里然后通过Product配置声明复制关系。我建议在external/下建一个独立的目录方便后续维护和替换版本mkdir -p /aosp/external/frida-gadget/lib mkdir -p /aosp/external/frida-gadget/lib64 cp libfrida-gadget.so /aosp/external/frida-gadget/lib/ cp libfrida-gadget.so /aosp/external/frida-gadget/lib64/然后在设备的mk文件里声明复制关系。Pixel 3对应的设备目录是device/google/crosshatch/在device.mk里追加# Frida gadget for persistence hook PRODUCT_COPY_FILES \ external/frida-gadget/lib64/libfrida-gadget.so:system/lib64/libfrida-gadget.so \ external/frida-gadget/lib/libfrida-gadget.so:system/lib/libfrida-gadget.so为什么32位和64位两个库都要放因为目标App可能是32位或者64位进程你无法预先假设。Gadget的ABI必须和目标进程一致否则System.load会直接报错。这里建议优先使用14.x或15.x比较新的Gadget版本在Android 9的Bionic环境上稳定性更好。老版本的Gadget在Android 9上偶发初始化失败花在排查上的时间远大于升级版本的时间。3.3 第二步修改Zygote.java实现按需注入这是整个方案的核心改动。目标文件是frameworks/base/core/java/com/android/internal/os/Zygote.java。先定位handleChildProc方法我在源码里找了一下Android 9的handleChildProc有一段这样的逻辑private void handleChildProc(Arguments parsedArgs, FileDescriptor[] pipeFds, boolean isZygote, String instructionSet) { ... if (parsedArgs.niceName ! null) { ... } ... }我们要做的就是在子进程正式进入ZygoteInit.zygoteInit之前根据niceName也就是进程对应的包名决定要不要加载Gadget。我在handleChildProc里增加了一个hookIfNeeded调用并且新写了一个私有方法来实现按需加载private void handleChildProc(Arguments parsedArgs, FileDescriptor[] pipeFds, boolean isZygote, String instructionSet) { ... if (parsedArgs.niceName ! null) { hookIfNeeded(parsedArgs.niceName); } ... } private static void hookIfNeeded(String niceName) { if (niceName null) { return; } // 通过系统属性配置需要Hook的包名列表 String hookList SystemProperties.get(persist.frida.hook, ); if (hookList.isEmpty()) { return; } boolean shouldHook false; for (String name : hookList.split(,)) { if (name ! null name.trim().equals(niceName)) { shouldHook true; break; } } if (!shouldHook) { return; } try { if (VMRuntime.getRuntime().is64Bit()) { System.load(/system/lib64/libfrida-gadget.so); } else { System.load(/system/lib/libfrida-gadget.so); } Log.i(TAG, frida gadget loaded for niceName); } catch (Throwable t) { Log.e(TAG, failed to load frida gadget, t); } }这段代码有几个设计点值得说一下。使用系统属性而不是硬编码包名列表是我强烈建议的做法。你永远不知道测试过程中会临时增加多少个目标应用硬编码意味着每次都要重新编译刷机而getprop/setprop可以在设备运行时动态调整完全不需要重新编译系统。我用的是persist.前缀的属性这样重启设备后配置还在不会因为一次重启就丢失Hook名单。使用VMRuntime.getRuntime().is64Bit()判断当前进程的ABI比用Build.SUPPORTED_64_BIT_ABIS更准确。后者反映的是系统镜像支持哪些ABI前者才是当前进程实际运行在哪种模式下。有些App同时有32位和64位so实际跑的可能是32位进程如果单纯按系统能力判断加载错库就是白加载。还有一点要注意handleChildProc里本身有一个对parsedArgs.niceName的判空分支我在插入代码时特意把hookIfNeeded的调用放在这个判空之后避免niceName为null时误判。有些系统进程的niceName本来就是null压根不需要注入。3.4 第三步给SELinux开小灶如果不改SELinux策略前面做的所有工作等同于白干。Android 9的SELinux管控相当严格普通App进程默认连/system/lib64下面新加的Gadget库都可能加载不了。最容易看到的现象是目标App启动后logcat里出现一堆avc: denied的报错然后hookIfNeeded里的System.load抛出异常。虽然我在代码里catch了Throwable但Gadget根本没加载成功整个方案就失去了意义。最简单的放行方案是直接修改system/sepolicy/private/untrusted_app.te在文件末尾加上对system_file的读、映射和执行权限# Allow loading frida gadget from system image allow untrusted_app system_file:file { read open getattr map execute_no_trans execute };不过“给untrusted_app放开system_file的执行权限”这种做法等于让任意第三方App都能执行系统分区里的文件虽然Gadget的路径是固定的但从安全角度来说不够精细。更规范的方案是为Gadget定义一个独立的文件类型。先在system/sepolicy/private/file_contexts里声明路径和类型的映射/system/lib(64)?/libfrida-gadget\.so u:object_r:frida_file:s0再在system/sepolicy/public/frida_file.te定义这个类型type frida_file, file_type, mlstrustedobject;然后在system/sepolicy/private/untrusted_app.te里只放行untrusted_app对这个特定类型的访问allow untrusted_app frida_file:file { read open getattr map execute_no_trans execute };这样即使系统里存在其他恶意App它也不能随便去执行/system/lib64下的任意文件只能执行我们指定的Gadget。另外还有一个容易被忽略的点如果Gadget的JS脚本依赖JIT或者动态代码生成还需要给目标进程增加execmem权限。Android 9对untrusted_app的execmem管控还不像后来那么严但我在实际测试中确实遇到过个别版本在特定场景下需要这个权限。稳妥起见可以加一行allow untrusted_app self:process execmem;这个权限本身是敏感权限但它只作用于进程自身生成可执行内存在我们这个Hook场景下属于必要开销。如果你用的Gadget版本不需要JIT可以把这行注释掉减少暴露面。3.5 编译、刷机、配置Gadget脚本三步修改都完成之后回到编译环境开始构建export USE_CCACHE1 export CCACHE_DIR/aosp/.ccache ccache -M 100G cd /aosp source build/envsetup.sh lunch aosp_crosshatch-userdebug make -j$(nproc) 21 | tee build.log第一次编译时间会比较长我自己的机器是16核32G内存全量编译大概花了四个多小时。建议开启ccache后续如果只改Java层和sepolicy增量编译会快很多十几分钟就能出镜像。编译产物在out/target/product/crosshatch/下关键的文件有system.img、boot.img和vbmeta.img。刷机命令很简单adb reboot bootloader fastboot flashing unlock fastboot flashall -w fastboot reboot注意flashall -w会清空所有用户数据刷机前确认测试机没有需要保留的数据。刷完机之后先把配置文件和JS脚本放进/data/local/tmp由于我们前面在sepolicy里已经放行了untrusted_app对shell_data_file的访问这一步可以这样做adb push frida-gadget.config /data/local/tmp/ adb push frida-hook.js /data/local/tmp/ adb shell chmod 644 /data/local/tmp/frida-gadget.config adb shell chmod 644 /data/local/tmp/frida-hook.js然后设置需要Hook的包名名单adb shell setprop persist.frida.hook com.test.target最后启动目标App正常情况下logcat里会出现我们打印的加载日志adb logcat | grep frida gadget看到frida gadget loaded for com.test.target就说明Gadget已经成功load进目标进程了。接着在JS脚本里放一个测试用的console.log如果日志能打出来整个链路就完全跑通了。4. 验证结果和坑位实录4.1 怎么确认Gadget真的跑起来了确认Gadget加载成功不能只看logcat里那行打印还得确认它真正执行了JS脚本。我第一次验证时踩过一个误会logcat里确实打印了“frida gadget loaded”但JS脚本里的输出一个都没有。排查半天发现是配置文件路径读错了Gadget默认找的配置文件是/data/local/tmp/frida-gadget.config而我把文件命名成了frida.config导致它一直走默认的空配置脚本根本不会执行。建议验证时在JS里加一句最醒目的输出console.log([] frida gadget injected, target is running);然后在logcat里过滤自己的Tagadb logcat -s Frida:V如果你能看到上面那句输出说明Gadget已经加载、配置文件生效、JS脚本正常执行了。如果是通过Frida的log输出通常会带frida相关的Tag具体看Gadget版本而定。4.2 典型问题排查SELinux、ABI、版本兼容我在整个过程中踩过的坑整理成一张速查表比看大段日志分析实用得多现象可能原因排查和解决办法System.load异常logcat出现avc deniedSELinux策略没放行用adb shell dmesg | grep avc看具体拒绝规则按报错内容补sepolicy64位进程加载成功但32位进程加载失败ABI判断错误或缺少32位Gadget库确认/system/lib/libfrida-gadget.so存在确认代码走的是VMRuntime判断JS脚本完全没有输出配置文件路径不对或脚本路径读不到确认/data/local/tmp/frida-gadget.config存在且权限644检查配置里path字段目标App启动后整体卡顿或ANRGadget初始化阻塞主线程换用较新Gadget版本或改用listen模式先排除脚本死循环问题persist.frida.hook设置了但没有加载属性没有及时生效adb shell getprop persist.frida.hook确认值杀目标进程后重新启动编译时报Java版本错误用了OpenJDK 11Android 9必须用OpenJDK 8切换JDK版本后重新source envsetuprepo同步中断导致build缺文件源码不完整重新repo sync -c --no-tags -j8必要时rm -rf后重新同步ABI这个坑值得单独说一下。我最初在代码里用了Build.SUPPORTED_64_BIT_ABIS.length 0来判断结果遇到一个只带32位so的App虽然系统支持64位但目标进程本身是32位的最终Gadget加载到了错误位数的库。后来改成VMRuntime.getRuntime().is64Bit()之后这个问题彻底解决。4.3 性能与稳定性情况在我自己的测试机上这个方法连续跑了两周目标App每天重启几十次没有出现过一次因为Gadget加载导致的系统级崩溃。我同时监控了几个指标应用冷启动时间、内存占用、CPU占用。加载了Gadget的进程冷启动时间大约增加80~150ms主要是dlopen和JS引擎初始化带来的开销。这个增量在调试场景完全可以接受但对那些对启动时间极度敏感的应用比如秒开类应用可能会有感知。内存占用方面Gadget本身会多占用大概20~30MB如果再算上V8引擎的堆内存大概在50MB上下。如果你同时给十几个进程注入这个内存开销会成倍放大所以在生产环境里我非常不建议做全进程注入一定要通过persist.frida.hook做好名单管理。5. 扩展玩法与经验建议5.1 不止目标App系统进程也能Hook这套方案最大的优势其实是“全局能力”。只要你愿意system_server、SystemUI、各种系统服务进程都能被纳入Hook名单。比如说你想监控系统弹窗的调用来源直接在persist.frida.hook里加上com.android.systemui然后把对应的JS脚本写好就能在SystemUI进程里拦截相关方法。这种能力放在常规逆向场景里要么靠Xposed插件要么靠刷模块现在一个系统镜像就全搞定了。不过系统进程和普通App进程有一个关键区别它们的SELinux域不是untrusted_app。system_server是system_server域SystemUI可能有自己的platform_app域所以需要额外在sepolicy里放行这些域对frida_file的访问。规则写法类似把untrusted_app换成对应的域就行。5.2 换到Android 10/11/12要改什么这套方案的架构在Android 10之后依然成立但有三处需要适配。第一Zygote.java的handleChildProc方法在Android 10、11、12里的代码结构都有变动尤其是Android 12引入了更多启动流程的改造注入点的位置需要对应调整。第二SELinux策略文件的组织方式在高版本里有变化比如Android 10后引入了sytem/sepolicy/private和sytem/sepolicy/public之外的一些新规则需要根据报错增量调整。第三Android 10之后system分区加载方式的改动对Gadget so本身没有影响但System.load的SELinux要求可能会变化。总的来说Android 9是最容易落地的版本如果项目不急建议先用这个版本把整套流程跑通再往高版本迁移。5.3 我的日常使用习惯项目稳定之后我习惯把配置文件和JS脚本都放在/data/local/tmp下而不是打进system.img。这样日常调脚本、加Hook点都只需要替换文件后重开目标进程完全不用重新编译系统镜像。具体操作就是adb push hook.js /data/local/tmp/frida-hook.js adb shell am force-stop com.test.target adb shell am start -n com.test.target/.MainActivity这一套下来几秒钟就能完成一次脚本热更新。Gadget配置文件里设置的on_change: reload在某些版本上甚至能支持脚本文件变更后自动重载不过这个特性我在个别Gadget版本上遇到过不稳定所以依赖这个机制前一定要先在目标环境里测一遍。另外提一个合规提醒这种系统级Hook能力是把双刃剑它能在不破坏目标App的前提下做深度分析也意味着一旦系统镜像泄露别人就能拿去做恶意用途。所以定制镜像一定要保管好不要随意分发测试机也要锁好Bootloader设置避免研究中间产物被利用。所有测试请在你自己的设备、已授权的环境中进行不要越界。
返回列表