1. 项目概述:为什么我们要深入SO文件看3DES?
如果你在安卓逆向或者移动安全领域摸爬滚打过一阵子,肯定遇到过这样的场景:目标App的核心业务逻辑,比如登录、支付、数据请求的签名,都藏在一个或多个后缀为.so的共享库文件里。你用Jadx、JEB把Java层翻了个底朝天,发现关键函数都是native声明,真正的“硬骨头”都在这些SO库里。而其中,3DES(Triple DES)作为一种经典的分组加密算法,至今仍被不少“历史悠久”或对兼容性有要求的应用所采用。逆向分析SO文件中的3DES加密逻辑,绝不仅仅是“找到密钥”那么简单。它是一次从应用层到Native层、从高级语言到汇编指令的完整穿越,考验的是你对加密算法原理、程序执行流程、内存布局以及逆向工具链的综合运用能力。
简单来说,这个项目的核心价值在于:掌握一套从定位加密函数、分析算法实现到最终提取或复现加密逻辑的完整方法论。无论你是为了安全审计、漏洞挖掘,还是单纯想理解某个App的通信协议,这套方法都是绕不开的硬核技能。接下来,我将以一个虚构但典型的“登录请求参数加密”场景为例,带你走一遍完整的分析流程,分享我踩过的坑和总结出的技巧。
2. 核心思路与工具链选型
逆向分析SO文件中的加密逻辑,本质上是一个“定位 -> 理解 -> 复现”的过程。你的思路清晰与否,直接决定了后续工作的效率。
2.1 逆向分析的核心路径拆解
面对一个加固或混淆过的APK,我们的分析路径通常是阶梯式的:
- Java层定位:首先在Java代码中寻找线索。搜索
loadLibrary、System.load调用,找到加载的SO库名。同时,关注所有native方法的声明,其方法名(如native String encryptData(String data))是连接Java与Native层的关键桥梁。Jadx或JEB的反编译结果会显示这些native方法对应的JNI函数命名(如Java_com_example_app_MainActivity_encryptData)。 - SO库提取与初步分析:使用Apktool解包APK,在
lib/目录下找到对应架构(通常是armeabi-v7a或arm64-v8a)的SO文件。用readelf、objdump或IDA Pro查看其导出函数表,验证是否包含我们在Java层找到的JNI函数符号。 - 静态分析深入:将SO文件载入IDA Pro或Ghidra进行反汇编。我们的首要目标是找到并理解那个核心的加密函数。这里的关键是识别加密算法的特征,比如3DES的常量表(S-Box虽然DES/3DES与AES不同,但其复杂的位操作和固定的初始置换IP、逆初始置换IP^-1等结构仍有迹可循)、密钥扩展逻辑、以及典型的Feistel网络结构。
- 动态调试验证:静态分析可能遇到混淆或逻辑复杂难以理解的情况。这时需要动态调试。将App运行在模拟器或真机上,使用
frida、gdb或IDA Pro的远程调试功能,在加密函数入口下断点,观察输入、输出以及内存中的密钥和中间状态,这是验证静态分析结论、获取关键数据(如密钥)的最直接手段。 - 算法复现与验证:基于分析结果,用Python、C或Java重新实现加密逻辑,确保其输出与原始App完全一致。这一步是检验逆向成果的最终标准。
2.2 工具链的选型与配置心得
工欲善其事,必先利其器。下面是我经过多年实战筛选出的工具组合及配置要点:
反编译与解包:
- Jadx-GUI:Java层反编译的首选,图形化界面友好,搜索、跳转方便。对于轻度混淆的代码还原效果很好。
- Apktool:命令行工具,用于解包APK获取资源、清单文件和最重要的
lib/*.so文件。务必使用最新版本以应对新的打包方式。 - JEB:商业级反编译器,对混淆代码的处理能力、反编译质量通常比Jadx更强,特别是对于Dex2C等高级混淆。如果预算允许或项目关键,它是利器。
静态分析:
- IDA Pro:逆向分析的“瑞士军刀”,功能无比强大。其反汇编引擎、图形化视图、交叉引用(Xrefs)功能对于分析SO文件至关重要。个人心得:刚开始可以多用它的“文本视图”,熟悉后再切换到“图形视图”,后者对理解程序流程分支更有帮助。一定要善用“重命名变量”、“添加注释”功能,这是让你在复杂汇编中保持清醒的关键。
- Ghidra:NSA开源的神器,完全免费且功能强悍。它的反编译器质量极高,能直接将汇编代码转为可读性更强的类C代码,极大降低了逆向门槛。对于预算有限的个人或团队,Ghidra是绝对的首选。技巧:Ghidra的“数据类型管理器”和“符号表”功能非常强大,正确识别库函数(如
memcpy,strlen)和JNI环境指针(JNIEnv*)能大幅提升分析效率。
动态调试:
- Frida:基于插桩的动态分析框架,堪称“黑魔法”。它允许你通过JavaScript脚本在运行时拦截、修改函数调用,注入自定义逻辑。对于快速验证函数功能、Hook加密函数获取输入输出和密钥,效率极高。避坑指南:Frida脚本在Android高版本(特别是Android 8以上)可能遇到反调试或检测,需要配合一些绕过技巧,如使用
frida-server的隐藏版本,或先于App启动注入。 - IDA Pro Debugger或GDB:更传统的调试方式。需要将IDA或GDB连接到运行在模拟器/真机上的进程。这种方式更底层,能观察所有寄存器、内存状态,适合进行精细的指令级分析。配置关键:确保目标设备(模拟器)的调试端口(如
23946)开放,并且使用对应架构的调试服务器(android_server或gdbserver)。
- Frida:基于插桩的动态分析框架,堪称“黑魔法”。它允许你通过JavaScript脚本在运行时拦截、修改函数调用,注入自定义逻辑。对于快速验证函数功能、Hook加密函数获取输入输出和密钥,效率极高。避坑指南:Frida脚本在Android高版本(特别是Android 8以上)可能遇到反调试或检测,需要配合一些绕过技巧,如使用
辅助与效率工具:
- radare2 (r2):命令行下的逆向框架,轻量、脚本化能力强,适合自动化分析任务。
- 010 Editor:二进制文件编辑器,带有强大的模板功能。可以用来手动分析SO文件头、查看节区、或直接修改二进制数据(慎用)。
- Python + pwntools/unicorn:用于编写自动化分析脚本或模拟执行代码片段。Unicorn引擎可以模拟CPU指令执行,对于脱壳或分析混淆代码片段非常有用。
注意:工具没有绝对的好坏,只有是否适合当前场景。我通常的流程是:Jadx快速浏览Java层 -> Apktool解包取SO -> Ghidra进行主要静态分析(因其反编译功能强大) -> 遇到疑难杂症或需要动态验证时,使用IDA Pro进行动态调试或Frida进行快速Hook。不要迷信单一工具,组合拳才是王道。
3. 定位与识别SO中的3DES加密函数
拿到SO文件后,茫茫的汇编代码从哪里看起?盲目搜索是不可取的,需要有策略地定位。
3.1 从Java层到Native层的符号追踪
这是最直接的入口。假设我们在Jadx中看到如下代码:
public class SecurityHelper { static { System.loadLibrary("crypto-lib"); // 加载的库名 } public native byte[] encryptWith3DES(byte[] input, String key); }那么,对应的Native层JNI函数名很可能是Java_com_example_app_SecurityHelper_encryptWith3DES。在Linux/Android的约定中,符号名中的.会被替换为_。你可以直接在IDA Pro或Ghidra的导出函数列表或字符串窗口中搜索encryptWith3DES或Java_前缀来定位。
如果符号被剥离(Stripped),导出函数列表里找不到明确的函数名,就需要更费工夫:
- 在Jadx中查看所有
native方法,记录其签名。 - 在SO文件中搜索这些方法签名对应的字符串。JNI函数在注册时(无论是静态注册还是动态注册),其方法签名(如
([BLjava/lang/String;)[B)常以字符串形式存在于.rodata段。 - 找到签名字符串后,查看其交叉引用(Xrefs),就能定位到使用它的函数,那很可能就是我们的目标函数或JNI注册函数。
3.2 通过算法特征进行静态识别
当无法直接通过符号定位时,就需要依靠对3DES算法特征的识别。3DES是DES的三次应用,核心仍是DES的Feistel结构。在汇编层面,一些特征可以成为线索:
- 常量表查找:DES算法内部有8个固定的S-Box(替换盒)。虽然现代实现可能用计算代替查表,但一些库(尤其是较老或追求清晰度的实现)仍会将这些S-Box以常量数组的形式存储在
.rodata段。在IDA中,你可以查看rodata段,寻找大量连续的、看似随机的字节数据(每个S-Box是4x16的矩阵,共64个6-bit输入映射到4-bit输出,在代码中常以64字节数组表示)。找到这样的数组,并追踪其交叉引用,很可能就找到了DES/3DES的核心运算函数。 - 密钥扩展模式:DES的密钥是64位(含8位奇偶校验),实际使用56位。3DES使用两个或三个56位密钥(K1, K2, K3)。算法第一步是对密钥进行置换选择(PC-1),生成16轮的子密钥。在汇编中,你会看到一系列固定的位移动和置换操作,这些操作没有复杂的算术运算,主要是移位(SHL/SHR)和按位与/或(AND/OR)。识别出这种模式的循环(通常是16轮),是一个强信号。
- Feistel网络结构:DES加密每轮的处理流程是固定的Feistel结构:将64位输入分成左右32位(L0, R0),然后
R1 = L0 ^ F(R0, K1),L1 = R0,如此迭代16轮。在反编译的代码中,你可能会看到一个清晰的循环,循环体内包含对F函数的调用(该函数内部包含扩展置换E、S-Box替换和置换P),以及左右数据块的交换(XOR和赋值操作)。识别出这种“分割->处理->交换”的循环模式,基本就能确定是DES或其变种。 - 函数名或字符串线索:有时,开发者会留下一些“友好”的字符串,比如在日志函数中输出
"3DES encrypt start",或者函数内部调用了一些知名加密库的函数(如OpenSSL的DES_set_key_unchecked,DES_ecb3_encrypt)。在字符串窗口搜索DES、encrypt、decrypt、crypto等关键词可能会有意外收获。
3.3 动态Hook快速验证函数功能
当静态分析找到疑似函数后,最快验证其功能的方法就是动态Hook。这里以Frida为例,展示一个简单的脚本框架:
// frida -U -f com.example.app -l hook_3des.js Java.perform(function() { // 找到包含native方法的类 var SecurityHelper = Java.use("com.example.app.SecurityHelper"); // Hook native方法(Frida会自动处理JNI转换) SecurityHelper.encryptWith3DES.implementation = function(input, key) { console.log("[*] encryptWith3DES called!"); console.log(" Input (hex): " + bytesToHex(input)); console.log(" Key: " + key); // 调用原函数获取结果 var result = this.encryptWith3DES(input, key); console.log(" Output (hex): " + bytesToHex(result)); console.log(" Output (base64): " + base64Encode(result)); // 可以在这里将输入输出和密钥保存到文件,供后续分析 send({type: 'encrypt_data', input: bytesToHex(input), key: key, output: bytesToHex(result)}); return result; }; // 辅助函数:字节数组转十六进制字符串 function bytesToHex(bytes) { return Array.from(bytes, function(byte) { return ('0' + (byte & 0xFF).toString(16)).slice(-2); }).join(''); } // 辅助函数:字节数组转Base64 function base64Encode(bytes) { return Java.use("android.util.Base64").encodeToString(bytes, 0); } });运行这个脚本,触发App的加密操作(比如点击登录),你就能在控制台看到加密函数的输入、密钥和输出。这不仅能验证函数功能,还能直接捕获到关键的测试向量(一组明文、密钥、密文),这对于后续算法复现和验证至关重要。
实操心得:动态Hook时,时机很重要。如果App有反调试或反Hook检测,可能会在启动初期进行检查。一种策略是使用Frida的
-f参数在App启动前注入,或者HookSystem.loadLibrary函数,在SO库加载后、但任何加密函数被调用前,立即Hook我们的目标函数。另外,对于参数是复杂对象(如自定义类)的情况,需要仔细查看Jadx反编译的代码,理解其数据结构,才能正确打印或修改。
4. 静态反汇编与3DES逻辑还原
假设我们已经通过动态Hook确认了目标函数,并获取了若干组输入输出。现在需要深入SO文件内部,理解其具体的3DES实现方式。这里我们使用Ghidra进行演示,因为它的反编译视图对分析逻辑帮助巨大。
4.1 加载SO文件与初步反编译
将libcrypto-lib.so文件拖入Ghidra。创建项目并导入后,Ghidra会自动进行分析。分析完成后,在“Symbol Tree”窗口的“Functions”列表里,找到我们之前定位的函数(例如Java_com_example_app_SecurityHelper_encryptWith3DES)。双击打开,Ghidra会显示反汇编的汇编代码和反编译后的伪C代码。
首先,关注函数的参数。一个典型的JNI函数签名是JNIEnv* env, jobject thiz, ...,后面才是Java层传入的参数。在我们的例子中,函数原型可能看起来像:
jbyteArray Java_com_example_app_SecurityHelper_encryptWith3DES(JNIEnv *env, jobject thiz, jbyteArray input, jstring key)反编译代码会显示Ghidra推断出的参数类型。你需要根据上下文和JNI函数调用惯例(如使用(*env)->GetByteArrayElements获取Java字节数组)来确认和修正这些类型定义。这是关键一步,错误的类型定义会导致后续分析完全偏离。
4.2 解析3DES的具体实现模式
3DES有多种工作模式,如ECB、CBC、CFB等。模式不同,代码结构差异很大。我们需要在反编译代码中寻找线索:
- ECB模式:最简单的模式,每个数据块独立加密。代码中通常只有一个对核心加密函数的调用(或循环调用),没有“初始化向量(IV)”的处理,也没有前一个密文块与当前明文块的异或(XOR)操作。如果你在函数里看不到IV相关的参数或变量,很可能是ECB。
- CBC模式:最常用的模式之一。它需要一个IV,并且加密过程是:
CipherBlock[i] = Encrypt(PlainBlock[i] ^ CipherBlock[i-1])。在代码中,你会看到:- 一个IV变量被初始化(可能来自参数,也可能是固定的值如全零)。
- 在一个循环中,存在一个XOR操作,操作数之一是前一轮的密文(或初始的IV)和当前的明文。
- 然后将XOR的结果送入加密函数。
- 加密结果被保存,既作为输出,也作为下一轮的“前序密文”。
- 其他模式:CFB、OFB等模式也有其特定结构,但相对少见。核心是观察数据流中是否存在反馈(前序输出影响后续输入)以及是否以块为单位处理。
如何确认?结合动态Hook获取的数据。如果你发现相同的明文和密钥,每次加密结果都不同,那基本可以排除ECB,可能是CBC(IV随机)或其他带反馈的模式。如果你能通过Hook捕获到IV,那就能直接确认。
在反编译代码中,寻找类似下面的模式:
// 疑似CBC模式加密循环 previous_cipher_block = iv; // 初始化 for (i = 0; i < data_len; i += 8) { // 8字节=64位,一个DES块 // 1. 将当前明文块与上一密文块(或IV)异或 for (j = 0; j < 8; j++) { block_to_encrypt[j] = plaintext_block[i+j] ^ previous_cipher_block[j]; } // 2. 对异或结果进行3DES加密 triple_des_encrypt(block_to_encrypt, encrypted_block, key_schedule); // 3. 输出密文块,并更新“上一密文块”为当前输出 memcpy(&ciphertext[i], encrypted_block, 8); memcpy(previous_cipher_block, encrypted_block, 8); }4.3 密钥处理与数据填充分析
密钥处理:Java层传入的密钥(String或byte数组)在Native层如何被处理成3DES所需的密钥?3DES有效密钥长度可以是16字节(对应K1=K3的双密钥3DES)或24字节(对应三个独立密钥的三密钥3DES)。常见处理方式包括:
- 直接使用字节数组作为密钥。
- 对字符串进行MD5、SHA1等哈希,取哈希值的前16或24字节作为密钥。
- 对字符串进行某种自定义的变换(如字节反转、与固定值异或等)。 在反编译代码中,追踪对
key参数的处理过程,看是否有哈希函数调用(搜索MD5_Init,SHA1_Update等特征),或循环变换操作。
数据填充:分组加密需要对不是块大小整数倍的数据进行填充。常见填充方式有PKCS#5/PKCS#7。在代码中,填充通常发生在加密之前:
int original_len = input_length; int padded_len = ((original_len + 7) / 8) * 8; // 计算填充到8字节倍数后的长度 // 或者更直接地:padded_len = original_len + (8 - (original_len % 8)); unsigned char* padded_data = malloc(padded_len); memcpy(padded_data, input, original_len); // 填充剩余的字节,值为填充的字节数 (PKCS#7) for (i = original_len; i < padded_len; i++) { padded_data[i] = (unsigned char)(padded_len - original_len); }在解密端,同样需要去除填充。分析时注意寻找分配内存后、加密循环前,对数据长度进行计算和填充的代码段。
4.4 核心加密函数的识别与理解
最终,所有的逻辑都会调用到一个核心的“块加密”函数。这个函数可能叫des_encrypt_block、TDEA_Encrypt或是内联展开的。在这个函数内部,你需要识别出3DES的三次DES应用过程。标准的3DES-EDE(Encrypt-Decrypt-Encrypt)模式是:Cipher = E_K3(D_K2(E_K1(Plaintext)))。
在反编译代码中,它可能表现为三次对同一个des_crypt函数的调用,但传入不同的子密钥(key_schedule1,key_schedule2,key_schedule3),并且第二次调用是“解密”模式(DES加密和解密算法相同,只是子密钥的使用顺序相反,但有些实现会有一个模式参数)。也可能是一个集成的函数,内部完成了三次操作。
技巧:如果你识别出了DES的Feistel轮函数(F函数),那么整个DES的加密流程就清晰了。3DES无非是把这个流程套用三次。此时,你的重点应该放在密钥扩展和数据流向上,确保你理解K1, K2, K3是如何被使用(或生成,如果是双密钥模式)的。
5. 动态调试与关键数据提取
静态分析给出了蓝图,但有些细节(如运行时计算出的密钥、动态生成的IV、内存中的中间状态)必须通过动态调试才能准确获取。当代码被混淆或逻辑极其复杂时,动态调试更是唯一出路。
5.1 搭建Android原生代码调试环境
这里以IDA Pro远程调试为例,环境基于Android模拟器(如Android Studio的AVD):
- 准备调试目标:
- 将待调试的APK安装到模拟器。
- 使用
adb push将IDA Pro安装目录下的android_server(或android_server64)推送到模拟器的/data/local/tmp目录。 adb shell进入模拟器,给android_server添加可执行权限 (chmod 755),并以root权限运行它 (./android_server)。
- 端口转发:
adb forward tcp:23946 tcp:23946将模拟器的调试端口转发到本地。 - IDA Pro附加进程:
- 打开IDA Pro,选择
Debugger -> Attach -> Remote ARM Linux/Android debugger。 - Hostname填
localhost,Port填23946。 - 在进程列表中找到目标App的进程(通常可以通过包名识别),点击OK附加。
- 打开IDA Pro,选择
- 定位代码与下断点:附加成功后,IDA会加载进程的内存映射。你需要找到目标SO库在内存中的基地址。可以通过
Ctrl+S打开段列表,查找libcrypto-lib.so。然后,将静态分析时找到的目标函数地址(RVA,相对虚拟地址)加上SO的基地址,得到内存中的绝对地址,按G跳转到该地址,按F2下断点。
5.2 在加密函数入口拦截并观察
触发App的加密操作(如点击登录按钮),调试器会在断点处暂停。现在,你可以观察一切:
- 寄存器:ARM架构下,函数的前几个参数通常通过寄存器
R0, R1, R2, R3传递,更多参数通过栈传递。R0通常是JNIEnv*,R1是jobject this,我们的input和key可能在R2,R3或栈上。你需要结合反编译代码和ARM调用约定来确认。 - 栈帧:查看栈内存,寻找传入的指针(指向Java数组或字符串对象在Native层的表示)。
- 内存数据:一旦你确定了输入缓冲区、密钥缓冲区的内存地址,就可以在IDA的Hex View中查看其内容。这是获取原始密钥和明文的最直接方法。记得将内存数据转储(Dump)保存下来。
- 单步执行:按
F7(Step into)或F8(Step over)逐步执行,观察程序流是否与你静态分析的理解一致。重点关注分支跳转、循环以及关键的数据处理函数调用。
5.3 提取密钥与算法参数
动态调试的核心目标之一是提取密钥。密钥可能以多种形式出现:
- 明文出现在参数或局部变量中:最理想的情况,在函数入口处就能在寄存器或栈上看到指向密钥数据的指针。
- 运行时计算生成:密钥可能是通过对传入的字符串进行哈希或变换得来。你需要单步跟踪这个计算过程,在计算完成后、密钥被用于加密前,从内存中提取最终的结果。
- 硬编码在数据段:密钥可能直接以字节数组的形式存储在SO文件的
.rodata段。在静态分析时如果发现了可疑的常量数组,可以在动态调试时查看该内存地址的内容进行验证。 - 从外部资源或服务器获取:更复杂的情况,密钥可能通过网络请求或从文件解密获得。这就需要你扩大调试范围,回溯密钥的来源。
技巧:在关键的内存地址(如计算后的密钥缓冲区)设置内存访问断点(Memory Breakpoint)。当程序读取或写入该地址时,调试器会中断,这能帮你精确定位密钥被使用的位置。
5.4 处理反调试与代码混淆
现代App,特别是金融、游戏类应用,普遍带有反调试和代码混淆机制:
- 反调试:检测
ptrace、检查/proc/self/status中的TracerPid、检测调试器端口等。应对方法包括:使用修改过的、隐藏自身的调试服务器;在调试器附加前先运行App,然后让调试器附着(Attach)到子进程;或者使用Frida等基于插桩的工具,其侵入性不同于传统调试器,有时能绕过检测。 - 代码混淆(Obfuscation):控制流扁平化、指令替换、插入垃圾代码等,让反编译结果难以阅读。应对方法:
- 动态跟踪:忽略复杂的静态结构,直接通过动态调试观察真实的执行流和数据处理过程。数据是不会骗人的。
- 模拟执行:对于小段的关键混淆代码,可以使用Unicorn引擎编写脚本模拟执行,直接得到输出。
- 模式识别:混淆通常不会改变算法的本质输入输出和核心运算(如S-Box查找、置换)。聚焦于识别这些不变的特征点。
避坑指南:动态调试环境极不稳定,App可能崩溃。务必频繁保存IDA数据库(
.idb文件)。在修改内存或寄存器值时要格外小心,最好先备份。对于生产环境或线上App的分析,务必在隔离的测试环境中进行,避免法律风险。
6. 算法复现、验证与常见问题排查
分析完成后,最终考验是能否独立复现出完全一致的加密逻辑。这是验证你逆向分析是否正确的终极标准。
6.1 使用高级语言复现3DES加密
根据你的分析结果(工作模式、填充方式、密钥处理逻辑),选择一种你熟悉的语言进行复现。Python因其丰富的库和快速原型能力,常被用于此阶段验证。
假设我们分析出是CBC模式、PKCS7填充、密钥为传入字符串的MD5哈希值前24字节。复现代码如下:
from Crypto.Cipher import DES3 from Crypto.Util.Padding import pad import hashlib import base64 def encrypt_3des_cbc(plaintext: str, key_str: str, iv: bytes) -> bytes: """ 复现SO文件中的3DES-CBC加密逻辑 :param plaintext: 待加密的明文字符串 :param key_str: 原始密钥字符串 :param iv: 初始化向量,16进制字符串或字节 :return: 加密后的字节串 """ # 1. 密钥处理:对字符串取MD5,取前24字节作为3DES密钥 key_md5 = hashlib.md5(key_str.encode('utf-8')).digest() # 16字节 # 3DES需要24字节密钥,这里简单地将MD5结果重复一部分来凑24字节(实际逻辑需根据逆向结果调整) # 常见做法:K1=前8字节, K2=后8字节, K3=前8字节 (2TDES) 或 K1,K2,K3分别取MD5后拼接的其他数据 # 假设分析结果是取MD5后,将其与自身反转拼接成24字节(示例,具体按实际分析来) derived_key = key_md5 + key_md5[:8] # 凑成24字节,这只是示例! # **重要**:实际的密钥派生逻辑必须严格按照逆向分析的代码来写,这里只是演示。 # 2. 数据填充:PKCS7 plaintext_bytes = plaintext.encode('utf-8') padded_data = pad(plaintext_bytes, DES3.block_size, style='pkcs7') # 3. 创建加密器,使用CBC模式 # 注意:DES3.new默认使用3DES-EDE模式(加密-解密-加密) cipher = DES3.new(derived_key, DES3.MODE_CBC, iv=iv) # 4. 加密 ciphertext = cipher.encrypt(padded_data) return ciphertext # 验证:使用从动态调试中捕获的一组测试向量 test_plaintext = "username=test&password=123456" test_key_str = "mySecretKey123" test_iv = b'\x00' * 8 # 假设IV是全零,实际根据分析确定 test_expected_ciphertext_hex = "a1b2c3d4e5f67890..." # 从Hook或调试中获取的密文 ciphertext = encrypt_3des_cbc(test_plaintext, test_key_str, test_iv) ciphertext_hex = ciphertext.hex() print(f"复现加密结果: {ciphertext_hex}") print(f"预期加密结果: {test_expected_ciphertext_hex}") print(f"结果匹配: {ciphertext_hex == test_expected_ciphertext_hex}")关键点:derived_key的生成逻辑是复现中最容易出错的地方,必须百分百还原Native代码中的每一步操作,包括字节顺序(大端/小端)、哈希算法、截取位置、可能的额外变换等。
6.2 验证与交叉检查
复现后,需要进行全面验证:
- 单元测试:使用从App中捕获的多组(至少3-5组)不同的明文、密钥、IV和密文进行测试,确保全部通过。
- 边界测试:测试空输入、超长输入、恰好为块大小倍数的输入等边界情况,看填充和处理逻辑是否一致。
- 与标准库对比:用相同的参数(处理后的密钥、IV、填充方式)调用标准的3DES库(如Python的
Crypto.Cipher.DES3)进行加密,看结果是否与你的复现代码一致。这可以排除你在复现核心算法时的错误。 - 回归到原始App:如果可能,修改你的复现代码,使其成为一个简单的测试工具,与原始App在相同输入下对比输出,这是最直接的验证。
6.3 常见问题与排查技巧实录
在逆向和复现过程中,我踩过无数的坑。下面是一些典型问题及排查思路:
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 复现的密文与App输出前几位相同,后面不同 | 填充方式错误或CBC模式IV处理错误。 | 1. 检查填充逻辑。PKCS7填充的字节值等于填充长度。解密后验证去除填充的逻辑。2. 确认IV是否正确。在CBC中,第一个块使用IV,后续块使用前一个密文块。动态调试时,在加密函数开始处和每个加密循环后,分别dump IV和每个中间密文块进行比对。 |
| 密文完全对不上 | 密钥派生逻辑错误或3DES的工作模式(EDE/EED)判断错误。 | 1.密钥是重中之重。在动态调试中,在密钥被最终送入加密函数(如DES_set_key)的瞬间,dump内存中的密钥字节,与你的派生结果逐字节比较。2. 确认是3DES-EDE(加密-解密-加密)还是3DES-EEE(加密-加密-加密)。标准3DES是EDE,但有些实现可能不同。查看反编译代码中三次DES调用的模式参数。 |
| 只有特定长度的输入能加密成功 | 缓冲区溢出或长度计算错误。 | 检查Native代码中内存分配和长度传递的逻辑。特别是Java数组长度到Native层jbyteArray转换时,是否使用了GetArrayLength正确获取。以及填充后的长度计算是否正确。 |
| 动态调试时,断点无法命中或程序崩溃 | 反调试检测或代码自修改(SMC)。 | 1. 尝试在JNI_OnLoad函数开始处下断点,这是SO加载时最早执行的函数,可能在此处进行反调试检测或解密自身代码。2. 使用Frida Hook反调试函数(如ptrace,fork等)并使其失效。3. 考虑使用模拟执行(Unicorn)来绕过反调试。 |
| 反编译代码杂乱,无法理解逻辑 | 控制流混淆。 | 1. 在Ghidra或IDA中,尝试使用其反混淆插件或脚本(如果有)。2. 聚焦于数据流而非控制流。寻找对输入数据、密钥数据的操作,跟踪其变化过程。3.动态调试是破局关键。在真实运行中观察代码执行路径和数据变化,可以忽略大量虚假分支。 |
个人心得:逆向分析就像侦探破案,证据(动态调试的数据)比推理(静态分析的逻辑)更重要。当复现结果不一致时,最有效的办法是回到动态调试环境,在加密函数的入口、密钥处理完成后、填充完成后、每个加密块操作前后设置多个断点,完整地记录下每一阶段的数据快照,然后与你的复现代码的中间状态进行逐字节比对。差异点就是突破口。保持耐心,细致地对比每一个字节,问题总会水落石出。
最后,别忘了整理你的分析笔记、关键代码片段和测试向量。这不仅是对本次工作的总结,更是未来面对类似挑战时宝贵的知识库。逆向工程的世界没有银弹,每一次深入的分析,都是对耐心、细心和技术深度的考验,而成功还原逻辑的那一刻,所有的付出都是值得的。