Android Native层Hook实战:Frida逆向分析sig3签名算法
1. 项目概述:一次从应用层到Native层的深度逆向之旅
最近在分析某手App的某个接口时,遇到了一个老朋友:sig3参数。这个参数对于经常做移动端逆向的朋友来说,应该不陌生,它往往是客户端请求签名体系中的关键一环,用于验证请求的合法性与时效性。与常见的纯Java层实现的签名逻辑不同,这次遇到的sig3生成逻辑,其核心部分被下沉到了Android的Native层(即C/C++代码编译的.so动态库中)。这意味着,传统的基于Java层Hook(如Xposed、Frida的Java API)的分析方法在这里会“碰壁”,你无法直接拦截到计算sig3的Java方法调用,因为真正的计算发生在更底层的Native代码里。
这恰恰是当前移动安全对抗的一个典型缩影。为了提升关键逻辑(如加密算法、核心校验、风控策略)的安全性,越来越多的应用选择将这部分代码用C/C++编写,并编译成Native库。这样做的好处显而易见:Native代码的反编译和逆向分析难度远高于Java字节码,混淆和加固手段也更为有效,能极大增加攻击者的分析成本。因此,掌握Android Native层的Hook技术,就成为了逆向工程师必须点亮的技能树。
本次实战,我们的目标非常明确:定位并分析某手App中生成sig3参数的Native函数,并成功Hook它,获取其输入输出,甚至动态修改其行为。围绕这个目标,我将重点分享两种在实战中最高效、最实用的Native Hook姿势:基于Frida的Interceptor和基于Cydia Substrate(或它的现代继任者)。这两种方法各有优劣,适用场景也不同,我会结合本次sig3的逆向过程,详细拆解它们的原理、操作步骤以及我踩过的那些坑。无论你是刚刚接触Native逆向的新手,还是想深化理解的老兵,相信这篇从实战出发的总结都能给你带来直接的帮助。
2. 逆向目标分析与环境搭建
在开始Hook之前,盲目动手是大忌。我们需要先对目标有一个清晰的认知,并准备好称手的“兵器”。
2.1 sig3参数与Native层逻辑定位
sig3参数通常出现在App的网络请求头或POST数据中,是一串看起来像Base64或Hex编码的长字符串。我们的第一步是确认它的生成确实涉及Native层。
定位方法:
- 动态抓包与静态搜索:使用抓包工具(如Charles、Fiddler或mitmproxy)捕获包含
sig3的请求。然后,将目标App的APK文件解包,使用jadx-gui等工具反编译其Java代码。在Java代码中全局搜索“sig3”。你可能会发现一些线索,比如一个名为NativeSignatureHelper的类,其中包含public static native String generateSig3(String param)这样的方法声明。关键字native就是铁证,表明该方法的实现在Native库中。 - 分析so库:根据上一步找到的类名和方法名,我们可以确定需要分析的Native库。在APK的
lib/目录下(或解压后的lib/armeabi-v7a、lib/arm64-v8a等),找到对应的.so文件。通常库名与包名或类名相关,比如libsignature.so或libcore.so。 - 确定目标函数符号:Native函数在.so库中通过“符号”来标识。对于JNI(Java Native Interface)函数,其符号名遵循特定的格式:
Java_{包名_类名}_{方法名}。例如,如果完整类名是com.kwai.signature.NativeHelper,方法名是generateSig3,那么对应的JNI函数符号可能就是Java_com_kwai_signature_NativeHelper_generateSig3。这是我们后续Hook的关键标识。
在本次实战中,我们假设通过上述方法,已经定位到目标函数位于libkwai_signature.so中,函数符号为Java_com_kwai_security_SigUtil_calcSig3。
2.2 核心工具链准备
工欲善其事,必先利其器。以下是本次实战的核心工具,建议在开始前配置好环境。
1. Frida:动态注入的瑞士军刀Frida是一个强大的动态代码插桩框架,它允许你将JavaScript代码片段注入到目标进程(包括Android App)中,从而Hook函数、读写内存、调用函数等。对于Native Hook,我们主要使用它的Interceptor模块。
- 安装:在电脑端(Python环境)执行
pip install frida-tools。 - 服务端:需要将对应架构的
frida-server推送到Android设备并运行。这是Frida与设备上目标进程通信的桥梁。 - 优势:脚本化、跨平台、支持同时Hook Java和Native,非常适合快速验证和动态分析。
2. Cydia Substrate / Frida-Gadget:更底层的Hook方案Cydia Substrate(及其Android版本)是一个更老牌、更底层的代码修改框架。它通过直接修改目标进程的内存来实现Hook。虽然原版Substrate在Android高版本上支持有限,但其思想和一些现代实现(如某些定制ROM或工具链)依然有参考价值。更常见的替代方案是使用Frida-Gadget。Gadget是一个.so库,你可以将它注入到目标App中,然后通过Frida的frida-gadget模式进行连接和脚本注入,这在某些对抗Frida-server的场景下有用。
- 与Frida-Interceptor的区别:Substrate/Gadget的Hook发生在更早的阶段,有时能绕过基于检测
ptrace(Frida-server使用)的反调试。但配置相对复杂。
3. 反汇编与调试器
- IDA Pro / Ghidra:用于静态分析.so文件,理解目标函数的逻辑、参数和返回值。这是逆向工程的基石。Ghidra是免费且强大的替代品。
- adb (Android Debug Bridge):与Android设备通信的必备工具,用于安装应用、推送文件、端口转发等。
4. 测试设备一台已经Root的Android物理手机或模拟器(如Genymotion、官方模拟器)。Root权限是进行深度Hook和内存操作通常所必需的。
注意:环境配置的坑
- 架构匹配:确保电脑端的Frida版本与手机端的
frida-server版本一致,并且frida-server的架构(arm, arm64, x86等)与你的手机CPU架构匹配。不匹配会导致连接失败。- 端口冲突与权限:运行
frida-server后,使用adb shell进入设备,先su获取root权限,再./frida-server &运行。确保设备的防火墙或安全软件没有阻止Frida的通信端口(默认27042)。- 应用可调试:对于非系统应用,可能需要修改其AndroidManifest.xml中的
android:debuggable="true",并重新打包签名,才能使用某些调试特性。但在仅使用Frida进行Hook时,Root权限通常已足够。
3. 姿势一:使用Frida Interceptor进行精准Hook
Frida的Interceptor API是进行Native Hook最直接、最常用的方法。它允许你在函数被调用时和执行后插入自己的代码,从而观察和修改函数的输入输出。
3.1 Frida Interceptor Hook原理与脚本结构
Interceptor.attach(targetAddress, callbacks)是核心API。它的工作原理是:Frida在目标函数的开头注入一段跳转代码(trampoline),将执行流重定向到Frida的控制代码,在执行完用户定义的回调(onEnter,onLeave)后,再跳转回原函数继续执行或直接返回。
一个典型的Frida Native Hook脚本结构如下:
// hook_native_sig3.js Java.perform(function () { // 首先,如果需要,可以同时Hook Java层的相关方法,作为切入点 // var SigUtil = Java.use('com.kwai.security.SigUtil'); // SigUtil.calcSig3.implementation = function (param) { // console.log(`Java层 calcSig3 被调用,参数: ${param}`); // var result = this.calcSig3(param); // console.log(`Java层 calcSig3 返回: ${result}`); // return result; // }; // 重点:Hook Native函数 // 方法1:通过模块名和函数符号名查找地址(推荐) var nativeFuncAddr = Module.findExportByName('libkwai_signature.so', 'Java_com_kwai_security_SigUtil_calcSig3'); // 方法2:如果符号被剥离,可能需要通过偏移量或特征码定位(更复杂) // var baseAddr = Module.findBaseAddress('libkwai_signature.so'); // var nativeFuncAddr = baseAddr.add(0x1234); // 假设偏移量0x1234 if (nativeFuncAddr) { console.log(`[*] 找到目标Native函数地址: ${nativeFuncAddr}`); Interceptor.attach(nativeFuncAddr, { // 函数被调用时进入 onEnter: function (args) { console.log(`\n[+] Native calcSig3 被调用!`); // args[0] 是 JNIEnv* // args[1] 是 jobject this // args[2] 是第一个参数(jstring),对应Java层的String param var jniEnv = args[0]; var thisObj = args[1]; var inputStr = args[2]; // 将jstring转换为JavaScript字符串 var inputCStr = Java.vm.getEnv().getStringUtfChars(inputStr, null).readCString(); console.log(`[+] 输入参数: ${inputCStr}`); // 保存到上下文,供onLeave使用 this.input = inputCStr; // 你也可以在这里修改args[2]来改变输入(需要构造新的jstring) }, // 函数执行完毕,即将返回时进入 onLeave: function (retval) { // retval 是返回值,对于返回String的JNI函数,是jstring类型 var resultJStr = retval; var resultCStr = Java.vm.getEnv().getStringUtfChars(resultJStr, null).readCString(); console.log(`[+] 函数返回值 (sig3): ${resultCStr}`); console.log(`[+] 完整调用: ${this.input} -> ${resultCStr}`); // 同样,你可以修改retval来改变返回值 // var newRetVal = ... 构造新的jstring // retval.replace(newRetVal); } }); console.log(`[*] Hook已安装成功!`); } else { console.log(`[!] 未找到目标函数,可能库未加载或符号名错误。`); } });3.2 实战Hook sig3生成函数
将上述脚本保存为hook_sig3.js。接下来在电脑端执行:
# 首先确保frida-server已在设备上运行 adb shell su ./data/local/tmp/frida-server & # 退出adb shell # 在电脑端,使用Frida附加到目标App进程 frida -U -f com.kwai.video -l hook_sig3.js --no-pause # -U 连接到USB设备 # -f 启动指定包名的应用 # -l 加载脚本 # --no-pause 立即启动主线程如果一切顺利,当App运行到调用calcSig3生成sig3时,你的终端就会打印出详细的输入和输出信息。通过观察不同请求下的输入输出对,你就能逐步分析出sig3的生成规律。
实操心得与避坑指南:
- JNI类型转换是难点:
onEnter中的args是Native类型的指针。处理jstring,jarray等JNI类型时,必须通过Java.vm.getEnv()获取JNIEnv接口来正确转换。直接args[2].readCString()会导致崩溃或乱码。上面的示例展示了标准做法。 - 参数索引(args)的确定:对于JNI函数,前两个参数固定是
JNIEnv*和jobject/jclass。真正的业务参数从args[2]开始。你需要根据目标函数的签名来确定参数个数和类型。可以通过静态分析IDA中的函数原型,或参考JNI文档。 - so库的加载时机:你的脚本必须在目标.so库被加载到进程内存之后执行。如果Hook得太早(库还没加载),
Module.findExportByName会返回null。有几种策略:- 使用
setImmediate或Java.perform确保在App启动后执行。 - 使用
Module.load事件监听器:Module.on('loaded', function(module){...}),在特定库加载时自动安装Hook。 - 先Hook一个Java方法,在那个Java方法里确保Native库已加载,再执行Native Hook。
- 使用
- 多线程与调用栈:
sig3生成可能在网络线程中被调用。使用Thread.backtrace(this.context, Backtracer.ACCURATE)可以在onEnter或onLeave中打印调用栈,帮助你理解函数的调用路径,这对于复杂逻辑分析至关重要。 - 修改参数与返回值:在
onEnter中,你可以通过修改args数组的元素来改变传入参数。在onLeave中,通过retval.replace(newValue)来修改返回值。务必注意内存管理,如果你创建了新的JNI对象(如NewStringUTF),要避免内存泄漏,但通常替换返回值时,Frida可能会处理一些上下文。
4. 姿势二:使用Cydia Substrate(或Frida-Gadget)进行底层注入
当面对一些加强了反调试、检测Frida的App时,直接使用frida-server附加进程可能会失败(进程崩溃、无法附加)。这时,我们可以考虑更底层的注入方式。
4.1 Cydia Substrate(MSHookFunction)原理
Cydia Substrate的核心是MSHookFunction函数。它的原理是直接对目标函数的内存进行写操作,将函数开头的几条指令替换为跳转指令(如ARM的B或BLX),跳转到我们自定义的替换函数(hook函数)。在hook函数里,我们执行自己的逻辑,然后再选择是否调用原函数(被保存下来的原始指令块)。
这种方式比Frida的Interceptor更底层,不依赖Frida的运行时注入机制,因此有时能绕过基于ptrace或/proc/pid/status检测的防护。
然而,原版Cydia Substrate对Android高版本的支持不佳,且需要将Hook代码编译成另一个.so库,通过LD_PRELOAD或修改init.rc等方式注入,过程繁琐。因此,在当今的实战中,我们更多是借鉴其思想,或者使用Frida-Gadget模式。
4.2 使用Frida-Gadget实现免Server注入
Frida-Gadget是一个动态库(libfrida-gadget.so)。你可以将它打包进目标APK,或者注入到目标进程的内存中。当它被加载时,会监听一个端口或Unix Socket,等待来自外部的Frida控制端(如frida命令行工具)连接。这样就不需要在设备上运行一个独立的frida-server进程,降低了被检测的风险。
操作步骤:
- 获取Gadget库:从Frida官网下载对应设备架构的
libfrida-gadget.so。 - 注入到APK:
- 重新打包:解压APK,将
libfrida-gadget.so放入对应的lib/armeabi-v7a/等目录。修改AndroidManifest.xml,确保android:extractNativeLibs="true"(如果为false,需要修改为true或使用其他加载方式)。然后重新打包并签名。 - 修改so加载路径:更常见的方法是,不修改APK,而是通过
ptrace或LD_PRELOAD在进程启动时,让目标进程动态加载libfrida-gadget.so。有现成的工具如objection(objection patchapk命令)可以自动化完成部分工作,或者使用frida的--gadget模式配合定制脚本。
- 重新打包:解压APK,将
- 编写脚本与连接:Hook脚本(JavaScript)可以嵌入到Gadget配置中,或者仍然通过外部连接后加载。连接时,不再使用
-U(USB),而是使用-H(Host)指定Gadget监听的网络地址和端口。
# 假设Gadget配置为监听本机TCP端口27042 frida -H 127.0.0.1:27042 -l hook_sig3.js实操心得与避坑指南:
- 复杂性高:相比
frida-server,Gadget模式的配置和注入过程复杂得多,需要对APK结构、动态链接过程有更深的理解。对于新手,建议先熟练掌握frida-server模式。 - 稳定性考量:强行修改APK或注入so,可能导致应用崩溃或功能异常,尤其是当应用本身有完整性校验(如签名校验、文件哈希校验)时。
- 对抗升级:一些高级别的安全防护同样会检测非常规的so库(如
libfrida-gadget.so)或非常规的线程/端口。这是一个持续的对抗过程。 - 选择时机:不要盲目使用Gadget。绝大多数情况下,
frida-server模式已经足够。只有当目标App明确检测并阻止了frida-server的附加时,才考虑升级到Gadget模式或其他更隐蔽的Hook方案。
5. 逆向分析与结果验证
无论采用哪种Hook姿势,成功Hook到目标函数只是第一步。我们最终目的是理解sig3的生成算法。
5.1 通过Hook数据推断算法逻辑
在成功拦截到多次calcSig3的调用后,你会收集到一系列(输入,输出)对。例如:
- 输入:
“/api/feed&ts=1234567890”-> 输出:“aBcDeFgHiJkL...123” - 输入:
“/api/user&ts=1234567891”-> 输出:“xYzAbCdEfGh...456”
分析方法:
- 观察输入输出长度关系:输出长度是否固定?是否随输入长度线性增长?这能提示是否是哈希(固定长度)或加密(分组密码,输出与输入有关)。
- 变化部分分析:固定一部分输入(如路径
/api/feed),只改变ts(时间戳),观察输出变化。如果ts变化一位,输出完全不同且无规律,很可能使用了哈希(如MD5、SHA)或带随机数的加密。 - 寻找关键常量或密钥:在Hook的基础上,你可以进一步深入Native函数内部。使用Frida的
Memory.scan搜索特定字节序列(如常见的哈希初始化常量、AES的S盒等),或者直接使用IDA静态分析calcSig3函数汇编代码,结合动态Hook看到的输入输出,推断其算法。你可能会发现它调用了OpenSSL或BoringSSL库中的HMAC_sha256或AES_encrypt等函数。 - 参数追踪:除了明文的字符串输入,函数可能还依赖其他隐式参数,如存储在Native层的密钥、设备唯一标识符等。这些可能需要你Hook更底层的函数(如密钥读取函数)或分析全局变量。
5.2 算法复现与校验
基于分析,你可能会得出一个假设:“sig3= Base64Encode( HMAC-SHA256( secret_key, input_string ) )”。
复现步骤:
- 用Python或你熟悉的语言,按照假设的算法实现一个生成函数。
- 从Hook中提取一组或多组未在算法推导中使用的(输入,输出)测试数据。
- 用你的复现代码计算这些输入的
sig3,与Hook捕获的真实输出进行比对。 - 如果完全一致,恭喜你,算法破解成功。如果不一致,回到分析步骤,检查是否遗漏了其他输入(如请求体、URL参数排序、额外的盐值等),或者算法本身更复杂(多轮哈希、自定义混淆)。
验证的意义:逆向工程不是“猜”,而是“验证”。只有能稳定复现出与目标程序完全一致的结果,你的分析才是可靠的。这个复现的代码,就是后续自动化脚本或研究的基础。
6. 常见问题排查与进阶技巧
在实战中,你绝不会一帆风顺。下面是我总结的一些典型问题及解决思路。
6.1 Hook失败问题排查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
Module.findExportByName返回null | 1. 库未加载。 2. 函数符号名错误。 3. 符号被剥离(Stripped)。 | 1. 使用Process.enumerateModules()检查目标so是否在模块列表中。2. 使用 Module.enumerateExports('libxxx.so')或Module.enumerateSymbols('libxxx.so')列出所有符号,核对名称。3. 如果符号被剥离,需通过偏移量Hook: Module.findBaseAddress('libxxx.so').add(offset)。偏移量需从IDA静态分析中获取。 |
| Frida脚本注入后App崩溃 | 1. Hook函数时参数/返回值处理错误(如JNI类型转换错误)。 2. 多线程冲突。 3. 脚本内存错误。 | 1.逐步注释法:先注释掉onEnter和onLeave内所有逻辑,只留console.log,看是否崩溃。然后逐步恢复代码,定位崩溃行。2. 检查JNI类型转换是否正确,特别是字符串和数组。 3. 确保对内存的读写在合法范围内(使用 Memory.readByteArray,Memory.protect等)。4. 尝试在 onEnter开头使用this.保存上下文,避免访问可能无效的指针。 |
Frida无法附加进程 (Failed to attach: access denied) | 1. 设备未Root或权限不足。 2. 目标App有反调试/反附加保护。 3. frida-server未运行或版本不匹配。 | 1. 确认adb root可用,或已获取su权限。2. 尝试在App启动前附加 ( -f模式),而非启动后附加 (-n模式)。3. 检查 frida-ps -U是否能列出进程。4. 考虑使用 frida-gadget模式或其它隐藏Frida的技术(如修改frida-server名称)。 |
| Hook成功但无日志输出 | 1. 函数未被调用。 2. Hook的时机不对,函数在Hook安装前已被调用完毕。 3. 脚本逻辑错误(如条件判断过滤了调用)。 | 1. 确认你的操作确实会触发sig3生成(如进行特定网络请求)。2. 将Hook代码放在 Module.on('loaded', ...)监听器中,确保so一加载就Hook。3. 在 onEnter里打印简单的标记(如console.log('[+] HOOKED!')),确认Hook是否真的生效。 |
| 性能问题或卡顿 | Hook函数被高频调用(如加密循环中)。 | 在onEnter/onLeave中避免复杂的操作(如频繁的console.log)。可以设置条件,只对特定参数或调用栈的触发进行打印。使用send()将数据发往PC端处理,而不是在设备上处理。 |
6.2 进阶技巧:对抗与反对抗
- 对抗符号剥离:发布版的.so常会剥离符号表,使
findExportByName失效。解决方法:- 特征码定位:在IDA中分析函数,找到一段独一无二的指令序列(特征码)。使用Frida的
Memory.scan在so的内存范围内搜索这段特征码,找到函数入口地址。 - 偏移量定位:通过IDA静态分析,找到目标函数相对于.so文件基地址的偏移量(RVA)。在运行时用
Module.findBaseAddress获取基址,加上偏移量得到函数地址。
- 特征码定位:在IDA中分析函数,找到一段独一无二的指令序列(特征码)。使用Frida的
- Hook内部函数:有时直接Hook JNI函数仍然太“高”,逻辑复杂。可以尝试Hook其内部调用的更基础的库函数,如
libc的malloc、memcpy,或加密库的SHA1_Init、AES_set_encrypt_key等。这些函数调用频繁,需要精心过滤,但一旦成功,能获得更底层的数据流。 - 内存断点与追踪:Frida的
Memory.accessMonitor可以监控对特定内存地址的读写。如果你通过静态分析找到了存储密钥或中间结果的全局变量地址,可以对其设置访问监控,追踪数据流向。 - 调用栈监控:在Hook函数时打印调用栈(
Thread.backtrace),可以帮助你快速理解该函数在业务逻辑中的上下文,找到上层调用者,有时能发现更便捷的Hook点(例如,Hook调用它的Java函数可能更简单)。
逆向工程是一场与开发者之间的“猫鼠游戏”。你的Hook技术越熟练,对系统底层理解越深,就越能穿透层层保护,触及核心逻辑。本次通过对某手Appsig3参数的Native层Hook实战,我们系统性地演练了从目标定位、环境搭建,到使用Frida Interceptor和了解底层注入方案,再到算法分析与验证的完整流程。其中,Frida的灵活性与强大功能使其成为当前动态分析的首选工具,而理解其原理和局限,并能应对各种异常情况,才是从“会用”到“精通”的关键。记住,每一次失败和排查的过程,都是你经验值增长的宝贵机会。