扫描局域网MAC帧

在工业网络调试场景中,直接捕获和分析局域网中的 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)); // 字节 0-5:目标 MAC
const srcMac = formatMac(buffer.subarray(6, 12)); // 字节 6-11:源 MAC
const etherTypeNum = buffer.readUInt16BE(12); // 字节 12-13:EtherType(大端序)
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; // 字节 0 高 4 位:版本号,IPv4=4
const ihl = buffer[0] & 0x0F; // 字节 0 低 4 位:头部长度(单位 4 字节)
const headerLength = ihl * 4; // 实际头部长度,通常 20 字节
const totalLength = buffer.readUInt16BE(2); // 字节 2-3:IP 报文总长度
const ttl = buffer[8]; // 字节 8:生存时间
const protocolNum = buffer[9]; // 字节 9:上层协议号
const protocol = IP_PROTOCOLS[protocolNum] || `Unknown(${protocolNum})`;
const srcIp = formatIp(buffer.subarray(12, 16)); // 字节 12-15:源 IP
const dstIp = formatIp(buffer.subarray(16, 20)); // 字节 16-19:目标 IP
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); // 字节 0-1:源端口
const dstPort = buffer.readUInt16BE(2); // 字节 2-3:目标端口
const seqNum = buffer.readUInt32BE(4); // 字节 4-7:序列号
const ackNum = buffer.readUInt32BE(8); // 字节 8-11:确认号
const dataOffset = (buffer[12] >> 4) * 4; // 字节 12 高 4 位 × 4 = 头部长度
const flagsByte = buffer[13]; // 字节 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); // 字节 0-1:源端口
const dstPort = buffer.readUInt16BE(2); // 字节 2-3:目标端口
const length = buffer.readUInt16BE(4); // 字节 4-5:UDP 报文长度
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.payloadparseIP() → 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); // 字节 0-1:事务标识
const protocolId = data.readUInt16BE(2); // 字节 2-3:协议标识,Modbus=0
const length = data.readUInt16BE(4); // 字节 4-5:后续字节数
const unitId = data.readUInt8(6); // 字节 6:单元标识(从站地址)
const functionCode = data.readUInt8(7); // 字节 7:功能码

// 功能码 3/4:读寄存器请求
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) {
// TPKT 头
const tpktVersion = data.readUInt8(0); // 字节 0:TPKT 版本,必须为 3
const tpktLength = data.readUInt16BE(2); // 字节 2-3:TPKT 总长度

// ISO on TCP (COPT)
const isoLength = data.readUInt8(4); // 字节 4:COPT 长度
const isoTpduCode = data.readUInt8(5); // 字节 5:TPDU 编码

// S7 PDU
const s7Data = data.subarray(7);
result.pduType = s7Data.readUInt8(0); // PDU 类型:0x01=Job, 0x03=Ack Data, 0x07=User Data
result.pduReference = s7Data.readUInt16BE(2); // PDU 引用号
}
偏移 长度 字段 说明
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); // 字节 0-1:命令(小端序)
result.length = data.readUInt16LE(2); // 字节 2-3:数据长度
result.sessionHandle = data.readUInt32LE(4); // 字节 4-7:会话句柄
result.status = data.readUInt32LE(8); // 字节 8-11:状态码
result.senderContext = data.subarray(12, 20).toString('hex'); // 字节 12-19:发送方上下文
result.options = data.readUInt32LE(20); // 字节 20-23:选项

// CIP 数据(偏移 24 起)
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; // 首字节高 4 位:消息类型
const flags = data.readUInt8(0) & 0x0F; // 首字节低 4 位:标志
const remainingLength = data.readUInt8(1); // 第二字节:剩余长度

// PUBLISH 消息解析 Topic
if (messageType === 3 && data.length > 4) {
const topicLength = data.readUInt16BE(2); // 字节 2-3:Topic 长度
result.topic = data.subarray(4, 4 + topicLength).toString('utf8'); // Topic 字符串
}
}
偏移 长度 字段 说明
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);

// 构造 14 字节以太网头 + 载荷
const frame = Buffer.concat([
Buffer.from(dstMac.match(/.{2}/g).map(h => parseInt(h, 16))), // 目标 MAC
Buffer.from(srcMac.match(/.{2}/g).map(h => parseInt(h, 16))), // 源 MAC
Buffer.from([0x08, 0x00]), // EtherType: IPv4
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 # 启动 Web 界面
mac-capture send -i eth0 -t modbus # 发送工业模板帧
mac-capture templates # 列出可用模板

总结

该工具将 tshark 的抓包能力与 Node.js 的可编程性结合,实现了从捕获、解析到发送的完整 MAC 帧处理流程,特别适合工业网络调试和设备模拟场景。通过 Web 界面和 CLI 双模式,既满足交互式调试需求,也支持自动化集成。


扫描局域网MAC帧
https://cszy.top/20260417-扫描局域网MAC帧/
作者
csorz
发布于
2026年4月17日
许可协议