用 mitmproxy 插件解析 gRPC 接口流量
效果预览
用 Charles、Fiddler 抓包时,gRPC 接口的请求和响应是一堆乱码:
....#..
......application/grpc..
接入本文的插件后,mitmweb 里直接显示可读 JSON:
{
"user_context": {
"device_id": "abc123",
"ward_id": "xyz456"
}
}
点击"Copy raw request and response"也能导出完整的请求 + 响应文本,而不是二进制。
一、工作原理(一句话)
mitmproxy 充当手机和服务器之间的"翻译",截获 gRPC 流量后,用你提供的 .proto 描述文件把二进制还原成 JSON,再显示在界面上。
你需要准备的:
.proto文件(或编译好的.pb文件)——告诉插件每个字段叫什么名字- 手机信任 mitmproxy 的 CA 证书——让手机不拒绝"翻译"的存在
二、安装依赖
重要:mitmproxy 官网提供独立安装包(双击安装那种),那个版本自带 Python,无法安装第三方库,本插件无法使用。必须用
pip安装。
确认 Python 版本(需要 3.11+):
python --version
# Python 3.13.x ✓
安装依赖包:
# 抓包必需
pip install mitmproxy protobuf
# 仅在需要从 .proto 源文件编译 .pb 时才需要
pip install grpcio-tools
| 包 | 用途 |
|---|---|
| mitmproxy | 抓包工具本体 |
| protobuf | 把二进制 Protobuf 还原成 JSON |
| grpcio-tools | 包含 protoc 编译器,把 .proto 编译成 .pb(可选) |
验证安装:
mitmweb --version
# Mitmproxy: x.x.x
# Python: 3.x.x
如果版本号不对,说明命令行找到的是官网独立包而不是 pip 安装的版本,检查 PATH 环境变量,把独立包的路径移除。
三、准备 .proto 文件
Protobuf 的二进制里只有字段编号(1, 2, 3…),没有字段名(user_id, device_id…)。必须有 .proto 文件才能还原字段名。
情况一:有源码权限(自己团队的项目)
直接从代码仓库拿 .proto 文件。通常分两套:
- 后端 proto:定义管理后台/服务端接口,一般在后端仓库的
proto/目录 - App 端 proto:定义 App 调用的接口,一般在 App 仓库的
src/main/proto/目录
两套的 package 名不同,需要分别编译成独立的 .pb 文件(下一节说明)。
情况二:服务器开启了 gRPC 反射
安装 grpcurl:
# macOS
brew install grpcurl
# Linux
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
# 或直接下载二进制:https://github.com/fullstorydev/grpcurl/releases
# Windows(Scoop)
scoop install grpcurl
# 或从 releases 页面下载 grpcurl_*_windows_x86_64.zip 解压
检查反射是否开启:
grpcurl -plaintext your-server:port list
# 如果返回服务列表,说明反射已开启:
# com.example.service.UserService
# com.example.service.DeviceService
# ...
导出 FileDescriptorSet(可直接用于本插件):
# 列出所有服务下的方法
grpcurl -plaintext your-server:port describe
# 导出完整 descriptor,生成可供插件使用的 .pb 文件
grpcurl -plaintext -protoset-out app.pb your-server:port describe
-protoset-out 输出的就是标准 FileDescriptorSet 二进制格式,直接放到插件目录即可,无需 protoc 编译。
生产环境出于安全考虑通常关闭反射。如果
grpcurl list返回错误或空结果,换其他方法。
情况三:没有 proto,只有 APK
按以下方法逐步尝试,先试简单的。
方法一:直接搜索 APK 内的文件(最简单,先试这个)
APK 本质是 ZIP,直接解压搜索:
# macOS / Linux
unzip app.apk -d apk_out
find apk_out -name "*.proto"
find apk_out -name "*.pb" -o -name "*.bin" -o -name "*descriptor*"
ls apk_out/assets/
# Windows(PowerShell)
Expand-Archive app.apk -DestinationPath apk_out
Get-ChildItem -Recurse apk_out -Include "*.proto","*.pb","*.bin" | Select FullName
找到 .pb 文件就直接用,找到 .proto 就按第四节编译。
方法二:jadx 反编译 + 提取 descriptor 字节数组
Protobuf 编译生成的 Java 类在静态初始化块里内嵌了整个 proto 描述符的字节数组。即使没有 .proto 源文件,也能从反编译结果里把它还原出来。
第一步:用 jadx 反编译 APK
# 安装 jadx:https://github.com/skylot/jadx/releases
jadx app.apk -d jadx_out
第二步:找到 proto 生成的 Java 类
proto 生成的类文件名通常与 .proto 文件名一致,且包含特征字符串:
# 搜索包含 descriptor 字节数组的文件
grep -rl "com.google.protobuf.Descriptors.FileDescriptor" jadx_out/sources/
# 查看其中一个文件,确认是 proto 生成类
grep -n "descriptorData\|internalBuildGeneratedFileFrom" \
jadx_out/sources/com/example/proto/DeviceAccountServiceOuterClass.java | head -5
第三步:提取字节数组,重建 .pb 文件
proto 生成类的静态块里有以下两种格式之一:
格式 A(较新版本,字节数组):
static {
byte[] descriptorData = new byte[] {
10, 32, 100, 101, 118, 105, 99, 101, ...
};
// ...
}
格式 B(较旧版本,转义字符串):
static {
java.lang.String[] descriptorData = {
"\n device_account.proto\022\..."
};
// ...
}
用下面的 Python 脚本扫描 jadx 输出目录,提取所有 descriptor 并合并成可用的 .pb 文件:
"""
从 jadx 反编译目录提取 protobuf descriptor,生成 app.pb
用法:python extract_pb.py jadx_out/sources app.pb
"""
import re
import sys
import os
from google.protobuf import descriptor_pb2
def extract_bytes_array(java_src: str) -> bytes | None:
"""提取格式 A:byte[] descriptorData = new byte[]{ 10, 32, ... }"""
m = re.search(
r'byte\[\]\s+descriptorData\s*=\s*new\s+byte\[\]\s*\{([^}]+)\}',
java_src, re.DOTALL
)
if not m:
return None
tokens = [t.strip() for t in m.group(1).split(',') if t.strip()]
try:
return bytes([(int(t) + 256) % 256 for t in tokens])
except ValueError:
return None
def extract_string_array(java_src: str) -> bytes | None:
"""提取格式 B:String[] descriptorData = { "\\n device_account.proto..." }"""
m = re.search(
r'String\[\]\s+descriptorData\s*=\s*\{(.*?)\};',
java_src, re.DOTALL
)
if not m:
return None
# 合并多个字符串片段,解码 Java unicode 转义
fragments = re.findall(r'"((?:[^"\\]|\\.)*)"', m.group(1))
raw = ''.join(fragments)
try:
return raw.encode('raw_unicode_escape').decode('unicode_escape').encode('latin-1')
except Exception:
return None
def main(src_dir: str, out_pb: str):
fds = descriptor_pb2.FileDescriptorSet()
seen = set()
found = 0
for root, _, files in os.walk(src_dir):
for fname in files:
if not fname.endswith('.java'):
continue
path = os.path.join(root, fname)
try:
content = open(path, encoding='utf-8', errors='ignore').read()
except Exception:
continue
# 只处理含有 protobuf descriptor 特征的文件
if 'Descriptors.FileDescriptor' not in content:
continue
data = extract_bytes_array(content) or extract_string_array(content)
if not data:
continue
try:
fdp = descriptor_pb2.FileDescriptorProto()
fdp.ParseFromString(data)
key = fdp.name
if key and key not in seen:
seen.add(key)
fds.file.append(fdp)
found += 1
print(f" ✓ {key}")
except Exception as e:
print(f" ✗ {fname}: {e}")
if found == 0:
print("未找到任何 descriptor,请检查 jadx 反编译结果")
sys.exit(1)
with open(out_pb, 'wb') as f:
f.write(fds.SerializeToString())
print(f"\n已生成 {out_pb},包含 {found} 个 proto 文件")
if __name__ == '__main__':
if len(sys.argv) != 3:
print(f"用法:python {sys.argv[0]} <jadx_sources_dir> <output.pb>")
sys.exit(1)
main(sys.argv[1], sys.argv[2])
运行:
python extract_pb.py jadx_out/sources app.pb
# 输出示例:
# ✓ proto/api/services/account/device_account_service.proto
# ✓ proto/api/model/account/device_account_info.proto
# ...
# 已生成 app.pb,包含 23 个 proto 文件
生成的 app.pb 直接放到插件目录使用,无需重新编译。
方法三:Frida 运行时 hook(最通用,需 debug 包或 root 设备)
前两种方法都依赖静态分析,如果代码有混淆可能失效。Frida 在 App 运行时直接截获 protobuf 加载的 descriptor,绕过混淆。
第一步:安装 Frida
pip install frida-tools
在 Android 设备上安装对应版本的 frida-server(从 frida releases 下载):
adb push frida-server /data/local/tmp/
adb shell "chmod 755 /data/local/tmp/frida-server"
adb shell "/data/local/tmp/frida-server &"
第二步:运行 hook 脚本
# dump_descriptors.py
frida -U -n com.example.app -l dump_descriptors.js --runtime=v8
dump_descriptors.js 内容:
// hook protobuf 的 FileDescriptor 加载,把每个 descriptor 的字节 dump 到本地
const collected = {};
Java.perform(() => {
const FileDescriptorProto = Java.use(
'com.google.protobuf.DescriptorProtos$FileDescriptorProto'
);
// hook internalBuildGeneratedFileFrom(protobuf 3.x 常见入口)
const FileDescriptor = Java.use('com.google.protobuf.Descriptors$FileDescriptor');
FileDescriptor.buildFrom.overload(
'com.google.protobuf.DescriptorProtos$FileDescriptorProto',
'[Lcom.google.protobuf.Descriptors$FileDescriptor;'
).implementation = function (proto, deps) {
const name = proto.getName();
if (name && !collected[name]) {
collected[name] = true;
const bytes = proto.toByteArray();
// 把字节数组发送到 Python 端
send({ type: 'descriptor', name: name }, bytes);
}
return this.buildFrom(proto, deps);
};
});
第三步:Python 端接收并合并
"""
接收 Frida dump 的 descriptor,保存为 app.pb
用法:python recv_descriptors.py com.example.app app.pb
"""
import sys
import frida
from google.protobuf import descriptor_pb2
def main(pkg: str, out_pb: str):
fds = descriptor_pb2.FileDescriptorSet()
seen = set()
script_src = open('dump_descriptors.js').read()
def on_message(msg, data):
if msg.get('type') == 'send' and data:
payload = msg['payload']
if payload.get('type') == 'descriptor':
name = payload['name']
if name not in seen:
seen.add(name)
fdp = descriptor_pb2.FileDescriptorProto()
fdp.ParseFromString(bytes(data))
fds.file.append(fdp)
print(f" ✓ {name}")
device = frida.get_usb_device()
session = device.attach(pkg)
script = session.create_script(script_src)
script.on('message', on_message)
script.load()
print(f"正在监听 {pkg},操作 App 触发 gRPC 请求...")
print("完成后按 Ctrl+C 保存")
try:
import sys; input()
except KeyboardInterrupt:
pass
with open(out_pb, 'wb') as f:
f.write(fds.SerializeToString())
print(f"\n已生成 {out_pb},包含 {len(fds.file)} 个 proto 文件")
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])
python recv_descriptors.py com.example.app app.pb
# 然后操作 App 触发各类 gRPC 请求
# 按 Ctrl+C 结束,生成 app.pb
Frida hook 只能捕获 App 实际加载过的 descriptor,没有触发过的接口不会出现。多操作几个页面,覆盖更多接口。
情况四:什么都没有
mitmproxy 内置的 Protobuf view 可以显示字段编号和原始值(但没有字段名),聊胜于无。
四、编译 .pb 文件
.pb 文件(FileDescriptorSet)是 .proto 的编译产物,插件运行时加载它。只需要编译一次,proto 有更新时重新编译即可。
--include_imports 参数很关键:把所有依赖的 proto 一起打包,生成一个自包含的文件。
macOS / Linux
GRPC_TOOLS=$(python -c "import grpc_tools; print(grpc_tools.__path__[0])")
# 编译后端 proto
python -m grpc_tools.protoc \
--proto_path=BackendService/proto \
--proto_path=SharedApis/src/main/proto \
--proto_path="$GRPC_TOOLS" \
--descriptor_set_out=backend.pb \
--include_imports \
$(find BackendService/proto -name "*.proto")
# 编译 App 端 proto(package 不同,单独一个文件)
python -m grpc_tools.protoc \
--proto_path=MobileApp/Common/src/main/proto \
--proto_path=SharedApis/src/main/proto \
--proto_path="$GRPC_TOOLS" \
--descriptor_set_out=app.pb \
--include_imports \
$(find MobileApp/Common/src/main/proto -name "*.proto")
Windows(PowerShell)
$GRPC_TOOLS = python -c "import grpc_tools; print(grpc_tools.__path__[0])"
# 编译后端 proto
$protos = (Get-ChildItem -Recurse -Filter "*.proto" BackendService\proto).FullName
python -m grpc_tools.protoc `
--proto_path=BackendService/proto `
--proto_path=SharedApis/src/main/proto `
"--proto_path=$GRPC_TOOLS" `
--descriptor_set_out=backend.pb `
--include_imports `
@protos
# 编译 App 端 proto
$protos = (Get-ChildItem -Recurse -Filter "*.proto" MobileApp\Common\src\main\proto).FullName
python -m grpc_tools.protoc `
--proto_path=MobileApp/Common/src/main/proto `
--proto_path=SharedApis/src/main/proto `
"--proto_path=$GRPC_TOOLS" `
--descriptor_set_out=app.pb `
--include_imports `
@protos
路径说明:
--proto_path统一用正斜杠(/),protoc 在 Windows 下也能识别。
验证编译结果
# 文件存在且大小合理(通常几十到几百 KB)
ls -lh service.pb
# Python 验证可加载
python -c "
from google.protobuf import descriptor_pb2
fds = descriptor_pb2.FileDescriptorSet()
fds.ParseFromString(open('service.pb','rb').read())
print(f'加载成功,包含 {len(fds.file)} 个 proto 文件')
"
五、配置插件文件
5.1 目录结构
工作目录/
├── grpc_decode.py # 插件主文件(下方提供完整代码)
├── config.json # 记录当前使用的 pb 文件名(自动生成,见 5.2)
└── your_service.pb # 编译好的 descriptor,文件名自定义
插件启动时读取同目录的 config.json,按其中 pb_file 字段加载指定的 .pb 文件。config.json 不存在时回退到加载目录内所有 *.pb 文件。
5.2 插件代码
将以下代码保存为 grpc_decode.py,与 .pb 文件放在同一目录。
适配新项目无需修改代码:只需在 config.json 里指定 pb 文件名:
{
"pb_file": "your_service.pb"
}
config.json 不存在时,插件自动加载目录内所有 *.pb 文件。
"""
mitmproxy 插件:自动解码 gRPC/protobuf 请求和响应,并修正 octet-stream 图片响应头
用法:mitmweb -s grpc_decode.py --listen-port 8080
mitmweb 中选中流量后:
- application/grpc 流量显示解码后的 JSON
- application/octet-stream 图片响应头自动改为 image/png 等,mitmweb 内联展示图片
"""
import struct
import json
import os
import glob
import re
from datetime import datetime
from mitmproxy import http, contentviews
from mitmproxy.contentviews import Contentview
from google.protobuf import descriptor_pool, descriptor_pb2, message_factory
from google.protobuf.json_format import MessageToJson
# ── 加载 descriptor ────────────────────────────────────────────────────────────
BASE_DIR = os.path.dirname(__file__)
pool = descriptor_pool.DescriptorPool()
fds = descriptor_pb2.FileDescriptorSet()
def _get_pb_paths():
# Docker 模式:优先使用 PB_DIR 环境变量指定的目录
pb_dir = os.environ.get("PB_DIR", "")
if pb_dir:
cfg_path = os.path.join(pb_dir, "config.json")
try:
cfg = json.loads(open(cfg_path, encoding="utf-8").read())
pb = cfg.get("pb_file", "")
if pb:
full = pb if os.path.isabs(pb) else os.path.join(pb_dir, pb)
if os.path.isfile(full):
return [full]
except Exception:
pass
found = glob.glob(os.path.join(pb_dir, "*.pb"))
if found:
return found
# 本地模式:读取插件同目录下的 config.json
cfg_path = os.path.join(BASE_DIR, "config.json")
try:
cfg = json.loads(open(cfg_path, encoding="utf-8").read())
pb = cfg.get("pb_file", "")
if pb:
full = pb if os.path.isabs(pb) else os.path.join(BASE_DIR, pb)
if os.path.isfile(full):
return [full]
except Exception:
pass
return glob.glob(os.path.join(BASE_DIR, "*.pb"))
for pb_path in _get_pb_paths():
pb_name = os.path.basename(pb_path)
try:
tmp = descriptor_pb2.FileDescriptorSet()
with open(pb_path, "rb") as f:
tmp.ParseFromString(f.read())
loaded = 0
for fd in tmp.file:
fds.file.append(fd)
try:
pool.Add(fd)
loaded += 1
except Exception:
pass
svc_names = [f"{fd.package}.{svc.name}" for fd in tmp.file for svc in fd.service]
print(f"[grpc_decode] ✓ {pb_name}:{loaded}/{len(tmp.file)} 个文件描述符,{len(svc_names)} 个服务")
for n in svc_names:
print(f"[grpc_decode] · {n}")
except Exception as e:
print(f"[grpc_decode] ✗ 解析 {pb_name} 失败:{e}")
# ── 工具函数 ───────────────────────────────────────────────────────────────────
def get_message_class(type_name: str):
try:
desc = pool.FindMessageTypeByName(type_name.lstrip("."))
return message_factory.GetMessageClass(desc)
except Exception:
return None
def get_method_io(grpc_path: str):
try:
parts = grpc_path.strip("/").split("/")
if len(parts) != 2:
return None, None
full_service, method_name = parts
try:
svc_desc = pool.FindServiceByName(full_service)
except KeyError:
svc_desc = None
for fd in fds.file:
for svc in fd.service:
candidate = f"{fd.package}.{svc.name}"
if candidate == full_service or full_service.endswith("." + svc.name):
try:
svc_desc = pool.FindServiceByName(candidate)
break
except Exception:
pass
if svc_desc:
break
if not svc_desc:
return None, None
method_desc = svc_desc.methods_by_name.get(method_name)
if not method_desc:
return None, None
return (get_message_class(method_desc.input_type.full_name),
get_message_class(method_desc.output_type.full_name))
except Exception:
return None, None
def decode_grpc_body(data: bytes, msg_class) -> str | None:
"""解码 gRPC framing,支持流式多帧,返回格式化 JSON 字符串,失败返回 None"""
if not data or len(data) < 5:
return None
results = []
offset = 0
while offset + 5 <= len(data):
compressed = data[offset]
length = struct.unpack(">I", data[offset + 1:offset + 5])[0]
if offset + 5 + length > len(data):
break
proto_bytes = data[offset + 5:offset + 5 + length]
offset += 5 + length
if compressed:
results.append({"_error": "compressed message not supported"})
continue
try:
msg = msg_class()
msg.ParseFromString(proto_bytes)
obj = json.loads(MessageToJson(msg, preserving_proto_field_name=True))
results.append(obj)
except Exception as e:
results.append({"_error": str(e)})
if not results:
return None
if len(results) == 1:
return json.dumps(results[0], ensure_ascii=False, indent=2)
return json.dumps(results, ensure_ascii=False, indent=2)
def _get_ct_and_flow(content_type, flow, http_message):
ct = content_type
if ct is None and http_message is not None:
ct = http_message.headers.get("content-type", "")
return ct or "", flow
# ── gRPC/Protobuf Content View ────────────────────────────────────────────────
class GrpcContentView(Contentview):
name = "gRPC/Protobuf"
def prettify(self, data: bytes, metadata):
content_type = metadata.get("content_type") if hasattr(metadata, "get") else getattr(metadata, "content_type", None)
flow = metadata.get("flow") if hasattr(metadata, "get") else getattr(metadata, "flow", None)
http_message = metadata.get("http_message") if hasattr(metadata, "get") else getattr(metadata, "http_message", None)
ct, fl = _get_ct_and_flow(content_type, flow, http_message)
if not ct.startswith("application/grpc") or fl is None:
return "(not a gRPC flow)"
path = fl.request.path
is_request = (http_message is fl.request)
req_class, res_class = get_method_io(path)
msg_class = req_class if is_request else res_class
if msg_class:
return decode_grpc_body(data, msg_class) or "(empty)"
else:
return f"(未找到消息类型: {path})\n原始长度: {len(data)} bytes"
def render_priority(self, data: bytes, metadata) -> float:
content_type = metadata.get("content_type") if hasattr(metadata, "get") else getattr(metadata, "content_type", None)
http_message = metadata.get("http_message") if hasattr(metadata, "get") else getattr(metadata, "http_message", None)
ct = content_type
if ct is None and http_message is not None:
ct = http_message.headers.get("content-type", "")
if data and ct and ct.startswith("application/grpc"):
return 2
return 0
# ── 时间戳转换 Content View ────────────────────────────────────────────────────
_RE_SECOND = re.compile(r'second|epoch', re.IGNORECASE)
_TS_MIN, _TS_MAX = 946684800, 4102444800
_RE_MILLI = re.compile(r'milli', re.IGNORECASE)
def _convert_timestamps(obj):
"""递归遍历 JSON 对象,把字段名含 'second'/'epoch' 的时间戳转为可读字符串。
字段名含 'milli' 时视为毫秒时间戳,自动除以 1000。"""
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
if _RE_SECOND.search(k):
ts = None
if isinstance(v, (int, float)):
ts = int(v)
elif isinstance(v, str) and v.strip().lstrip('-').isdigit():
ts = int(v.strip())
if ts is not None:
if _RE_MILLI.search(k) and _TS_MIN * 1000 <= ts <= _TS_MAX * 1000:
ts_sec = ts // 1000
try:
readable = datetime.fromtimestamp(ts_sec).strftime('%Y-%m-%d %H:%M:%S')
out[k] = f"{readable} ({ts})"
except Exception:
out[k] = v
elif _TS_MIN <= ts <= _TS_MAX:
try:
readable = datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S')
out[k] = f"{readable} ({ts})"
except Exception:
out[k] = v
else:
out[k] = _convert_timestamps(v)
else:
out[k] = _convert_timestamps(v)
else:
out[k] = _convert_timestamps(v)
return out
if isinstance(obj, list):
return [_convert_timestamps(i) for i in obj]
return obj
class TimestampContentView(Contentview):
name = "时间戳解析"
def prettify(self, data: bytes, metadata):
content_type = metadata.get("content_type") if hasattr(metadata, "get") else getattr(metadata, "content_type", None)
flow = metadata.get("flow") if hasattr(metadata, "get") else getattr(metadata, "flow", None)
http_message = metadata.get("http_message") if hasattr(metadata, "get") else getattr(metadata, "http_message", None)
ct, fl = _get_ct_and_flow(content_type, flow, http_message)
if not ct.startswith("application/grpc") or fl is None:
return "(not a gRPC flow)"
path = fl.request.path
is_request = (http_message is fl.request)
req_class, res_class = get_method_io(path)
msg_class = req_class if is_request else res_class
if not msg_class:
return f"(未找到消息类型: {path})"
raw_json = decode_grpc_body(data, msg_class)
if not raw_json:
return "(empty)"
try:
obj = json.loads(raw_json)
converted = _convert_timestamps(obj)
return json.dumps(converted, ensure_ascii=False, indent=2)
except Exception:
return raw_json
def render_priority(self, data: bytes, metadata) -> float:
content_type = metadata.get("content_type") if hasattr(metadata, "get") else getattr(metadata, "content_type", None)
http_message = metadata.get("http_message") if hasattr(metadata, "get") else getattr(metadata, "http_message", None)
ct = content_type
if ct is None and http_message is not None:
ct = http_message.headers.get("content-type", "")
if data and ct and ct.startswith("application/grpc"):
return 1.5
return 0
class TimestampJsonContentView(Contentview):
name = "时间戳解析(JSON)"
def prettify(self, data: bytes, metadata):
content_type = metadata.get("content_type") if hasattr(metadata, "get") else getattr(metadata, "content_type", None)
http_message = metadata.get("http_message") if hasattr(metadata, "get") else getattr(metadata, "http_message", None)
ct = content_type
if ct is None and http_message is not None:
ct = http_message.headers.get("content-type", "")
if not ct or "json" not in ct:
return "(not a JSON response)"
try:
obj = json.loads(data.decode("utf-8", errors="replace"))
converted = _convert_timestamps(obj)
return json.dumps(converted, ensure_ascii=False, indent=2)
except Exception as e:
return f"(解析失败: {e})"
def render_priority(self, data: bytes, metadata) -> float:
content_type = metadata.get("content_type") if hasattr(metadata, "get") else getattr(metadata, "content_type", None)
http_message = metadata.get("http_message") if hasattr(metadata, "get") else getattr(metadata, "http_message", None)
ct = content_type
if ct is None and http_message is not None:
ct = http_message.headers.get("content-type", "")
if data and ct and "json" in ct:
return 0.5
return 0
grpc_view = GrpcContentView()
ts_view = TimestampContentView()
ts_json_view = TimestampJsonContentView()
# ── 图片魔术字节检测 ────────────────────────────────────────────────────────────
_OCTET_TYPES = {"application/octet-stream", "binary/octet-stream"}
def _detect_image_mime(data: bytes) -> str | None:
if not data or len(data) < 12:
return None
if data[:8] == b'\x89PNG\r\n\x1a\n':
return "image/png"
if data[:3] == b'\xff\xd8\xff':
return "image/jpeg"
if data[:4] == b'GIF8':
return "image/gif"
if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
return "image/webp"
return None
# ── mitmproxy 钩子 ─────────────────────────────────────────────────────────────
_PENDING_MAX = 500 # 最多暂存 500 个未完成 flow,超出时丢弃最旧的
class GrpcDecoder:
def __init__(self):
self._pending: dict[int, tuple] = {}
def request(self, flow: http.HTTPFlow):
ct = flow.request.headers.get("content-type", "")
if not ct.startswith("application/grpc"):
return
path = flow.request.path
req_class, res_class = get_method_io(path)
# 防止 _pending 无限增长(flow 超时/流式未完成时不会触发 response)
if len(self._pending) >= _PENDING_MAX:
drop = list(self._pending.keys())[:len(self._pending) - _PENDING_MAX + 1]
for k in drop:
self._pending.pop(k, None)
self._pending[id(flow)] = (path, res_class)
pass
def response(self, flow: http.HTTPFlow):
raw = flow.response.raw_content or b""
ct = flow.response.headers.get("content-type", "")
if ct.split(";")[0].strip().lower() in _OCTET_TYPES:
mime = _detect_image_mime(raw)
if mime:
flow.response.headers["content-type"] = mime
info = self._pending.pop(id(flow), None)
if not info:
return
path, res_class = info
pass
def _grpc_readable(f: http.HTTPFlow) -> str:
lines = []
req = f.request
lines.append("=" * 60)
lines.append(f"▶ REQUEST {req.method} {req.pretty_url}")
lines.append("=" * 60)
for k, v in req.headers.items():
lines.append(f"{k}: {v}")
lines.append("")
ct_req = req.headers.get("content-type", "")
if ct_req.startswith("application/grpc"):
req_class, _ = get_method_io(req.path)
if req_class:
lines.append(decode_grpc_body(req.raw_content or b"", req_class) or "(empty)")
else:
lines.append(f"[binary gRPC {len(req.raw_content or b'')} bytes, 未找到 descriptor]")
else:
lines.append(req.get_text(strict=False) or "")
if not f.response:
return "\n".join(lines)
resp = f.response
lines.append("")
lines.append("=" * 60)
lines.append(f"◀ RESPONSE {resp.status_code} {resp.reason}")
lines.append("=" * 60)
for k, v in resp.headers.items():
lines.append(f"{k}: {v}")
if resp.trailers:
lines.append("--- trailers ---")
for k, v in resp.trailers.items(multi=True):
lines.append(f"{k}: {v}")
lines.append("")
ct_res = resp.headers.get("content-type", "")
if ct_res.startswith("application/grpc"):
_, res_class = get_method_io(req.path)
if res_class:
lines.append(decode_grpc_body(resp.raw_content or b"", res_class) or "(empty)")
else:
lines.append(f"[binary gRPC {len(resp.raw_content or b'')} bytes, 未找到 descriptor]")
else:
lines.append(resp.get_text(strict=False) or "")
return "\n".join(lines)
def load(loader):
contentviews.add(grpc_view)
contentviews.add(ts_view)
contentviews.add(ts_json_view)
try:
from mitmproxy.addons import export as _export_mod
_orig_raw = _export_mod.formats["raw"]
def _patched_raw(f):
ct = ""
if isinstance(f, http.HTTPFlow) and f.request:
ct = f.request.headers.get("content-type", "")
if ct.startswith("application/grpc"):
return _grpc_readable(f)
return _orig_raw(f)
_export_mod.formats["raw"] = _patched_raw
except Exception:
pass
def done():
contentviews.remove(grpc_view)
contentviews.remove(ts_view)
contentviews.remove(ts_json_view)
addons = [GrpcDecoder()]
六、配置 Android 设备
6.1 让 App 信任代理证书(debug 包)
Android 7.0 以后,App 默认不信任用户手动安装的 CA 证书。对于 debug 包,加一个配置文件就能解决:
第一步:新建 app/src/main/res/xml/network_security_config.xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<debug-overrides>
<trust-anchors>
<!-- debug 构建时信任用户安装的 CA(包括 mitmproxy CA) -->
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>
第二步:在 AndroidManifest.xml 的 <application> 标签里引用它:
<application
android:networkSecurityConfig="@xml/network_security_config"
...>
<debug-overrides>只在 debug 构建生效,不影响 release 包的安全性,放心添加。
release 包需要 root 后把证书推到系统信任区,这里不展开。
6.2 安装 mitmproxy CA 证书
先启动 mitmweb:
mitmweb -s grpc_decode.py --listen-port 8080
mitmproxy 自动生成 CA 证书,位于:
- Windows:
C:\Users\{用户名}\.mitmproxy\mitmproxy-ca-cert.pem - macOS/Linux:
~/.mitmproxy/mitmproxy-ca-cert.pem
把证书文件传到 Android 设备,然后安装:
设置 → 安全 → 加密与凭据 → 安装证书 → CA 证书
(小米/华为等在"更多安全设置"里)
6.3 配置 Wi-Fi 代理
PC 和 Android 设备需在同一局域网。查看 PC 的 IP:
# Windows
ipconfig | findstr IPv4
# macOS/Linux
ifconfig | grep "inet "
Android 设备:
设置 → Wi-Fi → 长按当前网络 → 修改网络
→ 高级选项 → 代理 → 手动
→ 代理主机名:{PC 的局域网 IP}
→ 代理端口:8080
6.4 验证代理生效
手机浏览器访问 mitm.it,看到 mitmproxy 证书安装页面说明代理配置正确。
七、启动与使用
# 在放有 grpc_decode.py 和 .pb 文件的目录下运行
mitmweb -s grpc_decode.py --listen-port 8080
浏览器打开 http://127.0.0.1:8081 进入 mitmweb 界面。
触发 App 的 gRPC 请求,在 mitmweb 里选中流量,body 区域显示解码后的 JSON。
导出完整流量文本:选中流量 → Export → Copy raw request and response,粘贴后得到:
============================================================
▶ REQUEST POST https://api.example.com/pkg.Service/GetInfo
============================================================
content-type: application/grpc
x-token: eyJ...
{
"user_context": {
"device_id": "abc123",
"ward_id": "xyz456"
}
}
============================================================
◀ RESPONSE 200 OK
============================================================
content-type: application/grpc
--- trailers ---
grpc-status: 0
{
"info": {
"nickname": "小明",
"avatar_url": "https://..."
}
}
八、常见问题
Q:启动时报 ModuleNotFoundError: No module named 'google'
mitmweb 用的是官网独立包(自带 Python),找不到 pip 安装的 protobuf。
解决:确认 mitmweb --version 显示的 Python 路径和 pip 是同一个安装。去掉 PATH 里官网独立包的路径,用 pip 安装的版本启动。
Q:body 显示"未找到消息类型: /pkg.Service/Method"
两个原因:
config.json里的pb_file路径不对,或对应文件不存在——检查文件是否在插件目录下- 该服务的 proto 没有编译进
.pb文件——检查编译命令的--proto_path是否包含了所有依赖
Q:导出时报 ValueError: Sending HTTP/1.1 trailer headers requires transfer-encoding: chunked
没有加载插件直接用 mitmweb 原生导出功能时会出现。确保启动命令带了 -s grpc_decode.py。
Q:mitmweb 里 gRPC body 还是显示乱码,没有 JSON
检查插件是否正常加载:mitmweb 终端输出里应该能看到 gRPC 请求的打印。如果没有,说明插件没有生效,检查启动命令是否加了 -s grpc_decode.py。
Q:App 数据不显示了(接入代理后)
通常是证书信任问题:
- debug 包:检查
networkSecurityConfig是否正确配置 - release 包:需要把证书安装到系统信任区(需要 root)
注意:不要修改插件里的 trailer 处理逻辑,把 grpc-status 移出 trailer 会导致 gRPC 客户端解析失败。
Q:图片接口响应体显示不了图片预览
mitmweb 只内联显示 image/png、image/jpeg 等标准 MIME。CDN 返回图片时有时用 application/octet-stream,插件的 response hook 已通过魔术字节自动修正为正确 MIME,开启插件即生效。
九、接入清单
新项目接入时对照核查:
pip install mitmproxy protobuf # 抓包必需 pip install grpcio-tools # 有 .proto 源文件需要自己编译时才需要
十、版本兼容性说明
插件依赖以下版本组合,升级时需注意:
| 依赖 | 当前适配版本 | 风险点 | 升级后影响 |
|---|---|---|---|
| mitmproxy | 12.x | Contentview / contentviews.add() / contentviews.remove() 是 12 的 API |
升级到 13+ 如再次变更注册方式,三个 Content View 均无法加载,mitmweb body 区域将回退到二进制显示 |
| Python | 3.10+ | 代码中使用了 str | None 联合类型语法 |
低于 3.10 直接报 SyntaxError,插件无法启动 |
| protobuf | 4.x–6.x(已在 6.33.6 验证可用) | message_factory.GetMessageClass() 接口 |
若未来版本移除该函数,所有 gRPC 消息解码为空,仅显示 (未找到消息类型) |
| mitmproxy 内部 export | 12.x | export.formats["raw"] 是 hack 内部结构 |
版本更新后该字典键名/结构变化时,"Copy raw" 导出 gRPC 流量会回退为二进制;已用 try/except 包住,不影响主要抓包功能 |
升级建议:每次升级 mitmproxy 或 protobuf 大版本后,用一条 gRPC 请求验证 body 区域是否正常显示 JSON。
附:实现原理
本节供感兴趣的读者深入了解,跳过不影响使用。
gRPC 协议栈
App (Android/iOS)
└── gRPC-Java / gRPC-OkHttp
└── HTTP/2
└── TLS (HTTPS)
└── TCP
gRPC 请求路径格式固定:/{包名}.{服务名}/{方法名},Content-Type 固定为 application/grpc。响应结束时通过 HTTP/2 trailer 帧(区别于普通响应头)传递 grpc-status、grpc-message。
TLS 拦截
mitmproxy 工作在 MITM(中间人)模式,用自签名证书与 App 握手,同时与真实服务器建立另一条 TLS 连接:
App ──TLS──► mitmproxy ──TLS──► 服务器
前提:App 没有做 Certificate Pinning。gRPC-Java 默认使用系统信任库(useTransportSecurity()),不做 Pinning,可以直接拦截。
gRPC 消息帧格式
gRPC 在 HTTP/2 DATA 帧之上还有一层分帧,固定 5 字节帧头:
┌─────────────┬─────────────┬──────────────┐
│ Byte 0 │ Bytes 1-4 │ Bytes 5+ │
│ 压缩标志 │ 消息长度 │ Protobuf │
│ 0=不压缩 │ 大端 u32 │ 二进制 │
└─────────────┴─────────────┴──────────────┘
一元请求:body 里只有一个帧。
服务端流式响应(Server Streaming):body 里包含多个连续的帧,每个帧都是独立的 [5字节头 + Protobuf],紧密拼接在一起:
[帧头1][Protobuf1][帧头2][Protobuf2][帧头3][Protobuf3]...
插件的 decode_grpc_body() 用 while 循环解析所有帧,单帧时返回 JSON 对象,多帧时返回 JSON 数组。
mitmproxy 插件架构
插件提供两种能力:
Hook(钩子):在请求/响应到达时触发,可修改内容。插件的 GrpcDecoder.request() 和 response() 属于这一类,负责打印日志、修正图片响应头。
Content View:告诉 mitmweb 如何在界面上显示 body。GrpcContentView 实现 Contentview Protocol,render_priority 返回 2(高于内置 gRPC view 的 1),保证 auto 模式下优先使用本插件。
导出功能的实现
mitmweb 的 Copy 按钮调用 export.formats["raw"],内部用 HTTP/1.1 汇编器序列化响应。gRPC 响应携带 HTTP/2 trailer(grpc-status),HTTP/1.1 汇编器遇到 trailer 但没有 Transfer-Encoding: chunked 会报错:
ValueError: Sending HTTP/1.1 trailer headers requires transfer-encoding: chunked
解决方案:在插件 load() 里 patch export.formats["raw"],gRPC 流量改走 _grpc_readable() 直接读 raw_content 和 trailers 格式化文本,完全绕过 HTTP/1.1 汇编器。

浙公网安备 33010602011771号