Frida Hook与Protobuf解析:逆向工程中穿透SO层与二进制协议的实战指南
2026/8/14 3:31:47 网站建设 项目流程

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-tools==16.1.4 frida==16.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库。

  1. 提取APK与分析结构:使用apktool解压APK文件。

    apktool d target_app.apk -o output_dir

    解压后,重点关注lib/目录下的子文件夹(如armeabi-v7a,arm64-v8a),里面存放着.so文件。

  2. 定位关键库:并非所有so库都需要关注。我通常通过以下线索筛选:

    • 库名:包含crypto,sign,encrypt,protocol,pb,protobuf等字眼的库嫌疑最大。
    • 导入导出函数:使用readelfobjdump工具查看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"
  3. 确定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是我们监控的输出缓冲区指针,那么srcsize就告诉我们数据从哪里来、有多大。

实操心得:在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 number=1)是varint类型,值为1;第二个字段是字符串类型,值为my_username。工具还会在最后尝试生成一个可能的.proto文件定义。这是我们的突破口。

4.2 人工分析与结构推测

自动工具生成的定义可能不准确,尤其是嵌套消息、枚举或某些特殊类型。我们需要结合上下文进行人工分析。

  1. 字段编号与类型:Protobuf编码中,每个字段都由字段编号和类型组成。常见的类型对应关系:0x08=varint,0x12=length-delimited (通常是字符串或子消息)。我们需要根据字段编号的连续性、值的范围来猜测字段的含义。例如,字段1的值是1,在登录请求中,它很可能是一个LoginType枚举,1代表“密码登录”。

  2. 字符串与二进制:对于length-delimited类型,它可能是字符串,也可能是嵌套的子消息,或者纯粹的字节数组(如加密数据、签名)。如果工具解析出来是乱码,那它很可能是一个嵌套消息,需要进一步解析。这时,你可以尝试将这一串十六进制数据单独保存为另一个文件,再用protobuf-inspector解析它,看看是否能拆出更深层的结构。

  3. 结合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(f"LoginType: {req.login_type}, User: {req.username}") except Exception as e: print(f"Parse 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解析逻辑。有几种方法:

  1. 使用Frida的Python桥接(不推荐在移动端):比较重,环境复杂。
  2. 将解析逻辑用JavaScript重写:这是最直接高效的方式。我们可以使用google-protobuf的JavaScript版本,或者使用轻量级的解析库如protobufjs。但由于Frida的Node环境与浏览器/标准Node环境略有差异,直接引入可能有问题。
  3. 我推荐的实用方法:利用Frida的send/recv机制,与一个本地的Python解析服务进行Socket通信。Frida脚本将二进制数据发送到电脑上的Python脚本,Python脚本解析后返回JSON结果,再由Frida脚本打印或处理。

架构如下:

  • Frida脚本 (JS):Hook so函数,捕获二进制数据,通过send()将其发送到主机。
  • Python解析服务 (运行在电脑上):使用fridaon_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 内存修改与数据重写

实时解析的下一步就是实时修改。假设我们想篡改登录请求中的用户名。

  1. 在Python解析服务中,解析出LoginRequest对象,修改username字段。
  2. 将修改后的对象重新序列化成二进制字节流modified_bytes
  3. 将这个字节流传递回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在加载时拦截,但更常用的是在setImmediatesetTimeout中延迟执行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)来获取导出函数地址更可靠。对于PIE,Frida的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 效率与稳定性技巧

  1. 脚本化侦察:编写一个通用的“函数调用追踪”脚本,批量Hook某个so的所有导出函数,记录调用参数和返回值,快速定位关键函数。
  2. 条件化Hook:在复杂的App中,目标函数可能被频繁调用。使用条件判断来过滤无关调用,避免日志刷屏和性能损耗。
    Interceptor.attach(funcAddr, { onEnter: function(args) { var interestingParam = Memory.readInt(args[2]); if (interestingParam != 0x100) { // 只关心特定类型的请求 return; // 提前返回,不记录日志 } this.isInteresting = true; // ... 记录日志 } });
  3. 使用CModule处理复杂逻辑:如果需要在JS中执行高性能或复杂的C级别内存操作(如自定义结构体解析),可以考虑使用Frida的CModule功能,将C代码编译成V8可执行的机器码,极大提升效率。
  4. 保存与回放:将成功Hook到的参数和内存数据保存下来(FileAPI),便于离线分析和重现问题。这对于调试偶现的崩溃至关重要。

这套从Frida Hook so到Protobuf解析的流程,本质上是一个“观察-假设-验证”的循环。它没有一成不变的公式,极度依赖你对目标程序的理解、逆向分析的经验和调试的耐心。每一次成功的破解,都是对这些工具链和思维方法的一次深度打磨。当你看到那些经过Protobuf编码的、冰冷的二进制字节流,在你的脚本中重新变成结构清晰、含义明确的JSON对象时,那种成就感,正是驱动我们在这个领域不断深挖的动力所在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询