私有二进制协议抓包解析自定义解码器教程 ,解决抓到私有二进制协议只有一堆字节

0 阅读22分钟

抓到私有二进制协议只有一堆字节?教你写个解码器把它拆开

一、真实场景:抓到了流量,但看不懂

做逆向分析或安全测试的人大概率遇到过这种情况:目标不是 HTTP,也不是常见的 RPC 框架,而是一条自研的 TCP/UDP 长连接——常见于游戏客户端、IoT 设备、车机系统、公司内部网关这类场景。抓包工具能稳稳地把这条连接的收发流量都抓下来,但打开一看,无论代理抓包还是网卡抓包,展示出来的都是一坨十六进制字节:

00 00 00 1F 7B 22 63 6D 64 22 3A 31 30 30 31 2C ...

内置的自动识别机制会尝试按 HTTP、TLS、常见 RPC 框架的特征去匹配,但私有协议往往连个公开的协议名字都没有——它可能是团队几年前定的一套内部规范,帧头几个字节是长度,中间夹一个类型字段,负载是 JSON 或者自己压缩过的二进制结构。工具认不出来,属于正常情况:全世界的私有协议长什么样,没有人能提前写好通用识别规则。

这时候通常有两条路:一是上 Wireshark,写 Lua Dissector 插件;二是找一个更轻量的方式,用几行脚本描述"这条流的一帧消息长什么样",让抓包工具照着切。这篇文章讲第二条路——用自定义解码器(decoder script)把私有二进制协议从"一坨字节"还原成结构化消息,涵盖完整 API、四个由浅入深的示例,以及和 Wireshark Dissector 方式的对比。

二、核心心智模型:什么是"累积式字节解码器"

在讲具体 API 之前,有必要先讲清楚这套设计背后的思路,因为它决定了你写解码器时该怎么想问题。

如果你写过网络框架里的自定义协议解析代码——比如 Netty 里继承 ByteToMessageDecoder 写过累积式解码器——这套心智模型你会很熟悉:TCP/UDP 传过来的是一条连续的字节流,没有天然的"消息边界",应用层要自己判断"从当前位置开始,够不够切出一条完整消息"。够,就切一条、告诉框架用掉了多少字节;不够,就原地等,等更多字节到齐了再问一次。

自定义解码器沿用的正是这个模型,只是把"写 Java 类、编译、打包、重启服务"这套流程,压缩成了"写一个 JS 函数、保存、点一下重新解码"。你只需要回答一个反复被问到的问题:

"从这里开始,能切出一条完整消息吗?"

能,切一条出去,告诉工具这条消息用掉了多少字节;不能,原地不动。工具会拿着"剩下的字节"再问一次同样的问题,如此循环,直到某次回答"不能"(凑不出完整的一条),循环结束,剩下没切完的尾巴按原始数据展示——这通常对应一条还没收完整的消息,或者你解码逻辑没覆盖到的边界情况。

这套模型天然带来两个好处,也正是官方设计里明确替你处理掉的两件事:

  • 不用操心方向:一条连接有发送流和接收流两个方向,工具会分别、独立地把这两条流喂给你的解码钩子各跑一遍,切出来的消息自动标好是发送 ↑ 还是接收 ↓,脚本里完全不需要判断方向,只管"怎么切"。
  • 切出来不用管渲染:你的解码器唯一的职责是拆帧——把一条连续字节流切成一条条独立消息,剥掉帧头、解压。至于这条消息内部是 JSON、protobuf 还是 plist,工具会对每一条 push 出来的消息再跑一次自动识别,能认出结构化格式的会继续往下解析。你不需要在解码器里手写 JSON.parse 或者 protobuf 反序列化。

三、decode(buf, out) 完整 API 讲解

自定义解码器的核心就是实现一个函数,签名固定:

function decode(buf, out) {
  // buf: 当前待解析的字节流
  // out: 输出收集器
  // 返回值:这次调用消费掉的字节数
}

3.1 入参 buf:待解析的字节流

  • 类型是 ArrayBuffer,内容是"从上一次成功消费结束的位置,到当前流末尾"的全部剩余字节。随着连接持续收发数据,每一轮调用里 buf 都可能变长。
  • buf.byteLength 就是当前还剩多少字节可用,写长度判断时基本都要用到它。
  • 容易踩的坑:buf 是原始的 ArrayBuffer,不是 Uint8Array,不能直接用下标取字节——buf[0] 拿到的是 undefined,不会报错,但逻辑完全是错的。想按字节读,要么用内置的读整数辅助函数(下一节会讲),要么自己包一层视图:new Uint8Array(buf) 或 new DataView(buf)。

3.2 出参 out:输出收集器

  • out 是一个普通数组,调用 out.push(消息) 就能推出一条消息,一次 decode 调用里可以 push 多条(比如一次性凑齐了两条完整消息)。
  • push 的内容可以是内置辅助函数返回的 ArrayBuffer(比如 sub(...) 截出来的一段、gunzip(...) 解压出来的结果),也可以是字符串;其他类型会被直接忽略,不会报错也不会显示。

3.3 返回值:这次到底吃掉了多少字节

返回值是一个整数 number,语义很关键:

  • 大于 0:表示从 buf 开头数,消费掉了这么多字节。工具会把这部分字节丢弃,把剩下的字节重新组成新的 buf,再调一次 decode,如此循环。
  • 等于 0、不写 return、或者返回负数:都视为"这次凑不出完整的一条",工具停止循环,把当前剩余的所有字节按原始数据(raw)展示出来。

有个细节必须强调:消息是在"确认消费"之后才真正登场的。只有返回值 > 0,本次调用里 push 出去的消息才会真正显示;如果你 push 了消息,但返回的是 0,循环照样停止,这次 push 会被直接丢弃。所以"推一条消息"和"如实返回它占用了多少字节"必须成对出现,少一半都不对。

返回值会被自动夹在缓冲区长度以内,防止越界读越界写把工具搞崩,但这只是兜底保护,写解码逻辑时还是要如实计算、返回真正用掉的字节数,否则会导致后续帧错位、越解越乱。

循环结束后,没被消费掉的尾部字节不会丢弃,会作为一条原始数据展示——这对应"最后半条不完整的消息还没收完"这种很常见的情况,不用特殊处理,工具会自动兜底。

3.4 生命周期与状态管理

对一条连接,发送流和接收流各自独立跑一遍解码流程:每一遍里 decode 会被循环调用,每次把当前剩余的未消费字节交给你,你切一条、返回消费数,工具用剩下的字节再调一次,往复直到返回 0(或负数)为止。

每条流(每个方向)都用一份独立、全新的脚本执行环境,发送流和接收流互不干扰、互不串味。如果你的协议需要跨多次调用记点状态——比如统计已经解出了多少条消息、记录上一条消息的类型、维护一个简单的状态机——只需要把变量声明在 decode 函数外层:

let msgCount = 0
function decode(buf, out) {
  if (buf.byteLength < 4) return 0
  const total = 4 + u32be(buf, 0)
  if (buf.byteLength < total) return 0
  msgCount++
  log(`第 ${msgCount} 条消息`)
  out.push(sub(buf, 4, total - 4))
  return total
}

这类外层变量会在同一条流的多次调用之间保留,换方向或者重新触发解码时会被重置,不用担心状态污染到别的连接或别的方向。

解码本身是针对已经抓到的数据做的一次"离线加工",不是实时跟着抓包过程跑一次就完事——脚本改完保存,回到抓包记录里再点一次"解码为",就会用新规则把同一条连接重新解一遍。调试一个复杂协议的过程,基本就是"改一点、重解一次、看结果"的快速迭代。

3.5 出错处理:写坏了会怎样

这是实际使用中很多人会担心的问题:脚本里有 bug 怎么办?答案是:不会影响抓包本身,也不会丢数据。

  • 脚本执行时抛出异常,或者单次执行超过时间上限,都会被当作"这次没消费"处理,剩余字节原样按原始数据展示。
  • 抓包过程不会被中断,已经抓到的数据也不会丢失,顶多是这条连接暂时"没解开、只能看到原始字节"。
  • 报错信息不会被静默吞掉:异常信息和超时提示会显示在"解码为"结果上方的"调试输出"面板里,并且标明是来自发送方向 ↑ 还是接收方向 ↓,方便定位是哪个方向的哪次调用出的问题。

四、内置辅助函数一览

写解码器时不需要从零手写字节读取逻辑,以下这些辅助函数在脚本里可以直接调用:

类别签名说明
取子段sub(buf, off[, len])从偏移 off 开始截取,省略 len 则截到末尾,返回 ArrayBuffer
读整数u8(buf, off)读 1 字节无符号整数
读整数u16be(buf, off) / u16le(buf, off)读 2 字节无符号整数,大端 / 小端
读整数u32be(buf, off) / u32le(buf, off)读 4 字节无符号整数,大端 / 小端
转文本hex(buf)转成十六进制字符串,便于打印调试
转文本ascii(buf)转成文本,用于查找分隔符、按行切割等场景
判压缩gzipMagic(buf)判断是否是 gzip 格式,返回布尔值
解压gunzip(buf) / inflate(buf) / unzstd(buf) / lz4dtx(buf)按对应算法解压;解不动时原样返回,不会抛异常
调试log(...args) / console.log(...args)打印任意值到调试面板

除了这些封装好的辅助函数,标准的字节读写能力(DataView、Uint8Array 等定型数组)也可以直接用——辅助函数只是为了少写几行样板代码,遇到辅助函数覆盖不到的场景(比如非对齐的位域、变长整数编码),完全可以自己用 DataView 手写。

需要注意运行环境是沙箱化的标准 JavaScript:ArrayBuffer、各类定型数组、DataView、JSON、Math、RegExp、Date 等内置对象都能用,但没有浏览器或 Node 环境的完整 console(只有 log / console.log 可用)、没有 TextDecoder、没有 fetch、没有 setTimeout、没有 require。字节转文本统一用 ascii(buf),不要指望 TextDecoder。另外每次 decode 调用都有执行时间上限,写出死循环或者跑得过久的逻辑会被中断、当作"未消费"处理,不会拖垮整个工具,但也意味着解码逻辑要写得干净利落,别指望靠死等或者暴力轮询绕过边界判断。

五、四个由浅入深的代码示例

下面从最常见的长度前缀协议开始,逐步过渡到带类型分派的复合协议。

5.1 示例一:长度前缀协议 [4字节大端长度][负载]

这是私有二进制协议里最常见的一种帧结构:帧头固定 4 字节,存的是负载部分的长度(大端序),后面跟着对应长度的负载。

function decode(buf, out) {
  if (buf.byteLength < 4) return 0       // 长度头还没到齐,先等
  const total = 4 + u32be(buf, 0)        // 整条 = 4 字节头 + 负载
  if (buf.byteLength < total) return 0   // 整条还没到齐,先等
  out.push(sub(buf, 4, total - 4))       // 剥掉头,把负载交给自动识别
  return total                           // 用掉这一条,接着切下一条
}

思路拆解:

  1. 先判断连长度头(4 字节)都不够,直接返回 0 等待更多数据——这一步很容易漏掉,漏了的话 u32be(buf, 0) 在字节不够时行为是未定义的,会导致后续逻辑跑偏。
  2. 读出负载长度,算出整条消息的总长度 total。
  3. 再判断当前 buf 里的字节够不够凑出完整的一条(帧头 + 负载都要在),不够就继续等。
  4. 够了,用 sub 把负载部分(去掉 4 字节头)切出来 push 出去,返回 total 告诉工具这一条总共用了多少字节。

这段代码天然支持 TCP 粘包/半包场景——不管一次 buf 里塞进来几条完整消息、还是半条消息,工具会通过循环调用把这套逻辑反复跑到把能切的都切完为止。

5.2 示例二:按分隔符/按行切割

有些私有协议走的是文本行协议的思路,每条消息以 \n 结尾:

function decode(buf, out) {
  const i = ascii(buf).indexOf('\n')
  if (i < 0) return 0                     // 还没遇到换行,等
  out.push(sub(buf, 0, i))               // 推这一行(不含换行)
  return i + 1                           // 连换行一起消费掉
}

思路拆解:把 buf 转成文本,找第一个换行符的位置 i。找不到(-1)说明这一行还没收完,等更多字节;找到了,把换行符之前的部分(不含换行本身)push 出去,返回值是 i + 1——注意这里要把换行符本身也算进消费掉的字节数,否则下一轮解析开头会多出一个 \n,逻辑会错位。

5.3 示例三:整条一次性处理(例如整包解压)

有些场景不需要拆帧,只需要判断整条数据是不是被压缩过、解压完就完事:

function decode(buf, out) {
  out.push(gzipMagic(buf) ? gunzip(buf) : buf)  // 是 gzip 就解开
  return buf.byteLength                          // 全部消费,循环随即结束
}

思路拆解:用 gzipMagic 判断当前字节是不是 gzip 格式,是就用 gunzip 解压后 push,不是就原样 push。返回值直接是 buf.byteLength,意味着这一次调用把当前所有字节都消费掉了,循环立刻结束(因为剩余字节数变成 0)。这种写法适合"一条连接只有一条消息"或者"消息边界由更底层的传输层保证"的场景。

5.4 示例四:按类型分派(帧头带类型字段)

实际的私有协议往往更复杂一些,帧头里除了长度还夹了一个类型字段,不同类型的负载处理方式不一样:

function decode(buf, out) {
  if (buf.byteLength < 4) return 0
  const total = 4 + u32be(buf, 0)
  if (buf.byteLength < total) return 0
  const type = u8(buf, 4)                        // 首字节是消息类型
  const body = sub(buf, 5, total - 5)
  out.push(type === 2 ? gunzip(body) : body)     // 类型 2 是压缩过的
  return total
}

思路拆解:前面判断长度头、算总长度的逻辑和示例一一样,多出来的一步是从负载起始位置(偏移 4)读一个字节作为类型字段,再从偏移 5 开始截取真正的业务负载。根据类型字段决定要不要 gunzip 解压——这里类型 2 代表压缩过的负载,其余类型原样传递。这个模式可以很自然地扩展成 switch 语句处理更多类型、甚至针对不同类型做不同的字段解析。

四个示例背后是同一套骨架:先判断够不够、再算总长度、再切、再返回消费数。真实项目里遇到的私有协议,绝大多数是在这套骨架上叠加字段(版本号、序列号、校验和、可选压缩标志),把这套模式吃透,剩下的都是照猫画虎。

六、怎么用、怎么调试

实际操作流程大致分这几步:

  1. 新建解码器:在解码器编辑器里新建一个脚本,起个名字(比如按协议名或者按连接用途命名,方便以后复用)。编辑器内置了长度前缀、Magic 签名、分隔符、定长、gzip、按类型分派等常见骨架模板,插入一个作为起点,改字段偏移和类型判断即可,不用从空白文件开始写。
  2. 写解码逻辑:参照第五节的四个示例,结合抓到的原始字节动手比对——用十六进制视图数清楚帧头几个字节是长度、长度是大端还是小端、有没有类型字段,把这些信息落实成 decode 函数里的判断逻辑。
  3. 应用解码器:抓到一条看不懂的连接,右键选择"解码为",选中刚写好的解码器,这条连接的收发流量会立刻按解码规则重新拆解展示。
  4. 用 log 调试:解码逻辑不确定的地方,直接插入 log(...) 打印任意中间值——比如打印每次算出来的 total、type,或者某个字段的十六进制表示(配合 hex())。输出会显示在"解码为"结果上方的"调试输出"面板里,并标明是发送方向 ↑ 还是接收方向 ↓产生的,定位问题很直接。
  5. 反复迭代:不需要重新抓包。脚本改完保存,回到同一条连接再点一次"解码为",马上用新规则重新解析。私有协议往往需要来回试几次才能把所有字段对齐,这个"改完立刻重解"的循环是效率的关键。

另外值得一提的是,很多结构相对规整的私有帧其实不用写脚本也能自动拆开:工具的自动识别引擎会尝试探测常见的定长前缀边界并自动逐帧展开,一条裸的二进制流在不写任何脚本的情况下也可能被直接拆成一条条消息;如果只是长度前缀、Magic 签名边界、分隔符(含十六进制形式)、定长这几种常见结构,编辑器里现成的分帧模板改几个数值就能用。真正需要上自定义 JS 脚本兜底的,是那些带类型分派、条件压缩、多层嵌套这类自动识别搞不定的复杂结构。

七、常见问题 FAQ

Q1:为什么 buf 不能直接用下标取字节,比如 buf[0]? buf 是原始的 ArrayBuffer,不是 Uint8Array,ArrayBuffer 本身不支持下标索引,buf[0] 会拿到 undefined 而不是报错,这种"静默错误"反而更容易被忽略。正确做法是用内置的 u8/u16be/u32be 等辅助函数读整数,或者自己包一层 new Uint8Array(buf) / new DataView(buf) 再操作。

Q2:解码脚本写错了、抛异常了,会不会影响正在进行的抓包? 不会。解码是对已经抓到的数据做的离线处理,脚本抛异常或者执行超时,都会被当作"这次没消费",工具把剩余字节按原始数据展示,不会中断抓包、也不会丢失已经抓到的数据。报错信息会显示在调试输出面板里,方便定位问题,但不影响抓包会话本身。

Q3:能不能处理压缩过的私有协议,比如帧头标了压缩标志位? 可以,示例三和示例四都覆盖了这种场景。内置的 gzipMagic 可以判断是否是 gzip,gunzip/inflate/unzstd/lz4dtx 四种解压函数覆盖了常见压缩算法,解不动时会原样返回而不是抛异常,配合帧头里的类型/标志字段做条件解压是很常见的写法(参考示例四)。

Q4:一条消息切错了、返回的消费字节数不对,会有什么后果? 如果返回的字节数比实际消息长度多或少,会导致后续所有帧都跟着错位,越解析越乱,表现通常是从某条消息开始内容变得不可读或者结构混乱。排查时建议用 log 打印每一轮算出的 total 和实际 buf.byteLength,对照原始十六进制数据核实计算是否正确。返回值本身会被自动夹在缓冲区长度以内防止越界崩溃,但这只是兜底,不能替代正确的长度计算。

Q5:需要处理连接的方向(发送/接收)吗? 不需要。工具会把一条连接的发送流和接收流分开、各自独立跑一遍解码流程,两个方向各用一份全新的脚本执行环境,切出来的消息会自动标好方向。解码器只需要专注于"怎么切一条消息",不用写任何方向判断逻辑。

Q6:切出来的消息里如果是 JSON 或 protobuf,还需要在脚本里手动解析吗? 不需要。解码器 push 出去的每一段消息,工具会再对它做一次自动识别,如果内容是 protobuf、JSON、plist 等已支持的结构化格式,会被自动继续解析成可读结构。解码器的职责始终是"拆帧、剥头、解压",不用越俎代庖去写 JSON.parse 或者手搓 protobuf 反序列化。

八、和 Wireshark 自定义 Dissector 的对比

Wireshark 处理私有协议的标准做法是写 Lua Dissector(或者用 C 写插件),本质上也是在描述"这段字节该怎么拆、每个字段是什么含义",思路上和这里讲的自定义解码器是同一类问题的两种解法,值得客观比较一下:

  • 能力边界:Wireshark 的 Dissector 机制非常成熟、功能全面,可以精细控制字段名、字段树状展示、协议偏好项、跨协议的子解析器链(比如在 TCP 之上挂一个自定义协议,再在自定义协议之上挂 HTTP),生态和文档都很完善,是协议逆向和标准化协议分析的老牌工具。
  • 学习曲线:写 Dissector 需要理解 Wireshark 自己的一套 API 体系——Proto、ProtoField、DissectorTable、Pinfo、Tvb 这些概念,加上 Lua 语言本身的一些坑(比如没有内置的字节缓冲类型,字段声明比较繁琐),对第一次接触的人门槛不低,通常需要读完一整套官方文档和示例才能上手写出一个能跑的插件。
  • 心智模型的差异:这里讲的解码钩子有意把范围收窄到"只做拆帧"这一件事——判断边界、切一刀、返回消费字节数,字段级的展示和结构化解析交给工具的自动识别去做,不需要在脚本里手写字段树。这个取舍换来的是更低的门槛:不用学习专门的插件 API,一个熟悉 JS 的开发者看着示例基本可以直接上手,改完保存立刻在同一条连接上重新验证效果,不需要重启工具或者重新加载插件。
  • 适用场景的差异:如果目标是长期维护一个协议的完整解析规范、供团队多人复用,或者需要非常细粒度的字段树展示,Wireshark Dissector 的成熟生态仍然是更扎实的选择。如果只是想快速把一条私有连接的字节流"翻译"成能看懂的结构,验证一个猜测、定位一个字段,轻量的 JS 解码钩子上手更快、迭代更快。

两者不是互斥关系,实际工作中完全可以先用轻量脚本快速摸清协议结构、验证猜想,确认协议规范稳定后再决定要不要沉淀成正式的 Wireshark 插件。

九、小结

私有二进制协议"抓得到但看不懂",本质上不是抓包能力的问题,而是缺一层"怎么把字节流切成消息"的描述。这层描述的核心就是一个函数:反复回答"从这里开始,能不能切出一条完整消息",能就切、返回消费了多少字节,不能就等——这正是累积式字节解码器的心智模型,写过 Netty 之类框架里类似解码器的人会很快找到手感。

具体到操作层面,记住几个关键点基本就够用了:buf 是 ArrayBuffer 不能直接下标取字节;返回值必须如实反映消费的字节数,push 消息和返回消费数要成对出现;跨调用的状态写在函数外层;出错不会影响抓包,善用 log 在调试面板里定位问题;协议内部的 JSON/protobuf/plist 不用手动解析,自动识别会接手。

如果手头没有现成工具可以试这套流程,抓包鹰(TraceEagle)内置了这个自定义解码功能,配合网卡抓包或应用层抓包使用,覆盖游戏协议、IoT 设备协议、公司内部私有 RPC 这类非 HTTP 二进制流量的场景;同时对常见的定长前缀、Magic 签名、分隔符结构也提供了不写脚本就能自动拆帧的能力,复杂结构再上自定义解码器兜底。工具免费、跨平台,主打"抓得到,解得开,看得懂",遇到私有协议这类硬骨头时,这套从自动拆帧到手写解码器的分层设计,基本能覆盖从简单到复杂的大部分场景。