Unidbg与Frida实战:逆向分析移动App签名算法x-sign
1. 项目概述与核心价值逆向分析移动应用中的核心算法对于安全研究、自动化测试乃至业务逻辑理解都至关重要。今天我们就来深入探讨一个非常具体且具有代表性的实战案例如何利用Unidbg和Frida这两大神器搞定某鱼App中关键的x-sign签名算法。这个签名是App与服务器通信时进行身份校验和数据防篡改的核心通常被保护在Native层的.so库中增加了直接分析的难度。如果你是一名移动安全研究员、爬虫工程师或者是对Android Native Hook、算法还原感兴趣的开发者那么这篇文章就是为你准备的。我将不仅展示最终的结果和代码更会详细拆解整个分析过程的思路、工具的选择与配合、以及每一步操作背后的“为什么”。你将看到如何从茫茫的机器码和内存读写中精准定位到生成签名的关键函数并最终将其逻辑还原。文末会附上完整的Trace脚本和Hook代码你可以直接参考并应用到类似场景中。2. 工具选型与核心思路拆解在开始动手之前我们必须明确为什么选择Unidbg和Frida这个组合而不是单一工具。这背后是对不同工具特性与项目需求匹配度的深度考量。2.1 为什么是Unidbg FridaFrida是一个动态插桩工具它的强大之处在于“动态”和“实时”。你可以在真机或模拟器上运行目标App然后通过JavaScript脚本在运行时拦截、修改任意函数包括Java层和Native层的输入输出。它的优势是“所见即所得”能够直接观察到算法在真实环境中的执行流程和数据处理非常适合快速验证猜想、动态跟踪数据流。但是它的分析过程严重依赖App的运行环境每次测试都需要启动App、触发相应功能效率相对较低且对于复杂的、多线程的算法逻辑动态跟踪可能不够系统和全面。Unidbg是一个基于Java的模拟器它可以在PC上直接模拟运行Android的.so库文件而无需启动整个App或Android系统。它的核心价值在于“静态分析的可重复性与深度”。你可以编写Java测试代码反复调用目标Native函数并利用其强大的Trace、Debugger功能记录下函数执行过程中每一条指令、每一次内存访问。这为静态逆向分析提供了无与伦比的便利你可以像调试普通Java程序一样单步跟踪Native代码的执行。但Unidbg的弱点在于环境模拟可能不完美某些高度依赖系统特定环境或硬件的指令可能无法执行。因此组合使用就成了最佳策略用Frida进行快速的、前期的侦察和验证定位关键函数和大致逻辑用Unidbg进行深度的、可反复的静态跟踪与分析精确还原算法细节。两者相辅相成Frida像侦察兵Unidbg像工程兵。2.2 整体逆向分析思路我们的目标是还原x-sign的生成算法。一个典型的移动端签名算法流程通常如下收集参数App将请求参数如URL、时间戳、设备信息等按照特定规则拼接或初步处理。核心计算将处理后的数据送入Native层的加密/哈希函数如MD5、SHA、AES或自定义算法进行计算。结果格式化将计算得到的二进制结果进行Base64、Hex等编码最终生成x-sign字符串。逆向的思路就是逆着这个流程来定位入口首先找到生成x-sign的Java层方法或JNI函数。动态追踪使用Frida Hook该入口打印输入参数和输出结果验证其功能并尝试追踪其内部调用链。提取样本收集多组不同输入对应的x-sign输出为后续分析提供数据样本。静态深挖将关键的.so库文件拖入IDA等反编译工具进行静态分析同时使用Unidbg加载该库通过Frida提供的函数地址线索在Unidbg中设置断点或进行指令级Trace。算法还原结合Unidbg的Trace日志寄存器值、内存读写和静态反编译的代码一步步分析出算法的具体步骤和逻辑。代码实现将分析清楚的算法逻辑用高级语言如Python、Java重新实现。3. 实战环境准备与前期侦察工欲善其事必先利其器。在开始逆向之前我们需要搭建好分析环境并完成初步的侦察工作。3.1 环境与工具清单目标App某鱼App的某个版本建议使用较旧版本逆向难度相对较低。你需要准备其APK文件。反编译工具Jadx-GUI用于反编译APK查看Java/Smali代码寻找签名入口。IDA Pro或Ghidra用于静态分析Native层的.so库文件。动态分析工具Frida版本建议12.x以上。包括Frida Server运行在Android设备上和Frida客户端运行在PC上。Android 模拟器或Root过的真机用于运行目标App和Frida Server。模拟执行工具Unidbg直接从GitHub克隆最新源码它是一个Java项目建议使用IntelliJ IDEA打开和编译。辅助工具adb用于连接和管理Android设备。Python用于编写Frida脚本和一些数据处理脚本。注意所有工具请从官方渠道或可信源获取。使用模拟器时建议使用Android 7.0或8.1镜像兼容性较好。真机Root有风险请使用备用机。3.2 定位签名生成入口这是逆向的第一步也是最关键的一步。如果入口找错了后面所有工作都是徒劳。关键词搜索使用Jadx-GUI打开目标APK。在全局搜索框中搜索x-sign。你可能会在Java代码中发现设置HTTP请求头的代码例如headers.put(x-sign, sign)。找到设置这个header的类和方法。调用栈分析如果直接搜索不到或者结果太多可以尝试Hook网络请求库如OkHttp的Interceptor或RequestBody。写一个简单的Frida脚本打印所有HTTP请求的Header观察x-sign是在哪里被添加的。定位Native方法找到设置x-sign的Java方法后查看其实现。通常签名的计算会通过System.loadLibrary加载的Native库来完成。你会看到类似native String getSign(String param)的声明。记下这个Native方法所在的类名和方法名这是我们的核心入口。假设我们通过分析定位到签名入口为com.xxx.yyy.SignUtils类中的native String getXSign(String param1, String param2)方法。3.3 编写初步的Frida侦察脚本现在我们编写第一个Frida脚本用于验证入口并获取初步信息。// frida_scout.js Java.perform(function() { // 定位目标类 var SignUtils Java.use(com.xxx.yyy.SignUtils); // Hook native方法对应的JNI函数如果已知 // 更通用的方法是Hook这个Java的native方法 SignUtils.getXSign.implementation function(param1, param2) { console.log([*] Hooked getXSign); console.log( param1: param1); console.log( param2: param2); // 调用原方法获取结果 var result this.getXSign(param1, param2); console.log( result x-sign: result); console.log(-----------------------------------); // 保存样本到文件可选 send({type: sample, p1: param1, p2: param2, sign: result}); return result; }; // 也可以Hook加载so库的代码确认so文件名 var System Java.use(java.lang.System); System.loadLibrary.implementation function(libname) { console.log([*] Loading library: libname); this.loadLibrary(libname); }; });运行这个脚本 (frida -U -f com.target.app -l frida_scout.js)然后操作App触发签名请求。你将在控制台看到打印的参数和签名结果。收集多组数据观察参数与签名之间的变化规律这有助于后续判断算法类型例如是否对时间戳敏感。4. Unidbg模拟执行与深度Trace通过Frida我们确认了入口和基本的输入输出。接下来进入深度分析阶段使用Unidbg。4.1 搭建Unidbg测试环境首先从APK的lib目录通常是lib/armeabi-v7a或lib/arm64-v8a中找到目标.so文件假设叫libsign.so。同时需要提取出/data/app/...目录下的该so文件因为APK中的是压缩的或者直接使用解压APK得到的so。创建一个Unidbg的Java项目核心代码如下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 XSignEmulator { private final AndroidEmulator emulator; private final VM vm; private final Module module; public XSignEmulator() { // 1. 创建模拟器支持32位ARM emulator AndroidEmulatorBuilder.for32Bit().build(); Memory memory emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23)); // API Level 23 // 2. 创建Android虚拟机 vm emulator.createDalvikVM(); // 可以添加必要的JNI类如果so依赖了某些Android类 // vm.addJniClass(new JniClass()); // 3. 加载关键so库 DalvikModule dm vm.loadLibrary(new File(path/to/your/libsign.so), false); dm.callJNI_OnLoad(emulator); module dm.getModule(); System.out.println([] SO库加载成功基地址: 0x Long.toHexString(module.base)); } public String getXSign(String param1, String param2) { // 4. 调用JNI函数 // 首先需要知道目标函数的符号或地址。可以通过Frida的Module.enumerateExports获取。 // 假设我们已知函数符号为Java_com_xxx_yyy_SignUtils_getXSign DvmObject? context vm.resolveClass(com/xxx/yyy/SignUtils).newObject(null); // 调用静态Native方法 Number result module.callFunction(emulator, 0xXXXXX, // 函数地址先用0代替 context, // JNIEnv vm.addLocalObject(new StringObject(vm, param1)), vm.addLocalObject(new StringObject(vm, param2))); // 处理结果假设结果是jstring StringObject so (StringObject) vm.getObject(result.intValue()); return so.getValue(); } public static void main(String[] args) throws IOException { XSignEmulator emu new XSignEmulator(); String sign emu.getXSign(test_param1, test_param2); System.out.println(Generated x-sign: sign); emu.emulator.close(); } }这段代码是一个框架其中0xXXXXX是目标函数的偏移地址我们需要通过Frida来获取。4.2 使用Frida获取关键函数地址修改之前的Frida脚本不仅Hook Java方法还要Hook JNI的RegisterNatives或直接枚举so的导出函数来找到Native函数的绝对地址。// frida_find_addr.js Java.perform(function() { // 方法1Hook RegisterNatives (更精准) var symbols Module.enumerateSymbolsSync(libsign.so); for (var i 0; i symbols.length; i) { var sym symbols[i]; // 查找JNI注册函数通常包含RegisterNatives或JNI_OnLoad if (sym.name.indexOf(RegisterNatives) ! -1 || sym.name.indexOf(JNI_OnLoad) ! -1) { console.log([*] Found potential registration function: sym.name at sym.address); Interceptor.attach(ptr(sym.address), { onEnter: function(args) { // args[2] 是一个JNINativeMethod结构体数组指针 var methodsPtr args[2]; console.log([*] JNINativeMethod array at: methodsPtr); // 可以在这里解析结构体获取每个Native函数的名字和地址 } }); } // 方法2直接搜索我们已知的符号名如果so没有剥离符号 if (sym.name.indexOf(Java_com_xxx_yyy_SignUtils_getXSign) ! -1) { console.log([] Found target function: sym.name at sym.address); // 计算偏移量地址 - so基地址 var moduleBase Module.getBaseAddress(libsign.so); var offset sym.address - moduleBase; console.log( Module Base: moduleBase); console.log( Offset: 0x offset.toString(16)); // 这个offset就是我们要填入Unidbg的 callFunction 的地址。 } } });运行这个脚本获取到目标函数在libsign.so中的偏移地址例如0x12345。将这个偏移地址加上Unidbg中module.base加载基址就得到了在Unidbg环境中的绝对地址用于callFunction。4.3 利用Unidbg进行指令级Trace这是Unidbg最强大的功能。我们可以在调用目标函数前后开启Trace记录下所有的指令执行和内存访问。public String getXSignWithTrace(String param1, String param2) { // 创建调试器 emulator.attach().addBreakPoint(module.base 0x12345); // 在目标函数入口下断点 // 设置Trace emulator.traceCode(module.base, module.base module.size); // Trace整个模块的代码执行 emulator.traceRead(module.base, module.base module.size); // Trace内存读 emulator.traceWrite(module.base, module.base module.size); // Trace内存写 // 设置输出到文件 emulator.getMemory().setRedirect(module.base, new File(trace.log)); // 调用函数 Number result module.callFunction(emulator, module.base 0x12345, ...); // 关闭Trace emulator.getMemory().setRedirect(module.base, null); StringObject so (StringObject) vm.getObject(result.intValue()); return so.getValue(); }执行后会生成一个庞大的trace.log文件。里面记录了从函数入口开始每一条ARM指令的执行情况、寄存器的值、以及每次内存读写的位置和数据。这正是逆向分析所需的“上帝视角”。4.4 分析Trace日志定位关键操作打开trace.log内容格式类似[0x40001234] ldr r0, [r1, #0x4] ; r10x40500000 - r00x61616161 (aaaa) [0x40001238] add r2, r0, r3 ; r00x61616161, r30x5 - r20x61616166 [0x4000123c] str r2, [sp, #0x10] ; write 0x61616166 to memory 0xbefff010我们的目标是找到最终生成x-sign字符串的“写入”操作。就像文章开头示例中那样搜索Memory WRITE记录特别是写入到最终输出缓冲区的那几次。通过观察写入的数据如0x37557a61对应小端序azU7可以拼出最终的签名字符串。你需要结合IDA的静态分析。在IDA中打开libsign.so跳转到Unidbg Trace中记录的关键地址如0x401335d0查看该处的反编译代码。通常最终写入字符串的操作会发生在类似memcpy,strcpy, 或者循环赋值的地方。往前追溯找到生成这些数据的源头可能是某个加密函数如MD5_Init, MD5_Update, MD5_Final的调用或者是自定义算法的循环。5. 核心算法还原与代码实现通过反复的Trace和静态分析我们逐步理清了算法逻辑。假设分析发现x-sign的生成流程如下将输入参数param1和param2用连接。连接后的字符串加上一个固定的盐值salt。对步骤2的结果进行一次MD5计算。将MD5的16字节结果与另外41个字节的固定数据可能来自设备信息或其他固定值进行拼接。对步骤4拼接后的完整字节数组再进行一次自定义的变换可能包含位移、异或、查表等操作。将变换后的结果进行Base64编码得到最终的x-sign。5.1 还原自定义变换逻辑这是最复杂的一步需要仔细分析Trace中内存数据的变化。例如在Trace中你发现了一段循环它遍历一个字节数组对每个字节进行如下操作byte[i] ((byte[i] 4) 0xFF) | ((byte[i] 4) 0xFF); byte[i] byte[i] ^ 0x5A;那么这个变换逻辑就被还原了。你需要将Trace中观察到的所有算术和逻辑运算都记录下来。5.2 编写完整的算法还原代码Python示例基于以上分析我们可以用Python实现这个算法import hashlib import base64 def custom_transform(data: bytes) - bytes: 还原的自定义变换函数 transformed bytearray(data) for i in range(len(transformed)): # 假设分析出的变换是高低4位互换然后与0x5A异或 low transformed[i] 0x0F high (transformed[i] 0xF0) 4 transformed[i] ((low 4) | high) ^ 0x5A return bytes(transformed) def generate_x_sign(param1: str, param2: str, fixed_salt: str, fixed_41_bytes: bytes) - str: 生成x-sign :param param1: 动态参数1 :param param2: 动态参数2 :param fixed_salt: 固定的盐值从so中分析得出 :param fixed_41_bytes: 固定的41字节数据从so中分析得出 :return: x-sign字符串 # 1. 拼接参数 combined_str param1 param2 # 2. 加盐 salted_str combined_str fixed_salt # 3. MD5 md5_hash hashlib.md5(salted_str.encode(utf-8)).digest() # 16 bytes # 4. 拼接固定数据 full_data md5_hash fixed_41_bytes # 16 41 57 bytes # 5. 自定义变换 transformed_data custom_transform(full_data) # 6. Base64编码 x_sign base64.b64encode(transformed_data).decode(utf-8) return x_sign # 使用示例 if __name__ __main__: # 这些固定值需要从Unidbg Trace或静态分析中提取 FIXED_SALT your_salt_from_so FIXED_41_BYTES bytes.fromhex(0123456789abcdef...) # 41字节的Hex test_param1 value1 test_param2 value2 sign generate_x_sign(test_param1, test_param2, FIXED_SALT, FIXED_41_BYTES) print(fGenerated x-sign: {sign}) # 与Frida Hook抓取的真实签名进行对比验证6. 常见问题与排查技巧实录在实际操作中你一定会遇到各种各样的问题。这里分享一些我踩过的坑和解决技巧。6.1 Unidbg环境初始化失败问题加载so时崩溃报错Invalid instruction或Unsupported memory read。排查检查架构确认你的so是armeabi-v7a(32位ARM) 还是arm64-v8a(64位ARM)。Unidbg需要对应构建模拟器 (for32Bit()/for64Bit())。检查依赖目标so可能依赖其他so如libc_shared.so,liblog.so。确保在Unidbg中通过memory.setLibraryResolver正确加载了所有依赖库。可以先用readelf -d libsign.so | grep NEEDED命令查看依赖。初始化顺序确保先调用dm.callJNI_OnLoad(emulator)再调用其他JNI函数。6.2 Trace文件过大或无效问题Trace了整个模块日志文件瞬间几十GB或者Trace的内容没有包含关键函数。技巧精准Trace不要一开始就Trace整个模块。先在目标函数入口 (module.base offset) 和下一条指令 (4) 处下断点。当断点命中后再开启局部的Trace。例如emulator.traceCode(breakPointAddress, breakPointAddress 0x1000)只Trace函数开始的一小段。条件断点Unidbg的Debugger支持条件断点。可以在内存写入特定值如写入最终输出缓冲区时再触发Trace极大减少日志量。使用traceWrite和traceRead很多时候只关心内存读写就足够了traceCode信息量太大。优先使用traceWrite来捕捉最终结果的生成。6.3 Frida Hook Native函数失败问题HookRegisterNatives没触发或者Hook了函数但参数不正确。排查时机问题确保Frida脚本在so加载之前就被注入。使用-f参数在App启动时附加或者Hookdlopen函数。符号剥离发布版的so通常剥离了符号Java_com_xxx_yyy_SignUtils_getXSign这样的符号名不存在。此时需要通过RegisterNatives来动态查找。HookRegisterNatives后解析其第三个参数JNINativeMethod*数组打印出每个方法的name和signature找到你的目标方法。参数转换JNI函数的第一个参数是JNIEnv*第二个参数是jclass或jobject。在Frida中直接读取args[2],args[3]等才是真正的业务参数。需要使用Java.vm.getEnv().getStringUtfChars()等方法来正确转换jstring等对象。6.4 算法还原后结果不一致问题自己实现的算法生成的签名与Frida抓取的真实签名对不上。调试数据比对在Unidbg中在算法关键节点如MD5计算前、变换前、Base64编码前使用Inspector.inspect(bytes, “label”)打印出字节数组。同时在你的Python实现中对应位置也打印出字节数组的Hex。逐段比对找到第一个出现差异的地方。检查编码确保所有字符串到字节的编码一致UTF-8GBK。确保Base64编码的配置一致标准Base64还是URL Safe是否有填充。注意字节序Trace中看到的内存数据是小端序Little-Endian在还原代码处理多字节数据如int时要注意。固定值提取错误重新检查从so中提取的“盐值”和“固定41字节”是否正确。可以使用Unidbg的Debugger在算法使用这些值的地方下断点直接导出内存中的值。6.5 附完整的Unidbg Debugger Hook代码示例以下是一个在Unidbg中在特定地址下断点并导出上下文信息的完整示例这对于提取中间数据非常有用import com.github.unidbg.debugger.Debugger; import com.github.unidbg.debugger.DebuggerFactory; import com.github.unidbg.debugger.ida.AndroidServerDebugger; // ... 在初始化模拟器后 ... Debugger debugger emulator.attach(); long targetAddr module.base 0xABCD; // 你找到的关键函数地址或指令地址 debugger.addBreakPoint(targetAddr, new BreakPointCallback() { private int hitCount 0; Override public boolean onHit(Emulator? emulator, long address) { hitCount; RegisterContext context emulator.getContext(); System.out.println(String.format([BreakPoint %d] Hit at 0x%x, hitCount, address)); // 读取ARM寄存器 (32位环境) int r0 context.getIntArg(0); // 第一个参数 int r1 context.getIntArg(1); // 第二个参数 // ... 读取其他寄存器 // 如果参数是指针指向了有意义的数据如字符串、数组 if (r0 ! 0) { UnidbgPointer ptr UnidbgPointer.pointer(emulator, r0); if (ptr ! null) { // 假设它指向一个以null结尾的C字符串 String str ptr.getString(0); System.out.println( Arg1 (String): str); // 或者指向一个字节数组 // byte[] data ptr.getByteArray(0, length); // Inspector.inspect(data, Data at breakpoint); } } // 打印调用栈有助于理解函数层次 emulator.getUnwinder().unwind(); // 返回true表示暂停模拟器false则继续执行用于快速打印 return false; } });逆向工作就像侦探破案需要耐心、细心和逻辑推理。Unidbg和Frida是你的放大镜和指纹采集仪。通过动态抓取与静态分析的反复印证最终总能拨开迷雾还原出算法的本来面目。记住没有无法逆向的算法只有还没找到的入口和还没花够的时间。每一次成功的逆向不仅是对目标的理解更是对自身分析能力的一次锤炼。