Unidbg与HookZz实战:动态追踪与逆向魔改SHA1算法

Unidbg与HookZz实战:动态追踪与逆向魔改SHA1算法

1. 项目概述:当Unidbg遇见魔改算法

在移动安全逆向分析这个行当里,最让人头疼的往往不是那些标准的加密算法,而是经过开发者“精心”魔改过的版本。你费尽心思定位到了一个关键函数,发现它在调用SHA1,满心欢喜地掏出标准库准备模拟,结果一跑,出来的结果和真机对不上。这时候你就知道,你碰上“魔改算法”这块硬骨头了。今天要聊的,就是如何利用Unidbg这个强大的动态模拟执行框架,结合HookZz这个精巧的Hook工具,去深度追踪、逆向并最终复现一个被魔改过的SHA1算法。这不仅仅是调用一个API那么简单,而是一场从黑盒到白盒的完整逆向实战。

Unidbg允许我们在PC上模拟运行Android或iOS的本地库(so文件),而无需真机或模拟器,这为动态分析提供了极大的便利。HookZz则为我们提供了在Unidbg环境中进行函数级、指令级Hook的能力,让我们能够像在调试器中一样,观察算法的每一步数据流转。面对一个魔改的SHA1,我们的目标很明确:不是去硬啃混淆后的汇编代码,而是通过动态追踪,看清数据在算法中的完整变换过程,从而推导出魔改的具体逻辑。这个过程,就像给一个密封的黑盒子装上透明的观察窗,我们虽然不能直接看到内部的齿轮结构,但能看清每一个小球(数据)进去后是如何被染色、变形、再排列后出来的,从而反推出内部的加工规则。

2. 核心工具链:Unidbg与HookZz的黄金组合

2.1 Unidbg:跨平台的Native代码模拟器

Unidbg的核心价值在于“模拟”而非“解释”。它模拟了ARM或ARM64的CPU指令集、内存管理、系统调用以及关键的JNI函数,使得一个为Android编译的so库,能够在你的Java或Python程序中“跑起来”。这解决了逆向分析中的一个核心痛点:很多关键逻辑和加密算法都藏在so库里,静态分析(IDA Pro看汇编)晦涩难懂,动态调试又受限于环境(需要root过的真机、对抗反调试等)。Unidbg直接把战场拉到了我们熟悉的开发环境里。

在实战中,我们通常用Unidbg来加载目标so,调用指定的JNI函数或Native函数,并获取其返回值。对于加密算法,我们往往关心输入(明文)和输出(密文)。通过构造不同的输入,观察输出,我们可以初步判断算法的性质。但面对魔改算法,仅仅知道输入输出是不够的,我们必须窥探中间过程。这就需要Hook。

注意:Unidbg的版本和配置对稳定性影响很大。建议使用活跃维护的Fork版本,并仔细配置虚拟机参数(如内存、CPU架构)。不同的so库可能依赖特定的系统属性或文件,需要在Unidbg环境中通过resolver进行合理的补环境操作,否则可能导致so加载失败或函数执行异常。

2.2 HookZz:精准的指令级钩子框架

HookZz是一个基于动态二进制插桩(DBI)思想的Hook框架,它可以实现在目标指令执行前或执行后插入我们的回调函数。在Unidbg的语境下,HookZz让我们能够Hook so库中任意地址的指令。这对于追踪算法流程至关重要。

想象一下SHA1算法的标准流程:初始化缓冲区 -> 对输入数据进行填充 -> 以512位数据块为单位进行80轮的主循环运算,每轮更新5个状态变量(A, B, C, D, E)。魔改可能发生在任何环节:可能是初始常量(IV)被替换了,可能是填充规则变了,可能是每轮运算中使用的非线性函数或常量被修改了,甚至可能是在标准流程前后增加了额外的变换步骤。

如果没有Hook,我们只能看到输入和最终的输出,中间80轮、每轮数十次运算全部是黑盒。有了HookZz,我们可以:

  1. Hook算法入口函数:记录原始输入。
  2. Hook内存读写指令:追踪那5个核心状态变量(A, B, C, D, E)在每一轮之后的值是如何变化的。标准SHA1的每一轮运算都会更新这些变量,魔改的逻辑就藏在这些更新值的偏差里。
  3. Hook关键的逻辑判断或循环指令:了解算法的控制流,判断是否增加了额外的轮次或步骤。

通过在这些关键点埋下“探针”,我们就能得到一份算法执行的“心电图”,所有数据的流动和变化都清晰可见。接下来,就是对比这份“心电图”和标准SHA1的“心电图”,找出所有异常的心跳(魔改点)。

3. 实战部署:搭建追踪环境与定位目标

3.1 环境准备与Unidbg项目初始化

首先,你需要一个Java开发环境(Maven或Gradle项目)。将Unidbg的依赖添加到你的项目中。这里以一个简化的Maven依赖示例和核心代码结构为例:

// 示例:创建一个简单的Unidbg模拟环境 public class ModifiedSHA1Tracer { public static void main(String[] args) { // 1. 创建Android模拟器 (这里以32位ARM为例) AndroidEmulator emulator = new AndroidARMEmulator("com.example.target"); Memory memory = emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23)); // API Level 23 // 2. 加载目标so库 Module module = emulator.loadLibrary(new File("target_lib.so")); // 3. 找到目标函数 // 通常你需要通过导出符号或特征码来定位SHA1相关的函数。 // 假设我们已知函数符号为:Java_com_example_app_NativeHelper_calculateSHA1 Number funcAddress = module.findSymbolByName("Java_com_example_app_NativeHelper_calculateSHA1").getAddress(); System.out.println("Target Function Address: 0x" + Long.toHexString(funcAddress.longValue())); // 后续步骤将在此地址上使用HookZz进行Hook } }

定位目标函数是第一步,也是最需要经验的一步。如果so没有剥离符号,你可以直接通过JNI_OnLoad或类似Java_开头的符号找到加密函数。如果符号被剥离,你就需要通过静态分析(IDA)寻找特征,比如标准SHA1初始化常量0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0在so数据段中的出现,或者通过交叉引用找到使用这些常量的函数。

3.2 使用HookZz植入关键探针

HookZz在Unidbg中通常通过HookZzInstrument来使用。我们需要在关键地址上安装Hook。我们的策略是分层级的:

  1. 第一层:函数入口Hook。记录传入的明文数据(通常是JNI的jbyteArray参数)。

    Instrumentation instrumentation = new HookZzInstrument(emulator); emulator.getInstrumentation().addListener(instrumentation); // Hook函数入口 instrumentation.attach(funcAddress, new EnterCallback() { @Override public void onEnter(Emulator<?> emulator, long address, Object[] args) { // args[1] 可能是代表明文的jobject (jbyteArray) Pointer inputPtr = emulator.getObject(args[1]); byte[] inputData = inputPtr.getByteArray(0, inputPtr.getSize()); System.out.println("[ENTRY] Input Data (Hex): " + bytesToHex(inputData)); } });
  2. 第二层:内存写操作Hook(关键)。这是追踪状态变量的核心。我们需要找到算法中更新那5个状态变量(假设存储在某个内存区域或寄存器)的指令。通过静态分析,你可能发现一个循环结构,在循环体内有连续的5次STR(存储)指令到一片连续的内存地址。Hook这片内存区域的写操作。

    // 假设通过分析,我们知道状态变量存储在基地址为 stateBase 的5个4字节整数上 long stateBase = 0xCF00; // 示例地址 for (int i = 0; i < 5; i++) { final int index = i; instrumentation.attachWrite(stateBase + i * 4, 4, new WriteCallback() { // 监控4字节写操作 @Override public void onWrite(Emulator<?> emulator, long address, int size, long value) { // value 是写入的新值 System.out.printf("[WRITE] State[%d] updated to: 0x%08X%n", index, value); // 我们可以记录下每一轮更新后的完整状态 [A, B, C, D, E] } }); }
  3. 第三层:循环计数器Hook。为了将状态更新与轮次对应起来,最好能Hook循环计数器。找到控制80轮循环的指令(比如一个递减并跳转的指令SUBS+BNE),Hook它,以便在每轮开始时或结束时打印轮次。

    instrumentation.attach(loopControlAddress, new EnterCallback() { int round = 0; @Override public void onEnter(Emulator<?> emulator, long address, Object[] args) { round++; System.out.println("[LOOP] Round: " + round); } });

通过这三层Hook,当你在Unidbg中调用目标函数时,控制台会输出一份详细的执行日志:输入数据、每一轮循环的编号、以及每一轮结束后5个状态变量的新值。

4. 数据采集与魔改逻辑分析

4.1 采集标准SHA1的参照数据

在分析魔改算法之前,你必须有一个“标尺”。你需要用同样的输入,运行一个标准的、已知正确的SHA1算法(比如用Java的MessageDigest.getInstance("SHA-1")),并记录下它在每一轮之后的状态变量值。这需要你修改一个开源的标准SHA1实现(例如Bouncy Castle的源码),在它的轮函数内部插入日志代码,输出每一轮后的A,B,C,D,E。

得到两份日志:一份来自标准SHA1(我们的“标尺”),一份来自被Hook的、魔改的so。将它们并排对比。

4.2 对比分析与魔改点定位

对比分析是核心的逆向思维过程。你可能会发现以下几种典型的魔改模式:

  1. 初始值(IV)魔改:如果从第一轮开始,状态变量的值就和标准值对不上,但在后续轮次中,每一轮的状态变化规律(即本轮新状态与上一轮状态及输入数据块的关系)却和标准算法一致,那么魔改很可能只是替换了初始的5个魔术常量。这是最简单的魔改。

  2. 轮常量(K)魔改:SHA1的80轮中,每20轮使用一个不同的常量Kt(0x5A827999, 0x6ED9EBA1, 0x8F1BBCDC, 0xCA62C1D6)。如果你发现状态值在某个20轮区间开始出现系统性偏差,但计算模式相同,那么可能是这20轮所用的Kt被改成了别的值。

  3. 非线性函数(F)魔改:SHA1每20轮使用一个不同的布尔函数F。如果状态变化规律在20轮边界处发生了与标准算法不同的偏差,可能是F函数被替换了。

  4. 运算顺序或位运算魔改:这是更复杂的魔改。可能是在标准的轮函数A = (B leftrotate 5) + F + E + K + W[t]之外,增加了额外的异或、加减或循环移位操作。这需要你仔细比对每一轮的计算结果。例如,你发现魔改算法的A_new总是等于标准算法的A_new ^ 0x12345678,那么这个异或操作就是魔改点。

  5. 前后处理魔改:可能在标准SHA1计算完成后,对最终的160位(5个状态变量)结果再进行一次变换,比如整体字节序反转、与一个固定值异或、或者再进行一次自定义的哈希。

为了高效对比,我通常会将日志导入到Excel或编写Python脚本进行差分分析。脚本会逐轮比较5个状态值,一旦发现差异,就高亮显示,并尝试计算差异值的规律(是固定的吗?是否与轮次相关?是否与输入数据相关?)。

实操心得:不要试图一次性理解所有差异。先从最大的、最明显的差异入手,比如第一轮的结果就不同,那就先聚焦初始化阶段。如果前面几十轮都一样,从第60轮开始不同,那就重点分析第60轮附近的逻辑(可能是进入了使用不同Kt或F函数的阶段,或者从这里开始插入了额外代码)。分而治之,是逆向复杂逻辑的不二法门。

5. 算法复现与验证

5.1 基于分析结果修改标准实现

通过对比分析,你假设出了魔改的规则。例如,你发现魔改算法只是把初始的5个常量[A, B, C, D, E][0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0]改成了[0x01234567, 0x89ABCDEF, 0xFEDCBA98, 0x76543210, 0xF0E1D2C3](注意,这里只是举例,实际魔改不会这么简单)。

那么,你就基于一个标准的、易于修改的SHA1实现(比如Python的hashlib源码或一个清晰的C语言实现),找到初始化部分,将常量替换掉。

如果魔改涉及轮常量Kt,就找到定义Kt数组的地方进行替换。如果魔改了非线性函数F,就重写对应的函数。如果增加了额外的运算,就在轮函数的对应位置插入你的代码。

5.2 构造多组测试向量进行验证

复现之后,绝不能只用一个测试用例就宣告成功。你需要构造一个全面的测试集:

  • 边界案例:空输入、极短输入(1字节)、长度刚好超过一个块(56字节,因为SHA1填充后是64字节一个块)、长度很长的输入。
  • 随机案例:生成几十组随机长度的随机字节作为输入。
  • 针对性案例:如果怀疑魔改与输入数据的某些位有关,可以构造特定的模式(如全0、全1、0xAA、0x55等)。

对于每一组测试向量,分别运行:

  1. 原始目标so(通过你的Unidbg Hook脚本调用,并记录最终输出)。
  2. 你复现的算法程序

比较两者的输出是否完全一致。如果所有测试用例都通过,那么恭喜你,你已经成功逆向并复现了这个魔改SHA1算法。

5.3 常见问题与排查技巧实录

在实际操作中,你几乎一定会遇到各种问题。下面是一个常见问题速查表:

问题现象可能原因排查思路与解决方案
Unidbg加载so失败,报错找不到依赖库so依赖了其他系统库或自定义库。使用memory.setLibraryResolver添加更多解析器,或使用AndroidResolver自动处理常见系统库。对于自定义库,需要将其一并加载。
Hook的函数没有被调用函数地址定位错误;函数被混淆或间接调用。1. 确认符号名或特征地址正确。
2. 尝试Hook其调用者(上层函数)。
3. 使用instruction tracing模式,追踪代码执行流,看是否跳过了目标函数。
Hook后程序崩溃或行为异常Hook破坏了寄存器状态或栈平衡;Hook点选择在了关键指令中间。1. 在Hook回调中,务必小心处理寄存器,使用emulator.getBackend().reg_readreg_write时确保恢复原状。
2. 尽量选择在函数入口(第一条指令)或函数出口(返回指令前)进行Hook。
3. 对于指令级Hook,确保Hook的是完整指令的边界。
状态变量更新日志混乱,无法与轮次对应写操作Hook的地址不准确;有多个地方更新同一片内存。1. 静态分析更仔细,确认状态变量的存储位置。可能是一个全局数组,也可能在堆栈上。
2. 在Hook写操作时,同时打印调用栈(Thread.currentThread().getStackTrace()),区分不同上下文的写入。
复现算法的结果与so输出大部分一致,但偶尔不同魔改逻辑可能依赖于某些动态因素,如时间戳、设备ID(被硬编码在so中)。1. 检查so中是否有访问/dev/urandomgettimeofday或读取特定文件/proc信息的代码。
2. 在Unidbg中Hook这些系统调用,记录并模拟其返回值,确保执行环境一致。
对比发现差异毫无规律,像是随机扰动算法可能使用了反调试或代码自修改技术。1. 检查so是否在运行时解密了部分代码段(常见于加固so)。
2. 尝试在Unidbg中完整dump出解密后的内存代码段,并基于此进行分析和Hook。

独家避坑技巧:在开始深度Hook之前,先做一个“烟雾测试”。用几组简单的输入调用目标函数,只Hook入口和出口,确认Unidbg环境能正确运行并得到输出。然后,写一个最简单的脚本,比较so的输出和标准SHA1的输出,确认它们确实不同(确实是魔改的)。这个简单的验证可以避免你在一个错误的方向(比如so根本没被调用,或者算法根本不是SHA1系)上浪费大量时间。

6. 从追踪到通用化:构建算法识别模式库

完成一次具体的魔改SHA1逆向后,你的收获不应该只是一个能输出正确结果的脚本。更宝贵的,是形成一套方法论和模式识别能力。

你可以开始建立自己的“魔改算法特征库”:

  • 特征一:常量替换。记录下常见的被替换的IV和Kt值。有些开发团队会使用公司名、项目名的哈希值作为魔改常量。
  • 特征二:填充规则变异。标准的SHA1填充是比特1后接一堆0,最后64位是消息长度。有些魔改会改变这个规则,比如先补0x80,再补0x00,或者长度编码使用大端序等。这可以通过Hook内存中填充后的完整消息块来发现。
  • 特征三:附加变换。记录下那些在标准轮函数前后增加的常见操作,比如对状态变量进行额外的循环移位、与轮次相关的常量进行加减等。

当下次遇到另一个魔改哈希算法(可能是MD5、SHA256的变种)时,你可以快速套用这套Hook追踪流程。首先Hook并对比初始状态,判断是否为常量替换;然后对比第一轮运算后的状态,判断主逻辑是否被修改;最后对比最终输出前的处理。这样,你的分析效率会呈指数级提升。

这个从具体实战中抽象出通用模式的过程,正是逆向工程师从“工匠”走向“专家”的关键。工具(Unidbg、HookZz)是手臂,而方法论和经验积累的大脑。每一次对魔改算法的成功逆向,不仅解决了一个具体问题,更是为你的大脑神经网络添加了一个强大的识别模式节点。