grpc-web-text 中 base64 加密的 protobuf 序列化与反序列化(逆向)


文章尚未完成,仅保留大纲

一、前言

1.1 目标

在服务上模拟前端发送 grpc-web-text 格式的请求,并且能解析服务器返回的相同格式的响应。

1.2 实现步骤

  1. 学习 grpc、protobuf 、grpc-web 协议
  2. 搭建最小测试环境
  3. 运行测试环境,确认编码规则一致
  4. 逆向推导 .proto 文件
  5. 测试 .proto 文件序列化与反序列化结果
  6. [非必须] 手动实现 protobuf 与 base64 之间的转码与解码

二、gRPC

2.1 gRPC 协议

https://grpc.io/docs/what-is-grpc/

2.2 protobuf 原理

通过一个完整例子彻底学会protobuf序列化原理

2.3 gRPC-web 协议

https://grpc.github.io/grpc/core/md_doc__p_r_o_t_o_c_o_l-_w_e_b.html

三、环境配置

3.1 gRPC-Web 环境搭建

项目代码运行所需的环境:

  • Docker
  • Node.js 16+
  • protobuf compiler
  • JavaScript

1. 安装 Protobuf 编译器

sudo apt update
sudo apt install protobuf-compiler
protoc --version

2. 下载 gRPC-Web Echo Server 项目代码

gRPC-Web-Echo 是我根据 grpc-web example 仓库的 Echo Server 项目改进而来,把跑在 Docker 容器里的前端和后端提取了出来,以方便本地测试。后端使用了 nodemon 热更新,前端则采用 webpack 实现。

下载代码:

https://github.com/Eished/gRPC-Web-Example

git clone git@github.com:Eished/gRPC-Web-Example.git

该项目包含三个部分:

  • Front-end JS client (web-client)
  • Envoy proxy (运行在 docker 中的代理)
  • gRPC backend server (node-server)

进入项目根目录:

3. 启动 Envoy 代理

docker-compose up -d

4. 启动 gRPC 后端服务

cd node-server
npm install
npm run start

6. 启动前端服务

cd web-client
npm install
npm run start

访问 http://localhost:8088/ ,在页面输入任意字符发送,服务器将返回输入的字符。

四、逆向推导 proto 文件

4.1 请求的 proto 文件还原

1. 通过浏览器的Network控制面板查看请求

  1. 请求头内容类型为 application/grpc-web-text
  2. payload为

2. 查看发出请求的函数

  1. 打上断点,
  2. 单步调试分析主要流程,
  3. 找到参数进行序列化的函数

3. 把序列化的JS文件下载(非必须,在上一步找到该代码复制保存也可以)

  1. 找到请求的序列化函数

4. 根据该序列化函数推导出请求参数的名称和类型

  1. 例如参数名 phasewriteEnum(1,r) 表示 phase 序号为 1 并且类型为 enum,其它参数类似。

  2. 得到的 .proto 文件如下

5. 使用得到的proto文件进行序列化和请求测试

  1. 在 gRPC-Web Echo Server 测试环境中使用 .proto 进行序列化测试

  2. 将该文件复制到项目的 protos 目录中

  3. 请求正常发出,并且有正确的response,response解析会报错,因为response的proto文件还没有分析,接下来还原response的proto文件。

4.2 响应的 proto 文件还原

  1. 有了前面的经验,能推测出响应的函数名,搜索后果然如此。

  2. 根据该序列化函数推导出请求参数的名称和类型

  3. 在 gRPC-Web Echo Server 测试环境中测试

五、测试 proto 文件序列化与反序列化

5.1 安装依赖,编译ts代码

https://github.com/timostamm/protobuf-ts

安装转换插件

# with npm:
npm install @protobuf-ts/plugin

# with yarn:
yarn add @protobuf-ts/plugin

安装运行时依赖

# with npm:
npm install @protobuf-ts/runtime

# with yarn:
yarn add @protobuf-ts/runtime

安装 node-fetch

# For Node.js, use the polyfill node-fetch.
npm install node-fetch

yarn add node-fetch

根据proto文件编译ts代码

npx protoc \
  --ts_out protos/ \
  --ts_opt long_type_string \
  --proto_path protos \
  protos/test.proto 

5.2 编写代码运行

import { GrpcWebFetchTransport } from '@protobuf-ts/grpcweb-transport'
import { TestRequest } from 'protos/test'
import { TestClient } from 'protos/test.client'

const transport = new GrpcWebFetchTransport({
  baseUrl: 'https://目标api地址/',
})

const client = new TestClient(transport)
const step1 = TestRequest.create({
  msg: 1,
})
const response = await client.test(step1).then((res) => res.response)
console.log('test', response)

测试正常运行,但 protobuf-ts/grpcweb-transport 有些服务器自定义错误状态码无法被抛出,抛出是统一的状态码。所以手动处理 protobuf。

六、手动实现 protobuf 与 base64 之间的转码与解码

不使用 protobuf-ts 发送\接收请求,而是序列化为 protobuf 再转码 base64,使用 axios 发送请求并处理响应。

6.1 编译 gRPC-web 客户端代码

安装编译器

# 代码编译器
yarn add ts-protoc-gen -D

仅生成 protobuf 文件,无需生成 Service 和 Client 文件

protoc \
  --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts \
  --js_out=import_style=commonjs,binary:./protos \
  --ts_out=grpc-web:./protos \
  -I ./protos \
  protos/test.proto

可选:如果使用 gRPC-web 发送请求时需要安装

# grpc-web 基本依赖
yarn add google-protobuf @types/google-protobuf @improbable-eng/grpc-web

# 编码转换器
yarn add @improbable-eng/grpc-web-node-http-transport 

6.2 protobuf 转 base64

使用 TypeScript 进行转码:

简易版:

// 将 Protocol Buffers 数据 Uint8Array 放入 Buffer
const encodedData = Buffer.from(TestRequest.serializeBinary())
console.log(encodedData)
// 创建一个包含5个零字节的 Buffer
const zeroBuffer = Buffer.alloc(5)
// 第5位写入Uint8Array数据长度
zeroBuffer.writeInt32BE(TestRequest.serializeBinary().length, 1)
// 将 Buffer 和 TestRequest 的二进制数据拼接在一起
const combinedBuffer = Buffer.concat([zeroBuffer, encodedData])
console.log(combinedBuffer.toString('base64'))

生成 header 的完整版:

const toBytesInt32 = (num: number): Uint8Array => {
  // an Int32 takes 4 bytes
  const arr = new ArrayBuffer(4)
  const view = new DataView(arr)
  // byteOffset = 0; litteEndian = false
  view.setUint32(0, num, false)
  return new Uint8Array(arr)
}

const createHeader = (name: string, value: string): Buffer => {
  const buffers: Array<number> = []
  for (const char of name) {
    buffers.push(char.charCodeAt(0))
  }
  buffers.push(':'.charCodeAt(0))
  for (const char of value) {
    buffers.push(char.charCodeAt(0))
  }
  buffers.push('\r'.charCodeAt(0))
  buffers.push('\n'.charCodeAt(0))
  return Buffer.from(buffers)
}

export const encodeProtobuf = <
  GRPCReply extends { serializeBinary: () => Uint8Array },
>(
  reply: GRPCReply,
): string => {
  const replyBuffer = reply.serializeBinary()
  const replyLength = toBytesInt32(replyBuffer.length)

  const headers = Buffer.from([
    ...createHeader('grpc-status', '0'),
    ...createHeader('grpc-message', 'OK'),
  ])
  const headersLength = toBytesInt32(headers.length)

  const response = Buffer.from([
    0, // mark response start ("data" frame coming next)
    ...replyLength,
    ...replyBuffer,
    128, // end of "data" frame, trailer frame coming next
    ...headersLength,
    ...headers,
  ])

  return response.toString('base64')
}

function main(): void {
  // UserPB is protobuf object created with protoc-gen-grpc
  // Any protobuf object with a serializeBinary function 
  // which returns an Uint8Array will do
  const reply = new UserPB.SetUserReply()
  reply.setUserId('mock-user-id')
  reply.setFirstName('foo')
  reply.setLastName('bar')
  const encoded = encodeProtobuf(reply)
  console.log(encoded)
}

main()

6.3 base64 转 protobuf

直接解析:

echo "AAAAAAwIARDABxoFL3Rlc3Q=" | base64 -d | xxd

使用 protobuf compiler 解析:

echo "AAAAAAwIARDABxoFL3Rlc3Q=" | base64 -d | tail -c +6 | protoc --decode_raw

使用 TypeScript 进行解码:

头部解析 + 数据解析

#!/usr/bin/env node
import { exec } from 'child_process'
import { promisify } from 'util'
import minimist from 'minimist'
import chalk from 'chalk'
import which from 'which'

const argv = minimist(process.argv.slice(2))
const asyncExec = promisify(exec)
const { log } = console

const grpcWebTexts: Array<string> = [
  'AAAAADEKB2pvZmxveWQSJmh0dHBzOi8vcGljc3VtLnBob3Rvcy9pZC8xMzMvMjc0Mi8xODI4',
  'AAAAAAMInRQ=gAAAACBncnBjLXN0YXR1czowDQpncnBjLW1lc3NhZ2U6T0sNCg==',
]

const getBinaryHex = (grpcTexts: Array<string>) => {
  const buffers: Array<string> = []

  grpcTexts.forEach(text => {
    const buffer = Buffer.from(text.trim(), 'base64')
    const bufString = buffer.toString('hex')
    buffers.push(bufString)
  })

  return buffers
}

const validateProtocExist = () => {
  const resolved = which.sync('protoc', { nothrow: false })
  if (!resolved) {
    log(
      chalk.red(
        `\ngrpcwebtext-parser requires ${chalk.bgRed(
          chalk.white(`protoc`),
        )}\nPlease download it before running this program\n`,
      ),
    )
    process.exit(1)
  }
}

function main() {
  validateProtocExist()

  const { _: text } = argv

  if (text.length === 0) {
    console.error('No input grpc text, defaulting to demo values')
  }

  // bytes are in base 16
  const buffers = text.length ? getBinaryHex(text) : getBinaryHex(grpcWebTexts)

  // eslint-disable-next-line @typescript-eslint/no-misused-promises
  buffers.forEach(async (buffer, i) => {
    const responseStartMarkerLen = 2

    if (buffer.substr(0, responseStartMarkerLen) !== '00')
      return log(chalk.bgRed('Cannot Parse'))
    const frameLen = buffer.substr(2, 4 * 2)

    const frameLenInt = parseInt(frameLen, 16) * 2
    const frameDataIndex = responseStartMarkerLen + frameLen.length
    const frameData = buffer.substr(frameDataIndex, frameLenInt)
    const frameParsed = await asyncExec(
      `echo ${frameData} | xxd -r -p | protoc --decode_raw`,
    )
    log(`\n${chalk.bgBlue('Web gRPC text:')} ${text[i] || grpcWebTexts[i]}\n`)
    log(frameParsed.stdout)

    const trailersLen = 2
    const trailersLenIndex = frameDataIndex + frameLenInt
    const hasTrailers = buffer.substr(trailersLenIndex, trailersLen) === '80'

    if (hasTrailers) {
      const headerLenIndex = trailersLenIndex + trailersLen
      const headerLen = buffer.substr(headerLenIndex, 4 * 2)
      const headerLenInt = parseInt(headerLen, 16) * 2
      const headerIndex = headerLenIndex + headerLen.length
      const headerData = buffer.substr(headerIndex, headerLenInt)

      log('Trailing headers:')

      // '0d0a' is hex value of the '\r\n' ASCII characters
      headerData.split('0d0a').forEach(header => {
        log(Buffer.from(header, 'hex').toString('ascii').replace(':', ': '))
      })
    }
  })
}

main()

文章作者: iKnow
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 iKnow !
  目录