简介:本资源是一份面向网络协议开发与测试工程师、Wireshark高级使用者的技术实践文档,聚焦解决自定义私有协议在Wireshark中无法解析的典型痛点。文档以基于UDP的员工信息查询服务(QueryRequest/QueryResponse)为真实案例,系统讲解如何利用Wireshark内嵌的Lua 5.1引擎编写可加载的解析插件,涵盖Proto协议声明、ProtoField字段定义、dissector函数编写、init.lua配置调用等核心环节,并附有抓包截图与字段结构对照说明。资源为单文件Word文档(.doc),大小264KB,内容完整、图文结合,适合作为Lua协议解析入门的实操指南与速查笔记。目前已有371人学习下载,读者可直接复用文中脚本框架,快速实现对自有二进制协议的可视化解析,显著提升调试效率与协议分析深度。
1. 用 Lua 写 Wireshark 插件解析自定义协议:不是“写个脚本就完事”,而是让二进制流开口说话
你抓了一包 UDP 流量,目标端口是 12345,Wireshark 显示全是 Data —— 没有协议名、没有字段展开、Info 列只写“UDP 12345 → 12345”,连消息类型是请求还是应答都得手动 hexdump 翻前 2 字节。这种场景在嵌入式通信、IoT 设备调试、私有 RPC 接口联调中太常见了。别再靠截图+Excel 对照表人工解码了。本文讲的,就是用 Lua 给 Wireshark “装上新眼睛”:不改源码、不编译、不重启开发环境,仅靠一个.lua文件 + 三处关键配置,就能把裸 UDP payload 解析成带语义、可过滤、可展开的树状结构。它不是玩具 Demo,而是某高校实验室在调试自研传感器组网协议时落地的真实方案;也不是“Lua 入门教程”,所有代码都经过 Wireshark 3.6.x ~ 4.2.x 多版本实测(注意:Lua 5.1 兼容性仍是硬约束);更不是“抄完就能跑”的黑匣子——你会看到 offset 偏移怎么算、字节序怎么选、字段长度怎么防越界、Info 列怎么动态填充。适合正在被私有协议抓包分析卡住的嵌入式工程师、协议栈开发者、测试自动化工程师。如果你的协议跑在 UDP/TCP/USB/UWB 上,且字段结构固定(哪怕含变长字段),这篇就是你的后悔药。
2. 协议声明与字段建模:Proto 和 ProtoField 不是语法糖,是解析逻辑的骨架
Wireshark 的 Lua 插件不是“把二进制当字符串切”,而是构建一套可被 GUI 渲染、被过滤器识别、被导出功能消费的元数据模型。这分两步:先声明协议实体(Proto),再定义其原子字段(ProtoField)。跳过这步直接写dissector,就像没画电路图就焊板子——能亮,但一碰就短路。
2.1 Proto.new:给你的协议起个“身份证号”
-- d:/myproto.lua my_proto = Proto("myProto", "Employee Query Protocol", "Custom protocol for employee ID lookup")这行代码干了三件事:
"myProto"是协议在 Wireshark 内部的唯一标识符(必须全小写、无空格、不能和内置协议重名),后续所有dissector、filter都靠它索引;"Employee Query Protocol"是显示在 Packet Details 树顶节点的友好名称(支持中文,但建议英文,避免某些版本乱码);"Custom protocol..."是协议描述,会出现在Internals > Supported Protocols列表里。
提示:协议名一旦注册,就不能在运行时修改。若改名后 reload 脚本,旧名仍残留,新名不生效——必须完全退出 Wireshark 再启动。
2.2 ProtoField:25 种字段类型里,你真正需要的只有 5 种
原文只用了uint16/uint32/string,但实际项目中常踩坑的是:string默认按\0截断,而你的协议可能用固定长度填充(如Char[32]);uintX默认大端,但嵌入式设备多用小端。下面给出该协议完整字段定义,并标注每个参数的实战意义:
-- 字段定义区(放在 Proto 声明之后) local f_usMsgType = ProtoField.uint16("myProto.msg_type", "Message Type", base.DEC, { [0] = "QueryRequest", [1] = "QueryResponse" }, 0x0000, "Message type field", HFILL) local f_uiEmployeeID = ProtoField.uint32("myProto.emp_id", "Employee ID", base.DEC, nil, 0x00000000, "Employee identification number", HFILL) local f_usQueryResult = ProtoField.uint16("myProto.query_result", "Query Result", base.DEC, { [0] = "Success", [1] = "Failed" }, 0x0000, "Query operation status", HFILL) local f_szEmployeeName = ProtoField.string("myProto.emp_name", "Employee Name", base.ASCII)关键参数说明:
"myProto.msg_type":必须以协议名开头加点号,这是 Wireshark 过滤器语法的基础(如myProto.msg_type == 1);base.DEC:显示为十进制(也可用base.HEX,base.OCT);- 第四个参数
{[0]="...", [1]="..."}是值映射表,仅对整数型字段有效,Wireshark 会自动在 GUI 下拉框和 Info 列显示文字; 0x0000是掩码(mask),用于提取位域(本例全字段,掩码为 0);"Message type field"是字段描述,鼠标悬停时显示;HFILL是宏,等价于FT_HFILL,表示该字段参与自动布局(必填,否则 GUI 不渲染);base.ASCII:string字段默认按\0结束,但Char[32]是固定长度,所以不能用base.ASCII—— 正确做法见 4.3 节。
最后,必须将字段挂载到协议对象:
my_proto.fields = { f_usMsgType, f_uiEmployeeID, f_usQueryResult, f_szEmployeeName }注意:字段顺序不决定解析顺序,但影响 Packet Details 树的显示顺序。建议按协议文档字段顺序排列。
2.3 DissectorTable:让 Wireshark 知道“该找谁干活”
协议声明好了,字段也建模了,但 Wireshark 还不知道“什么情况下调用你”。这就靠DissectorTable—— 它是 Wireshark 的“路由表”。UDP 端口匹配是最常用场景:
-- 获取 UDP 端口解析器表 local udp_table = DissectorTable.get("udp.port") -- 将 my_proto 绑定到端口 12345 udp_table:add(12345, my_proto)"udp.port"是内置表名(大小写敏感),其他常用表包括:
"tcp.port":TCP 端口绑定;"ip.proto":IP 协议号绑定(如0x11对应 UDP);"wtap_encap":链路层封装类型(如WTAP_ENCAP_ETHERNET);
提示:若协议跑在 TCP 上,且服务端使用动态端口,可用
"tcp.port"+add_for_decode_as()实现“Decode As”手动指定,比硬编码端口更灵活。
3. 解析器函数编写:buffer/pinfo/tree 三要素协同,不是简单 for 循环
dissector函数是插件的灵魂,它接收原始字节流并生成可视化结构。很多人以为“读几个字节、转成数字、add 进 tree 就完事”,结果 Info 列永远是静态文本、过滤器失效、变长字段崩溃。根本原因是没吃透buffer/pinfo/tree三者的职责边界。
3.1 buffer:不只是字节数组,是带元信息的“可切片内存块”
buffer对象封装了原始报文数据,提供安全访问接口。绝不能用buffer:byte(i)或string.sub(buffer, i, j)直接操作——这会绕过 Wireshark 的边界检查,导致崩溃或解析错位。正确姿势是:
-- 获取 buffer 总长度(单位:字节) local len = buffer:len() -- 取偏移 offset 开始的 2 字节,返回新的 buffer 对象 local sub_buf = buffer(offset, 2) -- 将 sub_buf 按小端转 uint16 local value = sub_buf:le_uint() -- 按大端转(嵌入式常见) -- local value = sub_buf:uint()buffer(offset, length)是核心操作,offset从 0 开始,length必须 ≤buffer:len() - offset,否则抛异常。血泪经验:每次取字段前,务必校验剩余长度:
if buffer:len() < offset + 2 then pinfo.cols.info:set("Truncated packet: missing msg_type") return end3.2 pinfo:Packet Info 不是摆设,是 Info 列和协议列的唯一信源
pinfo对象控制 Packet List 窗口两列内容:
pinfo.cols.protocol:set("myProto"):设置协议列(Protocol 列),影响“Decode As”和统计;pinfo.cols.info:set("QueryRequest for ID 1"):设置 Info 列(Info 列),支持动态文本,是调试第一眼信息。
玄学坑:pinfo.cols.info只接受字符串,不能拼接 table 或 number。必须显式tostring():
local emp_id = buffer(offset,4):le_uint() pinfo.cols.info:set("QueryRequest for ID " .. tostring(emp_id)) -- ✅ 正确 -- pinfo.cols.info:set("QueryRequest for ID " .. emp_id) -- ❌ Wireshark 4.0+ 会静默失败3.3 tree:不是“画树”,是“构造可交互的协议语义图”
tree是 Packet Details 窗口的根节点,tree:add()返回子节点,可链式调用。关键规则:
tree:add(proto, buffer_range, label):创建协议节点(如“My Protocol...”);node:add_le(field, buffer_range):添加小端字段(add_be为大端);node:add(field, buffer_range):添加默认字节序字段(通常为大端);node:add_text("Raw data: " .. buffer(0,len):bytes()):添加纯文本行(调试用)。
避坑重点:buffer_range必须和字段定义长度严格一致!例如f_usMsgType是uint16,就必须传buffer(offset,2),传buffer(offset,1)会触发断言失败。
4. 完整解析器实现:从静态文本到动态语义,四步拆解协议结构
现在把前面所有模块组装成可运行的dissector。核心逻辑是:根据消息类型分支解析,每步校验长度,动态填充 Info 列。这不是线性流程,而是带状态机的解析。
4.1 初始化与基础校验
function my_proto.dissector(buffer, pinfo, tree) -- 1. 基础校验:至少 2 字节(msg_type) if buffer:len() < 2 then pinfo.cols.protocol:set("DATA") pinfo.cols.info:set("Too short for myProto header") return end -- 2. 设置协议列 pinfo.cols.protocol:set("myProto") -- 3. 创建协议树节点 local subtree = tree:add(my_proto, buffer(), "Employee Query Protocol") -- 4. 初始化偏移 local offset = 0buffer()等价于buffer(0, buffer:len()),表示整个 buffer。subtree是后续所有字段的父节点。
4.2 解析消息类型并分支
-- 解析 msg_type (2 bytes) local msg_type = buffer(offset, 2):le_uint() subtree:add_le(f_usMsgType, buffer(offset, 2)) offset = offset + 2 -- 动态设置 Info 列 if msg_type == 0 then pinfo.cols.info:set("QueryRequest") elseif msg_type == 1 then pinfo.cols.info:set("QueryResponse") else pinfo.cols.info:set("Unknown msg_type: " .. tostring(msg_type)) return end这里subtree:add_le()使用le_uint(),因为协议文档明确usMsgType是小端。Info 列已从静态文本升级为状态感知。
4.3 解析员工 ID(通用字段)
-- 解析 emp_id (4 bytes),QueryRequest 和 QueryResponse 都有 if buffer:len() < offset + 4 then pinfo.cols.info:set("Truncated: missing emp_id") return end local emp_id = buffer(offset, 4):le_uint() subtree:add_le(f_uiEmployeeID, buffer(offset, 4)) offset = offset + 4 -- 更新 Info 列(追加 emp_id) if msg_type == 0 then pinfo.cols.info:set("QueryRequest for ID " .. tostring(emp_id)) else pinfo.cols.info:set("QueryResponse for ID " .. tostring(emp_id)) end注意:emp_id在两种消息中位置相同,复用解析逻辑,避免重复代码。
4.4 分支解析剩余字段(QueryResponse 专属)
-- 仅 QueryResponse 有后续字段 if msg_type == 1 then -- 校验剩余长度:至少 2 字节(query_result)+ 32 字节(name) if buffer:len() < offset + 2 + 32 then pinfo.cols.info:set("Truncated: incomplete QueryResponse") return end -- 解析 query_result (2 bytes) local query_result = buffer(offset, 2):le_uint() subtree:add_le(f_usQueryResult, buffer(offset, 2)) offset = offset + 2 -- 解析 emp_name (32 bytes,固定长度,非 null-terminated) -- ⚠️ 关键:不能用 string 字段,要用 bytes + tostring local name_bytes = buffer(offset, 32):bytes() -- 去除尾部 \0 填充,取有效字符 local emp_name = "" for i = 1, #name_bytes do if name_bytes:byte(i) ~= 0 then emp_name = emp_name .. string.char(name_bytes:byte(i)) else break end end -- 手动 add 字符串(因 ProtoField.string 会截断) subtree:add(f_szEmployeeName, buffer(offset, 32)):set_text("Employee Name: " .. emp_name) offset = offset + 32 -- 更新 Info 列 if query_result == 0 then pinfo.cols.info:set("QueryResponse for ID " .. tostring(emp_id) .. " -> Success: " .. emp_name) else pinfo.cols.info:set("QueryResponse for ID " .. tostring(emp_id) .. " -> Failed") end end end为什么不用ProtoField.string?
因为string字段默认按\0截断,而Char[32]是固定长度填充(如"Liu Dehua\0\0\0...")。直接add(f_szEmployeeName, buffer(offset,32))会显示全部 32 字节(含乱码)。上面代码手动提取有效字符,再用set_text()设置显示文本,兼顾准确性和可读性。
5. 避坑 / 常见问题 / 排查:那些让你重启三次还找不到原因的 Lua 黑盒
写 Lua 插件最痛苦的不是语法,而是 Wireshark 的静默失败机制——脚本有错,它不报错,只是不加载、不解析、Info 列空白。以下是真实踩过的坑,按现象→原因→解决整理:
5.1 现象:Wireshark 启动后,Internals > Supported Protocols里找不到myProto
原因:init.lua中dofile("d:/myproto.lua")路径错误,或myproto.lua文件存在语法错误(如少括号、变量未声明),Wireshark 加载时静默跳过。
解决:
- 在
init.lua开头加print("init.lua loaded"),启动时看 Console(Tools > Lua > Console)是否有输出; - 用
luac -p d:/myproto.lua预编译检查语法(luac是 Lua 编译器,随 Wireshark 一起安装); - Windows 路径用正斜杠
/或双反斜杠\\,单反斜杠\会被 Lua 当作转义符(如"d:\myproto.lua"实际是"d:(tab)myproto.lua")。
5.2 现象:抓包文件打开后,UDP 12345 流量仍显示为Data,但Internals > Dissector tables > udp.port里能看到12345 -> myProto
原因:dissector函数内发生未捕获异常(如buffer(offset,2)越界),Wireshark 捕获后终止执行,不报错。
解决:
- 在
dissector开头加pcall包裹主逻辑:function my_proto.dissector(buffer, pinfo, tree) local ok, err = pcall(function() -- 原来所有代码放这里 end) if not ok then pinfo.cols.info:set("Lua error: " .. tostring(err)) end end - 启用 Wireshark Debug 日志:
Edit > Preferences > Advanced > lua.debug设为TRUE,日志输出到Help > About Wireshark > Folders > Personal configuration目录下的log.txt。
5.3 现象:字段能解析,但Filter输入myProto.msg_type == 0提示“no such field”
原因:ProtoField名称未按协议名.字段名格式定义,或my_proto.fields未赋值。
解决:
- 检查
ProtoField第一个参数是否为"myProto.xxx"(不是"xxx"或"myproto.xxx"); - 在
init.lua末尾加print("my_proto.fields count: " .. #my_proto.fields),确认字段数组非空; - 重启 Wireshark 后,打开
Analyze > Display Filters...,在 Filter Expression 窗口搜索myProto,看字段是否列出。
5.4 现象:Info 列显示乱码(如QueryRequest for ID 1899273984),但字段值正确
原因:pinfo.cols.info:set()拼接了 number 类型变量,Wireshark 4.0+ 版本严格要求字符串。
解决:所有number/boolean变量必须tostring():
-- 错误 pinfo.cols.info:set("ID " .. emp_id) -- 正确 pinfo.cols.info:set("ID " .. tostring(emp_id))5.5 现象:DissectorTable.add()后,udp.port表里出现两个12345条目,一个指向myProto,一个指向data
原因:多次 reload 脚本,add()被重复执行,Wireshark 不去重。
解决:
- 在
add()前加判断:local udp_table = DissectorTable.get("udp.port") -- 先移除旧绑定(如果存在) udp_table:remove(12345) udp_table:add(12345, my_proto) - 或使用
add_for_decode_as()替代add(),它支持运行时覆盖。
6. 进阶技巧与验证方法:让插件从“能用”到“可靠”,附一份可直接运行的 checklist
写完插件不等于结束。真正的工程化落地,需要验证它在各种边界条件下不翻车。我给自己立了一条铁律:任何新写的 dissector,必须通过以下五项验证才能提交。这不是教条,而是某次线上协议升级后,因没测“超长 name 字段”导致测试同学漏掉关键 bug 的血泪教训。
6.1 构造边界测试包:用 Scapy 生成 5 类畸形包
手工抓包无法覆盖所有异常场景。用 Python + Scapy 生成标准包和 4 类畸形包,存为test_myproto.pcap:
| 测试类型 | 构造方法 | 验证目标 |
|---|---|---|
| 标准包 | UDP(dport=12345)/Raw(load=b'\x00\x00\x00\x00\x00\x01') | 基础解析是否正常 |
| 截断包 | UDP(dport=12345)/Raw(load=b'\x00\x00')(仅 msg_type) | buffer:len()校验是否触发 |
| 超长 name | UDP(dport=12345)/Raw(load=b'\x01\x00\x00\x00\x00\x01' + b'A'*33) | buffer(offset,32)是否越界崩溃 |
| 非法 msg_type | UDP(dport=12345)/Raw(load=b'\x02\x00\x00\x00\x00\x01') | else分支是否正确设置 Info |
| 零长度包 | UDP(dport=12345)/Raw(load=b'') | buffer:len() < 2是否拦截 |
# generate_test_pcap.py from scapy.all import * import struct # 标准 QueryRequest pkt1 = IP(dst="192.168.56.1")/UDP(dport=12345)/Raw(load=struct.pack('<H',0)+struct.pack('<I',1)) # 截断包(只有 msg_type) pkt2 = IP(dst="192.168.56.1")/UDP(dport=12345)/Raw(load=struct.pack('<H',0)) # 保存 wrpcap("test_myproto.pcap", [pkt1, pkt2])6.2 Wireshark 内置验证工具:三步定位解析瓶颈
Wireshark 自带诊断能力,比 print 调试高效十倍:
- 启用解析器统计:
Telephony > VoIP Calls(此菜单名有迷惑性,实际是通用解析器统计)→ 查看myProto的Packets和Errors计数; - 开启详细日志:
Edit > Preferences > Protocols > myProto→ 勾选Enable debug output,日志输出到 Console; - 强制重解析:右键 Packet →
Decode As...→ 选择myProto,观察 Info 列是否实时更新(验证dissector可重入)。
6.3 字段过滤与导出:证明你的插件已融入 Wireshark 生态
能解析只是起点,能被工作流消费才是价值。验证以下两点:
- 过滤器语法:在 Filter 栏输入
myProto.msg_type == 0 && myProto.emp_id == 1,确认只显示 ID=1 的请求; - 导出为 CSV:
File > Export Packet Dissections > As CSV→ 勾选myProto相关字段 → 打开 CSV,确认Message Type、Employee ID列数据正确。
6.4 一份可粘贴的部署 checklist(共 7 步)
把以下步骤做成 checklist,每次部署新插件时打钩,避免遗漏:
| 步骤 | 操作 | 验证方式 |
|---|---|---|
| ✅ 1 | init.lua末尾添加dofile("D:/myproto.lua")(路径用/) | 启动 Wireshark,Console 输出init.lua loaded |
| ✅ 2 | myproto.lua中ProtoField名称格式为"myProto.xxx" | Analyze > Display Filters搜索myProto,字段列表出现 |
| ✅ 3 | DissectorTable.add()前调用remove()防重复 | Internals > Dissector tables > udp.port中12345只有一条 |
| ✅ 4 | dissector函数首行加pcall包裹 | 发送非法包,Info 列显示Lua error: ...而非空白 |
| ✅ 5 | 所有pinfo.cols.info:set()中number变量加tostring() | Info 列无乱码数字 |
| ✅ 6 | 每次buffer(offset, n)前校验buffer:len() >= offset + n | 发送截断包,Info 列显示Truncated: ... |
| ✅ 7 | 用 Scapy 生成test_myproto.pcap,覆盖 5 类边界 | 打开 pcap,5 个包 Info 列均合理,无崩溃 |
从那以后我每次写 dissector,都强制走一遍这个 checklist,哪怕只是改一行add_le。它不快,但省去了 3 小时查buffer越界的绝望。希望帮到你。
本文还有配套的精品资源,点击获取