Unidbg Hook与FindHash:高效定位魔改MD5算法的逆向实战
1. 项目概述当MD5不再“标准”在移动安全逆向的日常工作中我们常常会遇到一些“不讲武德”的加密实现。你拿到一个APK发现它用了MD5心想这还不简单结果一调试发现标准MD5库算出来的结果和App里的对不上。这时候你就知道你遇到了“魔改MD5”——开发者对标准的MD5算法进行了自定义的修改可能是改了初始常量、旋转位移次数或者干脆在计算过程中插入了自己的“私货”。传统的逆向方法比如静态分析算法逻辑、动态调试跟踪每一步计算费时费力尤其是当算法被混淆或深度集成在Native层如.so库时更是让人头疼。今天要聊的就是一种我称之为“钓鱼执法”的高效策略我们不跟算法逻辑死磕而是主动“下钩”让程序自己把关键的计算结果“吐”出来。核心武器就是Unidbg和Hook技术再配合上对哈希算法特征的敏锐捕捉FindHash思路快速定位并验证魔改点。简单来说这个项目的目标不是从头到尾逆向整个魔改算法而是快速、精准地定位算法被修改的关键位置并验证修改后的算法逻辑。这就像在一条复杂的流水线上你不知道哪个环节被动了手脚但你可以通过在所有环节出口设置检查点Hook看出来的半成品中间状态是否符合标准从而锁定被篡改的工位。这种方法特别适合应对那些对标准算法进行微小但关键修改的场景能极大提升逆向分析效率。2. 核心思路与工具选型为什么是“钓鱼执法”2.1 “钓鱼执法”策略的精髓“钓鱼执法”这个比喻形象地概括了我们的核心思路主动诱导捕获证据。布设钓点Hook点选择我们不深入算法内部复杂的位运算迷宫而是在算法库如OpenSSL、Crypto等的标准函数入口处下钩。例如MD5的初始化MD5_Init、数据更新MD5_Update、最终计算MD5_Final函数。这些是算法必然经过的“关口”。准备鱼饵构造输入使用已知、可控的输入数据如字符串“123456”。我们既知道它在标准MD5下的输出也能预测其在标准算法各关键节点如每轮计算后的状态值的中间结果。等待咬钩执行与拦截在Unidbg模拟执行环境中运行目标代码当执行流经过我们Hook的函数时我们的钩子代码会被触发。检查渔获分析参数与状态在钩子函数中我们可以检查、打印、甚至修改函数调用时的参数如输入数据指针、数据长度和关键的内存状态如MD5的上下文结构体里面保存了A,B,C,D四个状态变量。通过对比这些捕获到的“中间状态”与标准算法的预期状态任何偏差都直接指向了魔改发生的位置。收网验证定位与验证一旦发现某个函数比如MD5_Update被调用时其内部状态更新逻辑与标准不符我们就锁定了魔改发生在这个函数内部或其后。再结合静态分析就能聚焦到具体的代码块进行深入分析。2.2 为什么选择Unidbg在Android Native逆向中动态调试.so库通常需要真机或模拟器环境搭建复杂且容易受到反调试机制的干扰。Unidbg完美地解决了这个问题跨平台与易用性它是一个基于Java的模拟器可以在你的开发机Windows/macOS/Linux上直接模拟执行ARM或ARM64的本地库代码无需真实的Android设备或完整的系统镜像。强大的Hook能力Unidbg内置了便捷的Hook框架如DalvikHook、IHook接口可以非常轻松地在函数入口、出口甚至任意指令处下钩拦截和修改寄存器、内存、参数值。这是实现“钓鱼”战术的技术基础。可控的执行环境你可以精确控制提供给模拟器的输入并观察每一步的执行结果非常适合算法分析和验证。2.3 “FindHash”思路的融入“FindHash”不是一个具体工具而是一种方法论。它源于对哈希算法特征的总结无论算法如何魔改它通常仍然会保留一些标准算法的“骨架”比如固定的初始值、固定的循环结构、对输入数据的分块处理等。我们的思路是特征记忆牢记标准MD5的“指纹”。例如初始链接变量A,B,C,D是固定的0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476。每512位64字节数据块进行一次主循环。主循环包含64步每步使用一个固定的常量K[i]和左循环位移次数s[i]。对比排查在Hook点我们不仅看输入输出更要看这些“特征点”。例如在MD5_Init被Hook时检查传入的上下文结构体中的初始A、B、C、D值是否被改写了。如果在MD5_Update中发现对数据块的处理轮数不是64或者位移次数发生了变化那就是明显的魔改信号。将Unidbg Hook与FindHash思路结合就形成了一套高效的组合拳Unidbg提供精准的“下钩”和“观察”能力FindHash提供快速“识别异常”的判断依据。3. 环境搭建与目标准备3.1 Unidbg环境搭建这里以在IntelliJ IDEA中创建一个简单的Java项目为例创建项目新建一个Maven或Gradle项目。添加依赖在pom.xmlMaven中添加Unidbg的依赖。建议使用最新的版本可以从Maven中央仓库获取。dependency groupIdcom.github.zhkl0228/groupId artifactIdunidbg/artifactId version0.9.8/version !-- 请检查并使用最新版本 -- /dependency准备目标SO文件从待分析的APK中解压出包含疑似魔改MD5算法的原生库文件通常是libxxx.so。可以使用apktool或直接解压APK。假设我们目标文件是libnative-lib.so将其放置在项目的资源目录如src/main/resources下。3.2 目标函数定位在Hook之前我们需要知道要Hook哪个函数。通常有两种方式静态分析使用IDA Pro、Ghidra或Radare2反汇编libnative-lib.so搜索字符串“MD5_Init”、“MD5_Update”、“MD5_Final”或者直接查看导出函数表。这是最准确的方式。动态推测如果函数被静态链接或混淆了名称可以通过分析JNI调用Java_com_example_xxx或观察程序行为来推测。例如一个计算MD5的JNI函数内部必然会调用底层的哈希计算函数。假设通过静态分析我们确定了目标函数是标准名称MD5_Init,MD5_Update,MD5_Final。我们记下它们在IDA中显示的地址偏移量例如MD5_Update的偏移是0x1234。注意Unidbg中Hook通常使用基地址偏移量的方式。SO文件加载到内存后有一个基地址module.base加上函数在文件中的偏移量就得到了函数在模拟内存中的实际地址。3.3 编写基础模拟执行代码首先我们编写一段Unidbg代码能够成功加载目标SO并执行到调用MD5相关函数的地方。这可能需要你调用一个触发加密的JNI函数。import com.github.unidbg.AndroidEmulator; import com.github.unidbg.Module; import com.github.unidbg.linux.android.AndroidEmulatorBuilder; import com.github.unidbg.linux.android.AndroidResolver; import com.github.unidbg.linux.android.dvm.*; import com.github.unidbg.memory.Memory; import java.io.File; import java.io.IOException; public class ModifiedMD5Hunter { private final AndroidEmulator emulator; private final VM vm; private final Module module; public ModifiedMD5Hunter() { // 1. 创建模拟器 emulator AndroidEmulatorBuilder.for32Bit().build(); Memory memory emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23)); // API 23 // 2. 创建Android虚拟机 vm emulator.createDalvikVM(); // 可以加载必要的APK如果需要的话 // vm.loadLibrary(new File(target.apk), true); // 3. 加载目标SO库 DalvikModule dm vm.loadLibrary(new File(src/main/resources/libnative-lib.so), false); module dm.getModule(); // 4. 设置JNI相关如果需要 vm.setJni(this); vm.setVerbose(false); // 关闭详细日志需要时再开启 } public void callTargetFunction() { // 这里调用触发MD5计算的JNI方法 // 例如vm.callJNI_OnLoad(emulator, module); // 如果SO有JNI_OnLoad // 或者直接调用一个具体的JNI函数 DvmObject? obj vm.resolveClass(com/example/app/NativeHelper).newObject(null); String result obj.callJniMethodObject(emulator, calculateMD5(Ljava/lang/String;)Ljava/lang/String;, 123456).getValue().toString(); System.out.println(计算结果: result); } public static void main(String[] args) { ModifiedMD5Hunter hunter new ModifiedMD5Hunter(); hunter.callTargetFunction(); } }这段代码建立了基础的模拟环境。下一步我们将植入关键的“鱼钩”。4. 核心Hook实现与“钓鱼”过程4.1 设计Hook类我们将为MD5_Init,MD5_Update,MD5_Final分别创建Hook并在其中加入我们的“检查点”逻辑。import com.github.unidbg.hook.HookContext; import com.github.unidbg.hook.IHook; import com.github.unidbg.hook.ReplaceCallback; import com.github.unidbg.hook.xhook.IxHook; import com.github.unidbg.arm.context.RegisterContext; public class MD5Hook { private final AndroidEmulator emulator; private final Module module; public MD5Hook(AndroidEmulator emulator, Module module) { this.emulator emulator; this.module module; } public void installHooks() { IHook hook emulator.getSyscallHook(); // Hook MD5_Init - 检查初始值 hook.replace(module.base 0x1230, new ReplaceCallback() { // 假设偏移0x1230是MD5_Init Override public HookStatus onCall(Emulator? emulator, HookContext context, long originFunction) { // 获取第一个参数通常是MD5_CTX* 上下文指针 long ctxPtr context.getLongArg(0); System.out.println(String.format([Hook MD5_Init] 上下文指针: 0x%x, ctxPtr)); // 调用原函数 HookStatus status HookStatus.RET(emulator, originFunction); // 调用原函数后检查ctx结构体中的初始状态值 (A,B,C,D) // 标准MD5初始值: A0x67452301, B0xEFCDAB89, C0x98BADCFE, D0x10325476 // 我们需要知道MD5_CTX结构体在内存中的布局。通常前16字节是A,B,C,D四个32位整数。 Memory memory emulator.getMemory(); int A memory.pointer(ctxPtr).getInt(0); int B memory.pointer(ctxPtr).getInt(4); int C memory.pointer(ctxPtr).getInt(8); int D memory.pointer(ctxPtr).getInt(12); System.out.println(String.format([After MD5_Init] 状态值: A0x%08X, B0x%08X, C0x%08X, D0x%08X, A, B, C, D)); if (A ! 0x67452301 || B ! 0xEFCDAB89 || C ! 0x98BADCFE || D ! 0x10325476) { System.out.println(!!! 发现异常 !!! 初始值被魔改); // 这里可以记录下魔改后的值用于后续分析 } return status; } }); // Hook MD5_Update - 检查数据处理过程这里是重点 hook.replace(module.base 0x1234, new ReplaceCallback() { // 假设偏移0x1234是MD5_Update Override public HookStatus onCall(Emulator? emulator, HookContext context, long originFunction) { long ctxPtr context.getLongArg(0); long dataPtr context.getLongArg(1); long dataLen context.getLongArg(2); System.out.println(String.format([Hook MD5_Update] 调用: ctx0x%x, data0x%x, len%d, ctxPtr, dataPtr, dataLen)); // 在调用原函数前我们可以保存当前的ctx状态用于之后对比 Memory memory emulator.getMemory(); int A_before memory.pointer(ctxPtr).getInt(0); int B_before memory.pointer(ctxPtr).getInt(4); // ... 保存B,C,D // 调用原函数 HookStatus status HookStatus.RET(emulator, originFunction); // 调用后获取新的状态值 int A_after memory.pointer(ctxPtr).getInt(0); int B_after memory.pointer(ctxPtr).getInt(4); // ... 获取C,D System.out.println(String.format([After MD5_Update] 状态变化: A: 0x%08X - 0x%08X, A_before, A_after)); // 打印数据块内容前16字节 byte[] inputBlock memory.pointer(dataPtr).getByteArray(0, Math.min(16, (int)dataLen)); System.out.println(输入数据块(Hex): bytesToHex(inputBlock)); // 这里可以加入更复杂的分析逻辑例如 // 1. 模拟标准MD5计算这个数据块得到预期的A_after_expected。 // 2. 对比 A_after 和 A_after_expected如果不一致则说明该次Update内部逻辑被魔改。 // 这需要实现一个标准的MD5单块计算函数但能极大精确定位。 return status; } }); // Hook MD5_Final - 检查最终输出 hook.replace(module.base 0x1238, new ReplaceCallback() { // 假设偏移0x1238是MD5_Final Override public HookStatus onCall(Emulator? emulator, HookContext context, long originFunction) { long digestPtr context.getLongArg(1); // 通常是第二个参数存储最终哈希结果的缓冲区 System.out.println(String.format([Hook MD5_Final] 输出缓冲区指针: 0x%x, digestPtr)); HookStatus status HookStatus.RET(emulator, originFunction); // 调用原函数后读取最终的MD5值16字节 Memory memory emulator.getMemory(); byte[] finalDigest memory.pointer(digestPtr).getByteArray(0, 16); System.out.println(最终MD5结果(Hex): bytesToHex(finalDigest)); // 计算标准MD5进行对比 String input 123456; // 需要和调用时传入的数据一致 String standardMD5 calculateStandardMD5(input); System.out.println(标准MD5结果: standardMD5); if (!bytesToHex(finalDigest).equals(standardMD5)) { System.out.println(!!! 最终结果不一致确认算法被魔改 !!!); } return status; } }); } private static String bytesToHex(byte[] bytes) { StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02x, b 0xff)); } return sb.toString(); } private static String calculateStandardMD5(String input) { try { java.security.MessageDigest md java.security.MessageDigest.getInstance(MD5); byte[] digest md.digest(input.getBytes(UTF-8)); return bytesToHex(digest); } catch (Exception e) { return ; } } }4.2 整合并执行“钓鱼”修改主类在调用目标函数前安装Hookpublic class ModifiedMD5Hunter { // ... 之前的成员变量和构造函数 ... public ModifiedMD5Hunter() { // ... 初始化代码 ... // 在加载库之后安装Hook MD5Hook md5Hook new MD5Hook(emulator, module); md5Hook.installHooks(); } public void callTargetFunction() { // 现在调用JNI函数时所有MD5相关调用都会被Hook并打印信息 DvmObject? obj vm.resolveClass(com/example/app/NativeHelper).newObject(null); String result obj.callJniMethodObject(emulator, calculateMD5(Ljava/lang/String;)Ljava/lang/String;, 123456).getValue().toString(); System.out.println(JNI返回的计算结果: result); } public static void main(String[] args) { ModifiedMD5Hunter hunter new ModifiedMD5Hunter(); hunter.callTargetFunction(); hunter.emulator.close(); } }运行这段代码你将在控制台看到详细的调用日志和状态信息。通过分析这些日志魔改的蛛丝马迹将无处遁形。5. 魔改点分析与验证实战假设我们运行上述代码后发现了以下异常日志[Hook MD5_Init] 上下文指针: 0xbffff100 [After MD5_Init] 状态值: A0x67452301, B0xEFCDAB89, C0x98BADCFE, D0x10325476 [Hook MD5_Update] 调用: ctx0xbffff100, data0xbffff200, len6 输入数据块(Hex): 313233343536 (123456的ASCII) [After MD5_Update] 状态变化: A: 0x67452301 - 0x8FA1D2F3 [Hook MD5_Final] 输出缓冲区指针: 0xbffff300 最终MD5结果(Hex): **e10adc3949ba59abbe56e057f20f883e** (这是标准123456的MD5) 标准MD5结果: e10adc3949ba59abbe56e057f20f883e看起来一切正常等等这太正常了反而可疑。如果App真的用了魔改MD5结果应该不一样。这说明我们的Hook可能没有打到真正的计算函数上。魔改可能发生在自定义函数开发者没有使用标准的MD5_系列函数而是自己写了一套函数名完全不同。内联或混淆算法逻辑被内联到JNI函数里或者函数名被混淆。这时“FindHash”的思路和更广泛的Hook策略就派上用场了。5.1 策略调整Hook内存分配与密码学相关函数如果标准函数入口找不到我们可以扩大“钓点”范围Hookmalloc/callocMD5上下文结构体MD5_CTX通常需要动态分配。Hook内存分配函数检查分配的大小MD5_CTX大小通常是92或96字节左右并记录返回的指针。后续跟踪对这个指针的读写操作。Hook 通用加密函数如OpenSSL的EVP_DigestInit_ex,EVP_DigestUpdate,EVP_DigestFinal_ex。很多应用会使用更抽象的EVP接口。Hookmemcpy/memset算法中常涉及内存操作。可以观察大块数据的复制和填充模式。基于特征搜索在Unidbg执行过程中我们可以编写代码扫描模拟内存寻找已知的MD5常量如正弦函数表T[64]从而定位算法代码区域再对该区域进行指令级Hook。5.2 实战案例定位魔改常量假设我们通过Hookmalloc找到了一个疑似上下文的结构体指针pCtx并且通过跟踪发现有一段循环代码在频繁访问它。我们可以在该地址上设置内存访问断点Unidbg支持emulator.getMemory().addHook监听内存读写。当我们发现程序在读取pCtx0A变量后与一个常量0x78787878进行异或操作而不是标准算法中的第一个常数0xd76aa478时我们就抓到了第一个魔改点常数表被替换了。验证方法我们在Hook中不仅打印状态还模拟一步标准计算。当读取到输入数据块和当前状态后我们立刻用标准算法计算下一步的状态变化并与程序实际计算出的新状态对比。一旦发现从某一步开始对不上就检查这一步用到的常数K[i]和位移s[i]。这样就能逐条定位出被修改的常数和位移。5.3 验证与输出算法逻辑找到所有魔改点后我们需要系统地验证。最好的方法是用Python或Java重新实现这个魔改算法。记录魔改参数从Hook日志中整理出魔改后的初始链接变量如果被改了。魔改后的64个常数K[i]可能全部或部分被改。魔改后的64个左循环位移次数s[i]。其他可能的改动如填充规则、字节序处理。编写验证脚本基于标准的MD5算法框架将上述魔改参数替换进去。批量测试用多组测试数据如空字符串、长字符串、中文等输入到目标App和你的验证脚本中对比输出结果是否完全一致。输出算法描述最终你可以生成一份清晰的文档描述这个魔改MD5与标准MD5的具体差异例如“此魔改MD5将标准算法第1轮的常数0xd76aa478替换为0x78787878将第16轮的左移次数从5改为7……”。6. 常见问题与排查技巧实录在实际操作中你肯定会遇到各种问题。以下是我踩过的一些坑和总结的技巧6.1 Unidbg模拟失败或崩溃问题加载SO或执行JNI时崩溃。排查检查依赖库目标SO可能依赖其他SO如libcrypto.so,liblog.so。确保Unidbg的AndroidResolver能正确找到并加载它们。可以在构造函数中增加memory.setVerbose(true)查看库加载日志。系统调用未实现SO使用了Unidbg尚未实现的系统调用。查看崩溃堆栈找到未实现的系统调用号然后在Unidbg中实现或Hook它有时可以简单返回一个默认值。JNI环境问题确保vm.setJni()正确设置了JNI接口。复杂的App可能需要注册更多的JNI方法。可以尝试先不安装Hook让最基础的调用跑通。6.2 Hook没有触发问题代码执行了但Hook点的打印信息没出现。排查地址错误最可能的原因。确认你Hook的地址module.base offset确实是目标函数的入口。用IDA等工具反复核对偏移量。注意Thumb模式和ARM模式Unidbg Hook的地址需要是实际执行地址Thumb模式地址最低位为1。函数未被调用你Hook的函数可能根本就没被用到。目标代码可能使用了其他函数或内联实现。回到静态分析重新梳理调用链。Hook时机太晚Hook需要在目标函数被首次调用之前安装。确保installHooks()在触发代码执行之前被调用。6.3 获取的函数参数或上下文结构不正确问题Hook中读取的ctxPtr指向乱码或者状态值看起来不合理。排查调用约定确保你按照正确的调用约定读取参数。对于ARM32前4个参数通常通过R0-R3寄存器传递。Unidbg的HookContext提供了getIntArg/getLongArg方法但索引要从0开始。对于MD5_Init(MD5_CTX *c)第一个参数索引0就是c。结构体布局MD5_CTX的内存布局可能因库版本如OpenSSL, glibc略有不同。你需要通过分析目标SO的伪代码确定A,B,C,D等成员在结构体中的偏移量。不能想当然地认为一定是(0,4,8,12)。指针有效性在解引用指针前最好用memory.pointer(addr).isValid()检查一下地址是否可读。6.4 性能与日志管理问题Hook太多或日志太详细导致模拟执行极慢控制台刷屏。技巧条件Hook只在特定条件下打印日志。例如只有当处理特定输入数据如前缀是“test”时才触发详细日志。抽样Hook在MD5_Update中可能被调用成千上万次。可以设置一个计数器每处理N个数据块才打印一次状态。输出到文件将关键日志重定向到文件便于后续分析。使用调试器模式Unidbg支持类似GDB的调试器可以单步、下断点这对于精细分析某一段代码比大量Hook更高效。6.5 应对反调试与混淆问题SO文件被加固或混淆增加了分析难度。策略Unidbg的优势很多基于时间、环境检测的反调试在Unidbg的模拟环境中可能失效因为Unidbg提供了一个“干净”的环境。Hook反调试函数可以提前Hookptrace,fopen检查/proc/self/status,syscall等常用反调试函数使其返回一个“正常”的值。指令级跟踪对于混淆严重的代码可以开启Unidbg的指令级跟踪emulator.getMemory().setCodeTrace(true)虽然会产生海量日志但能让你看清每一条指令的执行流结合静态分析可以理解混淆逻辑。聚焦目标牢记我们的目标是“定位魔改算法”而不是彻底还原整个混淆代码。一旦通过Hook捕获到关键的常数、位移或状态变化就可以暂停对混淆逻辑的深入追踪转而分析这些捕获到的数据。最后这种“钓鱼执法”式的分析其成功很大程度上依赖于你对标准算法特征的熟悉程度和对目标行为的合理推测。它不是一个全自动的工具而是一个需要分析师不断交互、假设、验证的探索过程。每一次成功的定位都是对算法理解和逆向思维的一次提升。当你看到自己编写的验证脚本与目标App输出完全一致的那一刻所有的调试和排查都是值得的。