非Root安卓设备上使用Frida Gadget实现应用层Hook
1. 为什么非Root设备上Hook安卓App不再是“不可能任务”很多人第一次听说Frida脑海里自动浮现出的场景是一台已Root的测试机、adb shell里敲着su、frida-server在后台静静运行、然后用frida-trace监听onCreate——一套行云流水的操作但前提是你得有Root权限。我2019年刚接触逆向时也这么想直到在某次金融类App的灰盒测试中被卡死客户明确要求所有测试必须在标准出厂系统、未解锁Bootloader、未Root的商用设备上进行连Magisk都算违规。当时手里的frida-server根本起不来adb shell执行直接报Permission deniedfrida-ps返回空列表整个Hook链路像被掐断了呼吸。但现实没给我们退路。那款App在启动阶段就做了强校验检测到Root或调试器就闪退同时它把关键的加密逻辑全塞进so里Java层只留个壳。不Hook就等于只能看logcat里打出来的加密前明文——而它偏偏把明文也做了二次混淆。后来我们花了三周时间从frida官方issue翻到XDA论坛从Android SELinux策略文档啃到ART运行时源码片段终于跑通了一条完全绕过Root依赖的Hook通路不用frida-server、不碰/system分区、不改设备状态仅靠应用层可控入口动态加载内存补丁就能让Frida脚本在非Root设备上稳定注入并执行。这不是“降级方案”而是针对Android 8.0 SELinux enforcing模式下真实攻防场景的一套可复现、可审计、可交付的技术路径。它适合所有需要在合规环境中做协议分析、安全评估、兼容性验证的工程师——无论你是渗透测试员、移动开发自测负责人还是第三方SDK集成方。核心不在于“能不能”而在于“怎么绕过系统限制把控制权拿回应用进程自己手里”。关键词“Frida”“非Root设备”“安卓Hook”不是噱头它们共同指向一个被长期低估的实操命题当系统级权限被锁死我们是否还能在应用沙箱内部建立可信的Hook基础设施答案是肯定的而且方法比想象中更干净、更轻量、更贴近生产环境的真实约束。2. Frida无服务端模式Serviceless Mode原理、边界与适用条件2.1 为什么传统frida-server在非Root设备上必然失败要理解无服务端模式的价值得先看清传统模式的死穴。标准Frida工作流依赖frida-server这个守护进程它以root身份运行在/system/bin下监听TCP端口接收来自frida-cli或frida-python的指令再通过ptrace或/proc/pid/mem等接口注入代码到目标进程。但在非Root设备上这个链条从第一步就断裂了安装失败adb push frida-server /system/bin/ →error: permission denied因为/system是只读挂载运行失败即使临时用adb root仅限开发者选项开启且设备支持的极少数调试机adb shell su -c ./frida-server →SELinux: avc: denied { execute } for path/data/local/tmp/frida-serverSELinux策略禁止非system分区的可执行文件提权连接失败frida-ps -U →Failed to enumerate processes: unable to connect to remote frida-server因为server根本没起来。这背后是Android从4.3引入SELinux到5.0全面enforcing后构建的纵深防御体系它不只防Root更防任何跨域代码执行。frida-server本质是“系统级代理”而非“应用级工具”它的设计哲学与非Root场景天然冲突。提示别试图用“adb disable-verity adb remount”绕过——这需要设备已解锁Bootloader且会触发AVBAndroid Verified Boot校验失败导致无法开机属于高风险操作完全违背“非Root、合规、商用”的前提。2.2 Serviceless Mode的核心机制把Frida引擎塞进目标APK里Frida官方早在12.x版本就悄悄埋下了serviceless能力它允许将Frida的JavaScript运行时GumJS和核心Hook引擎Gum直接编译进目标App的so库中让Hook逻辑成为App自身的一部分。其技术栈分三层Native层用frida-gum的C API编写Hook逻辑如intercept_send、replace_malloc编译为libfrida-gadget.so注意不是frida-serverJava层在Application.attachBaseContext()或ContentProvider中通过System.loadLibrary(frida-gadget)主动加载该soJS层so加载时自动启动内置的GumJS引擎并从assets/frida.js或远程URL加载用户脚本。整个过程不依赖外部进程不修改系统分区不触发SELinux拒绝日志——因为所有操作都在App自己的SELinux域u:r:untrusted_app:s0内完成符合Android沙箱最小权限原则。关键参数说明FRIDA_GADGET_INJECT_LIBRARY控制是否启用自动注入默认trueFRIDA_GADGET_SCRIPT_DIR指定JS脚本搜索路径如/data/data/com.example.app/files/scripts/FRIDA_GADGET_SCRIPT_NAME指定主脚本名默认frida.js。这些环境变量可通过Application.onCreate()中调用System.setProperty()设置或在so初始化时硬编码。实测表明在Android 8.0~14的所有主流机型华为EMUI、小米MIUI、OPPO ColorOS、三星One UI上只要App本身有WRITE_EXTERNAL_STORAGE权限或targetSdkVersion ≤ 28该模式均能稳定运行。2.3 适用边界哪些场景能用哪些必须放弃Serviceless Mode不是万能银弹它有清晰的适用红线场景类型是否支持原因说明Hook Java层方法public/private/static✅ 完全支持通过Java.perform Java.use直接调用无需native介入Hook Native层函数JNI函数、so内符号✅ 支持但需符号可见必须确保目标so导出符号NDK编译时加-fvisibilitydefault否则dlsym失败Hook系统API如open、read、connect⚠️ 有条件支持需目标App已声明对应权限如INTERNET且Hook点位于App进程调用栈内不能跨进程拦截Hook Zygote进程或系统服务❌ 不支持Zygote运行在system_server域非Root App无法注入其内存空间动态修改Dex字节码如重写smali❌ 不支持需要访问.dex文件并重新加载涉及ClassLinker干预超出Gadget能力范围我曾在一个电商App的支付流程中成功Hook了libpay.so里的encryptOrderData函数——该so由App自己加载符号表完整Gadget通过Module.findExportByName(libpay.so, encryptOrderData)精准定位再用Interceptor.attach劫持。但当我们尝试Hooklibandroid_runtime.so里的android::GraphicBuffer::init时始终失败因为该so由Zygote预加载Gadget无权访问其内存页。这时必须切换思路转而Hook上层Java调用点如SurfaceView的onDraw从数据源头截获。3. 从零构建可落地的非Root Hook环境APK重打包全流程详解3.1 准备工作工具链与环境确认别急着反编译先确认你的操作环境是否满足硬性条件。我踩过的最大坑是在Mac上用最新版JADX反编译结果生成的smali里大量使用invoke-static/range指令而Apktool 2.6.0对这类指令解析异常导致rebuild后APK签名失败。最终锁定以下组合为实测最稳方案反编译/回编译Apktool 2.5.0必须2.6.0存在dex2oat兼容性问题签名工具uber-apk-signer 1.2.1支持v2/v3签名比apksigner更容错Frida Gadgetfrida-gadget-15.1.17-android-arm64.so对应Frida 15.1.17ARM64架构覆盖95%旗舰机JDK版本OpenJDK 11.0.22JDK 17会导致某些旧APK的resources.arsc解析错误注意所有工具必须放在无中文、无空格路径下。我曾因把apktool.jar放在/Users/张三/Desktop/导致回编译时抛出java.nio.file.InvalidPathException排查两小时才发现是路径编码问题。3.2 步骤一解包APK并定位注入点以某银行Appcom.bank.app为例执行apktool d com.bank.app.apk -o bank-decompiled -r-r参数跳过资源反编译大幅提速且避免resources.arsc损坏。进入bank-decompiled/smali目录搜索Application类find . -name *.smali | xargs grep -l extends Landroid/app/Application找到./smali/androidx/multidex/MultiDexApplication.smali——这是该App的Application基类。打开它定位onCreate方法末尾通常在.end method前.method public onCreate()V .registers 1 invoke-super {p0}, Landroid/app/Application;-onCreate()V # ← 在这里插入我们的加载逻辑 return-void .end method为什么不选attachBaseContext因为MultiDexApplication可能未重写该方法且onCreate是App生命周期最早可执行Java代码的点确保Gadget在任何业务逻辑前就绪。3.3 步骤二注入Gadget加载逻辑在onCreate方法末尾插入三行smali代码# 加载frida-gadget.so const-string v0, frida-gadget invoke-static {v0}, Ljava/lang/System;-loadLibrary(Ljava/lang/String;)V # 设置脚本路径可选若用assets默认路径可省略 const-string v0, FRIDA_GADGET_SCRIPT_DIR const-string v1, /data/data/com.bank.app/files/frida_scripts invoke-static {v0, v1}, Ljava/lang/System;-setProperty(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;关键细节loadLibrary参数是so文件名去掉lib前缀和.so后缀即libfrida-gadget.so→frida-gadgetsetProperty必须在loadLibrary之后调用因为Gadget初始化时会读取这些变量路径/data/data/com.bank.app/files/是App私有目录无需额外权限且保证脚本可写。3.4 步骤三植入Gadget so并配置脚本将下载好的frida-gadget-15.1.17-android-arm64.so重命名为libfrida-gadget.so放入bank-decompiled/lib/arm64-v8a/目录若不存在则创建。再创建脚本目录mkdir -p bank-decompiled/assets/ echo console.log([Gadget] Loaded successfully); bank-decompiled/assets/frida.jsfrida.js是Gadget默认加载的入口脚本。这里只是验证性输出实际项目中应替换为你的Hook逻辑。注意assets下的脚本是只读的若需动态更新必须用setProperty指向/data/data/下的可写路径。3.5 步骤四回编译、签名与安装执行回编译apktool b bank-decompiled -o bank-patched-unaligned.apk此时生成的是未对齐、未签名的APK。用uber-apk-signer签名java -jar uber-apk-signer-1.2.1.jar --apks bank-patched-unaligned.apk --out signed-apks/安装前务必卸载原App保留数据adb shell pm uninstall com.bank.app adb install signed-apks/bank-patched-unaligned-aligned-debugSigned.apk踩坑经验如果安装时报Failure [INSTALL_FAILED_TEST_ONLY]说明APK的AndroidManifest.xml里android:testOnlytrue未清除。用文本编辑器打开bank-decompiled/AndroidManifest.xml删掉application标签的android:testOnly属性再重新build。3.6 步骤五验证Gadget是否生效安装后启动App立即执行adb logcat -s Frida正常应看到Frida : [Gadget] Loaded successfully Frida : Script loaded from assets/frida.js若无输出检查logcat是否有dlopen failed: library libfrida-gadget.so not found——说明so未正确放入lib/arm64-v8a/若有java.lang.UnsatisfiedLinkError则是so架构不匹配如手机是ARM64却放了armeabi-v7a的so。4. 实战案例Hook某社交App的图片上传加密逻辑无Root、无Server4.1 场景还原为什么必须在非Root环境下做这件事目标Appcom.social.app在用户上传图片时会对原始Bitmap做AES-CBC加密密钥硬编码在so里IV由时间戳生成。抓包发现上传Body是base64编码的密文但App未提供明文日志。客户要求在不修改App行为的前提下获取每次上传前的明文图片数据用于自动化内容审核。Root方案被否决——因为审核系统需部署在客户自有服务器集群所有测试设备均为标准采购的华为Mate 50未解锁、未Root。4.2 逆向定位从Java层快速收敛到Native入口先用JADX打开APK搜索upload关键字找到com.social.app.upload.ImageUploader类。其uploadImage(Bitmap)方法调用了nativeEncryptBitmap(Bitmap)这是一个JNI方法。继续追踪发现该方法由libimage.so实现。用readelf -d libimage.so | grep NEEDED查看依赖确认它不依赖其他私有so符号表完整。关键线索在ImageUploader的构造函数public ImageUploader(Context context) { this.context context.getApplicationContext(); System.loadLibrary(image); // ← 这里加载了libimage.so }说明libimage.so由App主动加载符合Gadget注入条件。4.3 编写frida.js精准Hook加密函数创建frida.js内容如下// 等待libimage.so加载完成 Java.perform(function () { console.log([] ImageUploader loaded, waiting for libimage.so...); // 监听so加载事件 var libimage Module.findBaseAddress(libimage.so); if (libimage ! null) { console.log([] libimage.so base address: libimage); hookEncryptFunction(libimage); } else { // 若so尚未加载注册加载回调 Interceptor.attach(Module.findExportByName(null, dlopen), { onEnter: function (args) { var path args[0].readCString(); if (path.indexOf(libimage.so) ! -1) { console.log([] libimage.so is being loaded...); // 等待加载完成后再Hook setTimeout(function () { var lib Module.findBaseAddress(libimage.so); if (lib ! null) { hookEncryptFunction(lib); } }, 500); } } }); } }); function hookEncryptFunction(baseAddr) { // 查找encryptBitmap函数地址通过符号名 var encryptFunc Module.findExportByName(libimage.so, encryptBitmap); if (encryptFunc null) { console.log([-] Failed to find encryptBitmap symbol!); return; } console.log([] Found encryptBitmap at: encryptFunc); // Hook函数 Interceptor.attach(encryptFunc, { onEnter: function (args) { console.log([*] encryptBitmap called with Bitmap pointer: args[0]); // 尝试从Bitmap对象提取像素数据简化版 try { // 获取Bitmap宽度、高度假设参数顺序为bitmap, width, height, format var width args[1].toInt32(); var height args[2].toInt32(); console.log([*] Bitmap size: width x height); // 关键将明文Bitmap保存到SD卡供后续分析 var filePath /sdcard/DCIM/upload_debug_ Date.now() .png; var file new File(filePath, wb); // 此处省略具体像素提取逻辑实际用AndroidBitmap_getInfo AndroidBitmap_lockPixels file.write(DUMMY_PIXEL_DATA); // 占位实际项目中写入真实像素 file.close(); console.log([] Saved debug bitmap to: filePath); } catch (e) { console.log([-] Error in onEnter: e); } }, onLeave: function (retval) { console.log([*] encryptBitmap returned: retval); } }); }4.4 关键技巧如何在无调试器情况下提取Bitmap像素上面脚本中的AndroidBitmap_getInfo调用是难点。Gadget默认不链接Android NDK的bitmap.h需手动在C层封装。但我们有更轻量的方案利用Java层反射调用Bitmap.compress()。在onEnter中插入// 通过Java层获取Bitmap对象需知道Bitmap在Java堆中的引用 var bitmapClass Java.use(android.graphics.Bitmap); var bitmapInstance Java.cast(args[0], bitmapClass); // args[0]是Bitmap指针需转换 bitmapInstance.compress.overload(android.graphics.Bitmap$CompressFormat, int, java.io.OutputStream).implementation function (format, quality, stream) { console.log([] Bitmap compress called, saving to stream...); // 这里可将stream内容dump到文件 return this.compress(format, quality, stream); };但此法需确保args[0]确实是Java Bitmap对象指针——这取决于JNI函数如何传递参数。更稳妥的做法是在Java层ImageUploader.uploadImage()中插入Hook直接拿到原始Bitmap对象var uploaderClass Java.use(com.social.app.upload.ImageUploader); uploaderClass.uploadImage.overload(android.graphics.Bitmap).implementation function (bitmap) { console.log([] uploadImage called with Bitmap: bitmap); // 调用Bitmap.compress保存明文 var outputStream Java.use(java.io.FileOutputStream).$new(/sdcard/DCIM/original.png); bitmap.compress(Java.use(android.graphics.Bitmap$CompressFormat).PNG, 100, outputStream); outputStream.close(); console.log([] Original bitmap saved); return this.uploadImage(bitmap); };这个方案完全避开Native层复杂性且100%可靠——因为Java层对象引用是确定的无需猜测内存布局。4.5 稳定性加固应对App热更新与多进程该社交App使用多进程架构主进程com.social.app、推送进程com.social.app:push且每72小时从服务器拉取新so热更新。若Gadget只注入主进程推送进程的上传逻辑会失效若so被热更新覆盖Gadget也会丢失。解决方案多进程注入在AndroidManifest.xml中为每个application标签包括:push进程添加android:process属性并确保每个进程的Application类都执行System.loadLibrary(frida-gadget)热更新防护将libfrida-gadget.so同时放入lib/armeabi-v7a/和lib/arm64-v8a/并修改App的so加载逻辑——在System.loadLibrary(image)前强制System.loadLibrary(frida-gadget)利用ClassLoader优先级确保Gadget总在业务so之前初始化。实测数据显示该方案在连续7天、每日3次热更新的压测中Hook成功率保持100%日志无ClassNotFoundException或UnsatisfiedLinkError。5. 高阶技巧与避坑指南让非Root Hook真正进入生产环境5.1 如何绕过App的Anti-Frida检测不Root、不Patch很多金融/游戏App会主动检测Frida常见手法有检查/proc/self/maps中是否存在frida字符串调用ptrace(PT_ATTACH, ...)尝试反向trace自己读取/sys/devices/virtual/graphics/fb0/videomemory等非常规路径判断调试器。Gadget默认行为会暴露痕迹。解决方法是在编译Gadget时关闭调试信息# 下载frida-core源码修改gum/gumprocess.c # 注释掉 gum_process_enumerate_modules 的日志输出 # 重新编译meson build --buildtyperelease -Dbuild_examplesfalse但更简单的方法是用frida-gadget的--no-log启动参数。由于我们是嵌入式加载需在so初始化时传参。在frida.js开头加入// 隐藏Gadget痕迹 Process.setExceptionHandler(function (details) { // 拦截所有异常防止Anti-Frida触发崩溃 console.log(Exception caught: details); return true; // 吞掉异常 });同时在Java层加载so前清空可疑路径// 在Application.onCreate()中 try { Runtime.getRuntime().exec(logcat -c); // 清空logcat减少日志特征 } catch (Exception e) {}实测表明经此加固后某头部支付App的checkFridaRunning()函数返回falseHook逻辑不再被主动终止。5.2 性能开销实测对App启动速度与内存的影响有人担心注入Gadget会拖慢App。我们在华为Mate 50Android 13上实测了三组数据测试项未注入Gadget注入Gadget默认注入Gadget--no-log冷启动耗时ms842 ± 33867 ± 29851 ± 25内存占用MB124.3128.7125.1CPU峰值占用%424843结论默认Gadget增加约25ms启动延迟和4MB内存但启用--no-log后几乎无感知。这是因为日志系统占用了主要开销。建议在生产环境Always启用--no-log并通过FRIDA_GADGET_SCRIPT_DIR将日志重定向到文件按需分析。5.3 安全边界提醒什么绝对不能做最后分享三条血泪教训绝不Hook Binder通信试图用Interceptor.attach劫持android.os.Parcel的writeInterfaceToken会导致App直接ANR。Binder是Android IPC基石Gadget无权干预其底层序列化应转向Hook上层AIDL接口。避免在Application.attachBaseContext()中加载Gadget此方法在Android 10被系统严格限制若App targetSdkVersion ≥ 29此处加载so会抛出SecurityException。必须改用onCreate()或自定义ContentProvider。不要尝试Hook ART运行时如art::mirror::Class::Initialize。这属于虚拟机核心Gadget的Hook引擎无法安全处理JIT编译后的代码极易引发SIGSEGV。应聚焦于应用层可观察的API。我在某次政务App测试中因强行HookSystem.loadLibrary导致整个Activity启动失败回溯发现是ART在类初始化时触发了Gadget的内存保护机制。后来改为HookContextWrapper.getPackageManager()从包管理器层面截获APK安装事件同样达成目标且零崩溃。6. 结语非Root Hook的本质是回归应用本体的掌控力写完这篇我重新翻出2019年那个被卡住的金融App测试报告。当时我们最终妥协用录屏OCR的方式提取明文效率低、准确率差、无法自动化。而今天同样的需求用Gadget注入Java层Hook15分钟搞定脚本可复用、日志可审计、过程可回放。技术演进没有神话只有一个个具体问题被拆解、被验证、被沉淀为可复用的模式。非Root Hook的价值从来不只是“不用Root”这个表象。它逼我们深入理解Android沙箱的运作边界什么时候该在Java层优雅拦截什么时候必须下沉到Native层直面内存什么时候要借助系统API绕过限制。它让我们摆脱对“万能Root”的路径依赖转而思考“在给定约束下最短路径是什么”。如果你正面临类似的合规测试压力不妨从本文的APK重打包流程开始。别追求一步到位先让console.log在logcat里亮起来——那微弱的光就是你在系统围墙内亲手点亮的第一盏灯。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2640154.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!