1. 项目概述:为什么我们需要深入Android SO逆向?
如果你在移动安全或者应用逆向的圈子里待过一阵子,肯定对Frida这个名字不陌生。它就像一把瑞士军刀,能动态注入、能Hook、能调试,几乎成了分析Android应用行为的标配。但不知道你有没有发现,很多教程和分享都停留在Java层,一碰到那些核心逻辑藏在.so动态库里的“硬骨头”,就有点束手无策了。市面上讲Frida Hook SO的教程,要么是“Hello World”级别的简单函数拦截,要么就是直接丢出一段复杂的内存操作代码,中间的“为什么”和“怎么想”的环节,常常是缺失的。
这正是“Frida Android SO逆向深入实践”这个标题背后,我们真正要解决的问题。它不是一个简单的工具使用指南,而是一套针对Android原生层(Native层)进行深度逆向分析的实战方法论。核心目标很明确:当应用的关键算法、核心验证逻辑、甚至反调试机制都编译进了C/C++写的SO文件里时,我们如何利用Frida这把利器,穿透层层保护,清晰地洞察其内部运行逻辑,并实现可控的干预。
为什么SO逆向这么重要?现在的应用,尤其是涉及金融支付、游戏保护、商业逻辑的App,出于性能和安全的考虑,会把最关键的代码用C/C++编写并编译成SO库。Java层可能只剩下一个薄薄的接口外壳。你Hook了Java方法,可能只拿到一个加密后的结果,或者一个“调用成功”的返回值,真正的加解密过程、密钥生成、协议构造,全在你看不见的SO里打转。不搞定SO,逆向分析就只做了一半。
所以,这篇文章适合谁?如果你是已经熟悉Frida基本操作(比如Hook Java方法)、了解Android应用基本结构,但在SO逆向门前徘徊的开发者或安全研究员;或者你正在分析一个App,发现其签名算法、通信协议核心都在Native层,急需一套可行的深入方案。接下来的内容,我会结合我踩过的无数个坑,从环境搭建的细节,到复杂内存结构体的解析,再到对抗反调试的实战,带你走完一个完整的SO深度逆向流程。我们不止讲“怎么做”,更重点剖析“为什么这么做”以及“遇到问题怎么想”。
2. 核心思路与工具选型:构建高效的SO分析工作流
面对一个需要逆向的SO文件,盲目地一头扎进去用Frida乱试,效率会非常低。一个清晰、高效的工作流是成功的一半。我的核心思路可以概括为:“静动结合,由外及内”。
2.1 静态分析先行:用IDA Pro照亮前路
在动用Frida进行动态调试和Hook之前,必须先用静态分析工具把SO文件“解剖”一遍。这里的主力工具是IDA Pro(或免费的Ghidra)。这一步的目标不是理解每一行代码,而是建立一张“地图”。
- 为什么要先静态分析?SO文件里可能有成百上千个导出函数和内部函数。Frida虽然强大,但你得知道你要Hook哪个函数,它的函数签名(参数类型、个数、顺序)是什么。静态分析可以帮助你快速定位关键函数。比如,通过搜索字符串“encrypt”、“decrypt”、“sign”、“check”等,或者识别常见的加密库函数(如OpenSSL的
MD5_Init,AES_encrypt),可以迅速缩小目标范围。 - 关键操作:
- 导出函数列表:在IDA的Exports窗口,查看所有JNI函数(通常以
Java_开头)和普通的C导出函数。JNI函数是Java调用Native的桥梁,往往是很好的切入点。 - 字符串检索:在Strings窗口,搜索与业务逻辑相关的关键词,然后通过交叉引用(Xref)找到使用这些字符串的函数。
- 识别加密函数:熟悉常见加密算法的特征码或函数名,有助于快速定位算法模块。
- 理清调用关系:对疑似关键函数,查看其被谁调用(Xref to)以及它调用了谁,理清代码脉络。
- 导出函数列表:在IDA的Exports窗口,查看所有JNI函数(通常以
静态分析的结果,是你制定Frida动态探测方案的基础。你会得到一份“可疑函数清单”和它们大致的逻辑流程图。
2.2 动态调试与Hook:用Frida深入腹地
有了静态分析提供的“坐标”,Frida就可以进行精准爆破了。动态分析的核心价值在于,你能看到函数执行时的真实数据:参数的具体值、内存的状态、函数的返回值。这是静态分析永远无法替代的。
- Frida的角色定位:在这个工作流中,Frida主要承担两个职责:
- 函数Hook与参数监控:在目标函数被调用时,打印出其所有参数的值、调用栈,甚至可以修改参数或返回值。这是最常用的功能。
- 内存遍历与搜索:当关键数据(如密钥、全局变量)存储在内存中时,Frida可以动态搜索和修改这些内存区域。
- 工具链搭配:Frida不是孤军奋战。通常我会搭配以下工具:
- adb (Android Debug Bridge):用于连接设备、安装应用、转发端口。这是所有Android调试的基础。
- objection:一个基于Frida的命令行工具,可以快速完成内存搜索、Hook类方法等常见任务,非常适合快速侦查。
- Jadx/GDA:反编译APK的Java代码,用于理解Java层如何调用Native函数,获取JNI函数的完整签名(包名、类名、方法名、参数类型)。
2.3 选型背后的逻辑:为什么是Frida+IDA?
你可能听说过ptrace、gdb等调试方式。选择Frida+IDA组合,主要基于以下几点考量:
- 非侵入性与高隐蔽性:Frida通过注入一个JavaScript运行时到目标进程来实现Hook,相对于传统的
ptrace附加调试,其痕迹更小,更容易绕过一些基于ptrace的反调试检测。虽然高级应用也会有Frida检测,但对抗是另一个层面的问题。 - 脚本化与自动化:Frida的所有操作都可以通过JavaScript脚本控制,这意味着你可以将复杂的探测逻辑写成脚本,反复执行、批量测试,极大提升效率。这是手动调试无法比拟的。
- 跨平台与语言友好:Frida支持Hook Java和Native(C/C++)层,一站式解决。JavaScript语言对于大多数开发者来说也比直接操作寄存器、内存的汇编调试更友好。
- IDA的不可替代性:对于SO文件的静态反汇编、结构体分析、伪代码生成(F5功能),IDA Pro目前仍然是功能最强大、用户体验最好的工具之一。它的交互式图表视图对于理解复杂控制流至关重要。
实操心得:不要试图用一个工具解决所有问题。我的习惯是,先用Jadx看Java调用链,用IDA静态分析SO找关键点,然后用Frida写脚本进行动态验证和深入挖掘。这个流程就像外科手术:先拍X光(静态分析)定位病灶,再用内窥镜(动态Hook)进行微创手术。
3. 环境搭建与基础Hook:从理论到第一个断点
工欲善其事,必先利其器。一个稳定、干净的环境是后续所有复杂操作的基础。这里我会详细说明每一步的意图和可能遇到的坑。
3.1 环境准备:不仅仅是安装
- Frida环境搭建:
- 服务端 (frida-server):这是要运行在Android设备(或模拟器)上的。关键点在于版本匹配。你必须确保
frida-server的版本与你电脑上安装的frida和frida-tools的Python包版本一致。使用frida --version和adb shell /data/local/tmp/frida-server --version来核对。版本不匹配是连接失败的常见原因。 - 客户端:在电脑上通过
pip install frida-tools安装即可。建议使用虚拟环境(如venv或conda)来管理Python依赖,避免包冲突。
- 服务端 (frida-server):这是要运行在Android设备(或模拟器)上的。关键点在于版本匹配。你必须确保
- Android设备选择:
- 真机 vs 模拟器:推荐使用真机进行逆向,特别是涉及硬件特性或强壳的应用。模拟器(如雷电、夜神)在某些检测面前很脆弱,但非常适合初学者练手和快速测试。雷电模拟器对Frida的支持比较友好。
- Root权限:Frida注入需要root权限。对于模拟器,通常自带root。对于真机,需要解锁Bootloader并刷入Magisk等工具获取root。注意:有些银行类App会在启动时检测root环境,需要配合隐藏root的工具(如Magisk Hide、Shamiko)使用。
- ADB配置:确保
adb devices能列出你的设备。需要将frida-server通过adb push上传到设备(如/data/local/tmp/),并赋予可执行权限(chmod 755)。
3.2 基础Hook:拦截你的第一个SO函数
假设通过静态分析,我们在libnative-lib.so里发现了一个可疑的导出函数:Java_com_example_app_MainActivity_stringFromJNI。这是一个典型的JNI函数。
下面是一个最基础的Frida脚本,用于Hook这个函数:
// hook_so_basic.js Java.perform(function () { // 1. 获取SO模块的基地址 var libnative = Module.findBaseAddress('libnative-lib.so'); console.log('[*] libnative-lib.so base address: ' + libnative); // 2. 如果知道函数偏移量,可以直接计算地址 // var funcOffset = 0x1234; // 从IDA中获取的偏移 // var funcAddress = libnative.add(funcOffset); // 3. 更常见的是通过导出函数名来Hook // 注意:Hook的是Native函数,需要使用Interceptor.attach var funcAddress = Module.findExportByName('libnative-lib.so', 'Java_com_example_app_MainActivity_stringFromJNI'); if (funcAddress) { console.log('[*] Found target function at: ' + funcAddress); Interceptor.attach(funcAddress, { // 进入函数时 onEnter: function (args) { console.log('\n[+] JNI function called!'); // args[0] 是 JNIEnv* // args[1] 是 jobject this // 对于这个例子,函数原型可能是:jstring func(JNIEnv*, jobject); // 我们可以打印或修改参数(如果需要) console.log(' this object:', args[1]); }, // 离开函数时 onLeave: function (retval) { // retval 是返回值,这里是jstring console.log('[+] Function returned.'); // 将jstring转换为JavaScript字符串查看 var retStr = Java.vm.getEnv().getStringUtfChars(retval, null).readCString(); console.log(' Return value (string):', retStr); // 你甚至可以修改返回值 // retval.replace(...); } }); } else { console.log('[-] Target function not found!'); } });脚本解析与注意事项:
Java.perform:这是Frida脚本的入口,确保代码在Java运行时上下文中执行,这对于后续可能需要的Java对象操作是必要的。Module.findBaseAddress/Module.findExportByName:这是定位SO中函数地址的关键。findExportByName用于查找导出符号表里的函数,对于JNI函数和明确导出的C函数有效。如果函数是静态的或经过混淆/加壳后符号被抹去,这个方法会失败,此时就需要通过偏移量(base + offset)或特征码扫描来定位。Interceptor.attach:这是Frida Hook Native函数的核心。onEnter和onLeave是两个最重要的回调。- 参数处理 (args):这是第一个大坑。在
onEnter中,args是一个数组,包含了调用该函数时传入的所有参数。但是,你需要知道函数的原型(参数类型和顺序)才能正确解析。对于JNI函数,前两个参数固定是JNIEnv*和jclass/jobject。对于普通C函数,你需要根据反汇编或调试来确定。错误地解析参数会导致脚本崩溃或得到错误数据。 - 返回值处理 (retval):在
onLeave中,retval是函数的返回值。同样,你需要知道返回类型。上面的例子演示了如何将jstring转换为可读的字符串。对于指针或整型返回值,直接处理即可。
运行脚本:
frida -U -f com.example.app -l hook_so_basic.js --no-pause-U: 连接到USB设备。-f: 启动目标应用包名。-l: 加载脚本。--no-pause: 启动后不暂停,立即执行。
踩坑记录:早期我经常遇到脚本注入后App崩溃的情况,十有八九是因为
args或retval的处理不对。比如,把一个本应是int的参数当成指针去readCString(),必然崩溃。一定要结合IDA的伪代码,确认好函数签名再写Hook脚本。对于不确定的参数,可以先尝试用.toInt32()或.readPointer()以最安全的方式打印出来看看。
4. 进阶内存操作:解析结构体与追踪数据流
基础Hook能拿到函数入口和出口的数据,但很多核心逻辑发生在函数内部,涉及复杂的内存操作,比如对某个结构体指针的读写。这时就需要深入内存,进行更精细的操作。
4.1 读取与修改任意内存
Frida提供了强大的内存读写API。
// 从指定地址读取一个32位整数 var intValue = ptr(0x12345678).readU32(); // ptr()将数字转换为NativePointer对象 console.log('Value at 0x12345678:', intValue); // 读取一个以null结尾的C字符串 var cString = ptr(0x87654321).readCString(); console.log('C String:', cString); // 写入内存 var targetAddress = ptr(0x11111111); targetAddress.writeU32(0xdeadbeef); // 写入一个32位整数4.2 Hook内部函数与偏移定位
当目标函数没有导出符号时,我们需要通过“基地址+偏移”的方式来定位。这个偏移量(offset)需要从IDA中获取。
- 在IDA中打开SO,找到目标函数。
- 查看函数起始地址,例如
0x0000B123。 - 查看SO的加载基地址(在IDA最下方或通过
Module.base在Frida中打印),假设是0x70000000。注意:这个IDA中的基地址是默认的,实际运行时SO会被系统加载到一个随机的基地址(ASLR)。 - 计算相对偏移:
函数地址 - IDA基地址 = 0xB123。这个偏移是固定的。 - 在Frida脚本中:
var libBase = Module.findBaseAddress('libtarget.so'); // 获取运行时基地址 var funcOffset = 0xB123; // 从IDA计算的固定偏移 var realFuncAddress = libBase.add(funcOffset); // 计算真实地址 Interceptor.attach(realFuncAddress, { ... });
4.3 解析复杂结构体
这是SO逆向中最考验功力的部分。假设我们Hook了一个函数,其第二个参数是一个指向某个复杂结构体的指针pStruct。
- 在IDA中分析结构体:通过交叉引用、字符串提示,在IDA中手动定义(
Shift+F9)或识别出这个结构体。假设我们分析出它是一个用户信息结构体:struct UserInfo { int userId; char username[32]; int level; long long points; }; - 在Frida脚本中按布局读取:
关键点:必须清楚每个成员的数据类型和大小,以及内存对齐规则(在32位和64位系统下可能不同)。onEnter: function(args) { var pStruct = args[1]; // 假设是第二个参数 console.log('[*] Struct pointer:', pStruct); // 按偏移量读取结构体成员 var userId = pStruct.readU32(); // offset 0 var usernamePtr = pStruct.add(4); // offset 4, 跳过int var username = usernamePtr.readCString(); // 读取字符串 var level = pStruct.add(4 + 32).readU32(); // offset 4+32=36 var points = pStruct.add(4 + 32 + 4).readU64(); // offset 40, 注意对齐 console.log(` UserId: ${userId}, Name: ${username}, Level: ${level}, Points: ${points}`); }int通常是4字节,long long是8字节,char数组是连续字节。
4.4 追踪指针链与全局变量
有时关键数据不在参数里,而是通过全局变量或多层指针间接访问。
- 全局变量:使用
Module.findBaseAddress后,加上全局变量的偏移量来访问。 - 指针链:例如有一个二级指针
ppData,指向一个指针pData,再指向实际数据。var ppData = ptr(0xA0000000); var pData = ppData.readPointer(); // 解引用第一层 var realData = pData.readPointer(); // 解引用第二层 console.log('Real data at:', realData.readCString());
实操心得:解析结构体时,最稳妥的方法是在IDA中把结构体定义清楚,并记录下每个成员的偏移量。可以写一个Frida工具函数来“映射”这个结构体,使代码更清晰。另外,对于频繁访问的复杂结构,可以考虑使用Frida的
Memory.alloc()在脚本中分配一个相同布局的内存,用于临时存储或修改数据,再写回目标进程。
5. 对抗反调试与混淆:实战中的生存技巧
当你兴致勃勃地注入脚本时,可能会发现App直接闪退,或者Frida提示连接被拒绝。这很可能遇到了反调试或反Hook机制。SO层是实施这些保护的重灾区。
5.1 常见的SO层反制手段
- 检测调试器:
ptrace自身:防止其他进程(如gdb, frida-server)附加。常见的ptrace(PTRACE_TRACEME, 0, 0, 0)。- 检查
/proc/self/status中的TracerPid字段。不为0表示正在被调试。 - 检查
/proc/self/task/pid/status。
- 检测Frida:
- 端口检测:Frida默认在
27042端口通信。检查该端口是否开放。 - 进程名/线程名检测:查找名为
frida-server、gum-js-loop等的进程或线程。 - 内存特征检测:在内存中搜索Frida相关字符串或代码片段。
- 文件检测:检查
/data/local/tmp/frida-server等路径。
- 端口检测:Frida默认在
- 代码混淆与加壳:
- 函数名混淆:抹去导出符号,增加静态分析难度。
- 控制流扁平化:打乱代码执行流程。
- 加壳:核心代码被加密,运行时解密。SO文件头被修改,阻止直接加载分析。
5.2 绕过策略与Frida脚本技巧
策略一:隐藏Frida
- 修改默认端口:启动
frida-server时指定非默认端口。
连接时:adb shell /data/local/tmp/frida-server -l 0.0.0.0:8080frida -H 192.168.1.5:8080 ... - 重命名frida-server:将二进制文件改名为一个不起眼的名字,如
/system/bin/servicemanager(注意备份原文件)。 - 使用定制编译的Frida:有些项目提供了修改了特征字符串的Frida版本,以规避简单的字符串扫描。
策略二:主动Hook反调试代码这是最根本的方法。用Frida去Hook那些反调试函数,让它们失效。
// Hook ptrace,使其调用失效或返回0 var ptraceAddr = Module.findExportByName(null, 'ptrace'); Interceptor.attach(ptraceAddr, { onEnter: function(args) { console.log('[+] ptrace called, request:', args[0].toInt32()); // 让PTRACE_TRACEME请求失败 if (args[0].toInt32() == 0 /*PTRACE_TRACEME*/) { console.log(' -> Blocking PTRACE_TRACEME'); this.blocked = true; } }, onLeave: function(retval) { if (this.blocked) { // 修改返回值为-1(错误)并设置errno retval.replace(ptr(-1)); } } }); // Hook fopen,当尝试打开/proc/self/status时返回空指针 var fopenAddr = Module.findExportByName(null, 'fopen'); Interceptor.attach(fopenAddr, { onEnter: function(args) { var path = args[0].readCString(); if (path && path.includes('/proc/self/status')) { console.log('[+] Blocking fopen for:', path); this.blocked = true; } }, onLeave: function(retval) { if (this.blocked) { retval.replace(ptr(0)); // 返回NULL } } });策略三:绕过简单的完整性检查
- 对于检查
/data/local/tmp/frida-server的,可以将其复制到其他目录。 - 对于内存特征扫描,目前绕过较复杂,可能需要深入修改Frida的运行时库。
策略四:处理加壳SO对于加壳的SO,动态分析是唯一途径。关键是在SO被解密并映射到内存后(通常在init或JNI_OnLoad执行期间或之后),再进行Hook。可以使用Module.load事件监听器。
// 监听目标SO的加载 Module.on('load', function(module) { if (module.name.indexOf('libencrypted.so') !== -1) { console.log('[+] Target SO loaded:', module.base); // 延迟一段时间,等待解密完成 setTimeout(function() { // 在这里进行Hook操作 hookDecryptedFunctions(module.base); }, 1000); // 延迟时间可能需要调整 } });避坑指南:反调试的对抗是猫鼠游戏。上面的方法可能对付一般的保护有效,但遇到商业级的加固(如梆梆、爱加密、腾讯御安全等),情况会复杂得多。这些加固会综合使用多种技术,甚至在内核层做手脚。对于这种情况,可能需要更底层的分析(如定制ROM、内核模块)或寻找加固本身的安全漏洞。对于初学者,建议先从没有强保护或保护较弱的App开始练习。
6. 实战案例:逆向一个简单的Native加密函数
让我们通过一个虚构但非常典型的案例,把前面的知识串联起来。假设目标App有一个登录功能,密码在Java层经过简单处理,然后传给一个Native函数native_encrypt_password进行加密,最后发送到服务器。我们的目标是弄清这个加密算法。
6.1 静态分析定位
- 用Jadx打开APK,搜索“encrypt”或“password”,找到调用Native方法的Java代码。发现调用:
String encrypted = NativeLib.encryptPassword(password); - 查看
NativeLib类,找到对应的native方法声明:public static native String encryptPassword(String str); - 根据JNI命名规则,对应的Native函数名应该是
Java_com_example_app_NativeLib_encryptPassword。 - 用IDA打开
libsecurity.so,在导出表中搜索这个函数,找到它。
6.2 动态Hook获取输入输出
编写Frida脚本,Hook这个函数,先摸清它的输入输出。
Java.perform(function() { var encryptFunc = Module.findExportByName('libsecurity.so', 'Java_com_example_app_NativeLib_encryptPassword'); Interceptor.attach(encryptFunc, { onEnter: function(args) { // args[0]: JNIEnv*, args[1]: jclass, args[2]: jstring password this.inputJstring = args[2]; var inputStr = Java.vm.getEnv().getStringUtfChars(this.inputJstring, null).readCString(); console.log('[+] encryptPassword called.'); console.log(' Input password:', inputStr); }, onLeave: function(retval) { var outputStr = Java.vm.getEnv().getStringUtfChars(retval, null).readCString(); console.log(' Output encrypted:', outputStr); // 记录下来,用于分析 this.encryptedResult = outputStr; } }); });运行脚本,输入test123,得到输出“xrY7fq3KzZ4=”。多试几组数据,比如123456->“Ld8sFp9gT1w=”,password->“a4V8qGm3Xp7=”。初步观察,输出像是Base64编码。
6.3 深入分析算法逻辑
现在需要看函数内部做了什么。回到IDA,查看encryptPassword的伪代码。发现它主要做了三件事:
- 将Java字符串转换为C字符串。
- 调用一个内部函数
do_encrypt。 - 将
do_encrypt的结果用Base64编码后返回。
接下来Hook这个内部函数do_encrypt。由于它没有导出,需要用偏移法。在IDA中看到do_encrypt的偏移是0x5A30。
var libBase = Module.findBaseAddress('libsecurity.so'); var doEncryptAddr = libBase.add(0x5A30); Interceptor.attach(doEncryptAddr, { onEnter: function(args) { // 分析伪代码后,得知args[0]是输入字符串指针,args[1]是输入长度,args[2]是输出缓冲区指针 this.inputPtr = args[0]; this.inputLen = args[1].toInt32(); this.outputPtr = args[2]; var inputStr = this.inputPtr.readCString(); console.log(`[+] do_encrypt called. Input: "${inputStr}", Len: ${this.inputLen}`); // 可以在这里dump输入内存 console.log(hexdump(this.inputPtr, { length: this.inputLen })); }, onLeave: function(retval) { // 假设加密后长度不变或已知,这里dump输出缓冲区 // 假设输出长度等于输入长度(如简单异或) console.log(' Output buffer:'); console.log(hexdump(this.outputPtr, { length: this.inputLen })); // 将内存数据转换为可打印的hex字符串,方便分析 var outBytes = []; for (var i = 0; i < this.inputLen; i++) { outBytes.push(this.outputPtr.add(i).readU8()); } console.log(' Output bytes:', outBytes.map(b => b.toString(16).padStart(2, '0')).join(' ')); } });运行后,输入test123,得到输出缓冲区字节:45 7A 12 BF 3A CD 9A。对比输入test123的ASCII码(74 65 73 74 31 32 33),看不出明显规律。可能不是简单异或。
6.4 识别算法与密钥
继续分析do_encrypt的伪代码。发现它调用了另一个函数generate_key,然后使用了一个查表操作。看起来像是一个简单的字节替换(S-Box)。我们在IDA中查看generate_key,发现它从一个固定的全局数组(const unsigned char key_table[256])中取数据。Hook这个全局数组的地址,将其内容dump出来。
// 假设通过IDA分析,key_table的偏移是0x10500 var keyTableAddr = libBase.add(0x10500); console.log('[+] Dumping key table:'); var keyTable = []; for (var i = 0; i < 256; i++) { keyTable.push(keyTableAddr.add(i).readU8()); } console.log(keyTable.map((b, idx) => `${idx.toString(16).padStart(2,'0')}:${b.toString(16).padStart(2,'0')}`).join(' '));将dump出的表与标准算法(如AES的S-Box)对比,发现并不匹配。观察do_encrypt逻辑:对每个输入字节,以其值作为索引,从key_table中取出一个字节作为输出。这本质上是一个简单的单字节替换加密(类似于凯撒密码的变种)。
6.5 验证与算法还原
现在我们已经掌握了算法:加密 = 对每个字节进行查表替换。那么解密呢?解密就是反向查表。我们需要找到或构造反向表。
- 构造解密表:遍历0-255作为
key_table的索引,其值就是加密后的字节。那么,如果加密后的字节是X,要找到是哪个原始字节加密成了X,就需要构建一个映射:reverse_table[加密字节] = 原始字节。var reverseTable = new Array(256).fill(0); for (var i = 0; i < 256; i++) { var encryptedByte = keyTable[i]; reverseTable[encryptedByte] = i; } console.log('Reverse table:', reverseTable); - 编写解密函数:有了反向表,就可以在JavaScript中轻松实现解密。
function decrypt(encryptedBase64) { var encryptedBytes = atob(encryptedBase64); // Base64解码 var decryptedArr = []; for (var i = 0; i < encryptedBytes.length; i++) { var byte = encryptedBytes.charCodeAt(i) & 0xFF; var originalByte = reverseTable[byte]; decryptedArr.push(String.fromCharCode(originalByte)); } return decryptedArr.join(''); } // 测试 console.log(decrypt('xrY7fq3KzZ4=')); // 应该输出 test123
至此,我们完整地逆向了一个简单的Native加密函数。这个过程涵盖了定位、Hook、参数分析、内存操作、算法识别和还原。对于更复杂的算法(如AES、RSA),思路是一样的,但需要更深入的密码学知识和耐心去跟踪密钥生成和运算过程。
7. 问题排查与调试技巧实录
在实际操作中,事情很少一帆风顺。下面是我总结的一些常见问题及其排查思路。
7.1 Frida连接失败或脚本不执行
- 症状:
frida -U -f ...命令报错,如Failed to spawn: unable to connect to remote frida-server。 - 排查:
- 检查设备连接:
adb devices确保设备在线且已授权。 - 检查frida-server:
adb shell ps | grep frida查看进程是否运行。用adb shell /data/local/tmp/frida-server --version检查版本。确保版本匹配。 - 检查端口占用:
adb forward --list,或尝试换端口启动server和连接。 - 检查防火墙/杀毒软件:电脑或手机上的安全软件可能拦截连接。
- 尝试USB模式:如果网络连接有问题,确保使用
-U(USB)选项,并且USB调试已开启。
- 检查设备连接:
7.2 脚本注入后应用崩溃
- 症状:注入脚本瞬间,App闪退。
- 排查:
- 脚本语法错误:使用
frida -l your_script.js --runtime=v8检查脚本语法。 - Hook了错误地址:确认函数地址是否正确。使用
Module.enumerateExports('libxxx.so')打印所有导出函数核对。 - 参数/返回值处理错误:这是最常见的原因。在
onEnter/onLeave中,避免对不确定的指针进行.readCString()等危险操作。先用.isNull()判断,或者用.readByteArray(size)安全读取。 - 内存访问违规:访问了未申请或已释放的内存。确保偏移计算正确。
- 反调试触发:App检测到Frida后主动崩溃。需要先实施反反调试措施。
- 脚本语法错误:使用
7.3 Hook不到目标函数
- 症状:脚本成功注入,但预期的Hook点没有打印日志。
- 排查:
- 函数名错误:JNI函数名包含完整的包名和类名,确保大小写和路径完全正确。使用
Module.enumerateExports或objection的memory list exports命令查看。 - 函数未导出:如果函数是静态的(static),不会出现在导出表。必须使用偏移量或特征码扫描来定位。
- 时机问题:脚本注入时,目标函数可能已经被调用过了(如在
JNI_OnLoad或初始化时)。尝试在Module.load回调中,或使用setImmediate延迟Hook。 - SO未加载:目标SO可能是在运行时动态加载的(
System.loadLibrary)。使用Module.load事件监听。
- 函数名错误:JNI函数名包含完整的包名和类名,确保大小写和路径完全正确。使用
7.4 特征码扫描定位无导出函数
当函数没有导出符号时,偏移量需要从IDA中获取。但如果SO每次编译偏移都变,或者有多个版本,就需要用特征码(Pattern)来定位。
function findPattern(moduleName, pattern) { var libBase = Module.findBaseAddress(moduleName); var size = Module.findSizeByName(moduleName); // pattern 是类似 "7F 45 4C 46 ?? ?? ?? ?? 00 00" 的字符串,?? 代表通配符 var result = Memory.scan(libBase, size, pattern, { onMatch: function(address, size){ console.log('[+] Pattern matched at: ' + address); return 'stop'; // 找到第一个就停止 }, onError: function(reason){ console.log('[-] Scan error: ' + reason); }, onComplete: function(){ console.log('[+] Scan complete'); } }); } // 在IDA中查看目标函数开头的机器码,将其转换为特征码注意:特征码需要足够独特,避免误匹配。且由于编译器优化和指令集差异,特征码可能不通用。
7.5 调试技巧:让Frida告诉你更多
- 使用
console.log():这是最基本的,但要注意不要在频繁调用的函数里打印太多信息,会拖慢速度甚至卡死。 - 使用
send()和recv()进行异步通信:可以将数据发送到你的Python控制端进行处理和显示,避免阻塞目标进程。 - 使用
Frida Stalker追踪指令流:对于极度复杂的混淆代码,可以追踪一小段代码的每一条指令执行,但开销巨大,慎用。 - 结合IDA动态调试:虽然Frida很强大,但有时还是需要传统的调试器。可以先用Frida Hook,在关键点触发断点,然后用IDA或gdb附加进行更细致的寄存器、内存查看。这需要更复杂的环境配置。
逆向工程,尤其是Native层的逆向,是一个需要极大耐心和细致观察力的工作。每一个成功的Hook背后,可能是数十次的尝试和失败。最重要的技巧是:大胆假设,小心求证,层层递进,做好记录。每次实验的结果(输入输出、内存快照)都记录下来,对比分析, patterns(模式)就会逐渐浮现。当你终于捋清一个复杂算法的脉络,那种成就感是无与伦比的。这条路没有捷径,但每一步都算数。