ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Frida Hook与Protobuf解析:逆向工程中穿透SO层与二进制协议的实战指南

Frida Hook与Protobuf解析:逆向工程中穿透SO层与二进制协议的实战指南 1. 逆向工程中的“黄金搭档”Frida Hook与Protobuf解析在移动安全分析、应用逆向和协议破解的圈子里我们经常会遇到一个棘手的组合核心逻辑被编译在原生共享库.so文件里而网络传输的数据则采用了高效的二进制序列化格式。最近我在分析一个金融类应用的数据流时就撞上了这个典型的“硬骨头”——关键的业务算法和签名逻辑都封装在so层而所有的请求响应数据都是Protobuf格式。如果你也正在为如何穿透这层“盔甲”而头疼那么我接下来要分享的这套基于Frida的实战方案或许能给你提供一条清晰的路径。这不仅仅是工具的使用更是一套从定位、Hook到解析、还原的完整方法论尤其适合那些需要对App进行深度行为分析、协议逆向或者漏洞挖掘的同行。简单来说我们的目标有两个一是用Frida这把“手术刀”精准地Hook住so层中的关键函数实时监控或修改其输入输出二是将Hook到的、看似乱码的Protobuf二进制数据还原成我们能读懂的结构化信息。整个过程就像是在不破坏外壳的情况下给一个正在运行的精密仪器安装上监控探头和数据解码器。下面我就结合这次实战把每一步的操作细节、踩过的坑以及总结出的技巧毫无保留地梳理出来。2. 环境准备与目标分析磨刀不误砍柴工在动手写第一行Hook脚本之前充分的准备工作是成功的一半。盲目地开始往往意味着要在各种环境错误和目标偏移上浪费大量时间。2.1 Frida环境搭建与选型首先自然是Frida。我强烈建议在Linux或macOS上进行开发因为很多辅助工具链在这两个平台上更顺畅。我的主力环境是Ubuntu 22.04。Frida安装直接使用pip安装是最快的方式。但这里有个关键点Frida-tools版本必须与Frida-server版本严格匹配。不匹配会导致连接失败或出现奇怪的错误。我通常使用固定版本号安装以确保稳定性。pip install frida-tools16.1.4 frida16.1.4安装后用frida --version检查一下。Frida-server部署这是运行在目标设备通常是Android手机或模拟器上的守护进程。你需要根据设备的CPU架构arm,arm64,x86_64等去Frida的GitHub Releases页面下载对应的frida-server二进制文件。推送到设备并启动adb push frida-server-16.1.4-android-arm64 /data/local/tmp/ adb shell cd /data/local/tmp chmod 755 frida-server-16.1.4-android-arm64 ./frida-server-16.1.4-android-arm64 保持这个shell窗口不要关闭。然后在主机上使用frida-ps -U命令如果能看到设备上的进程列表说明连接成功。注意对于需要Hook的系统级应用或某些加固过的App可能需要将手机root或者使用模拟器如Genymotion并刷入带root权限的系统镜像。非root环境虽然可以通过frida-gadget注入但步骤更繁琐且对App有侵入性。2.2 目标App与So库分析目标是一个使用Protobuf进行网络通信的App。我们的首要任务是找到负责核心逻辑和Protobuf序列化/反序列化的so库。提取APK与分析结构使用apktool解压APK文件。apktool d target_app.apk -o output_dir解压后重点关注lib/目录下的子文件夹如armeabi-v7a,arm64-v8a里面存放着.so文件。定位关键库并非所有so库都需要关注。我通常通过以下线索筛选库名包含crypto,sign,encrypt,protocol,pb,protobuf等字眼的库嫌疑最大。导入导出函数使用readelf或objdump工具查看so文件的动态符号表。# 查找所有导出函数 readelf -Ws libtarget.so | grep FUNC # 查找所有导入函数如可能调用了libprotobuf的函数 readelf -Ws libtarget.so | grep UND字符串分析使用strings命令快速搜索so文件中是否包含Protobuf相关的特征字符串如.proto文件中定义的message名称、字段名等。strings libtarget.so | grep -i login\|token\|request\|response确定Hook点这是我们最核心的一步。对于Protobuf关键函数通常是序列化SerializeToArray,SerializeToString和反序列化ParseFromArray,ParseFromString。我们需要在目标so中找到这些函数的地址。可以使用反汇编工具如IDA Pro, Ghidra, radare2进行静态分析定位这些函数。更动态的方法是先运行App然后用Frida枚举so中所有导出函数再根据函数名猜测。// 这是一个用于枚举模块导出函数的Frida脚本片段 Process.enumerateModules({ onMatch: function(module){ if (module.name.indexOf(libtarget) ! -1) { console.log(Found module: module.name base: module.base); Enumerate.exports(module.name); } }, onComplete: function(){} });通过分析我最终将目标锁定在libcore.so中的两个函数native_encode_request疑似序列化和native_decode_response疑似反序列化。3. Frida Hook So层函数从地址到参数找到了目标函数接下来就是用Frida挂上钩子。这里不仅仅是简单的Interceptor.attach更需要理解如何正确读取参数尤其是处理指针和复杂数据结构。3.1 基础Hook与参数打印假设我们通过分析确定了函数native_encode_request的偏移地址是0x1234相对于so基址。那么Hook脚本如下// hook_so_protobuf.js Java.perform(function () { // 获取目标模块 var libcore Module.findBaseAddress(libcore.so); if (libcore) { console.log([] libcore.so base: libcore); // 计算绝对地址 var encodeFuncAddr libcore.add(0x1234); // 假设的偏移 var decodeFuncAddr libcore.add(0x5678); // 另一个函数偏移 // Hook 编码函数 Interceptor.attach(encodeFuncAddr, { onEnter: function (args) { console.log(\n[] native_encode_request called!); // 通常第一个参数是JNIEnv*第二个是jobject从第三个开始是业务参数 // 假设第三个参数是指向输入结构体的指针arg_2第四个是输出缓冲区指针arg_3 this.inputPtr args[2]; this.outputPtr args[3]; // 打印指针地址 console.log( input struct ptr: this.inputPtr); console.log( output buffer ptr: this.outputPtr); // 我们可以尝试读取inputPtr指向的原始数据前N个字节 var inputData Memory.readByteArray(this.inputPtr, 64); // 读取64字节看看 console.log( input data (hex): Array.prototype.map.call(new Uint8Array(inputData), x (00 x.toString(16)).slice(-2)).join( )); }, onLeave: function (retval) { // 函数返回时输出缓冲区已经被填充 // 读取输出缓冲区的数据这很可能就是Protobuf编码后的字节流 // 首先需要知道长度这里假设通过另一个参数传递或者我们读取一个合理范围 var maxOutputSize 4096; // 假设一个足够大的值 var outputData Memory.readByteArray(this.outputPtr, maxOutputSize); // 我们需要找到实际长度Protobuf数据是自描述的但这里简单处理找到第一个连续为0的片段不严谨。 // 更稳妥的方式是Hook内存分配函数如malloc或根据上下文推断。 console.log( output buffer data (hex, first 128 bytes): Array.prototype.map.call(new Uint8Array(outputData.slice(0, 128)), x (00 x.toString(16)).slice(-2)).join( )); // 将原始字节保存到文件供后续解析 var filePath /sdcard/encode_output.bin; var f new File(filePath, wb); f.write(outputData); f.close(); console.log( [*] Raw protobuf data saved to: filePath); } });这段脚本能让我们在函数调用时打印出关键指针和输入的原始数据在函数返回后捕获可能生成的Protobuf二进制数据并保存到文件。这是一个良好的开端。3.2 处理复杂参数与结构体然而现实往往更复杂。参数可能不是简单的指针而是指向复杂结构体或C对象的指针。直接Memory.readByteArray读出来的可能是一堆无意义的字节因为其中包含成员变量、虚表指针等。策略一根据反汇编结果手动解析。如果你用IDA Pro分析过该函数你可能知道args[2]指向的结构体第一个字段是int type第二个字段是char* username。那么你可以这样读onEnter: function (args) { this.structPtr args[2]; // 读取第一个int字段假设在偏移0处 var msgType Memory.readInt(this.structPtr); // 读取第二个字段它是一个指针假设在偏移4处32位系统 var usernamePtr Memory.readPointer(this.structPtr.add(4)); // 读取该指针指向的C字符串 var username Memory.readCString(usernamePtr); console.log( Message Type: ${msgType}, Username: ${username}); }这需要深厚的逆向功底和对目标函数签名的准确理解。策略二暴力枚举与模糊匹配。当结构未知时我常用的“土办法”是在onEnter时以this.structPtr为起点读取一大块内存比如256字节并保存下来。同时在onLeave时再读取一次。对比两次的数据差异那些在函数执行后发生变化的字节很可能就是函数的输出结果比如计算出的签名、加密后的数据等。这能帮助我们快速定位输出区域。策略三Hook辅助函数。如果目标函数内部调用了更基础的函数比如malloc,memcpy,printf用于调试日志那么Hook这些函数能获得更多上下文信息。例如Hookmemcpy(dest, src, size)如果发现dest是我们监控的输出缓冲区指针那么src和size就告诉我们数据从哪里来、有多大。实操心得在Hook so层时耐心和日志是关键。不要指望一次就能抓到完美数据。我通常会写一个“侦察脚本”循环遍历so中所有可疑的导出函数在每个函数的入口和出口简单地打印参数地址和线程ID。通过大量运行App操作观察哪些函数在关键操作如点击登录时被频繁调用从而缩小目标范围。Frida的Stalker代码跟踪功能虽然强大但开销大且容易崩溃在初期定位阶段慎用。4. Protobuf数据解析从二进制到明文成功Hook并dump出二进制数据比如sdcard/encode_output.bin后我们面对的就是一串Protobuf编码的字节流。解析它需要对应的.proto消息定义文件。然而在逆向中这个文件几乎不可能直接获得。我们需要从二进制数据反推出消息结构这是一个“猜”的过程。4.1 使用Protobuf反编译器第一步是使用工具进行自动化的反编译。最常用的工具是protobuf-inspector。它是一个Python脚本可以解析未知的Protobuf二进制文件并尝试推断出消息定义和打印出可读的数据。# 克隆项目 git clone https://github.com/mildsunrise/protobuf-inspector.git cd protobuf-inspector # 使用脚本解析dump出的文件 python3 protobuf_inspector.py /sdcard/encode_output.bin decoded_output.txt查看decoded_output.txt你会看到类似这样的输出1: 0x08 (varint) 1 2: 0x12 (string) my_username 3: 0x1a (string) my_password_hash ...这表示第一个字段field number1是varint类型值为1第二个字段是字符串类型值为my_username。工具还会在最后尝试生成一个可能的.proto文件定义。这是我们的突破口。4.2 人工分析与结构推测自动工具生成的定义可能不准确尤其是嵌套消息、枚举或某些特殊类型。我们需要结合上下文进行人工分析。字段编号与类型Protobuf编码中每个字段都由字段编号和类型组成。常见的类型对应关系0x08varint0x12length-delimited (通常是字符串或子消息)。我们需要根据字段编号的连续性、值的范围来猜测字段的含义。例如字段1的值是1在登录请求中它很可能是一个LoginType枚举1代表“密码登录”。字符串与二进制对于length-delimited类型它可能是字符串也可能是嵌套的子消息或者纯粹的字节数组如加密数据、签名。如果工具解析出来是乱码那它很可能是一个嵌套消息需要进一步解析。这时你可以尝试将这一串十六进制数据单独保存为另一个文件再用protobuf-inspector解析它看看是否能拆出更深层的结构。结合Hook上下文这是最关键的一步。回想一下我们在Hook时打印的input struct里是不是有一个username如果它的值是my_username而dump出的Protobuf数据里第二个字段的值也是my_username那么我们就可以几乎确定字段2对应的是用户名。用同样的方法把Hook时捕获的输入参数整数、字符串等与Protobuf解析出的值一一对应起来就能逐步还原出整个消息结构。4.3 构建.proto文件与验证基于以上分析我们可以手动编写一个初步的.proto文件。// login_request.proto syntax proto3; message LoginRequest { int32 login_type 1; // 根据值1推测 string username 2; // 与Hook的username对应 string password_hash 3; // 可能是密码哈希 int64 timestamp 4; // 如果下一个varint值很大可能是时间戳 // ... 其他字段 }然后使用官方的protoc编译器生成对应语言的解析代码如Python。protoc --python_out. login_request.proto编写一个Python脚本用生成的解析类去加载我们dump的二进制文件。import login_request_pb2 with open(encode_output.bin, rb) as f: data f.read() req login_request_pb2.LoginRequest() try: req.ParseFromString(data) print(req) print(fLoginType: {req.login_type}, User: {req.username}) except Exception as e: print(fParse failed: {e}. Need to adjust .proto definition.)如果解析成功并打印出符合逻辑的数据说明我们的.proto定义基本正确。如果失败就根据错误信息如“unexpected wire type”调整定义比如把string改成bytes或者调整字段顺序注意字段编号顺序不影响但类型必须匹配。注意事项Protobuf 3 (syntax“proto3”)中字段默认值不参与序列化数字为0字符串为空””。如果你发现某个逻辑上应该存在的字段在解析后是默认值但在Hook上下文里它有非默认值那可能是因为这个字段在App里被定义为optional在proto2中或者是oneof的一部分。你需要反复调整.proto定义这是一个迭代的过程。5. 进阶动态Hook与实时解析联动手动dump文件再解析的效率太低尤其是在调试交互式协议时。我们的终极目标是在Frida脚本中实时Hook、实时解析、实时修改。5.1 在Frida中集成Protobuf解析我们需要在JavaScript的Frida环境中执行Python的Protobuf解析逻辑。有几种方法使用Frida的Python桥接不推荐在移动端比较重环境复杂。将解析逻辑用JavaScript重写这是最直接高效的方式。我们可以使用google-protobuf的JavaScript版本或者使用轻量级的解析库如protobufjs。但由于Frida的Node环境与浏览器/标准Node环境略有差异直接引入可能有问题。我推荐的实用方法利用Frida的send/recv机制与一个本地的Python解析服务进行Socket通信。Frida脚本将二进制数据发送到电脑上的Python脚本Python脚本解析后返回JSON结果再由Frida脚本打印或处理。架构如下Frida脚本 (JS):Hook so函数捕获二进制数据通过send()将其发送到主机。Python解析服务 (运行在电脑上):使用frida的on_message回调接收数据用我们推导出的.proto文件解析将结果打印或保存。示例代码片段Python主机服务 (parser_server.py):import frida import sys import login_request_pb2 # 你生成的proto解析模块 def on_message(message, data): if message[type] send: payload message[payload] if payload.get(type) protobuf_data: raw_bytes data # Frida传递的二进制数据在data参数中 try: req login_request_pb2.LoginRequest() req.ParseFromString(raw_bytes) print(f[*] Parsed LoginRequest: {req}) # 甚至可以修改某个字段 req.username hacked_user modified_bytes req.SerializeToString() # 如何将修改后的字节传回JS可以通过message返回一个base64编码 # 但更常见的做法是在JS端直接替换内存见下文 except Exception as e: print(f[!] Parse error: {e}) print(f Raw hex: {raw_bytes.hex()}) # ... 连接设备、附加进程、加载JS脚本的标准代码 ...Frida脚本 (dynamic_hook.js):Interceptor.attach(encodeFuncAddr, { onEnter: function(args) { this.outputPtr args[3]; }, onLeave: function(retval) { var outputData Memory.readByteArray(this.outputPtr, 2048); // 发送原始二进制数据到Python服务端 send({type: protobuf_data, desc: from encode_func}, outputData); // 如果我们想修改内存中的结果可以在这里操作 // 例如我们收到Python端返回的修改后的base64再写回内存 // var modifiedData recv(modified_data); // 需要异步处理这里只是示意 // if(modifiedData) { // var bytes Memory.readByteArray(this.outputPtr, modifiedData.length); // // 比较并修改... // } } });这样我们就实现了一个实时监控和解析的闭环。5.2 内存修改与数据重写实时解析的下一步就是实时修改。假设我们想篡改登录请求中的用户名。在Python解析服务中解析出LoginRequest对象修改username字段。将修改后的对象重新序列化成二进制字节流modified_bytes。将这个字节流传递回Frida脚本。由于send/recv的异步性直接在onLeave中同步等待回复比较麻烦。一个更简单粗暴但有效的方法是在Python端计算好修改后的字节然后在Frida脚本的onEnter中直接根据条件覆盖即将被函数使用的输入缓冲区或者在onLeave中覆盖函数的输出缓冲区。例如我们想在函数返回前修改输出onLeave: function(retval) { var outputPtr this.outputPtr; // 假设我们已经知道本次请求需要被修改并且新的字节数组是 newPayload (一个Uint8Array) if (shouldModify) { Memory.writeByteArray(outputPtr, newPayload); console.log([*] Output buffer modified!); } }关键点在于你必须精确知道要写入多少字节不能超出原缓冲区的大小否则会导致内存错误和程序崩溃。通常原缓冲区的长度可能通过另一个参数传递或者在函数返回值中。这又回到了对函数签名的精确理解上。6. 常见问题排查与实战技巧在这一整套流程中你会遇到无数坑。下面是我总结的一些典型问题及解决方法。6.1 Frida Hook相关问题Module.findBaseAddress返回null。原因so库可能尚未被加载或者名称不匹配如带有路径、版本号。解决使用Module.enumerateModules()列出所有已加载模块核对准确名称。或者使用Module.load在加载时拦截但更常用的是在setImmediate或setTimeout中延迟执行Hook确保so已加载。setTimeout(function() { var lib Module.findBaseAddress(libcore.so); if (lib) { /* hook */ } }, 1000);问题Hook导致App闪退。原因最常见的原因是在onEnter/onLeave中访问了无效的指针如对args[2]直接读内容但它可能是0或者修改了关键内存导致程序逻辑异常。解决增加指针有效性检查。使用try-catch包裹危险操作。在修改内存前务必确认长度和内容。onEnter: function(args) { this.arg2 args[2]; if (this.arg2 !this.arg2.isNull()) { // 安全地读取 var val Memory.readInt(this.arg2); } }问题Hook不到函数调用。原因函数地址不对偏移计算错误或者so有PIE导致基址随机化函数被内联优化了调用发生在其他线程而你的脚本只附加了主线程使用Interceptor.attach默认是当前线程。解决使用Module.getExportByName(moduleName, exportName)来获取导出函数地址更可靠。对于PIEFrida的Module.findBaseAddress会自动处理加载偏移。对于内联函数需要寻找其调用者进行Hook。使用Process.enumerateThreads()和Thread.backtrace()来检查函数是否在其他线程被调用。6.2 Protobuf解析相关问题protobuf-inspector解析出的字段编号不连续或类型奇怪。原因Protobuf采用了Varint编码和ZigZag编码对有符号负数字段可以缺失proto3中默认值字段不发送。工具可能将一些数字误判为字段编号。解决多收集几个不同场景下的数据包如登录成功/失败请求不同数据对比分析。同一个字段在不同数据包中编号应该一致。关注那些值会变化的字段。问题自己编写的.proto文件解析时抛出DecodeError。原因字段类型定义错误如把bytes定义为string或把嵌套消息定义为int32字段编号不对使用了proto2语法但未正确设置optional/required。解决从最简单的消息定义开始只定义你最有把握的一两个字段尝试解析。逐步添加字段。使用protoc --decode_raw input.bin命令protoc 3.5可以查看protobuf编译器对原始数据的解析这是一个非常重要的调试参考。问题实时解析时数据流不完整或粘包。原因网络层可能对数据进行了分片或添加了包头。你Hook到的可能是应用层已经组包好的Protobuf数据也可能是更底层的缓冲区。如果是在socket read/write层面Hook可能会抓到多个包混在一起的情况。解决需要结合上下文判断。如果Hook点是明确的业务层函数数据通常是完整的。如果是在底层IO函数你可能需要实现简单的协议拆包逻辑比如根据长度字段如果协议有来分割数据。6.3 效率与稳定性技巧脚本化侦察编写一个通用的“函数调用追踪”脚本批量Hook某个so的所有导出函数记录调用参数和返回值快速定位关键函数。条件化Hook在复杂的App中目标函数可能被频繁调用。使用条件判断来过滤无关调用避免日志刷屏和性能损耗。Interceptor.attach(funcAddr, { onEnter: function(args) { var interestingParam Memory.readInt(args[2]); if (interestingParam ! 0x100) { // 只关心特定类型的请求 return; // 提前返回不记录日志 } this.isInteresting true; // ... 记录日志 } });使用CModule处理复杂逻辑如果需要在JS中执行高性能或复杂的C级别内存操作如自定义结构体解析可以考虑使用Frida的CModule功能将C代码编译成V8可执行的机器码极大提升效率。保存与回放将成功Hook到的参数和内存数据保存下来FileAPI便于离线分析和重现问题。这对于调试偶现的崩溃至关重要。这套从Frida Hook so到Protobuf解析的流程本质上是一个“观察-假设-验证”的循环。它没有一成不变的公式极度依赖你对目标程序的理解、逆向分析的经验和调试的耐心。每一次成功的破解都是对这些工具链和思维方法的一次深度打磨。当你看到那些经过Protobuf编码的、冰冷的二进制字节流在你的脚本中重新变成结构清晰、含义明确的JSON对象时那种成就感正是驱动我们在这个领域不断深挖的动力所在。
返回列表