我所在的业务对 protobuf 的应用比较普遍,无论在 websocket 长连接的数据帧亦或是通用 http 请求过程中都喜欢使用 protobuf 进行序列化,这算是一种私有协议吧。虽然在私密性和性能上有一定的提升,但是测试的时候就非常难受了,所以我的目标很简单,就是能够自动将这些 pb 序列化的数据反序列化成人可以阅读的文本,比如 JSON 格式。

工具选择

显然,对于私有协议的抓包解包需要抓包工具支持插件开发,我平时习惯用 burpsuite,虽然它提供 http 的插件接口,但是它并没有提供 websocket 插件开发的接口(试过翻看文档,着实没找到。。),于是我将目光投向了国人开发的工具——whistle,刚好它提供了一些插件开发的文档,虽然最近更新也是几年前了,但是总归在设计上是支持的。插件开发的流程和原理我就不赘述了,可以看这里

whistle 官方提供了插件开发的 实例代码 ,令人头疼的是它的官方插件文档写得比较早,插件实例推出后没有更新相应的文档,而且我的场景实例有些差异,于是,开始了艰难的尝试之路。。。

解开 websocket 数据帧

第一个尝试是解 websocket 的数据帧,我将目光投向了 whistle.script, 因为需要反序列化 protobuf,所以要加载 *.proto 文件,找到了 js 里操作 pb 的库 protobufjs, 查看官方文档后,可以使用如下代码反序列化 ws 数据帧。


ProtoBuf.load(msgProto, function (err, root) {
    if (err)
        throw err;

    // Obtain a message type
    let AwesomeMessage = root.lookupType("msg.Msg");

    // Decode an Uint8Array (browser) or Buffer (node) to a message
    let message = AwesomeMessage.decode(data);

    // ... do something with message
    console.log(`Client: ${JSON.stringify(message)}`);
});

说明:msgProto 是本地的 *.proto 文件地址,msg.Msg 分别是 proto 文件定义的包名和 Message, .decode 方法将数据反序列化即从二进制的 pb 转为 js 对象。

这里有个小插曲,websocket 包里有两层 pb,如下:

// 消息系统消息
message Msg {
    optional uint64 id = 1; // 消息ID
    optional uint32 kind = 2; // 推送类型,详见: Kind
    optional uint32 type = 3; // 消息类型,详见:Type
    optional uint64 to_uid = 4; // UID
    optional uint64 session_id = 5; // session ID,必填
    optional bytes content = 6; // 消息内容(消息结构体pb二进制流)
    optional uint64 timestamp = 7; // 消息发送时间
}

content 字段就是内层 pb 序列化后的字节流,反序列化的时候根据外层的 type 选择 proto 对应的 Message 类型进行反序列化,所以需要加一层判断。代码类似如下:

function getContent(msgType, root, serialContent) {
    let content = null;
    switch (msgType) {
        case 1001:
            content = root.lookupType("msg.LikeCntMsg");
            break;
        case 1002:
            content = root.lookupType("msg.CcuMsg");
            break;
        ...
    }
    if (content != null) {
        return content.decode(serialContent);
    } else {
        return serialContent;
    }

这里根据 msgType 将序列化的 content 字段反序列化为 js 对象然后返回,剩下的内容就是构造最外层的对象,再打印在 console 中,完整代码如下:

exports.handleWebSocket = async (ws, connect) => {
    // 将请求继续转发到目标后台,如果不加则直接响应
    const res = await connect();
    // 获取客户端的请求数据
    ws.on('message', (data) => {
            ProtoBuf.load(msgProto, function (err, root) {
                if (err)
                    throw err;

                // Obtain a message type
                let AwesomeMessage = root.lookupType("msg.Msg");

                // Decode an Uint8Array (browser) or Buffer (node) to a message
                let message = AwesomeMessage.decode(data);

                // ... 打印客户端发送出去的数据
                console.log(`Client: ${JSON.stringify(message)}`);
            });
            // 可以修改后再发送到Server
            res.send(data);
        }
    );
    res.on('message', (data) => {
        ProtoBuf.load(msgProto, function (err, root) {
            if (err)
                throw err;
            // Obtain a message type
            let AwesomeMessage = root.lookupType("msg.Msg");
            // Decode an Uint8Array (browser) or Buffer (node) to a message
            let message = AwesomeMessage.decode(data);
            let con = getContent(message.type, root, message.content);
            // 重新构造最外层的对象结构,直接将转换的con覆盖message.content会有问题
            let frame = {
                id: parseInt(message.id).toString(),
                kind: message.kind,
                type: message.type,
                toUid: parseInt(message.toUid).toString(),
                sessionId: parseInt(message.sessionId).toString(),
                content: con,
                timestamp: parseInt(message.timestamp).toString()
            }
            // 在script的Console打印出服务端发送过来的数据
            console.log(`Server: ${JSON.stringify(frame)}`);

        });
        // 可以修改后再发送到Server
        ws.send(data);
    });
// 接收通过whistle.script页面Console的dataSource.emit('toSocketClient', {name: 'toSocketClient'})的数据
    ws.dataSource.on('toSocketClient', (data) => {
        ws.send(data);
    });
// 接收通过whistle.script页面Console的dataSource.emit('toSocketServer', {name: 'toSocketClient'})的数据
    ws.dataSource.on('toSocketServer', (data) => {
        res.send(data);
    });

};

打印出的内容如下:

Server: {"id":"371","kind":185,"type":1001,"toUid":"0","sessionId":"0","content":{},"timestamp":"0"}
Server: {"id":"1253034067729926","kind":0,"type":1002,"toUid":"32","sessionId":"6799373","content":{"ccu":"1"},"timestamp":"1612239130113"}
Client: {"kind":372,"type":2001}

解开 http 请求 Body

使用 whistle.script 只能在插件模块的 console 打印出明文数据,对于 websocket 这种长连接看连续的不同消息还算方便,但对于单个的 http 请求来说就很麻烦了,能不能直接在 Inspectors 里查看呢?于是我发现了 whistle.pipe 这个插件,它可以用来解析请求和响应数据并在 Inspector 模块中查看。于是我按照 whistle 的插件开发方式,使用 lack 创建了 reqRead、reqWrite 而不是 resRead、resWrite(与官方实例的解析响应内容不同,我需要解析的是请求的 body)。

我要解析的是一个上报数据接口,它的请求 body 是 pb 序列化的,message 定义如下:

// EventList即是request body
message EventList {repeated Event events = 1;}

message Event {
  optional Header header = 1;
  optional bytes body = 2;
}

message Header {
  optional uint32 id = 1; // 事件ID,事件类型唯一,详见:EventID
  optional uint32 scene_id = 2; // 场景ID,业务场景唯一,详见:SceneID
  optional uint64 uid = 3;      // 用户ID
  optional string device_id = 4;    // 设备ID
  optional string device_model = 5; // 设备类型,例如 “iPhone6 Plus”
  optional uint32 os = 6;           // 操作系统  0:Android 1: iOS 2:PCWeb
  optional string os_version = 7;     // 系统版本号,例如 “10.3.1”
  optional string client_version = 8; // 客户端版本号,例如 “2.39”
  optional string client_ip = 9;      // 客户端IP
  optional uint32 network = 10; // 网络类型  1: WI-FI;2:蜂窝; 3: 2G;4: 3G;5: 4G;
  optional string country = 11;     // 地区
  optional string ua = 12;          // User Agent
  optional string sdk_version = 13; // 上报SDK版本
  ...
}

服务端会根据 header 的 id 等字段来反序列化 body,场景跟 websocket 的两层 pb 类似。整个解析的思路就是在 reqRead 的时候将客户端已序列化的数据反序列出来,并输出 json,在 reqWrite 真正发出请求时将数据序列回去再发给真正的服务器,这样明文的信息就可以直接在 Inspector 中查看。我们先看看解析前后的情况:

解析前

解析后

下面就来看看 reqRead 和 reqWrite 应该如何编写:

// reqRead.js
let {getBodyObj, pb, event} = require("./base");

let ProtoBuf = require("protobufjs");

module.exports = (server/* , options */) => {
  server.on('request', (req, res) => {
    let body;
    req.on('data', (data) => {
      body = body ? Buffer.concat([body, data]) : data;
    });
    req.on('end', () => {
      if (body) {
        ProtoBuf.load([pb, event], function (err, root) {
          if (err)
            throw err;

          // Obtain a message type
          let AwesomeMessage = root.lookupType("event.EventList");

          // Decode an Uint8Array (browser) or Buffer (node) to a message
          let messageList = AwesomeMessage.decode(body);

          let events = []
          for (const singleEvent of messageList.events) {
            let body = getBodyObj(singleEvent.header.id, root, singleEvent.body)
            events.push({header: singleEvent.header, body: body});
          }
          res.end(JSON.stringify(events));
        });

      } else {
        res.end();
      }
    });
  });
};
// reqWrite.js
let {getBodyBytes, pb, event} = require("./base")
let ProtoBuf = require("protobufjs");

module.exports = (server/* , options */) => {
  server.on('request', (req, res) => {
    let body;
    req.on('data', (data) => {
      body = body ? Buffer.concat([body, data]) : data;
    });
    req.on('end', () => {
      if (body) {
        ProtoBuf.load([pb, event], function (err, root) {
          if (err)
            throw err;
          let events = JSON.parse(body);
          let eList = [];
          for (const singleEvent of events) {
            let bodyBytes = getBodyBytes(singleEvent.header.id, root, singleEvent.body);
            let EventMsg = root.lookupType("event.Event");
            let message = EventMsg.create({
              header: singleEvent.header,
              body: bodyBytes
            });
            eList.push(message);
          }
          let EventList = root.lookupType("event.EventList");
          let eventList = EventList.create({
            events: eList
          });
          let buffers = EventList.encode(eventList).finish();
          res.end(buffers);
        });
      } else {
        res.end();
      }
    });
  });
};  
// base.js
const getBodyObj = (eventId, liveRoot, bodyBuffer) => {
  let Body;
  Body = getPB(eventId, liveRoot)
  if (Body != null) {
    return Body.decode(bodyBuffer);
  } else {
    return bodyBuffer;
  }
}

const getBodyBytes = (eventId, liveRoot, bodyStr) => {
  let Body;
  Body = getPB(eventId, liveRoot)
  if (Body != null) {
    // get proto buffer
    return Body.encode(bodyStr).finish()
  } else {
    return bodyStr;
  }
}

function getPB(eventId, liveRoot) {
  let Body = null;
  switch (eventId) {
    case 10001:
      Body = liveRoot.lookupType("live_event.StreamExceptionEvent");
      break;
    case 10002:
      Body = liveRoot.lookupType("live_event.StreamStartEvent");
      break;
    ...
  }
  return Body;
}

exports.pb = "~/event.proto"
exports.event = "~/live_event.proto"

exports.getBodyObj = getBodyObj;
exports.getBodyBytes = getBodyBytes;

写在最后

由于时间和精力有限,并没有深入地研究 whistle 的整个插件开发机制,靠着官方实例插件代码,目前只是做到了 WebSocket、HTTP 包解析,不能够手动篡改,比如在 Composer 中构造请求,但已经满足了最迫切的需求,如果以后有时间了再研究分享。

Whistle 本身是一个非常棒的工具,无论是通用的 websocket、http 还是私有协议,都可以抓包解包,甚至是改包,但是要吐槽下 whistle 的插件开发文档,真的有些过时了,而且也写的不够清楚,开发过程遇到的问题很多时候不知道怎么求解,尤其对不太熟悉 nodejs 的人!


↙↙↙阅读原文可查看相关链接,并与作者交流