简介面向局域网运维与网络监测开发者的ARP抓包实践包聚焦设备发现与IP冲突设备识别基于C#与SharpPcap实现适合需要在自有系统中加入网络设备扫描、冲突告警能力的开发者参考与集成。压缩包共36个文件、约1002KB以11个cs源码文件为核心辅以2个dll依赖、4个exe示例程序、3个config配置及resx资源等源码、示例与运行依赖相对完整。包内已将抓取ARP包的功能封装为类并附使用示例开发环境为VS2015、C#与.NET 4.5.2依赖WinPcap、SharpPcap与PacketDotNet便于读者理解抓包流程、设备列表整理和冲突判定思路。已有560人学习适合具备一定C#基础、希望减少底层抓包调试成本的中级开发者按需取用。1. 从一台“时通时断”的终端说起ARP 抓包能给出什么答案局域网里最难受的故障不是彻底断网而是时通时断某台终端每隔几分钟掉一次线报障的人说不清现象交换机的 MAC 地址表看起来一切正常ping 网关时通时不通。这类问题十有八九出在 IP 冲突上——两台设备抢同一个地址谁先应答谁就通另一台被挤掉。要把它抓个现行最省事的办法不是登录每台交换机翻配置而是在局域网里被动抓 ARP 包把「IP → MAC」的对应关系持续记录下来再找出同一个 IP 被两个 MAC 使用的时间段。这份资源给的就是这样一套 C# SharpPcap 的实现监听本网段 ARP 报文输出在线网络设备列表并单独给出冲突设备清单适合需要快速定位内网地址问题的运维以及要做网络资产扫描、准入检测类工具的工程师。它不依赖交换机镜像口一台接入网段的普通机器就能跑。2. SharpPcap 跑通网卡枚举、混杂模式与 BPF 过滤动手之前先把一件事说明白SharpPcap 本身不抓包它只是 pcap 驱动的一层 .NET 封装。Windows 上真正干活的是 Npcap 这类兼容 pcap 接口的驱动Linux 上是 libpcapmacOS 上是系统自带的 BPF 设备。所以“代码写对了却一个包都抓不到”的情况九成不是 C# 的问题而是驱动没装、权限不够或者选错了网卡。这一章把采集层的三件事一次做对认网卡、开设备、加过滤。2.1 依赖与运行前提先确认底层驱动真的在项目里需要两个 NuGet 包SharpPcap 负责设备与抓包循环PacketDotNet 负责协议解析后面会讲为什么不能全靠它。装包命令很朴素dotnet add package SharpPcap dotnet add package PacketDotNet装完之后先别急着写业务代码跑一个最小验证枚举设备列表看能不能打印出网卡名和描述。这一步能同时验证驱动是否就位、当前进程有没有权限。运行身份很关键抓包需要访问底层设备Windows 上必须以管理员身份启动Linux 上要么 root要么给可执行文件加CAP_NET_RAW能力。常见做法是在调试阶段直接用管理员终端跑发布时再改成服务账户授权不要图省事给整个程序目录提权。平台底层依赖权限要求常见症状WindowsNpcap 兼容驱动管理员枚举为空或 Open 抛异常Linuxlibpcaproot 或 CAP_NET_RAW只能看到 lo容器内宿主机网卡直通privileged 或 host 网络抓不到业务网段流量2.2 枚举网卡并打开设备混杂模式、超时和缓冲区打开设备的参数直接决定后面能不能抓到“别人的包”。默认模式下网卡只接收目的 MAC 是自己的帧而 ARP 请求是广播、ARP 应答是单播给请求方你要观察整个网段的地址占用情况就必须进混杂模式。下面这段是采集层的初始化骨架using SharpPcap; using SharpPcap.LibPcap; // 1. 枚举所有可抓包设备打印出来供人工确认 var devices CaptureDeviceList.Instance; foreach (var d in devices) { // Name 是驱动内部标识Description 才是人能看懂的名字 Console.WriteLine(${d.Name}\t{d.Description}); } // 2. 按描述关键字挑一个实际项目里建议做成配置项而不是写死 var device devices .OfTypeLibPcapLiveDevice() .FirstOrDefault(d d.Description ! null d.Description.Contains(以太网)); if (device null) throw new InvalidOperationException(未找到可用网卡请检查驱动或改用配置指定); // 3. 打开设备混杂模式 1 秒读超时 4MB 缓冲 device.Open(new DeviceConfiguration { Mode DeviceModes.Promiscuous, // 关键接收非本机目的的帧 ReadTimeout 1000, // 毫秒决定事件回调的触发粒度 BufferSize 4 * 1024 * 1024, // 内核缓冲突发流量下防丢包 Snaplen 65535 // 截断长度ARP 帧只有 42 字节留足余量 });ReadTimeout设太小会让回调过于频繁CPU 空转设太大则事件延迟高冲突判定的时间精度会变差。1000 毫秒是个稳妥起点。BufferSize在只有 ARP 的过滤条件下其实用不满但局域网里如果有大量广播缓冲区给足能明显减少丢包。Snaplen不要设成 64 之类的小值虽然 ARP 帧短但同一个抓包句柄后期可能被复用到别的分析场景截断后再想改就得重开设备。2.3 BPF 过滤器只留 ARP性能差别是数量级的不加过滤的话一块千兆网卡在忙时能灌进来几十万 pps全部交给 C# 回调处理GC 压力会直接压垮程序。BPF 过滤器在内核层就把非 ARP 的帧丢掉这才是能长时间跑的前提。设置方式只有一行但写法有讲究// 只放行 ARPEtherType 0x0806 device.Filter arp; // 等价的底层写法某些驱动对协议名的支持不一致时更稳 // device.Filter ether proto 0x0806;arp这个关键字被 libpcap 的过滤器编译器识别编译发生在赋值那一刻语法错了会当场抛异常而不是静默失效——这点比很多配置项友好。如果赋值不报错但一个包都收不到先怀疑两件事一是过滤器写成了arp and host x.x.x.x这类把广播包也过滤掉的组合二是网卡本身没接到有流量的网段比如插在一条只有自己和路由器的隔离口上。2.4 采集与处理解耦别在回调里干重活抓包回调运行在驱动拉起的接收线程上在里面做字典写入、日志刷盘、界面刷新都会拖慢收包节奏严重时直接丢包。我的习惯是回调只做一件事把解析出来的轻量记录塞进一个有界队列另开一个消费线程做聚合和判定。队列满了就丢弃并计数这个计数器本身就是“系统扛不住”的告警信号。using System.Threading.Channels; // 有界队列容量按峰值 ARP 速率估一般 8192 足够 var queue Channel.CreateBoundedArpRecord(new BoundedChannelOptions(8192) { FullMode BoundedChannelFullMode.DropWrite, // 满了丢弃不阻塞抓包线程 SingleReader true }); device.OnPacketArrival (s, e) { var raw e.GetPacket(); if (!ArpParser.TryParse(raw.Data, out var rec)) return; if (!queue.Writer.TryWrite(rec)) Interlocked.Increment(ref _dropped); }; device.StartCapture(); // 非阻塞后台线程开始收包BoundedChannelFullMode.DropWrite是刻意选的抓包场景下“少几条记录”远好于“回调阻塞导致内核缓冲溢出丢掉一整段”。SingleReader true让消费端可以无锁读取配合一个Task.Run循环做批量聚合。停机的顺序也要注意先device.StopCapture()再queue.Writer.Complete()反过来会漏掉最后一批包。3. 拆 ARP 报文从字节偏移到可用的设备表ARP 包结构极简单固定 28 字节负载加 14 字节以太头但真正写起来坑不少VLAN 标签会让偏移整体后移 4 字节某些虚拟化环境会发出硬件类型非 1 的怪包免费 ARP 的字段组合又和普通请求不一样。这一章把解析、去重、主动探测三块拼起来最终产出一张能直接拿来用的设备表。3.1 字段偏移与合法性校验以太网帧头 14 字节其中第 12、13 字节是 EtherTypeARP 固定为0x0806。紧随其后的 28 字节才是 ARP 本体。把偏移列清楚比背代码有用偏移相对 ARP 起点长度字段典型值02硬件类型1以太网22协议类型0x0800IPv441硬件地址长度651协议地址长度462操作码1 请求 / 2 应答86发送方 MAC—144发送方 IP—186目标 MAC请求时为全 0244目标 IP—注意 802.1Q 的情况如果帧里带 VLAN 标签EtherType 位置变成0x8100真正的 ARP 类型往后挪 4 字节ARP 起点也随之后移。抓包时如果发现整段解析全乱先打印前 20 个字节的十六进制看一眼通常就是这个原因。3.2 手工解析与库解析两套都留着PacketDotNet 用起来舒服但它在遇到畸形包或非常规封装时会返回 null 或抛异常而抓包场景恰恰什么怪包都能遇到。实战里的做法是主路径用它兜底路径自己按偏移解两条路互为验证。using System.Net; using System.Net.NetworkInformation; using PacketDotNet; public readonly record struct ArpRecord( IPAddress SenderIp, PhysicalAddress SenderMac, IPAddress TargetIp, bool IsGratuitous, DateTime Timestamp); public static class ArpParser { public static bool TryParse(byte[] frame, out ArpRecord record) { record default; if (frame null || frame.Length 42) return false; // 以太头校验允许带一层 802.1Q 标签 int arpOffset 14; if (frame[12] 0x81 frame[13] 0x00) arpOffset 18; // EtherType 必须是 ARP int etherType (frame[arpOffset - 2] 8) | frame[arpOffset - 1]; if (etherType ! 0x0806) return false; // 硬件类型 1、协议类型 0x0800 才按 IPv4 处理 int hwType (frame[arpOffset] 8) | frame[arpOffset 1]; int protoType (frame[arpOffset 2] 8) | frame[arpOffset 3]; if (hwType ! 1 || protoType ! 0x0800) return false; var senderMac new PhysicalAddress(frame.Skip(arpOffset 8).Take(6).ToArray()); var senderIp new IPAddress(frame.Skip(arpOffset 14).Take(4).ToArray()); var targetIp new IPAddress(frame.Skip(arpOffset 24).Take(4).ToArray()); // 免费 ARP发送方 IP 等于目标 IP多见于地址宣告或冲突探测 bool gratuitous senderIp.Equals(targetIp); record new ArpRecord(senderIp, senderMac, targetIp, gratuitous, DateTime.Now); return true; } }frame.Length 42是硬性下限短于这个长度的帧不可能是完整 ARP。hwType和protoType的校验很多人会省掉但在虚拟化环境里确实会冒出硬件类型为 6IEEE 802 网络之类的包直接按以太网格式解会读出乱码 MAC。IsGratuitous标记后面判冲突时要用到它也是判断“设备刚上线”的最早信号。3.3 设备表的字段设计与去重设备表不是简单的IP → MAC字典那样第二次见到同一 IP 的不同 MAC 时你就把证据覆盖掉了。正确的结构是 IP 为主键、MAC 为子键的多值映射每个子项记首末次出现时间和计数。public sealed class DeviceEntry { public PhysicalAddress Mac { get; init; } null!; public DateTime FirstSeen { get; set; } public DateTime LastSeen { get; set; } public int Count { get; set; } public bool EverGratuitous { get; set; } } // IP - (MAC - 明细) private readonly DictionaryIPAddress, DictionaryPhysicalAddress, DeviceEntry _table new(); private readonly object _gate new(); public void Observe(ArpRecord rec) { lock (_gate) { if (!_table.TryGetValue(rec.SenderIp, out var macs)) _table[rec.SenderIp] macs new DictionaryPhysicalAddress, DeviceEntry(); if (!macs.TryGetValue(rec.SenderMac, out var entry)) { macs[rec.SenderMac] entry new DeviceEntry { Mac rec.SenderMac, FirstSeen rec.Timestamp }; } entry.LastSeen rec.Timestamp; entry.Count; entry.EverGratuitous | rec.IsGratuitous; } }lock在这个粒度上完全够用写入操作是微秒级的。真正需要小心的是别把Lock加在遍历整张表并输出报表的地方——那会把抓包消费线程卡住。输出的做法是先lock里做一次浅拷贝出了锁再序列化。3.4 主动探测被动抓不到的设备怎么补被动监听有个天然短板一个完全安静的设备只要不发 ARP你永远看不到它。而交换机里明明有它的 MAC 表项。补法是主动发一轮 ARP 请求把目标网段扫一遍public static void SendArpProbe(LibPcapLiveDevice device, IPAddress localIp, PhysicalAddress localMac, IPAddress targetIp) { var eth new EthernetPacket(localMac, PhysicalAddress.Parse(FF-FF-FF-FF-FF-FF), EthernetType.Arp); // 目标 MAC 填全 0这是 ARP 请求的标准写法 eth.PayloadPacket new ArpPacket( ArpOperation.Request, PhysicalAddress.Parse(00-00-00-00-00-00), targetIp, localMac, localIp); device.SendPacket(eth); }扫描节奏要克制常见做法是每秒 20 到 50 个包一个 /24 网段十几秒扫完。发包太猛会触发某些交换机的 ARP 抑制也容易被安全设备当成扫描行为记录。每轮扫完等 2 到 3 秒再收集应答因为 ARP 应答是单播回来的混杂模式下能收到但需要给对端一点时间。4. IP 冲突判定把“同一地址两个 MAC”变成可确认的结论设备表本身只是原始素材真正有价值的是冲突清单。判定逻辑听起来简单——同一个 IP 出现两个 MAC 就是冲突——但直接这么写跑十分钟就能给你报出几十条误报。原因在于网络里存在大量“合法的 MAC 漂移”网关主备切换、虚拟化桥接、无线漫游、集群虚拟地址都会让同一个 IP 短暂对应不同 MAC。这一章讲怎么把它做成可信的判定。4.1 冲突的最小充分条件一次真实的 IP 冲突在网络层面会留下这几个痕迹的组合同一个 IP 在短时间内被两个不同 MAC 使用且这两个 MAC 都不是已知的虚拟 MAC其中至少一方会发出免费 ARP 做地址宣告这是设备上线或检测到地址被占时的标准行为冲突双方的 ARP 请求会出现“我请求谁别人抢先应答”的时序竞争。单看第一条容易误报三条合起来准确率就上来了。实际实现里我会给每条候选冲突算一个置信度分数而不是二元判断见到的 MAC 数量 ×2、是否伴随免费 ARP ×3、是否在时间窗口内交替出现 ×2、是否命中白名单 ×0。分数到阈值才进冲突清单。这套打分制的好处是遇到拿不准的情况可以先把阈值调高观察一段时间而不是一律当真或一律当假。4.2 时间窗口与状态机窗口太短抓不到太长误报多。按经验10 分钟的滑动窗口对办公网够用DHCP 租约一般以小时计正常续租不会换 MAC而真实冲突往往表现为几分钟内的高频交替。下面是一个最小可用的检测器public sealed class ConflictDetector { private readonly TimeSpan _window; private readonly TimeSpan _minGap; // 两个 MAC 至少间隔多久才算交替 private readonly HashSetPhysicalAddress _whitelist; private readonly DictionaryIPAddress, List(PhysicalAddress Mac, DateTime At) _history new(); private readonly object _gate new(); public ConflictDetector(TimeSpan window, TimeSpan minGap, IEnumerablePhysicalAddress whitelist) { _window window; _minGap minGap; _whitelist new HashSetPhysicalAddress(whitelist); } public bool Observe(ArpRecord rec, out ConflictResult result) { result default; if (_whitelist.Contains(rec.SenderMac)) return false; // 虚拟 MAC 直接跳过 lock (_gate) { if (!_history.TryGetValue(rec.SenderIp, out var list)) _history[rec.SenderIp] list new List(PhysicalAddress, DateTime)(); list.Add((rec.SenderMac, rec.Timestamp)); // 只保留窗口内的记录顺便防内存增长 var cutoff rec.Timestamp - _window; list.RemoveAll(x x.At cutoff); var macs list.Select(x x.Mac).Distinct().ToList(); if (macs.Count 2) return false; // 检查两个 MAC 的出现时间是否真的交错而不是一次性先后出现 var ordered list.OrderBy(x x.At).ToList(); for (int i 1; i ordered.Count; i) { if (ordered[i].Mac.Equals(ordered[i - 1].Mac)) continue; if (ordered[i].At - ordered[i - 1].At _minGap) continue; result new ConflictResult(rec.SenderIp, macs, ordered[0].At, rec.Timestamp); return true; } return false; } } }_window建议 10 分钟、_minGap建议 30 秒起调。RemoveAll那行是长期运行的关键没有它字典会随运行时间线性膨胀跑一天下来几百万条记录内存和 GC 都受不了。返回的ConflictResult里带上首末时间运维拿着这个时间段去对比 DHCP 日志基本能锁定是哪台机器。4.3 白名单先排除正常的 MAC 漂移白名单不是可选项是必需品。以下这几类 MAC 如果不排除你会得到一张全是误报的清单类型典型 MAC 特征处理方式网关虚拟地址00-00-5E-00-01-xxVRRP白名单 单独监控虚拟化桥接宿主机上多张虚拟网卡共用 IP按网段排除无线漫游同一终端在不同 AP 上换 MAC按 OUI 排除或延长窗口负载均衡 VIP多台设备轮流应答同一地址白名单容器网络每次重启 MAC 变化按网段排除00-00-5E-00-01-xx这个网段是 VRRP 虚拟 MAC 的固定前缀看到它就该知道这是主备网关在切换不是冲突。但反过来也要留个心眼如果同一网段里出现了两个不同编号的 VRRP 虚拟 MAC 抢同一个 VIP那才是真故障所以我在白名单之外还会单独统计虚拟 MAC 的变化只是不把它算进“IP 冲突”这一栏。4.4 冲突清单的落地CSV、告警和留存检测出来的东西要能被人用起来否则就是自娱自乐。最实用的落地形式是两路输出一路 CSV 落盘做历史留存一路控制台或日志做实时告警。CSV 的字段设计要能支撑事后回溯我一般固定这几列public static void ExportCsv(IEnumerableConflictResult conflicts, string path) { var sb new StringBuilder(); sb.AppendLine(ip,first_seen,last_seen,mac_count,macs); foreach (var c in conflicts.OrderBy(x x.FirstSeen)) { // MAC 列表用分号连接避免和 CSV 的逗号冲突 var macs string.Join(;, c.Macs.Select(m m.ToString())); sb.AppendLine(${c.Ip},{c.FirstSeen:yyyy-MM-dd HH:mm:ss}, ${c.LastSeen:yyyy-MM-dd HH:mm:ss},{c.Macs.Count},{macs}); } File.WriteAllText(path, sb.ToString(), Encoding.UTF8); }时间格式固定成yyyy-MM-dd HH:mm:ss是为了能直接和 DHCP 服务器日志、交换机 syslog 做时间对齐。用StringBuilder而不是File.AppendAllText逐行写是因为冲突记录可能上千条逐行开关文件会把磁盘 IO 拖成瓶颈。文件用 UTF-8 编码别用系统默认编码否则跨机器打开会乱码。5. 避坑与排查抓不到包、列表乱跳、冲突误报的五个现场这一章全是踩过的坑。每一条我都按“看到什么现象 → 为什么会这样 → 怎么改”写因为这些故障的共同特点是现象和原因隔得很远光看报错根本猜不到。5.1 设备列表永远是空的只有自己现象程序正常启动日志显示“开始抓包”但设备表里只有本机一条记录其余全是零。原因最常见的是选错网卡。机器上装了虚拟机软件或者容器工具之后CaptureDeviceList会多出好几块虚拟网卡用First()随便取一个抓的往往是根本没流量的虚拟网桥。其次是网卡没进混杂模式或者交换机端口开了客户端隔离有些无线控制器默认开启隔离之后你只能看到自己的流量。解决先把枚举结果全部打印出来人工确认Description里到底哪一块是接内网的那块把名字写进配置再确认Mode DeviceModes.Promiscuous生效最后用arping或另一台机器 ping 一下本机看有没有包进来把问题分层定位在网卡、交换机还是代码。5.2 Open 直接抛异常堆栈里全是驱动符号现象device.Open(...)抛PcapException提示找不到设备或者权限不足重装程序也没用。原因驱动层面没有就位。Windows 上装了 SharpPcap 的包不等于装了 pcap 驱动NuGet 只带托管库Linux 上虽然 libpcap 通常在但普通用户没有CAP_NET_RAW一开设备就被拒。还有一种隐蔽情况这个网卡已经被别的抓包软件独占打开。解决先在命令行用系统的抓包工具验证驱动能列出网卡说明驱动没问题Windows 用管理员终端启动Linux 用sudo setcap cap_net_raw,cap_net_admineip 程序路径给能力而不是常年 root关掉其他正在抓包的进程再试。5.3 冲突清单每隔几秒新增一条全是误报现象刚跑起来几分钟冲突列表就刷了几十条仔细一看全是网关、服务器或者根本不存在的地址。原因没有做虚拟 MAC 白名单也没有区分“交替出现”和“先后出现”。主备网关切换时VIP 对应的 MAC 会瞬间从主设备变成备设备一次切换就能被当成冲突。DHCP 续租失败后重新申请到不同地址也会在设备表里留下两个 MAC 的痕迹。解决把 VRRP 虚拟 MAC、集群 VIP、虚拟化桥接网段加进白名单打开_minGap参数要求两个 MAC 的出现时间间隔大于 30 秒才算交替把窗口从默认值往上调到 10 分钟以上观察一段时间再逐步收紧。5.4 跑两三个小时后内存暴涨、界面卡死现象刚开始一切正常几小时后进程占用从几十兆涨到一两个 G任务管理器里显示的线程数也在涨。原因设备表和冲突历史只增不删。每个新见到的 IP-MAC 组合都新建一个字典项从不清理冲突历史的 List 也只在窗口内做RemoveAll但前提是那个 IP 还会再来包不来就永远留着。另外如果在抓包回调里直接更新界面控件跨线程访问会让 UI 线程排长队。解决给设备表加一个空闲淘汰任务比如每 5 分钟扫一遍把LastSeen早于 1 小时且计数很小的条目删掉定期TrimExcess界面更新一律走队列加定时器刷新别在回调里碰控件。这三条改完连续跑一周内存都能稳在合理区间。5.5 过滤器一加上就收不到包现象注释掉device.Filter arp能收到包加上之后一个都没有。原因BPF 编译失败时某些旧版本是静默忽略的看起来像“过滤器太严”。另外如果网卡上跑的是带 VLAN 标签的流量arp这个关键字匹配不到被 802.1Q 封装后的帧因为此时 EtherType 是0x8100而不是0x0806。解决把过滤器改成arp or (vlan and arp)覆盖带标签的情况或者干脆先不加过滤器跑一小段抓几百个包出来看看链路层到底长什么样再决定过滤表达式。赋值之后立刻做一次device.Filter回读校验能早发现编译问题。6. 长时间运行的稳定性与一次可复现的验证工具写完不算完能不能连续跑一周、报出来的东西准不准才是分水岭。我在长时间运行上踩得最狠的一次是程序跑了三天之后抓包线程静默死掉——没有异常、没有日志进程还在就是不收包了。后来定位到是网卡被系统休眠唤醒后驱动句柄失效而代码里没有任何重连逻辑。此后的习惯是给抓包加一个“心跳看门狗”每 60 秒统计一次收包计数连续三个周期为零且期间网段确实有流量可以发一个 ARP 探测自己验证就主动Close()再Open()一次并把事件重新挂上。// 看门狗连续 3 个周期零收包则重建设备句柄 private int _idleTicks; private void OnHeartbeat() { long now Interlocked.Read(ref _packetCounter); if (now Interlocked.Read(ref _lastCounter)) { if (_idleTicks 3) { ReopenDevice(); _idleTicks 0; } } else _idleTicks 0; Interlocked.Exchange(ref _lastCounter, now); }验证检测逻辑本身最直接的办法是制造一次可控冲突。在测试网段找一台机器手动把它的 IP 配成另一台在线设备的地址然后观察三件事设备表里这个 IP 下面是不是出现了两个 MAC冲突清单是不是在_minGap之内冒出来CSV 里记录的首末时间是不是和手动操作的时间对得上。这个验证我每次改判定逻辑都会走一遍因为参数一动误报和漏报的相对比例就会变凭感觉调是调不准的。验证项操作判定标准被动发现让目标机 ping 一次网关设备表 1 分钟内出现该机免费 ARP重启目标机网卡记录中IsGratuitous为真冲突检出手动改成已占用 IP窗口内报出MAC 数量为 2白名单触发一次主备切换冲突清单不新增这几步做完工具的可信度就有底了。参数上我一般把窗口设成 10 分钟、最小间隔 30 秒、白名单从网关 MAC 开始一条条加宁可漏报也不要把清单刷成噪音——一旦运维觉得这张表不可信再准的检测也没人看了。从那以后我每次上线这类被动监测工具都强制先用一台测试机跑满 24 小时把误报逐条对齐再交付希望帮到你。本文还有配套的精品资源点击获取