技术群里隔三差五就有人发截图:一片十六进制字节,配一句"这玩意儿怎么解?"。这种流量其实挺好认的——不是 HTTP,也不是 protobuf、JSON 这些能自动识别的格式,而是自己客户端和服务器约定的一套私有协议。说实话,碰上这类协议,通用抓包工具基本就止步了:它不知道你的消息从第几个字节切、头里有什么字段,只能把原始字节原样摊开。但止步归止步,不代表没辙——写个小解码器教它怎么读,这事儿能解,而且比想象中简单,核心就一个函数。
下面按动手的顺序走一遍:先看它怎么工作,再把函数写出来、挂上去、调通。全程不用重新抓包,改一版看一版,折腾起来没什么心理负担(这个是我最喜欢的一点)。
一、先看它怎么工作:一个"能不能切一条"的循环
在 TraceEagle 里,自定义解码器的模型是这样的:你写一个解码钩子,工具把某条连接的字节喂给它,反复问同一句话——"从这儿开始,能切出一条完整消息吗?"你切出来一条、告诉它用掉了多少字节,它拿剩下的字节再问一次,如此往复,直到你说"切不动了"。
如果你写过网络框架里的累积式字节解码器,这就是同一套心智模型:面对一条源源不断的字节流,自己决定消息边界在哪儿。有两个约定省了不少事:
- 方向不用你操心:一条连接的发送流和接收流,工具分开各跑一遍你的钩子,切出来的消息自动标好方向(发送 ↑ / 接收 ↓)。你的脚本只管"怎么切"。
- 切出来就不用管渲染:每条消息推出去之后,工具会再做一次自动识别——里面是 protobuf、JSON、plist 就继续解成结构化。你负责拆帧、剥头、解压,剩下的接力棒它接。
二、把函数写出来:decode(buf, out)
整个解码器就是一个函数,长这样:
function decode(buf, out) {
// buf: 当前待解析的字节流;out: 输出收集器;返回:这次消费掉的字节数
}
三个要素,一个个说吧:
buf——还没被消费的剩余字节。 类型是 ArrayBuffer,内容是从你上次切完的位置到目前末尾的全部字节。用 buf.byteLength 看还剩多少——每轮调用它都可能变大,这正是让你判断"够不够切一条"的依据嘛。
两个新手最容易栽的地方:第一,
buf是原始字节缓冲,不是Uint8Array,不能直接下标取字节(buf[0]拿到的是undefined,别问我怎么知道的)——读字节用内置的u8/u16be/sub这些辅助函数,或者自己包一层new Uint8Array(buf)。第二,push 了消息就必须返回它占用的字节数——如果 push 完返回 0,循环会停下,这次 push 直接被丢掉,白写。
out——输出收集器。 一个普通数组,out.push(消息字节) 就是推出一条消息,一次调用可以推多条。消息可以是内置辅助返回的 ArrayBuffer(比如 sub(...) 切出来的、gunzip(...) 解开的),也可以是字符串。
返回值——你这次消费了多少字节。 返回大于 0:工具丢掉开头这些字节,拿剩下的再调你一次;返回 0、负数或者干脆不 return:表示"开头还凑不齐一条完整消息",循环停止,剩下的字节原样展示(比如末尾那半条不完整的消息,不会丢)。
还有个细节挺贴心:发送流和接收流各用一份独立全新的脚本环境,互不串味;想跨多次调用记点状态(计数、上一条的类型什么的),把变量写在 decode 外面就行——它在同一条流里保留,换方向或重新解码时重置。
三、套模板写第一个:长度前缀
私有协议的形态五花八门,但长度前缀是最常见的一种——[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 // 用掉这一条,循环接着切下一条
}
逐行看一遍其实就三件事:先判断字节够不够(不够就 return 0 等下一波),算出这条消息总共多长,够了就剥掉头、把负载推出去、如实汇报用掉多少。整个过程没有任何黑魔法,写起来跟写业务代码也没什么区别嘛。
编辑器里还有几个现成骨架:分隔符 / 按行分帧、Magic 签名边界、定长、整包 gzip、按类型字段分派。真遇到特殊的结构,内置辅助也够用——取字节 sub、读整数 u8/u16/u32(大小端都有)、转文本 hex/ascii、判压缩 gzipMagic、解压 gunzip/inflate/unzstd。都是写脚本时随手要用的东西,省得自己造轮子。
四、挂上去、调起来
写完保存,应用的入口很直接:抓到那条看不懂的连接,右键选「解码为」,选中你的解码器,整条连接的收发立刻按你的规则拆开展示。
调的过程也没什么门槛,两个点:
打印随便打。 在 decode 里用 log(...) 或者 console.log(...) 打印任何值——字节数、类型字段、hex(sub(buf, 0, 8)) 看看头长什么样——输出会显示在结果上方的"调试输出"面板里,每行还标着来自发送 ↑ 还是接收 ↓。边打印边定位,比盲改快多了。顺嘴说一句:这个沙箱里没有浏览器 / Node 的全套 console,也没有 fetch、setTimeout、require,只有 log 和 console.log 被接到面板上;要把字节读成文本,用 ascii(buf)。
写坏了也不慌。 脚本抛异常或者单次执行超时,都会被当成"这次没消费"——剩余字节按原始数据展示,不会中断抓包、也不会丢数据,报错详情就显示在调试面板里。而且脚本改完保存,再点一次「解码为」,同一条连接立刻用新规则重解,不用回到抓包那一步重来——反复迭代到拆得满意为止,这一点在实际调协议的时候太关键了。
五、拆出来只是中间站
每条消息切出来之后,会再走一遍自动识别——负载是 protobuf 就解出字段号和值,是 JSON 就排好版,是 plist 也能还原本来的样子。说到底,自定义解码器负责的是"切",看懂内容还是那套查看与解码的能力在接。
所以整条链路是通的:剥掉头的负载在解码器里推出去 → 自动识别成结构化 → 配合多视图查看、请求对比这些接着往下用。前面写过一篇抓包数据全是乱码的读法,里面收尾提了一句"私有的交给解码器"——这篇算是把那句话展开了,真动手写起来,比看起来容易。
一些问题
脚本写坏了,会不会把抓包搞崩? 不会。异常和超时都按"没消费"处理,剩余字节照常展示,抓包不中断、数据不丢。
改完脚本要重新抓一遍包吗? 不用。解码是对已抓到的数据做的,保存脚本再点一次「解码为」就重跑了,想试几版试几版。
能引入 npm 的库吗? 不行,沙箱里只有内置的那套辅助(取字节、读整数、转文本、解压)加标准 JS 内置对象。实际用下来基本够——真不够的部分,用 DataView 自己读写也接得上。
解出来的还是乱码怎么办? 先确认切的位置对不对(用 log 打印边界字节看一眼),再检查负载是不是还压着一层呢(gzipMagic 判一下,是就 gunzip);剩下的交给自动识别,它认得出 protobuf 这类没扩展名的格式。
真遇上私有协议的时候,顺序其实很清楚:先看字节流的规律(头几个字节是不是长度、有没有固定 Magic),套一个现成模板改一改,右键「解码为」挂上去,打印几个值定位问题,然后就反复迭代。整套下来不用重新抓包、不用重启什么,改脚本、点一下、看结果——这种即改即验的循环,调试私有协议的时候是真省事啊。