
我从 SpiderDemo 的 03_protobuf_challenge 这题爬出来的过程还是有点意思的。当时打开练习平台看到题名里带着 protobuf 和 加密 两个词第一反应是又要逆向某个加密参数。真正开始抓包后才发现请求和响应体全是二进制乱码浏览器开发者工具的 Preview 面板一片空白Response 里只有一堆不可读的字节流这时候才意识到题目的重点根本不是加密而是 protobufProtocol Buffers序列化协议所谓加密其实是藏在 protobuf 字段里的签名参数。这篇文章就把我从头到尾的处理过程完整写一遍怎么判断接口用了 protobuf、没有原始 .proto 文件时怎么还原结构、怎么识别和复现签名参数、以及最终的序列化请求怎么拼出来。适合正在刷 SpiderDemo 系列练习的人也适合刚接触接口逆向、想搞懂 protobuf 编解码和签名还原逻辑的同学参考。1. 关卡初印象一个全是乱码的接口到底难在哪1.1 一个典型的 protobuf 挑战长什么样这道题模拟的是一个很常见的列表页接口前端页面长得很正常有分页、有列表项、有时间戳。但打开开发者工具的 Network 面板看接口请求会发现两个明显特征请求体Request Payload不是 JSON 字符串而是一堆十六进制字节在 DevTools 里显示成不可读字符。响应体同样不是 JSONContent-Type 标的是application/x-protobufPreview 标签页直接空掉。我当时的处理顺序是这样的先在 Console 里敲fetch重新拉一次接口把返回的arrayBuffer打出来确认是可打印范围之外的字节流随后用抓包工具把原始响应存成.bin文件准备离线拆解。这一步的目标很明确——先把数据到底长什么样固定下来不要反复在页面上刷新因为每次刷新时间戳都会变签名也会跟着变容易干扰分析。1.2 为什么练习平台偏要用 protobuf 做考题要理解这道题的价值得先搞明白 protobuf 和 JSON 的本质差异。JSON 是自描述的{name:张三}里字段名name就写在数据里任何人拿到都能读。protobuf 不一样它在传输时只保留字段编号和值字段名和类型信息全在.proto定义文件里通信双方按同一份定义文件编解码。这么说吧JSON 像快递盒上贴了完整的收件人姓名、电话、地址protobuf 则像一个只有编号没有名字的整理箱你必须有一张说明书.proto文件才知道 1 号位是名字、2 号位是电话、3 号位是地址。练习平台选 protobuf 出题考的就是没有说明书的情况下你能不能自己把说明书写出来。另外protobuf 通常是强类型语言后端接口之间的通信格式很多采集场景绕不开。你写 JSON 解析器那套经验在它面前完全失效不掌握编解码原理就寸步难行。1.3 这道题真正想考察的三项能力刷完这题之后我发现SpiderDemo 这类平台在 03 号题里实际想让你练的是三项能力识别能力判断一个接口是不是 protobuf而不是拿到二进制乱码就以为是 AES 加密或者其他未知格式。还原能力在拿不到.proto文件的情况下通过手工解析、黑盒推断、工具辅助把消息结构逆向出来。复现能力理解字段编号与类型规则后自己能构造出合法的 protobuf 请求体并通过签名校验。这三项能力对应的是真实工作流里读协议、解协议、写协议的完整闭环。后面几个章节我就按这个顺序讲。2. 如何一眼认准 protobuf五个特征和一套识别流程2.1 五个看得见的特征拿到一个二进制接口第一步先判断它是不是 protobuf。我总结出五个实用特征命中三个以上基本就能锁定方向响应头 Content-Type出现application/x-protobuf、application/x-google-protobuf、application/octet-stream且 body 不是图片或文件时重点怀疑 protobuf。十六进制首字节有规律用十六进制查看器打开响应体开头常见0A 12 1A 22这类值这些是低字节为 0x0A、0x12、0x1A、0x22…… 的 tag对应字段号 1、2、3、4 且 wire type 为 2长度分隔类型。这种从 0x0A 开始、按 0x08 步进递增的分布太有识别度了。可读字符串零散藏在二进制里protobuf 的string字段在序列化后就是明文 UTF-8 字节在乱码里能隐约看见英文单词或 JSON 片段。长度前缀清晰每个字段的值前往往有字节长度比如0A 17表示字段 1 后面跟着 23 字节数据。gzip 解压后仍然是二进制有些响应先走 gzip解压后不是 JSON 而是上述二进制结构。当时我对照这五个特征检查响应体前四个全中基本确认是 protobuf 无误了。2.2 从开发者工具到抓包工具的识别链路我的识别工具链分三步不复杂但很好用浏览器 DevTools主要看 Content-Type、响应大小、能否在 Preview 中渲染。如果 Preview 里能渲染 JSON 或图片那基本不是纯 protobuf。抓包工具的 Raw / Hex 视图Fiddler、Charles、mitmproxy 这类工具都支持十六进制查看。我习惯把响应体复制成 hex 字符串再丢进脚本里分析比肉眼盯乱码靠谱。命令行xxd/ Python 交互环境在本地把.bin文件打开用xxd看前 64 字节快速判断开头 tag 特征。提示无论用什么抓包工具都建议把原始字节完整保存下来不要只复制 DevTools 里转义后的字符串否则容易丢字节。我一般用 Python 的requests直接拉一遍接口把response.content写入文件这份原始数据就是后续所有分析的锚点。2.3 用 protoc --decode_raw 做最终确认如果你电脑里装了 protobuf 编译器protoc最终确认方式非常干脆protoc --decode_raw response.bin这个命令不需要.proto文件会直接按 protobuf 的编码规则把二进制流里所有字段编号、wire type、值全部打印出来。输出大概是这样的1 { 1: abc 2: 22 } 3 { 1: 1001 2: 苹果 3: 3.14 } 4: 1720000000 5: e6b0c2a5d1f...看到这种结构就百分百确定是 protobuf 了。--decode_raw不认识字段名但能告诉你字段编号、类型和嵌套关系这就足够我们继续往下走了。3. 不用 .proto 原文件怎么把字节流改造成可读结构3.1 先搞懂 protobuf 的 tag-length-value 规矩想要还原.proto必须先理解 protobuf 在字节层面上的编码规则。每个字段在流里的基本单位是 tag valuetag 的计算方式是tag (field_number 3) | wire_typefield_number是你在.proto里给字段分配的编号比如int64 id 1;的编号就是 1。wire_type表示值的编码类型最常遇到的三种Wire Type值对应字段类型说明0Varintint32、int64、uint32、uint64、bool、enum用一个或多个字节表示整数164-bitfixed64、double固定 8 字节2Length-delimitedstring、bytes、嵌套 message、packed repeated先写长度再写内容532-bitfixed32、float固定 4 字节Varint 又是最核心的小知识每个字节低 7 位是有效数据最高位是结束标记。1在流里就是一个字节01300则会编码成AC 02因为 300 的二进制是100101100拆成两组 7 位后得到0101100 0000010补上结束标记就变成10101100 00000010。3.2 手工拆包的完整过程我用一组实际字节演示一下。假设响应体开头是0A 03 61 62 63 10 96 01第一个字节0A二进制00001010低 3 位010 wire_type 2Length-delimited右移 3 位得到 field_number 1。所以这是字段 1字符串类型。第二个字节03长度 3。再取 3 个字节61 62 63就是 ASCII 的 abc。第五个字节10二进制00010000低 3 位000 wire_type 0Varint右移 3 位 field_number 2。这是字段 2整数类型。第六字节96 01按 Varint 解码9610010110最高位是 1 表示还有后续字节去掉最高位保留低 7 位得到0010110。01最高位是 0 表示结束保留0000001。合并成 14 位0000001 0010110即 150。所以这 8 个字节翻译过来就是message { string field_1 abc; int32 field_2 150; }。手工拆包虽然慢但对建立直觉特别有帮助。我第一次完整拆完一个小响应体后再看任何二进制数据都能在脑子里快速估算字段结构后面写自动化解析脚本也顺手很多。3.3 字段名怎么恢复编号与命名的还原逻辑protoc --decode_raw只能给出编号给不出字段名。字段名要从别的地方找。这道题里我用了三条路径换接口模式有些练习平台为了降低难度同一个数据既提供 JSON 接口也提供 protobuf 接口。我先切到 JSON 模式请求同一条数据拿到name、price、tags这些字段名再回来对照 protobuf 的字段编号。字段名和编号的对应关系一秒还原。从前端 JS 里翻页面源码里会引入一组操作 protobuf 的 JS 文件里面可能内嵌.proto定义或 message 对象。搜索关键词message、field、protobuf往往能找到字段名线索。按语义猜有些字段名可以从业务逻辑推断。字段号 1 通常是主键 ID字段号 2 是名称或标题字段号 3 是价格或数量最后几个字段是timestamp和sign。这种猜不是瞎猜是结合响应里可读字符串、数值大小、页面展示对应关系做的推理。还原出的结构越完整后面构造新请求就越简单。3.4 用工具和代码把解析固化下来手工确认了整体结构后我写的第一个正式.proto文件大概是这样的syntax proto3; package spiderdemo; message Item { int64 id 1; string name 2; double price 3; repeated string tags 4; } message ListResponse { int32 code 1; string message 2; repeated Item data 3; int64 timestamp 4; string sign 5; }然后用 Python 的google.protobuf库做正式解析import requests from google.protobuf import json_format import spider_pb2 resp requests.get(https://practice.example/api/items, headers{...}) obj spider_pb2.ListResponse() obj.ParseFromString(resp.content) print(json_format.MessageToDict(obj))json_format.MessageToDict可以把 message 对象转成 Python dict打印出来就和看 JSON 一样舒服。这一步做完响应侧的谜题基本解开了。如果暂时不方便安装protoc也可以用blackboxprotobuf这个库做黑盒解析它能自动识别未知字段类型输出 JSON 结构适合快速验证。4. 剥开加密外壳签名参数识别与还原4.1 先分类摘要、编码、签名、加密不是一回事很多人把题名里的加密两个字当成了算法逆向其实先要看清它到底属于哪一类。按我的分类习惯Base64编码不是加密。特征是末尾可能有字符集只有A-Za-z0-9/。MD5 / SHA 系列消息摘要不可逆。特征是定长 hex 字符串MD5 是 32 位SHA1 是 40 位SHA256 是 64 位。Hmac 系列带密钥的摘要也需要一个 key输出长度和普通 SHA 一样。AES / DES对称加密密文通常是一段看起来像随机字节的 Base64 字符串解密时需要 key 和 IV。RSA非对称加密特征是 JS 里能找到 PEM 格式的公钥密文长度较长。这道挑战里的加密字段在响应结构里是sign长度 32 位 hex明显是 MD5 摘要。真正的难点是定位它的生成逻辑以及复现签名时拼接顺序的掌握。4.2 从控制台堆栈里把签名逻辑揪出来定位签名逻辑我一般从两个入口下手全局搜索关键字在 DevTools 的 Sources 面板里搜索sign、signature、md5、sha、secret、appKey。很多平台会把签名函数名暴露在全局变量里。XHR 断点在 Network 面板里给目标接口设置 XHR/fetch 断点当请求发出前会暂停执行。然后查看调用堆栈Call Stack一步步往上找生成sign的调用。当时我通过搜索命中了这样一个核心函数示意function buildSign(params) { let keys Object.keys(params).sort(); let raw keys.map(k ${k}${params[k]}).join(); return md5(raw spider_demo_secret); }这是很典型的字典序排序 拼接 固定盐值 MD5模式。找到这里剩下的工作就是把它翻译成 Python 或 Node.js。提示如果在网页端调试时函数体被压缩混淆别急着硬读。先在return处打条件断点把进入函数前的参数对象打印出来然后用同样的参数在本地跑你怀疑的算法对比输出是否一致通常几次就能锁定。4.3 常见摘要与加密算法的指纹特征为了快速判断练习平台用的是什么算法我给自己整理了一张指纹对照表输出特征常见算法判断要点32 位 hex16 字节MD5最常见先试着复现40 位 hex20 字节SHA1老系统常用64 位 hex32 字节SHA256 / HmacSHA256判断是否带 key密文带/和末尾可能有AES / DES 后 Base64需要找 key 和 IV密文长且是 256 字节 hexRSAJS 里找公钥MD5 和 HmacSHA256 的输出都是 64 位 hex 里的 32 位或 64 位看起来差不多区分方法是看生成代码里有没有调用带 key 的 hmac 对象。如果是crypto.createHmac(sha256, key)那就是 Hmac如果是crypto.createHash(md5)就是纯摘要。4.4 签名拼接顺序最容易错的最后一公里签名还原里最坑的往往不是算法本身而是参数拼接顺序和格式。常见规则有按参数名 ASCII 字典序排序再拼接。按原始请求顺序拼接比如code0messageokdata...按协议里定义顺序来。只拼业务字段不拼空值空字段直接省略。末尾固定拼接盐值盐值可能是写死的也可能从某个接口动态下发。我在这个题目里踩的坑是平台签名时用String(params[data])把嵌套的 protobuf 消息先转成 JSON 字符串再参与拼接而不是直接用原始二进制字节。如果我直接把二进制参与 MD5得到的结果必然不一致。修正方法是先把业务数据构造成 Python dict用json.dumps(..., separators(,, :), ensure_asciiFalse)生成的字符串作为签名输入顺序和 JS 端完全对齐后才通过校验。注意如果本地复现的 MD5 和抓包里看到的sign不一致不要急着怀疑 Key 错了。先拿抓包当时的timestamp和页面参数原样跑一遍确认拼接顺序和序列化格式再去看盐值。5. 通关实例从响应字节到再次请求的完整链路5.1 抓取一份响应作为锚点为了避免分析过程中数据发生变化我先把一份完整的响应体存了下来同时记录这次请求的完整 URL、请求头、时间戳。这一步很重要因为后面需要反复验证用同样的参数能不能得到同样的签名没有锚点就等于没有对照组。import requests import time headers { User-Agent: Mozilla/5.0 ..., Content-Type: application/x-protobuf, } resp requests.get(https://practice.example/api/items, headersheaders) with open(response.bin, wb) as f: f.write(resp.content) print(resp.status_code, len(resp.content))5.2 定义 .proto 并完成首次解析按照上一章还原出的结构我写好了spider_pb2.py的.proto定义然后用 Python 解析出响应里的业务字段import spider_pb2 with open(response.bin, rb) as f: data f.read() resp spider_pb2.ListResponse() resp.ParseFromString(data) print(code:, resp.code) print(message:, resp.message) print(count:, len(resp.data)) print(timestamp:, resp.timestamp) print(sign:, resp.sign) for item in resp.data: print(item.id, item.name, item.price, item.tags)输出正常后响应侧就算彻底通关了。5.3 复现签名并构造下一次请求下一步是构建带签名的新请求。请求体也是 protobuf结构大概是ListRequestmessage ListRequest { int32 page 1; int32 page_size 2; int64 timestamp 3; string sign 4; }签名逻辑是从 JS 里还原出的字典序 salt MD5。构造流程如下import hashlib import time import request_pb2 def build_sign(page, page_size, timestamp): payload fpage{page}page_size{page_size}timestamp{timestamp}spider_demo_secret return hashlib.md5(payload.encode(utf-8)).hexdigest() page 2 page_size 20 timestamp int(time.time()) req request_pb2.ListRequest() req.page page req.page_size page_size req.timestamp timestamp req.sign build_sign(page, page_size, timestamp) payload req.SerializeToString()构造好payload之后把 Content-Type 设成application/x-protobuf直接 POST 或 GET 发出一份新请求。5.4 封装一个稳定可复现的函数为了让后面翻页和批量操作方便我封装成了独立函数def fetch_page(session, page, page_size): timestamp int(time.time()) sign build_sign(page, page_size, timestamp) req request_pb2.ListRequest() req.page page req.page_size page_size req.timestamp timestamp req.sign sign resp session.post( https://practice.example/api/items, datareq.SerializeToString(), headers{Content-Type: application/x-protobuf}, ) result spider_pb2.ListResponse() result.ParseFromString(resp.content) return json_format.MessageToDict(result)实测连续翻 10 页没有触发签名错误说明签名这块已经复现完整。整体流程从抓包到封装耗时大约一个晚上主要时间花在辨认字段名和修正拼接顺序上纯编码时间其实很短。6. 实战翻车记录这些坑我每个都踩过6.1 解析层的坑字段号猜错导致错位解析有一次我把某字段的wire_type看成 Varint实际上它是 Length-delimited导致后面所有字段全部解析失败。排查方法是用protoc --decode_raw对照标准输出先保证编号和类型完全一致再写.proto。忽略 repeated 字段的 packed 编码proto3 里repeated数值字段默认用 packed 编码也就是一个 length-delimited 的 tag后面跟一连串 varint手工解析时特别容易误判成嵌套 message。遇到10 03 01 02 03这种要想到它可能是一个 repeated int32 列表。解压顺序没弄清楚响应如果开启了 gzip必须先把压缩解开再解析否则拿到的是一堆以1F 8B开头的字节。更坑的是有些层先做了 protobuf 再压缩有些先压缩再去序列化顺序不对就会看到奇怪的报错。6.2 签名层的坑MD5 大小写敏感服务器用大写我用小写直接 401。排查时先看抓包里的sign是不是大写开头再对齐hexdigest().upper()或.lower()。盐值藏在响应里有些平台的盐值不是写死在 JS 里的而是上一次响应字段动态返回。如果请求一直签名失败记得回看响应里有没有类似salt、key、nonce的字段。时间戳过期签名里带timestamp是为了防重放练习平台通常会留 60 秒以内的宽容度。如果调试太久导致时间超出窗口签名字符串本身没错也会被拒。解决办法是把生成签名和发送请求放在同一个函数里不要手动复制时间戳。6.3 平台侧的小动作练习平台偶尔会在请求头里校验Referer或Origin浏览器里正常但脚本里一调就失败。处理方法是先用浏览器的完整请求头原样复现能通之后再逐步删减找出真正必要的几个。另外同一客户端短时间内大量请求可能触发频率限制我建议在循环里加上 0.5 到 1 秒的随机延迟做练习也要注意分寸。6.4 复盘我现在的固定操作流踩完这些坑之后我再遇到类似接口会直接按固定套路走保存原始响应的.bin文件备份锚点数据。用protoc --decode_raw快速确认是不是 protobuf 并拿到字段骨架。切 JSON 模式或翻 JS尽量恢复字段名。写.proto定义文件用 Python 解析出完整消息对象。在页面 JS 里定位sign或加密字段的生成函数记录拼接顺序和盐值。构造请求体时把签名生成和请求发送放同一个流程避免时间戳过期。用一次真实响应的参数验证签名算法是否复现正确再正式跑批量逻辑。这套流程跑顺之后03_protobuf_challenge 这类题基本半天内能拿下。最后说一点个人体会propotuf 的原始二进制确实比 JSON 劝退但只要你把 tag、wire type、varint 这三样东西吃透它其实比很多花哨的加密方案都容易还原因为字段结构本身就是最大的线索。练习平台的价值就是把这种没有文档的二进制协议暴露在你面前让你在安全可控的环境里把读包、验签、回说的能力练扎实。