
1. 项目概述从黑盒到白盒的逆向之旅在移动安全与逆向工程领域Android Native层的防护一直是攻防对抗的核心地带。当一个应用的关键业务逻辑比如核心的签名Sign算法被下沉到C/C编写的原生库.so文件中时传统的Java层Hook和分析手段就会瞬间失效。这就像面对一个上了锁的保险箱你知道里面有重要的东西但钥匙孔在另一个维度。最近我在分析B站客户端某个接口的签名生成机制时就遇到了这个典型的“黑盒”挑战。签名算法完全隐藏在libbangumi.so这样的原生库中通过JNIJava Native Interface与Java层交互直接进行静态分析犹如大海捞针动态调试又门槛极高。这个项目的目标非常明确在不逆向分析庞大且可能混淆过的.so文件汇编代码的前提下动态地捕获并理解其Sign算法的输入、输出及关键计算过程。这听起来像是一个不可能的任务但借助Frida和JNItrace这两个“神器”的组合我们能够搭建一座桥梁穿透Java与Native的边界让黑盒算法逐渐变得透明。Frida提供了强大的动态插桩能力而JNItrace则专门用于透视JNI调用。通过这个实战案例我想分享的不仅是如何搞定一个具体的Sign算法更是一套在面对任何Android Native层加密逻辑时都可以尝试的通用化、高效率的动态分析思路与方法论。无论你是移动安全研究员、爬虫工程师还是对底层原理感兴趣的开发者这套组合拳都能为你打开一扇新的大门。2. 核心工具链Frida与JNItrace的黄金组合工欲善其事必先利其器。在Android Native逆向中选择合适的工具直接决定了分析过程的效率和成功率。这个案例的核心依赖于两个工具Frida和JNItrace。它们并非简单的并列关系而是构成了一个从“宏观监控”到“微观Hook”的递进式分析体系。2.1 Frida动态插桩的瑞士军刀Frida是一个功能强大的动态代码插桩框架。它的核心原理是注入一个Google V8引擎到目标进程中然后允许我们使用JavaScript或Python脚本来实时地Hook挂钩目标函数、读写内存、甚至动态修改程序逻辑。在Android逆向中Frida通常用于Hook Java函数这是Frida最基础也是最常用的功能可以轻松拦截Java层的函数调用和返回。Hook Native函数通过函数符号名或内存地址直接Hook so库中的C/C函数这是攻克Native算法的关键。内存操作动态地搜索、读取、修改进程内存数据对于定位密钥、常量或中间变量至关重要。主动调用在运行时主动调用某个函数并指定参数用于测试或爆破。在这个项目中我们主要利用Frida的Native Hook能力。但问题来了面对一个陌生的.so文件我们如何知道该Hook哪个函数函数名是什么参数和返回值又是什么类型这正是JNItrace要解决的先决问题。2.2 JNItrace照亮JNI调用的探照灯JNItrace是一个基于Frida开发的专用工具它的设计目标非常聚焦追踪和记录一个Android应用中所有的JNI调用。当Java代码需要调用Native函数或者Native代码需要回调Java方法时都必须通过JNI接口。JNItrace通过Hook这些JNI接口函数如CallObjectMethod,CallStaticVoidMethod,GetStringUTFChars,NewByteArray等能够以清晰的日志形式展示出每一次跨越边界的调用详情。它的输出通常包括调用栈显示是从哪一行Java代码或哪一个Native地址发起的调用。函数签名被调用的Java方法签名或Native函数名。参数值传递的具体参数如字符串、数组、基本类型数值等。返回值调用返回的结果。对于我们的Sign算法逆向任务JNItrace的价值在于快速定位入口点。我们不需要一开始就去分析so的导出表或逆向汇编。我们只需要在App执行签名操作时比如点击播放、刷新列表运行JNItrace它就会告诉我们是哪个Java类的方法调用了Native方法这个Native方法的函数名是什么通常是Java_com_bilibili_xxx_sign这样的格式调用时传入了哪些参数很可能就包含了待签名的原始数据Native方法返回了什么很可能就是计算好的签名注意JNItrace会产生大量日志在复杂应用中可能会影响性能甚至导致卡顿。在实际操作中通常需要结合-i包含过滤器或-e排除过滤器参数来只关注我们感兴趣的类或方法否则信息洪流会让人无从下手。2.3 工具链协同工作流理解了这两个工具的角色我们的工作流就清晰了侦察阶段JNItrace使用JNItrace对目标App进行全局监听触发签名操作从海量JNI调用日志中筛选出与签名相关的关键调用记录。这一步帮我们找到“敌人在哪里”以及“他们交换了什么物资”。定位阶段分析日志分析JNItrace日志确定负责签名的Native函数名例如native_sign及其对应的Java层声明。同时明确函数的输入参数类型、数量、顺序和返回值类型。深入分析阶段Frida Native Hook编写专门的Frida脚本精确Hook上一步定位到的Native函数。在Hook脚本中我们不仅可以打印输入输出还可以追溯内部调用Hook这个Native函数内部可能调用的其他关键函数如加密库函数MD5_Init,AES_encrypt等。监控内存变化在函数执行前后读取关键内存地址的数据观察中间状态。参数篡改测试修改输入参数观察输出变化验证算法逻辑。验证与复现阶段根据Hook得到的数据流和逻辑关系使用Python或Java等高级语言尝试复现整个签名算法。这套组合拳的优势在于“非侵入性”和“高效率”。我们无需修改APK无需深入理解复杂的反汇编代码而是通过动态观察来推导逻辑非常适合快速分析、协议还原等场景。3. 实战拆解定位B站Sign算法的JNI边界理论说得再多不如一次实战。我们假设目标是通过B站App的某个接口例如获取视频播放地址来定位其Sign生成逻辑。请注意以下分析基于通用技术原理具体函数名和类名可能随版本更新而变化但方法论是普适的。3.1 环境准备与目标确认首先你需要一个Root过的Android真机或模拟器如雷电模拟器并安装好Frida环境。在电脑上安装好frida-tools、jnitrace可通过pip install jnitrace安装。将目标App例如tv.danmaku.bili安装到设备上。第一步不是直接上工具而是确认分析目标。通过抓包工具如Charles、Fiddler或mitmproxy拦截App的网络请求你会发现关键接口的请求参数中通常包含一个名为sign、_sign或w_rid的字段其值是一长串看似随机的十六进制字符串。这个字段就是我们的终极目标。记下发起这个请求的接口地址和大致时机例如进入视频播放页时。3.2 运行JNItrace进行初次侦察通过ADB连接设备并确保Frida Server已在设备上运行。使用以下命令启动JNItrace附加到目标App进程jnitrace -l libbangumi.so -m tv.danmaku.bili解释一下参数-l libbangumi.so这是一个过滤器只追踪与libbangumi.so这个库相关的JNI调用。因为前期抓包或经验可能暗示签名逻辑在这个库中。如果不确定可以先不加-l参数进行全局监听但日志量会非常大。-m tv.danmaku.bili指定目标App的包名。运行命令后JNItrace会输出等待连接的提示。此时在手机上操作App触发那个携带sign参数的网络请求比如点开一个视频。JNItrace控制台会开始刷出大量的日志。3.3 从日志海洋中捕捞关键信息面对快速滚动的日志我们需要寻找一些关键模式寻找“Call”类型调用重点关注CallStaticObjectMethod、CallObjectMethod、CallStaticIntMethod等。因为Java调用Native签名函数通常是为了获取一个返回对象如String或基本类型结果。搜索关键词在日志中搜索“sign”、“Sign”、“md5”、“sha”、“encode”等字符串。JNItrace会打印出Java方法名和对应的Native函数名。分析调用栈找到疑似调用后查看其调用栈。调用栈会显示这个Native调用是从哪个Java类的哪个方法发起的。这能帮助我们追溯到最上层的Java入口。一段简化后的、可能出现的关键日志示例如下... [] CallStaticObjectMethod called from libbangumi.so!0xa4b3c |- JNIEnv*: 0x7a4c8d2000 |- jclass: com.bilibili.lib.security.SignHelper |- Method: sign (Ljava/lang/String; Ljava/lang/String;)Ljava/lang/String; |- Arguments: [0x7fe4d5a3b0 “apivideo.viewcid1234567...”, 0x7fe4d5a3d0 “a1b2c3d4e5f6”] |- Return Value: “wrf1h89j3nvm45lpx7zq0...” (0x7fe4d5a3f0) ...这段日志是黄金信息它告诉我们Native函数位置它位于libbangumi.so中地址0xa4b3c。Java层对应关系这个Native函数在Java层被声明为com.bilibili.lib.security.SignHelper类中的一个静态本地方法sign。方法签名(Ljava/lang/String; Ljava/lang/String;)Ljava/lang/String;。这表明它接受两个String参数并返回一个String。具体参数第一个参数是待签名的原始参数字符串如apivideo.viewcid1234567...第二个参数可能是一个固定的密钥或盐值a1b2c3d4e5f6。返回值返回的正是我们抓包看到的那个长长的签名串wrf1h89j3nvm45lpx7zq0...。至此我们成功完成了侦察任务精准定位了目标我们需要深入分析的就是libbangumi.so中实现这个Java_com_bilibili_lib_security_SignHelper_sign函数的内部逻辑。实操心得JNItrace的日志非常详细初次使用容易被淹没。务必善用-i包含和-e排除过滤器。例如如果你已经知道签名相关的Java类在com.bilibili.lib.security包下可以使用jnitrace -i com.bilibili.lib.security.* -m tv.danmaku.bili来大幅减少无关日志聚焦目标。4. 深入虎穴使用Frida Hook Native函数有了明确的目标函数信息我们就可以从“宏观监控”转向“微观解剖”使用Frida编写精准的Hook脚本。4.1 编写Frida Hook脚本我们创建一个名为hook_sign.js的脚本。根据JNItrace的信息我们知道要Hook的函数名或其对应的Java Native方法名转换后的符号。在Linux/Android的so库中JNI函数的命名规则通常是Java_包名_类名_方法名其中点号被替换为下划线。因此我们的目标函数符号很可能是Java_com_bilibili_lib_security_SignHelper_sign。Java.perform(function () { // 首先我们可以先Hook Java层方法作为辅助验证和触发点 var SignHelper Java.use(‘com.bilibili.lib.security.SignHelper’); SignHelper.sign.overload(‘java.lang.String’, ‘java.lang.String’).implementation function (param1, param2) { console.log(‘[Java Hook] SignHelper.sign called!’); console.log(‘[Java Hook] Param1 (raw string): ‘ param1); console.log(‘[Java Hook] Param2 (key/salt): ‘ param2); var result this.sign(param1, param2); // 调用原方法 console.log(‘[Java Hook] Result (sign): ‘ result); return result; }; // 重点Hook Native层的函数 // 首先解析so库的基地址 var libbangumi Module.findBaseAddress(‘libbangumi.so’); if (libbangumi) { console.log(‘[Native] libbangumi.so base address: ‘ libbangumi); // 方式一如果知道函数符号名直接通过Module.getExportByName获取地址 var nativeSignFuncAddr Module.getExportByName(‘libbangumi.so’, ‘Java_com_bilibili_lib_security_SignHelper_sign’); // 方式二如果函数是动态注册RegisterNatives而非静态导出上述方法会失败。 // 此时需要通过JNItrace日志中的偏移地址如0xa4b3c来计算绝对地址 // var offset 0xa4b3c; // var nativeSignFuncAddr libbangumi.add(offset); if (nativeSignFuncAddr) { console.log(‘[Native] Target function address: ‘ nativeSignFuncAddr); // 使用Interceptor.attach进行Hook Interceptor.attach(nativeSignFuncAddr, { onEnter: function (args) { // 在函数进入时打印参数 // JNI函数的前两个参数通常是JNIEnv*和jclass/jobject console.log(‘\n[Native Hook] Java_com_bilibili_lib_security_SignHelper_sign Entered ’); // args[0]是JNIEnv* // args[1]是jclass (静态方法) 或 jobject (实例方法) // 我们的两个String参数从args[2]和args[3]开始 var jniEnv args[0]; var jclassObj args[1]; // 将jstring转换为可读的C字符串 var param1Ptr args[2]; // jstring var param2Ptr args[3]; // jstring if (param1Ptr ! 0) { var param1CStr Java.vm.getEnv().getStringUtfChars(param1Ptr, null).readCString(); console.log(‘[Native Hook] Param1 (C String): ‘ param1CStr); } if (param2Ptr ! 0) { var param2CStr Java.vm.getEnv().getStringUtfChars(param2Ptr, null).readCString(); console.log(‘[Native Hook] Param2 (C String): ‘ param2CStr); } // 可以在这里保存参数供onLeave时使用 this.param1Saved param1Ptr; this.param2Saved param2Ptr; }, onLeave: function (retval) { // 在函数离开时打印返回值 console.log(‘[Native Hook] Function Leaving ’); // retval是一个jstring对象指针 if (retval ! 0) { var resultCStr Java.vm.getEnv().getStringUtfChars(retval, null).readCString(); console.log(‘[Native Hook] Result (C String): ‘ resultCStr); } console.log(‘[Native Hook] \n’); } }); } else { console.log(‘[Native] Failed to find target function address.’); } } else { console.log(‘[Native] libbangumi.so not loaded yet.’); } });4.2 脚本详解与关键技巧这个脚本做了几件关键事情Java层Hook辅助首先Hook Java层的SignHelper.sign方法。这能让我们在Native Hook生效前或作为对照确认调用确实发生并验证参数。这是一个很好的双保险。定位Native函数尝试通过函数符号名查找地址。如果失败说明函数是动态注册则使用JNItrace中得到的偏移地址加上so基地址来计算绝对地址。这是处理动态注册JNI函数的常用技巧。Hook并打印参数在onEnter回调中我们解析JNI函数的标准参数列表。对于jstring类型的参数我们不能直接读取指针必须通过JNIEnv提供的函数如GetStringUTFChars将其转换为C字符串。这里我们使用了Java.vm.getEnv()来获取当前的JNIEnv指针然后调用其方法。打印返回值在onLeave回调中同样将返回的jstring转换为C字符串后打印。重要注意事项在Native Hook中直接调用JNIEnv的函数如GetStringUTFChars是危险操作因为此时线程状态和JNIEnv的可用性需要谨慎处理。上述代码在简单场景下可能工作但在复杂多线程环境中更稳妥的做法是在onEnter中只保存jstring的指针然后在onLeave中再进行转换和打印或者使用Frida的NativeFunction来模拟调用更底层的转换函数。一个常见的替代方案是直接读取jstring对象在内存中的内容对于Android ART运行时jstring内部有一个指向实际字符数据的指针但这需要对Android运行时结构有深入了解。4.3 运行脚本并观察将脚本推送到设备或通过Frida CLI加载frida -U -l hook_sign.js -f tv.danmaku.bili --no-pause再次在App中触发签名操作。你将在控制台看到来自Java层和Native层的详细日志。对比两者你应该能看到完全一致的输入和输出这证实了我们的Hook是成功的并且清晰地展示了算法的“边界行为”输入什么输出什么。5. 算法逻辑推断与内部函数追踪仅仅知道输入输出还不够我们的目标是复现算法。接下来需要深入这个Native函数内部看它到底做了什么。5.1 追溯内部调用链修改Frida脚本在Hook了目标函数之后继续Hook一些常见的加密哈希函数。因为Sign算法极有可能使用了MD5、SHA-1、SHA-256、HMAC或AES等标准算法。这些函数通常来自系统库如libcrypto.so或被静态链接到目标so中。// 在之前的脚本Interceptor.attach部分后添加 // 假设我们怀疑内部用了MD5 var md5InitAddr Module.findExportByName(‘libcrypto.so’, ‘MD5_Init’); var md5UpdateAddr Module.findExportByName(‘libcrypto.so’, ‘MD5_Update’); var md5FinalAddr Module.findExportByName(‘libcrypto.so’, ‘MD5_Final’); if (md5InitAddr) Interceptor.attach(md5InitAddr, { onEnter: function(args) { console.log(‘[Crypto] MD5_Init called’); } }); if (md5UpdateAddr) Interceptor.attach(md5UpdateAddr, { onEnter: function(args) { /* 可以尝试打印args[1]指向的更新数据 */ } }); if (md5FinalAddr) Interceptor.attach(md5FinalAddr, { onEnter: function(args) { console.log(‘[Crypto] MD5_Final called, digest will be stored at: ‘ args[1]); } });通过观察这些底层加密函数是否被调用、何时被调用、以及传入的数据我们可以推断出签名的大致流程比如是否是先拼接字符串然后进行MD5哈希最后可能再做一次十六进制编码或Base64编码。5.2 内存断点与数据监控另一种更底层的方法是使用Frida的Memory.scan或MemoryAccessMonitor来监控特定内存区域的变化。例如如果我们从MD5_Final的输出地址得到了签名结果的中间状态一个16字节的MD5摘要我们可以监控这块内存看后续是否有其他函数对它进行了处理比如转换为十六进制字符串。// 这是一个概念性示例实际操作更复杂 var suspectedDigestAddr ptr(‘0x7fe4d8a000’); // 假设这是MD5结果存放地址 MemoryAccessMonitor.enable({ base: suspectedDigestAddr, size: 16 }, { onAccess: function (details) { console.log(‘Memory accessed at ‘ details.address ‘ by ‘ details.from); // 打印访问时的调用栈看看是谁在操作这块数据 console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join(‘\n’) ‘\n’); } });5.3 参数篡改与逻辑验证动态分析的最大优势是可以进行交互式测试。我们可以在Hook的onEnter回调中修改传入的参数观察输出如何变化从而验证猜想。onEnter: function (args) { // ... 读取原始参数 ... var originalStr ...; // 测试1如果改变一个参数签名是否完全变化验证算法是否对所有输入敏感 // 测试2如果传入空字符串或固定字符串输出是否固定验证是否有盐值或密钥参与 // 注意直接修改args数组中的指针是危险且困难的通常的做法是修改jstring指向的内容或者更简单地在Java层Hook时就返回一个伪造的结果。 }更安全的做法是在Java层Hook的implementation中直接返回一个我们计算好的值从而绕过Native调用但这需要我们已经对算法有了一定猜测。6. 常见问题、排查技巧与避坑指南在实际操作中你几乎一定会遇到下面这些问题。这里记录了我踩过的坑和总结的应对策略。6.1 JNItrace无输出或找不到目标库问题运行jnitrace -l libxxx.so后没有任何日志输出或者提示找不到库。排查库未加载目标so库可能是在运行时动态加载的System.loadLibrary。确保在触发签名操作之后再启动JNItrace或者使用-m参数在App启动时就注入。库名错误so库的名称可能不准确。使用frida-ps -Ua找到目标进程ID然后使用frida -U PID附加后在Frida CLI中运行Process.enumerateModules()来列出所有已加载的模块确认正确的库名。过滤器太严-l参数指定的库名可能只是主库实际JNI调用可能发生在其他依赖库中。尝试不加-l参数进行全局监听或者使用-i过滤Java类。6.2 Frida Hook Native函数失败问题Module.getExportByName返回null无法Hook。排查动态注册这是最常见的原因。目标Native函数不是通过传统的JNI_OnLoad静态导出而是在运行时通过RegisterNatives动态注册的。JNItrace日志中显示的地址是偏移地址。解决方案是使用Module.findBaseAddress获取so基址然后加上偏移地址得到绝对地址进行Hook。函数符号剥离发布版本的so可能被剥离了符号表getExportByName依赖符号名。此时只能通过偏移地址、特征码扫描或HookRegisterNatives函数本身来定位目标函数。时机问题脚本注入时目标so库可能还未加载。将Hook代码包裹在setImmediate或监听Module.load事件中。Module.load(‘libbangumi.so’).then(function(module) { console.log(‘libbangumi.so loaded, base: ‘ module.base); // 在这里执行Hook逻辑 });6.3 Hook导致App崩溃或行为异常问题注入Frida脚本后App闪退或功能不正常。排查JNIEnv滥用在Native Hook的回调中不当使用JNIEnv指针是导致崩溃的主要原因。确保只在有效的JNI上下文中调用JNI函数。一个保守的策略是在onEnter中只保存参数指针在onLeave中也不进行复杂操作仅打印。更深入的分析可以考虑使用Frida的NativeFunction来调用libandroid_runtime中的相关函数或者直接进行内存读取。多线程竞争签名操作可能在多线程环境中进行。你的Hook回调函数必须是线程安全的。避免使用全局变量而不加锁打印日志时注意线程ID。反调试/反注入一些加固的App会检测Frida。需要尝试一些绕过手段如修改Frida默认端口、使用隐藏进程名的Frida Server、或者使用frida-gum的更底层API进行隐蔽注入。6.4 算法复杂内部调用链难以理清问题即使Hook了入口函数和常见加密函数算法流程依然像一团乱麻涉及多个so库和复杂运算。策略分而治之不要试图一次性理解所有。先确保能稳定捕获到“输入字符串A”产生“输出签名B”这个基本事实。黑盒测试设计多组有规律的输入如递增的数字、重复的字符观察输出的变化。这可以帮助判断算法是否是简单的哈希是否包含时间戳、随机数等。关注数据流使用Frida的Stalker功能追踪一小段代码的执行流程或者通过内存访问监控跟踪关键数据如初始字符串、中间哈希值、最终结果在内存中的流动和变形过程。借鉴与搜索很多App的签名算法并非完全独创它们可能基于某个公开的协议或算法库如某款开源网络库。将抓取到的输入输出样本结合你观察到的可能算法类型如MD5后取子串、Base64编码等去搜索引擎或代码仓库如GitHub搜索可能会有意外发现。6.5 性能问题与优化问题JNItrace或Frida脚本导致App运行极其缓慢。优化精准过滤这是最重要的。JNItrace务必使用-i/-eFrida脚本的Hook点要尽可能精确避免挂载像strlen、memcpy这样被高频调用的通用函数。减少日志输出在调试稳定后将console.log替换为条件输出或者写入文件避免频繁的I/O操作阻塞线程。使用C模块对于性能要求极高的跟踪可以考虑使用Frida的C模块来编写Hook代码但复杂度会大大增加。逆向工程是一场耐心的博弈尤其是面对Native层这种“深水区”。Frida和JNItrace给了我们强大的动态观测能力但如何解读观测到的现象如何设计实验去验证猜想依然依赖于分析者的经验和思维。这个B站Sign算法的案例展示的是一条标准化的攻击路径从网络协议定位目标用JNItrace进行边界侦察用Frida进行深入Hook和动态分析。掌握这条路径你就能应对大多数类似的Android Native层算法逆向挑战。