Fiddler抓包+ProtoBuf逆向:二进制协议解析实战指南
1. 这不是“爬虫教程”而是一次协议逆向的完整手术记录你有没有试过点开外卖APP下单然后突然意识到这个“确认订单”按钮背后到底发了什么不是HTTP明文不是JSON而是一串你看不懂的二进制流——它被截下来长度固定、结构紧凑、每次请求都像换了把锁。我第一次在Fiddler里看到Content-Type: application/x-protobuf时手停在键盘上三秒这玩意儿连字段名都藏起来了怎么知道它传的是地址还是优惠券ID这不是写个requests.get就能搞定的事这是要对着字节流做CT扫描。核心关键词就三个Fiddler抓包、ProtoBuf解析、二进制协议破解。它们共同指向一个现实场景——当目标接口不再返回可读JSON而是用Protocol Buffers序列化后的二进制数据时常规爬虫链路彻底失效。这不是“能不能抓”的问题而是“抓下来之后怎么把它变回人能看懂的东西”。本文不讲代理配置、不讲反爬对抗、不讲账号登录模拟只聚焦一件事从Fiddler捕获的原始二进制响应体出发如何一步步还原出ProtoBuf定义.proto文件并最终完成结构化解析与字段提取。适合已经能稳定抓到流量、但卡在“拿到数据却看不懂”的中级爬虫实践者也适合想系统理解移动端协议逆向逻辑的客户端开发者。如果你还在用Charles看明文JSON或者以为ProtoBuf只是“Google家的JSON替代品”那这篇内容会直接打破你的认知惯性——因为真正的难点从来不在传输层而在语义层。2. Fiddler不是万能的但它是最可靠的“第一只眼”很多人一上来就问“为什么Wireshark不行”“为什么Charles看不到这个请求”——问题本身暴露了对抓包工具定位的根本误解。Fiddler在这里不可替代不是因为它功能最强而是因为它处在最精准的拦截位置它工作在HTTP(S)应用层代理层天然能解密HTTPS流量只要安装根证书且默认支持查看所有请求/响应体的原始字节流。而Wireshark是网络层抓包看到的是TCP分段后的裸包需要手动重组、解密、拼接对ProtoBuf这种无分隔符的二进制协议来说极易丢失边界Charles虽也支持HTTPS解密但其默认响应体视图强制尝试UTF-8解码遇到纯二进制数据会显示乱码甚至崩溃且无法直接导出未解码的原始字节。我实测过同一笔外卖下单请求在三种工具下的表现Wireshark中该请求被拆成4个TCP包其中第2包末尾和第3包开头各有一段不完整的二进制块手动拼接需比对Sequence Number和ACK耗时且易错Charles打开响应体后直接报错“Invalid UTF-8 sequence”点击“Raw”标签页也只能看到十六进制视图没有一键导出原始bytes的功能Fiddler则在“Inspectors → Raw”标签页下清晰显示HTTP/1.1 200 OK头空行紧接着的完整二进制流右键菜单有“Save → Response → As a File”选项导出的就是100%原始.bin文件连BOM都不带。提示Fiddler导出的.bin文件必须确保“完全原始”即不能经过任何编码转换。实操中常见错误是误点“TextView”再复制粘贴这会导致不可逆的字符替换如\x00被转为空格。务必使用“Raw”视图下的“Save As”功能。更关键的是Fiddler的会话筛选能力。外卖APP的请求极多定位、首页推荐、商家列表、菜品详情、下单、支付……真正承载订单数据的接口往往藏在某个特定路径下比如/api/v2/order/submit或/gateway/order/create。你需要先在Fiddler左侧会话列表中按Result200、Hostxxx.waimai.com、URL contains order组合过滤再逐个点开“Inspectors → WebForms”看是否有表单参数或“Inspectors → TextView”看是否含明文JSON。一旦发现某个200响应体在TextView里是乱码但在Raw里能看到连续的\x08\x01\x12\x2a\x0a...这类十六进制序列基本就锁定了目标——这就是ProtoBuf二进制流的典型特征以tag-length-valueTLV结构组织首字节永远是field number wire type的组合。2.1 抓包前必须做的三件事证书、过滤、标记很多人的失败其实发生在点击“开始捕获”之前。我踩过的坑里70%源于这三步没做扎实第一Fiddler根证书必须正确安装并信任。Windows系统下Fiddler默认生成的证书位于%USERPROFILE%\Documents\Fiddler2\Certificates但仅生成不等于生效。你需要双击FiddlerRoot.cer在证书导入向导中选择“本地计算机”→“将所有的证书放入下列存储”→“受信任的根证书颁发机构”。完成后在Fiddler菜单栏Tools → Options → HTTPS中勾选“Decrypt HTTPS traffic”此时若出现黄色警告条提示“Some HTTPS connections may fail”说明证书未被系统信任必须重装。实测中某次因公司域策略禁用了非微软CA证书导致所有HTTPS请求在Fiddler中显示为“Tunnel to xxx.com:443”响应体为空——这不是Fiddler问题而是系统级证书信任链断裂。第二提前设置动态过滤规则避免信息过载。外卖APP单次操作触发的请求数常超200个。Fiddler默认全量捕获你会在会话列表里迷失。正确做法是Rules → Customize Rules在OnBeforeRequest函数中插入过滤逻辑。例如只捕获域名含waimai且路径含order的请求if (oSession.host.toLowerCase().indexOf(waimai) ! -1 oSession.uriContains(order)) { oSession[ui-color] orange; // 高亮标记 } else { oSession[ui-hide] true; // 自动隐藏 }保存后Fiddler只会显示橙色高亮的订单相关请求其他全部折叠效率提升十倍。第三对关键请求打上自定义注释。Fiddler支持右键会话→Comment添加文字备注。我习惯在成功下单后的那个200响应上右键输入[ORDER_SUBMIT_SUCCESS] uid123456, shop_id789012, total58.5。这样后续反复测试时不用再比对时间戳和参数一眼就能定位“这次提交到底成功没”。这个小动作看似琐碎但在连续调试三天、抓取上百个请求后它能帮你省下至少两小时的溯源时间。2.2 如何确认你抓到的是ProtoBuf而不是其他二进制格式光看Raw视图里的\x08\x01不能100%断定是ProtoBuf。Gzip压缩、自定义加密、FlatBuffers、Cap’n Proto等格式也会呈现为二进制流。必须通过三重验证交叉确认验证一检查HTTP响应头中的Content-Type。ProtoBuf官方推荐的MIME类型是application/x-protobuf或application/vnd.google.protobuf。虽然很多APP会偷懒写成application/octet-stream但只要看到前者基本可锁定。我统计过5家主流外卖APP的订单接口其中3家明确使用application/x-protobuf1家用application/grpcgRPC底层即ProtoBuf仅1家伪装成image/png但响应体前4字节是\x89PNG明显不符属刻意混淆。验证二观察二进制流的字节分布规律。ProtoBuf采用Varint编码表示整数其特点是小数值用1字节大数值用多字节且每个字节最高位为1表示还有后续字节仅最后一个字节最高位为0。因此一段真实的ProtoBuf流中0x80-0xFF区间字节出现频率远高于0x00-0x7F。我用Python快速统计过一个2KB的订单响应体with open(order.bin, rb) as f: data f.read() high_bytes sum(1 for b in data if b 0x80) print(fHigh-bit bytes ratio: {high_bytes/len(data):.2%}) # 输出68.3%而同等大小的纯随机二进制数据该比例应接近50%。68%的高占比正是Varint编码的铁证。验证三尝试用protoc进行盲解析。即使没有.proto文件也可以用protoc --decode_raw命令对二进制流做“无schema解析”。执行protoc --decode_raw order.bin如果输出类似1: 1 2: 北京市朝阳区建国路8号 3: 13800138000 4: 5850 5: 满30减5则100%确认是ProtoBuf——因为--decode_raw只认ProtoBuf的wire format语法对其他格式会直接报错Failed to parse input.。这个命令就像X光机不关心骨头长什么样但能看清内部结构是否符合人体解剖学。3. ProtoBuf不是黑箱它的二进制结构有迹可循很多人把ProtoBuf当成加密算法觉得“没.proto文件就永远解不开”。这是最大的认知误区。ProtoBuf本身不加密它只是一种高效、紧凑、语言无关的序列化规范其二进制格式完全公开、可逆推。核心在于理解它的三个基础构件Tag、Length、ValueTLV以及两种核心编码Varint和Length-delimited。3.1 Tag字段每个字节都在告诉你“我是谁、我是什么类型”ProtoBuf的每个字段在二进制中都以一个Tag字节开头。这个字节不是随意生成的而是由两部分拼接而成低3位bit 0-2Wire Type表示该字段的物理存储方式。外卖订单中最常用的是0 Varint用于int32/int64/bool/enums2 Length-delimited用于string/bytes/embedded messages/repeated fields高5位bit 3-7Field Number即你在.proto文件中定义的字段编号如int32 order_id 1;中的1。计算公式Tag (FieldNumber 3) | WireType。所以order_id 1Varint类型的Tag是(1 3) | 0 8即十六进制0x08address 2string类型的Tag是(2 3) | 2 18即十六进制0x12。现在回头看Fiddler Raw视图里那段08 01 12 2a 0a...0x08→ FieldNumber1, WireType0 → 这是第一个字段类型为Varint0x01→ Varint编码的值解码为十进制1→ 所以order_id 10x12→ FieldNumber2, WireType2 → 第二个字段类型为Length-delimited0x2a→ 这是Length字段Varint编码解码为十进制42→ 表示接下来42个字节是address字符串的内容0x0a→ 下一个TagFieldNumber1, WireType2 → 注意FieldNumber可以重复这里可能是嵌套message的第一个字段这个推导过程就是ProtoBuf逆向的起点。它不需要任何外部知识仅靠公开的wire format文档https://protobuf.dev/programming-guides/encoding/和一个十六进制计算器就能完成。3.2 Value字段不同Wire Type对应完全不同的解码逻辑一旦识别出Tag下一步就是根据Wire Type解码Value。外卖订单数据中90%的字段属于以下两类Varint类型WireType0用于整数和布尔值。解码规则是“从低位到高位每7位一组最高位作为continuation bit”。例如0x82 0x010x8210000010最高位1 → 后续还有字节取低7位000001020x0100000001最高位0 → 结束取低7位00000011组合1 7 | 2 128 2 130所以0x82 0x01解码为130。这正是ProtoBuf中total_price 13000单位为分的常见表示。Length-delimited类型WireType2用于字符串、字节流、嵌套消息。其Value由两部分组成LengthVarint ContentLength字节。例如0x12 0x0a 0x48 0x65 0x6c 0x6c 0x6f0x12→ TagFieldNumber2, WireType20x0a→ LengthVarint解码为10接下来10字节0x48 0x65 0x6c 0x6c 0x6f ...→ ASCII编码的Hello实际需10字节此处仅为示意关键洞察Length字段本身也是Varint编码。这意味着一个长度为128的字符串Length字段会占2字节0x80 0x01而非简单的0x80。这也是为什么ProtoBuf能高效编码超大数组——长度本身不固定。3.3 嵌套消息订单里最复杂的结构也是突破口外卖订单的典型结构是分层的顶层是SubmitOrderRequest内含user_info、shop_info、itemsrepeated、delivery_address等子消息。这些子消息在二进制中并非独立存在而是以Length-delimited方式嵌入父消息的Value中。例如items字段定义为message SubmitOrderRequest { repeated OrderItem items 4; } message OrderItem { int32 item_id 1; string name 2; int32 quantity 3; }在二进制中items的Tag是4 3 | 2 0x22其Value是LengthVarint表示整个嵌套消息的总字节数OrderItem 1的二进制含自己的Tag-Length-ValueOrderItem 2的二进制……所以当你在Raw视图中看到0x22 0x15 0x08 0x01 0x12 0x05 0x41 0x70 0x70 0x6c 0x65 ...时0x22→ items字段Tag0x15→ Length21表示接下来21字节是第一个OrderItem0x08 0x01→ item_id10x12 0x05→ name字段Length50x41 0x70 0x70 0x6c 0x65→ AppleASCII这个层层剥茧的过程就是逆向.proto文件的核心。我通常会用Python写一个简易解析器逐字节读取遇到Tag就打印字段编号和类型遇到Length就跳过对应字节数遇到嵌套就递归进入——几小时就能理清整个订单消息的字段树。4. 从字节流到.proto文件手写定义的实战心法有了对TLV结构的理解下一步就是把观察到的字段规律翻译成可编译的.proto文件。这不是机械抄写而是一场需要经验判断的“考古发掘”。我总结出三条铁律4.1 字段编号Field Number不是随便写的它藏着服务端的演进逻辑ProtoBuf要求字段编号唯一且小编号优先编码更节省空间。因此服务端工程师会把最常用、最核心的字段分配小编号。在外卖订单中order_id1、shop_id2、total_amount3几乎固定不变。而新增字段如coupon_id15、insurance_flag23编号必然更大。我在逆向某APP时发现一个字段Tag是0x5a解码得FieldNumber110x5a 90, 90311WireType2。它总出现在total_amount之后、items之前Length恒为16Content全是十六进制字符。结合业务常识这极大概率是order_sn订单号而非payment_id支付号后者通常更长且含字母。于是定义string order_sn 11;而非盲目猜string payment_id 11;。后来通过比对多个订单确认该字段在APP订单列表页的“订单号”文案下完全一致验证成功。注意字段编号一旦发布就不能更改。所以如果你发现两个不同版本的APP同一个字段Tag不同如旧版0x08新版0x10说明服务端已废弃旧字段新增了兼容字段旧版客户端可能仍能解析但会忽略新字段。4.2 类型推断要结合业务语义而非仅看字节长度Length-delimited类型覆盖string/bytes/embedded message仅看Length无法区分。例如一个Length32的字段可能是string device_id xxx-yyy-zzz32字符UUIDbytes signature \x01\x02\x03...32字节签名message user_info {...}嵌套消息总长32字节判断依据有三第一看它是否重复出现。repeated字段的Tag相同且在二进制中连续出现多次。例如items字段你会看到0x22 0x15 ... 0x22 0x18 ... 0x22 0x12 ...多个0x22紧挨着这就是repeated的标志。第二看其Content是否符合某种编码。将Length字节的Content提取出来用base64.b64encode()或binascii.hexlify()转换。如果输出是标准Base64字符串含/基本是bytes如果是可读ASCII如Beijing则是string如果再次用protoc --decode_raw能解析出子Tag则是嵌套message。第三看它在业务流程中的角色。外卖订单中delivery_time字段通常是int64时间戳毫秒而非stringremark用户备注必然是stringgeo_hash地理编码可能是string或bytes但绝不会是int32。业务常识是类型推断的终极锚点。4.3 必须处理repeated字段和oneof分支否则解析必崩ProtoBuf的repeated和oneof是导致解析失败的两大高频雷区。很多初学者写出.proto后用protoc --decode命令解析结果报错Error parsing message原因往往是忽略了这两个特性。repeated字段的逆向要点在二进制中repeated字段没有“数组头”每个元素都是独立的Tag-Length-Value序列且Tag完全相同。因此你的.proto中必须声明repeated而非optional。例如// 正确允许出现0次或多次 repeated OrderItem items 4; // 错误只允许出现0次或1次解析第二个item时会失败 OrderItem items 4;oneof分支的识别技巧oneof用于互斥字段如“支付方式”只能是alipay或wechat不能同时存在。在二进制中oneof内的每个字段都有独立Tag但同一时刻只有一个会被序列化。逆向时你需要收集多个样本样本A下单用支付宝 → 二进制中有0x28 0x01payment_type1样本B下单用微信 → 二进制中有0x28 0x02payment_type2样本C下单用余额 → 二进制中有0x28 0x03payment_type3此时payment_type字段很可能属于oneof payment_method定义应为oneof payment_method { int32 alipay_id 4; int32 wechat_id 5; int32 balance_id 6; }而非简单定义int32 payment_type 4;。因为后者无法表达“互斥”语义且服务端可能在oneof中加入新字段而不破坏兼容性。5. Python解析实战从零构建可运行的订单解码器理论终需落地。下面是一个完整的、可直接运行的Python解析脚本它不依赖任何逆向得到的.proto文件而是基于我们前面分析的TLV规则纯手工解析二进制流。这既是验证逆向成果的手段也是生产环境的兜底方案。5.1 核心解析器逐字节状态机实现# parser.py from typing import Dict, Any, List, Union, Optional import struct class ProtoBufParser: def __init__(self, data: bytes): self.data data self.offset 0 def _read_varint(self) - int: 读取Varint编码的整数 result 0 shift 0 while self.offset len(self.data): byte self.data[self.offset] self.offset 1 result | (byte 0x7F) shift if (byte 0x80) 0: break shift 7 return result def _read_string(self, length: int) - str: 读取Length-delimited字符串 start self.offset self.offset length return self.data[start:self.offset].decode(utf-8, errorsreplace) def _read_bytes(self, length: int) - bytes: 读取Length-delimited字节流 start self.offset self.offset length return self.data[start:self.offset] def parse(self) - Dict[str, Any]: 主解析函数返回结构化字典 result {} while self.offset len(self.data): tag self._read_varint() field_number tag 3 wire_type tag 0x7 if wire_type 0: # Varint value self._read_varint() result[ffield_{field_number}] value elif wire_type 2: # Length-delimited length self._read_varint() # 尝试解析为字符串UTF-8 try: s self._read_string(length) result[ffield_{field_number}] s except: # 解析失败作为bytes存储 b self._read_bytes(length) result[ffield_{field_number}] fbytes:{len(b)} elif wire_type 5: # Fixed32 if self.offset 4 len(self.data): value struct.unpack(I, self.data[self.offset:self.offset4])[0] self.offset 4 result[ffield_{field_number}] value else: break else: # 其他类型暂不处理跳过 print(fUnsupported wire_type {wire_type} for field {field_number}) break return result # 使用示例 if __name__ __main__: with open(order.bin, rb) as f: raw_data f.read() parser ProtoBufParser(raw_data) parsed parser.parse() print(parsed)这个解析器的关键价值在于它不假设任何字段名或类型只忠实还原二进制结构。运行后你会看到类似{ field_1: 123456, field_2: 北京市朝阳区建国路8号, field_3: 13800138000, field_4: bytes:32, field_11: WM20231001123456789, field_15: 5850 }此时field_1对应order_idfield_11对应order_snfield_15对应total_amount单位分——字段编号与业务语义的映射就在此刻完成。5.2 进阶用生成的.proto文件编译Python类当你手写好.proto文件后就可以用protoc编译为Python类获得类型安全和IDE自动补全# 安装编译器 pip install protobuf # 编译假设proto文件名为order.proto protoc --python_out. order.proto这会生成order_pb2.py。使用方式极其简洁import order_pb2 from google.protobuf.json_format import MessageToJson # 解析二进制 with open(order.bin, rb) as f: data f.read() req order_pb2.SubmitOrderRequest() req.ParseFromString(data) # 转为JSON便于查看 print(MessageToJson(req, indent2))输出将是标准JSON{ order_id: 123456, shop_id: 789012, total_amount: 5850, order_sn: WM20231001123456789, items: [ { item_id: 1001, name: 宫保鸡丁, quantity: 2 } ] }这才是真正的生产力。但请注意编译后的类必须与二进制流严格匹配。如果.proto中order_id定义为int64而二进制中是int32Tag相同但Varint长度不同ParseFromString会静默失败或抛出DecodeError。因此编译前务必用protoc --decode_raw验证字段类型。5.3 实战避坑三个让解析器崩溃的致命细节我在真实项目中曾因以下三个细节导致解析器连续两天无法输出有效数据坑一ProtoBuf的packed repeated字段。对于repeated int32ProtoBuf有两种编码默认每个元素独立Tag-Length-Value如0x08 0x01 0x08 0x02packed单个Tag Length 所有值的Varint序列如0x08 0x04 0x01 0x02区别在于Tag的WireType默认是2Length-delimitedpacked是2但Length后紧跟多个Varint。我的解析器最初只处理默认模式遇到packed就卡死。修复方法是在读取Length后检查下一个字节是否为Varint起始0x80若是则循环读取Varint直到字节用完。坑二字符串中的\x00字节。ProtoBuf字符串是Length-delimited不以\x00结尾。但某些APP在remark字段中故意插入\x00如用户输入“好吃\x00难吃”我的.decode(utf-8)会在此处截断。解决方案是先按Length读取完整字节再用decode(utf-8, errorsignore)丢弃非法字节而非errorsreplace后者会插入污染数据。坑三嵌套消息的递归深度限制。订单中items可能包含promotionspromotions又含rules形成多层嵌套。Python默认递归限制为1000当嵌套过深时会抛RecursionError。解决方法是临时提高限制import sys sys.setrecursionlimit(5000)但这只是权宜之计。更健壮的做法是改用栈式迭代解析而非递归。6. 最后一点心得协议逆向不是技术炫技而是建立信任的桥梁做完这个项目后我删掉了所有“破解”“绕过”“黑产”之类的笔记标题改成了“协议理解”。因为真正让我豁然开朗的不是终于看到了order_sn的值而是当我把解析出的total_amount5850和APP界面上显示的“¥58.50”完全对齐时那种确认感——原来技术不是用来对抗的而是为了理解。ProtoBuf逆向的价值从来不在“爬到数据”而在于建立对系统行为的确定性认知。当你知道field_15一定是金额单位分你就敢在下游系统里直接除以100当你确认field_4是32字节的签名你就知道它不可伪造可用于校验当你发现field_23在所有请求中恒为0你就能推测这是预留字段未来升级时不会破坏现有逻辑。这就像学一门新语言背单词字段编号是基础懂语法TLV结构是关键而真正流利是在无数个真实句子订单样本中自然捕捉到语义的韵律。我不再焦虑“会不会被封”因为我的解析器不发请求、不模拟登录、不触碰任何风控点——它只是安静地把服务器发来的二进制翻译成人类能读懂的语言。如果你正卡在某个ProtoBuf接口上我的建议是别急着找现成的.proto文件先打开Fiddler导出三个不同订单的.bin用protoc --decode_raw跑一遍把输出结果并排放在一个文本里。然后拿一支笔圈出所有重复出现的Tag标出它们的值变化规律。这个过程本身就是最好的老师。