在工业网络调试场景中,直接捕获和分析局域网中的 MAC 帧是一项基础但关键的能力。本文介绍一个基于 Node.js 的以太网 MAC 帧捕获与分析工具,支持实时抓包、工业协议解析和原始帧发送。
背景
工业现场网络中,设备之间通过以太网进行通信,协议种类繁多(Modbus TCP、EtherCAT、S7Comm、PROFINET 等)。传统的 Wireshark 虽然功能强大,但在以下场景中存在局限:
- 需要自动化、可编程的抓包流程
- 需要深度解析工业协议字段
- 需要模拟工业设备发送原始 MAC 帧
- 需要轻量级的 Web 界面实时查看
工具架构
项目基于 Node.js,核心模块包括:
| 模块 |
功能 |
capture.js |
调用 tshark 抓包,解析输出行 |
parser.js |
二进制协议解析:Ethernet / IP / TCP / UDP |
industrial.js |
工业协议解析:Modbus TCP、Ethernet/IP、S7Comm、MQTT 等 |
filter.js |
过滤引擎:MAC 过滤、BPF 过滤、端口/主机/协议过滤 |
send.js |
原始 MAC 帧发送:帧构造、Npcap 设备管理、工业模板 |
config.js |
配置管理:自动检测 Npcap/Wireshark 路径 |
抓包流程
抓包引擎通过 child_process 启动 tshark 进程,使用字段提取模式获取每一帧的关键信息:
1 2 3 4 5 6
| tshark -i <interface> -T fields \ -e eth.src -e eth.dst -e eth.type \ -e ip.src -e ip.dst -e ip.proto \ -e tcp.srcport -e tcp.dstport \ -e udp.srcport -e udp.dstport \ -e data.data
|
每行输出以管道符分隔,由 parseTsharkLine() 解析为结构化数据,应用层数据以十六进制形式传递给工业协议解析器。
二进制协议解析
一个完整的以太网 MAC 帧由多层协议头嵌套组成,解析过程就是从外到内逐层剥离头部、提取字段:
1 2 3 4 5 6 7 8 9
| ┌─────────────── Ethernet 头 (14B) ───────────────┐ │ Dst MAC (6B) │ Src MAC (6B) │ EtherType (2B) │ ├─────────────── IP 头 (20B~) ────────────────────┤ │ Version/IHL │ ... │ Protocol (1B) │ SrcIP │ DstIP│ ├─────────────── TCP/UDP 头 ──────────────────────┤ │ SrcPort │ DstPort │ ... │ ├─────────────── 应用层 Payload ──────────────────┤ │ 工业协议数据(Modbus / S7Comm / MQTT ...) │ └─────────────────────────────────────────────────┘
|
Ethernet 头部(14 字节)
1 2 3 4 5 6 7
| function parseEthernet(buffer) { const dstMac = formatMac(buffer.subarray(0, 6)); const srcMac = formatMac(buffer.subarray(6, 12)); const etherTypeNum = buffer.readUInt16BE(12); const etherType = ETHER_TYPES[etherTypeNum] || `Unknown(0x${etherTypeNum.toString(16)})`; return { dstMac, srcMac, etherType, etherTypeNum, payload: buffer.subarray(14) }; }
|
| 偏移 |
长度 |
字段 |
读取方式 |
说明 |
| 0 |
6 |
目标 MAC |
subarray(0,6) → hex |
广播帧为 ff:ff:ff:ff:ff:ff |
| 6 |
6 |
源 MAC |
subarray(6,12) → hex |
发送方物理地址 |
| 12 |
2 |
EtherType |
readUInt16BE(12) |
0x0800=IPv4, 0x0806=ARP, 0x86DD=IPv6, 0x88CC=LLDP |
EtherType 决定了后续 Payload 的解析方式。若为 0x0800(IPv4),则 buffer.subarray(14) 就是 IP 报文。
IP 头部(20 字节起,可变长度)
1 2 3 4 5 6 7 8 9 10 11 12
| function parseIP(buffer) { const version = (buffer[0] >> 4) & 0x0F; const ihl = buffer[0] & 0x0F; const headerLength = ihl * 4; const totalLength = buffer.readUInt16BE(2); const ttl = buffer[8]; const protocolNum = buffer[9]; const protocol = IP_PROTOCOLS[protocolNum] || `Unknown(${protocolNum})`; const srcIp = formatIp(buffer.subarray(12, 16)); const dstIp = formatIp(buffer.subarray(16, 20)); return { version, ihl, totalLength, ttl, protocol, srcIp, dstIp, payload: buffer.subarray(headerLength) }; }
|
| 偏移 |
长度 |
字段 |
读取方式 |
说明 |
| 0 |
1 |
Version / IHL |
高4位/低4位拆分 |
IHL=5 表示 20 字节头部 |
| 2 |
2 |
Total Length |
readUInt16BE(2) |
整个 IP 报文长度 |
| 8 |
1 |
TTL |
buffer[8] |
每经过一跳减 1 |
| 9 |
1 |
Protocol |
buffer[9] |
6=TCP, 17=UDP, 1=ICMP |
| 12 |
4 |
Source IP |
subarray(12,16) → 点分十进制 |
发送方 IP |
| 16 |
4 |
Dest IP |
subarray(16,20) → 点分十进制 |
接收方 IP |
Protocol 字段决定传输层类型,payload = buffer.subarray(headerLength) 就是 TCP/UDP 报文。
TCP 头部(20 字节起,可变长度)
1 2 3 4 5 6 7 8 9 10 11 12 13
| function parseTCP(buffer) { const srcPort = buffer.readUInt16BE(0); const dstPort = buffer.readUInt16BE(2); const seqNum = buffer.readUInt32BE(4); const ackNum = buffer.readUInt32BE(8); const dataOffset = (buffer[12] >> 4) * 4; const flagsByte = buffer[13]; const flags = []; for (const [bit, name] of [[0x01,'FIN'],[0x02,'SYN'],[0x04,'RST'],[0x08,'PSH'],[0x10,'ACK'],[0x20,'URG']]) { if (flagsByte & bit) flags.push(name); } return { srcPort, dstPort, seqNum, ackNum, dataOffset, flags, payload: buffer.subarray(dataOffset) }; }
|
| 偏移 |
长度 |
字段 |
读取方式 |
说明 |
| 0 |
2 |
Source Port |
readUInt16BE(0) |
发送方端口 |
| 2 |
2 |
Dest Port |
readUInt16BE(2) |
接收方端口(用于识别工业协议) |
| 4 |
4 |
Sequence |
readUInt32BE(4) |
序列号 |
| 8 |
4 |
Acknowledgment |
readUInt32BE(8) |
确认号 |
| 12 |
1 |
Data Offset |
(buffer[12] >> 4) * 4 |
头部长度,通常 20 字节 |
| 13 |
1 |
Flags |
位运算 |
SYN/ACK/FIN/RST 等 |
UDP 头部(固定 8 字节)
1 2 3 4 5 6
| function parseUDP(buffer) { const srcPort = buffer.readUInt16BE(0); const dstPort = buffer.readUInt16BE(2); const length = buffer.readUInt16BE(4); return { srcPort, dstPort, length, payload: buffer.subarray(8) }; }
|
| 偏移 |
长度 |
字段 |
读取方式 |
说明 |
| 0 |
2 |
Source Port |
readUInt16BE(0) |
发送方端口 |
| 2 |
2 |
Dest Port |
readUInt16BE(2) |
接收方端口 |
| 4 |
2 |
Length |
readUInt16BE(4) |
UDP 头 + 数据总长度 |
| 6 |
2 |
Checksum |
— |
校验和(解析时通常跳过) |
解析链路总结
每一层解析后,通过 payload 字段将剩余数据传递给下一层:
1
| buffer → parseEthernet() → ethernet.payload → parseIP() → ip.payload → parseTCP/UDP() → transport.payload → 工业协议解析
|
最终 transport.payload 就是应用层数据,根据端口号分发到对应的工业协议解析器。
工业协议 Payload 解析
应用层 Payload 到达后,根据 TCP/UDP 端口号识别协议,再按协议格式逐字节解析。
协议端口映射
| 端口 |
协议 |
解析内容 |
| 502 |
Modbus TCP |
事务ID、功能码、寄存器地址与值 |
| 44818 |
Ethernet/IP |
封装命令、会话句柄、CIP 数据 |
| 102 |
S7Comm |
TPKT 头、ISO 传输层、S7 PDU |
| 1883 |
MQTT |
消息类型、Topic、载荷 |
| 34960-34964 |
PROFINET RT |
实时帧 |
| 4840 |
OPC UA |
二进制协议 |
| 47808 |
BACnet |
楼宇自动化 |
Modbus TCP 解析
Modbus TCP 报文以 MBAP 头(7 字节)开头,后跟功能码和数据:
1 2 3
| ┌──── MBAP Header (7B) ────┬──── PDU ────┐ │ TxID(2) │ ProtoID(2) │ Len(2) │ UnitID(1) │ FuncCode(1) │ Data(...) │ └──────────────────────────┴─────────────┘
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| function parseModbusTCP(data) { const transactionId = data.readUInt16BE(0); const protocolId = data.readUInt16BE(2); const length = data.readUInt16BE(4); const unitId = data.readUInt8(6); const functionCode = data.readUInt8(7);
if (data.length > 8) { const payload = data.subarray(8); switch (functionCode) { case 3: case 4: result.startAddress = payload.readUInt16BE(0); result.quantity = payload.readUInt16BE(2); break; case 6: result.address = payload.readUInt16BE(0); result.value = payload.readUInt16BE(2); break; case 16: result.startAddress = payload.readUInt16BE(0); result.quantity = payload.readUInt16BE(2); break; } } }
|
| 偏移 |
长度 |
字段 |
说明 |
| 0 |
2 |
Transaction ID |
请求-响应配对标识 |
| 2 |
2 |
Protocol ID |
Modbus 协议 = 0x0000 |
| 4 |
2 |
Length |
UnitID + PDU 的总字节数 |
| 6 |
1 |
Unit ID |
从站地址 |
| 7 |
1 |
Function Code |
1=读线圈, 3=读保持寄存器, 6=写单寄存器, 16=写多寄存器 |
| 8+ |
变长 |
Data |
功能码决定格式 |
S7Comm (西门子) 解析
S7Comm 报文嵌套了 TPKT + ISO-on-TCP + S7 PDU 三层结构:
1 2 3
| ┌── TPKT (4B) ──┬── ISO COPT (3B) ──┬──── S7 PDU ────┐ │ Ver(1) │ Rsv(1) │ Len(2) │ Len(1) │ TPDU(1) │ TPDU-Len(1) │ Type(1) │ Ref(2) │ ... │ └────────────────┴───────────────────┴─────────────────┘
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function parseS7Comm(data) { const tpktVersion = data.readUInt8(0); const tpktLength = data.readUInt16BE(2);
const isoLength = data.readUInt8(4); const isoTpduCode = data.readUInt8(5);
const s7Data = data.subarray(7); result.pduType = s7Data.readUInt8(0); result.pduReference = s7Data.readUInt16BE(2); }
|
| 偏移 |
长度 |
字段 |
说明 |
| 0 |
1 |
TPKT Version |
必须为 0x03 |
| 2 |
2 |
TPKT Length |
整个 TPKT 报文长度 |
| 4 |
1 |
ISO Length |
COPT 头长度 |
| 5 |
1 |
ISO TPDU Code |
传输层 TPDU 类型 |
| 7 |
1 |
PDU Type |
0x01=Job Request, 0x03=Ack Data, 0x07=User Data |
| 9 |
2 |
PDU Reference |
请求-响应配对标识 |
Ethernet/IP 解析
Ethernet/IP 报文以 24 字节封装头开头,后跟可选的 CIP 数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function parseEthernetIP(data) { result.command = data.readUInt16LE(0); result.length = data.readUInt16LE(2); result.sessionHandle = data.readUInt32LE(4); result.status = data.readUInt32LE(8); result.senderContext = data.subarray(12, 20).toString('hex'); result.options = data.readUInt32LE(20);
if (data.length > 24) { const cipData = data.subarray(24); result.interfaceHandle = cipData.readUInt32LE(0); result.timeout = cipData.readUInt16LE(4); } }
|
| 偏移 |
长度 |
字段 |
说明 |
| 0 |
2 |
Command |
0x0064=List Identity, 0x0066=Register Session, 0x006F=Send RR Data |
| 2 |
2 |
Length |
封装数据长度 |
| 4 |
4 |
Session Handle |
会话标识 |
| 8 |
4 |
Status |
0=成功 |
| 12 |
8 |
Sender Context |
请求-响应配对 |
| 20 |
4 |
Options |
选项标志 |
| 24+ |
变长 |
CIP Data |
CIP 接口句柄 + 超时 + 实际数据 |
MQTT 解析
MQTT 报文首字节编码消息类型,第二字节为剩余长度:
1 2 3 4 5 6 7 8 9 10 11
| function parseMQTT(data) { const messageType = (data.readUInt8(0) >> 4) & 0x0F; const flags = data.readUInt8(0) & 0x0F; const remainingLength = data.readUInt8(1);
if (messageType === 3 && data.length > 4) { const topicLength = data.readUInt16BE(2); result.topic = data.subarray(4, 4 + topicLength).toString('utf8'); } }
|
| 偏移 |
长度 |
字段 |
说明 |
| 0 |
1 |
Type + Flags |
高4位:1=CONNECT, 3=PUBLISH, 8=SUBSCRIBE, 14=DISCONNECT |
| 1 |
1 |
Remaining Length |
剩余字节数 |
| 2+ |
变长 |
Variable Header |
PUBLISH 时为 Topic 长度(2B) + Topic 字符串 |
原始帧发送
通过 cap npm 包(封装 Npcap)打开网卡原始模式,构造以太网帧并注入网络:
1 2 3 4 5 6 7 8 9 10 11 12 13
| const Cap = require('cap').Cap; const c = new Cap(); c.open(deviceName, '', Buffer.alloc(65535), onRead);
const frame = Buffer.concat([ Buffer.from(dstMac.match(/.{2}/g).map(h => parseInt(h, 16))), Buffer.from(srcMac.match(/.{2}/g).map(h => parseInt(h, 16))), Buffer.from([0x08, 0x00]), payload ]);
c.send(frame);
|
内置 5 种工业模板和 6 种设备模拟器,可模拟 PLC、温度传感器、Modbus 设备、EtherCAT 从站等。
Web 界面
提供基于 Express + Socket.IO 的实时 Web 仪表盘:
- 协议分布环形图(Chart.js)
- MAC / 协议实时过滤
- 载荷十六进制解析
- 中英文国际化
CLI 命令
1 2 3 4 5 6
| mac-capture list mac-capture capture -i eth0 mac-capture analyze -f capture.pcap mac-capture web mac-capture send -i eth0 -t modbus mac-capture templates
|
总结
该工具将 tshark 的抓包能力与 Node.js 的可编程性结合,实现了从捕获、解析到发送的完整 MAC 帧处理流程,特别适合工业网络调试和设备模拟场景。通过 Web 界面和 CLI 双模式,既满足交互式调试需求,也支持自动化集成。