简介:这份C#网络数据包抓取工具面向具备一定C#基础的开发者、网络管理员与安全分析初学者,围绕指定IP和端口的监听需求,演示如何捕获并解析IP、TCP、UDP数据包,帮助理解套接字编程、TCP/IP协议栈及数据包捕获的基本原理。压缩包共82个文件,约1.12MB,以24个C#源码文件为核心,配合解决方案与项目文件、resx/resources界面资源、png/ico图像、可执行程序与DLL,以及少量txt、xml配置说明,构成可直接研究的完整工程。已有743人学习下载,说明其在网络调试与协议分析入门场景中具有一定参考价值。工程将抓包服务、过滤条件设置、主界面展示与关于窗口等模块分开组织,读者可据此梳理从Socket监听、协议头部解析到时间戳、源/目标地址、端口和协议类型等结果呈现的完整链路,并借鉴过滤与显示逻辑,用于网络排查、教学实验或安全分析的基础练习。
1. 从一次“只在客户现场复现”的连接超时说起
日志里只有一行 connection timeout,现场工程师说重启就好,回到工位怎么都复现不了——这种只在别人机器上出现的网络问题,靠加日志基本没指望。当时的做法是在那台工控机上挂了个 C# 写的小采集器,把本机网卡上 TCP、UDP 的报文按指定端口捞下来,半小时就看清了:SYN 发出去了,对面回的却是 RST。这份资源就是那类工具的完整实现,用 C# 做 IP/TCP/UDP 抓包、按端口过滤、把 payload 落成文件。它适合做上位机、工控采集、客户端联调的 .NET 开发,也适合想搞清一次握手到底发生了什么的同学;如果只想读协议教科书,它帮不上忙。
2. 抓包环境搭建:Npcap 驱动、SharpPcap 与网卡选择
2.1 三层依赖关系:驱动、托管封装、协议解析
C# 抓包这件事,技术栈是三层叠起来的,缺一层就跑不动。最底下是抓包驱动,Windows 上现在通用的是兼容 WinPcap 接口的那套 Npcap,它给应用层提供“旁路复制一份网卡数据”的能力;中间是 SharpPcap,把驱动的 C 接口封成 .NET 对象,负责枚举网卡、打开设备、设过滤器、注册回调;最上面是 PacketDotNet,只负责把 byte[] 按协议族一层层拆成字段,自己不碰网卡。
这三层的边界得先分清,不然排查时会找错方向。设备列表为空、打开设备报错,问题一定在第一层和第二层;能抓到包但字段读出来是 null、payload 对不上,问题一定在第三层。我见过有人在 PacketDotNet 里翻半天为什么抓不到包,其实驱动都没装。
为什么不直接用System.Net.Sockets.UdpClient或裸Socket?因为普通 socket 只能拿到“发给我的”和“我发出去的”应用层数据,TCP 头、重传、RST、三次握手这些统统看不见,更别说看同网段其它机器的流量。抓包驱动走的是另一条路,网卡收到什么就复制一份给应用,链路层以上的字节全都在。
| 能力 | 裸 Socket | 抓包驱动 + SharpPcap |
|---|---|---|
| 看 TCP 标志位、序号、窗口 | 拿不到 | 可以 |
| 看发往其它主机的包 | 不行 | 混杂模式下可以 |
| 抓本机回环流量 | 部分可以 | 需要装回环网卡支持 |
| 是否要管理员权限 | 不要 | 要 |
| 是否要额外装驱动 | 不要 | 要 |
表格里最后两行是新手最容易忽略的成本:这东西不是dotnet run就能跑的,驱动和权限都得提前准备好。
2.2 工程里装什么、目标平台怎么设
新建一个 .NET 控制台工程,两个 NuGet 包就够:SharpPcap 和 PacketDotNet。两个版本要配套,SharpPcap 5.x 之后依赖的 PacketDotNet 版本变了,如果手动指定老版本会出现Packet.ParsePacket签名对不上这类编译错误,建议直接装最新的两个,让 NuGet 自己解依赖。
dotnet new console -n PacketSniffer cd PacketSniffer dotnet add package SharpPcap dotnet add package PacketDotNet真正容易翻车的是目标平台。抓包驱动是原生 DLL,32 位进程只能加载 32 位的wpcap.dll,64 位进程只能加载 64 位的,混了就直接BadImageFormatException或者DllNotFoundException。我一般把工程钉死成 x64,不给自己留 AnyCPU 的余地:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net8.0-windows</TargetFramework> <!-- 钉死 x64,避免去加载 32 位的 wpcap.dll --> <PlatformTarget>x64</PlatformTarget> <Prefer32Bit>false</Prefer32Bit> <!-- 抓包要管理员权限,直接写进清单省得每次手动右键 --> <ApplicationManifest>app.manifest</ApplicationManifest> </PropertyGroup> </Project>Prefer32Bit这个属性很多人不知道,它在 exe 的 PE 头里打标记,让 AnyCPU 的进程在 64 位系统上以 32 位跑。把它关掉,位宽问题能少一半。运行的时候记得用管理员身份,普通权限下枚举网卡可能拿得到列表,但Open会失败或者抓不到任何流量。
2.3 先把网卡列出来再谈抓包
写抓包逻辑之前,先花十行代码确认“这台机器上到底有几块网卡、名字长什么样”。这一步做扎实,后面调过滤器会省很多时间。
using SharpPcap; using SharpPcap.LibPcap; // 枚举本机所有能被抓包的网卡(需要驱动已装好) var devices = CaptureDeviceList.Instance; if (devices.Count == 0) { Console.WriteLine("一块网卡都没找到,先确认抓包驱动装没装、进程有没有管理员权限"); return; } foreach (var dev in devices) { // Name 是 \Device\NPF_{GUID} 这种内部标识,调试日志里打它方便对账 // Description 是人能看懂的名字,选网卡时优先按它匹配 Console.WriteLine($"{dev.Name}"); Console.WriteLine($" 描述: {dev.Description}"); Console.WriteLine($" MAC : {dev.MacAddress}"); }这段代码的重点不在语法,在输出。Description 里通常带“以太网”“WLAN”“回环”这类字样,选网卡时按关键字匹配比记住 GUID 靠谱得多。要抓本机自己发给自己的流量(比如两个进程在本机走 TCP 通信),普通的以太网卡是抓不到的,得找描述里带 Loopback 的那块虚拟网卡——它只有在安装驱动时勾选了回环支持才会出现,这点后面避坑章节还会再提一次。
3. 抓包主链路:从打开设备到包回调的三个参数
3.1 Open 的四个参数决定了你抓不抓得到
打开设备这一步,参数设错就是“代码没错、一个包都没有”。SharpPcap 5.x 用DeviceConfiguration传参,四个字段各有各的脾气。
var device = CaptureDeviceList.Instance .First(d => d.Description != null && d.Description.Contains("以太网")); device.Open(new DeviceConfiguration { Mode = DeviceModes.Promiscuous, // 混杂模式:不是发给本机的包也收 ReadTimeout = 1000, // 读超时(ms):决定回调最长多久被触发一次 BufferSize = 8 * 1024 * 1024, // 驱动侧环形缓冲:抗突发用 Immediate = true // 关掉驱动聚合,小包立刻上交 });ReadTimeout是很多人理解错的一个。它不是“抓包持续多久”,而是驱动在没有新包时最多阻塞多久。设成 1000 意味着即使没有流量,回调也大约每秒被唤醒一次;设成 -1 就是一直阻塞到有包。如果你的程序还要响应停止按钮,设一个正数是必须的,不然停不下来。
BufferSize是驱动里那块环形缓冲的大小。默认值通常是 1MB,做 HTTP 调试够用,但碰上工控场景那种几十兆的连续数据流,1MB 会在 GC 卡顿的瞬间被填满,后面进来的包直接被驱动丢掉。我习惯先给到 8MB 甚至 32MB,再配合后面的队列设计。
Immediate = true关掉的是驱动侧的“攒够一定数量的包再一起交上来”的优化。开着它吞吐会高一点,但延迟变差,调握手、调时序的场景必须关掉。这三个参数加在一起,本质是在“延迟”和“抗突发”之间找平衡。
3.2 BPF 过滤器:指定端口的正确写法
指定端口这件事,不要自己在回调里if (port == 8080)。那种写法等于把所有包都搬进托管堆再用 if 筛,流量一大 GC 就崩。正确做法是用 BPF 过滤器,让驱动层先筛一遍,托管代码只看得到关心的包。
// BPF 语法,和 Wireshark 的过滤框基本一致 // tcp port 8080 -> 源端口或目的端口是 8080 的 TCP 包 // udp port 53 -> 源或目的 53 的 UDP 包(DNS) const string filter = "(tcp port 8080 or tcp port 8443) and host 192.168.10.20"; device.Filter = filter; // 赋值即生效,内部会编译这段表达式 device.OnPacketArrival += OnPacket; // 先挂回调 device.StartCapture(); // 再启动,顺序反了会漏掉前面的包device.Filter赋值的时候驱动会编译这段表达式,语法错会立刻抛PcapException,错误信息里带着“can't parse filter expression”。所以设置过滤器的代码要包在 try 里,把这句异常转成“过滤器语法错误:{表达式}”打出来,比让程序直接崩好排查得多。
常用的几种写法记住几个就够:tcp port 8080双向匹配;tcp dst port 8080只看目的端口,抓客户端请求时用;udp and portrange 5000-5010抓一段端口;not port 22排除掉自己的 SSH 流量——远程调试的时候不排除掉,日志会被自己的连接刷屏。
StartCapture()一定要在OnPacketArrival挂载之后调用。反过来的话,从启动到挂回调之间那几十毫秒的包就丢了,调时序问题的时候这点丢失足够让你误判。
3.3 回调函数里只做三件事
回调是整个程序的咽喉,它被驱动线程同步调用,里面多花一毫秒,驱动缓冲就多积一毫秒的包。所以回调里只做三件事:取原始数据、解析到 IP 层、按协议分发出去。
device.OnPacketArrival += (s, e) => { var raw = e.GetPacket(); // 拿到链路层原始帧 // LinkLayerType 决定第一层怎么拆:以太网、Raw IP、Linux SLL 各不相同 var packet = Packet.ParsePacket(raw.LinkLayerType, raw.Data); var ip = packet.Extract<IPPacket>(); // IPv4 和 IPv6 都能拿到 if (ip == null) return; // ARP、STP 这些非 IP 包直接跳过 switch (ip.Protocol) { case ProtocolType.Tcp: var tcp = packet.Extract<TcpPacket>(); // 端口判断只是为了日志可读,真正的过滤已经在 BPF 里做完了 HandleTcp(ip.SourceAddress, tcp.SourcePort, ip.DestinationAddress, tcp.DestinationPort, tcp.PayloadData, raw.Timeval.Date); break; case ProtocolType.Udp: var udp = packet.Extract<UdpPacket>(); HandleUdp(ip.SourceAddress, udp.SourcePort, ip.DestinationAddress, udp.DestinationPort, udp.PayloadData, raw.Timeval.Date); break; } };几个参数说明一下。e.GetPacket()返回的是一个结构体,里面的Data指向驱动缓冲区的副本,Timeval.Date是抓包时刻,精度到微秒,比DateTime.Now靠谱——做时序分析必须用这个时间。Packet.ParsePacket的第一个参数别硬编码成以太网,笔记本上换块网卡、或者抓到回环网卡上,链路层类型就变了,硬编码的结果是解析全 null。
PayloadData是 TCP/UDP 的应用层载荷,已经帮你把头部剥掉了。纯 ACK 包的PayloadData是空数组甚至 null,判断一下再处理,不然容易空引用。
4. 协议解析:把 IP/TCP/UDP 拆成业务能用的字段
4.1 PacketDotNet 的解析树怎么走
PacketDotNet 的设计是一棵树:最外层是EthernetPacket,它有个PayloadPacket指向IpV4Packet,再往下挂TcpPacket或UdpPacket。你可以逐层PayloadPacket as XxxPacket手动走,也可以用Extract<T>()让它自己找——后者代码短,前者在需要判断“到底解析到第几层失败”时更好用。
先把头部字段打全,确认解析链路是通的,再去谈业务逻辑。
void DumpHeader(Packet packet) { var eth = packet.Extract<EthernetPacket>(); var ip = packet.Extract<IPPacket>(); if (ip == null) return; var tcp = packet.Extract<TcpPacket>(); var udp = packet.Extract<UdpPacket>(); // 链路层:源/目的 MAC,排查“包到底有没有到这块网卡”时必看 Console.WriteLine($"eth {eth?.SourceHardwareAddress} -> {eth?.DestinationHardwareAddress}"); // 网络层:地址、TTL、总长度。TTL 异常低通常说明经过了多层转发 Console.WriteLine($" ip {ip.SourceAddress} -> {ip.DestinationAddress} ttl={ip.TimeToLive} len={ip.TotalLength}"); if (tcp != null) { // 标志位和序号是分析连接问题的核心:谁先发 SYN、谁回的 RST、有没有重传 Console.WriteLine($" tcp {tcp.SourcePort}->{tcp.DestinationPort} " + $"syn={tcp.Syn} ack={tcp.Acknowledgment} fin={tcp.Fin} " + $"seq={tcp.Sequence} ackNo={tcp.AcknowledgmentNumber} win={tcp.WindowSize}"); } if (udp != null) { Console.WriteLine($" udp {udp.SourcePort}->{udp.DestinationPort} len={udp.Length}"); } }字段名的坑:tcp.Acknowledgment是布尔值,表示 ACK 标志位;tcp.AcknowledgmentNumber才是确认号。我第一次写的时候把这两个搞混,打出来的确认号一直是 1/0,看了半天以为对方协议实现有问题。同理tcp.Sequence是序号不是长度,tcp.PayloadData.Length才是载荷字节数。
ip.TotalLength是整个 IP 包的长度(含头),ip.PayloadLength是去掉 IP 头之后的。做流量统计时用哪个要看口径,别混着用。
4.2 四元组会话表与 TCP 粘包处理
TCP 是字节流,一个业务消息可能被拆成三个包,也可能三个消息挤在一个包里。抓到的PayloadData只是一段字节,直接按包解析业务协议,早晚会解析到半个 JSON。
常见做法是维护一张会话表,key 用四元组(源 IP、源端口、目的 IP、目的端口),每个会话缓存一个MemoryStream,新数据追加进去,再按应用层协议的边界往外切。
// 四元组 -> 该连接的缓冲区,跨线程要用并发字典 private readonly ConcurrentDictionary<string, TcpStream> _streams = new(); void HandleTcp(IPAddress src, ushort sport, IPAddress dst, ushort dport, byte[] payload, DateTime ts) { if (payload == null || payload.Length == 0) return; // 纯 ACK / SYN 没有载荷 var key = $"{src}:{sport}-{dst}:{dport}"; var stream = _streams.GetOrAdd(key, _ => new TcpStream(ts)); stream.LastActive = ts; stream.Append(payload); // 按协议边界切消息:HTTP 看 \r\n\r\n + Content-Length,私有协议看长度字段 foreach (var msg in stream.ExtractMessages()) { Dispatch(key, msg); } }ExtractMessages的切分逻辑要看你的业务协议。文本协议找分隔符,二进制协议读前两字节的长度字段再判断缓冲区够不够。判断“够不够”这一步必须做,不够就留在缓冲区等下一个包,不能硬切。
会话表的回收是第二个坑。TCP 连接关掉之后_streams里的条目还在,跑一天下来几千个 key 挂在内存里。我一般的做法是起一个定时器,每分钟扫一遍,把LastActive超过 2 分钟且没有新数据的条目删掉,同时把残留数据落盘。这个清理看着不起眼,但它决定了这个工具能不能在工控机上连续挂一个月。
4.3 UDP 场景:一个 DNS 查询的字段提取
UDP 没有序号,也不保证顺序,所以不存在重组问题,但也没有“连接结束”这个信号。归并同一组问答只能靠五元组加时间窗,简单场景直接用端口区分就够了。下面这段是抓 DNS 查询,把事务 ID 和域名打出来,用来验证“指定端口过滤有没有生效”特别方便。
void HandleUdp(IPAddress src, ushort sport, IPAddress dst, ushort dport, byte[] payload, DateTime ts) { if (payload == null || payload.Length < 12) return; // DNS 头至少 12 字节 if (dport == 53 || sport == 53) { // 前两字节是事务 ID,用来把请求和响应配对 int id = (payload[0] << 8) | payload[1]; // 偏移 12 开始是 QNAME,长度前缀的标签序列:3www7example3com0 string name = ReadDnsName(payload, 12); Console.WriteLine($"[{ts:HH:mm:ss.fff}] dns id=0x{id:X4} {name} from {src}:{sport}"); } } static string ReadDnsName(byte[] buf, int offset) { var sb = new StringBuilder(); while (offset < buf.Length && buf[offset] != 0) { int len = buf[offset]; if ((len & 0xC0) != 0) break; // 0xC0 是压缩指针,单包内解析可以先跳过 sb.Append(Encoding.ASCII.GetString(buf, offset + 1, len)).Append('.'); offset += len + 1; } return sb.ToString().TrimEnd('.'); }(len & 0xC0) != 0这句是判断压缩指针的。DNS 响应里的域名经常用指针指回前面的字节,单包解析时遇到指针直接截断就行,要完整解析得把整个报文当上下文传进去。做调试用途的话,截断完全够。
UDP 的另一个注意点是payload.Length可能小于 UDP 头里声明的长度,IP 分片的情况下单个包拿不到完整载荷。做视频流、大报文采集时要考虑分片重组,写个按ip.Identification聚合的缓存;如果只是抓控制指令,一般不会碰上。
5. 避坑排查:抓不到包、丢包、崩溃的五个真实原因
5.1 现象:一块网卡都枚举不出来或回调一次没进
现象:CaptureDeviceList.Instance.Count是 0,或者设备能列出来但StartCapture()之后回调一次都不触发,程序安安静静跑着。
原因:抓包驱动没装、装了但是被安全软件拦了、或者进程不是管理员权限。还有一种隐蔽情况:装了驱动但安装时没勾“支持回环”,本机两个进程之间的通信就永远抓不到,因为流量根本没经过物理网卡。
解决:三步定位。先用管理员身份重开程序,看设备数量变不变;再把设备列表打印出来,确认要抓的那块网卡在不在里面;最后用另一个成熟的抓包工具在同一块网卡上抓一次做对照,它能抓到、你的抓不到,问题就在权限或者过滤表达式上。装驱动时把回环支持勾上,这一步装完就没法补,只能重装。
5.2 现象:过滤器赋值报错,或者设了等于没设
现象:device.Filter = "port=8080"抛PcapException,提示无法解析过滤表达式;或者过滤器设置成功了,但回调里还是收到了别的端口的数据。
原因:BPF 语法和 SQL、和 URL 参数都不一样,它没有等号,端口就写port 8080。另一个常见原因是把tcp port 8080写成了tcp and 8080,后者语法上不成立。还有一种情况是过滤器设在了错误的设备上——同时插了有线和无线两块网卡,流量走的是无线,你在有线那块上设过滤当然什么都看不到。
解决:把过滤器表达式先丢到 Wireshark 的过滤框里试,语法基本通用,能过再写进代码。赋值那段包 try-catch,把原始表达式一起打出来,日志里一眼就能看到是哪条写错了。选网卡的时候别用索引,用 Description 里的关键字匹配,并且把最终打开的是哪块网卡打进日志。
5.3 现象:跑几分钟开始丢包,内存一路涨到 1G
现象:抓包统计里 Dropped 计数持续增长,任务管理器里的内存曲线一路往上,最后程序卡死或者 OOM。
原因:两个独立问题叠在一起。丢包是因为回调里干了重活——解析 JSON、写文件、刷新 UI,驱动缓冲区被堵满,新包只能丢。内存涨是因为把e.GetPacket().Data这个数组的引用存进了List,而那块内存是驱动复用的缓冲区,你以为存了 100 万个包,实际存了 100 万个指向同一块内存的引用,同时 List 本身还在无限增长。
解决:回调里只做入队,而且入队前必须拷贝数据。用BlockingCollection加一个固定容量,满了就丢弃并计数,绝不无限堆积。解析、落盘、UI 全部放到消费线程。判断丢包看两个数字:驱动侧的 Dropped(缓冲区满)和你自己队列的 Dropped(消费跟不上),前者加缓冲,后者加消费者或降解析开销。
// 有界队列:容量到了就丢弃并计数,绝不让内存无限涨 private readonly BlockingCollection<RawPacket> _queue = new(new ConcurrentQueue<RawPacket>(), 100_000); private long _dropped; device.OnPacketArrival += (s, e) => { var p = e.GetPacket(); // 必须 ToArray() 拷贝:p.Data 指向驱动复用的缓冲区 var item = new RawPacket(p.Timeval.Date, p.Data.ToArray()); if (!_queue.TryAdd(item)) Interlocked.Increment(ref _dropped); };5.4 现象:启动就崩,报 BadImageFormatException 或 DllNotFoundException
现象:代码编译没问题,一运行就抛BadImageFormatException: 试图加载格式不正确的程序,或者DllNotFoundException: wpcap.dll。
原因:位宽不匹配是最主要的。进程是 32 位,系统里装的是 64 位驱动;或者反过来。AnyCPU加上默认开启的Prefer32Bit,会让一个看起来是 64 位的工程在 64 位系统上以 32 位进程启动,这个行为很多人不知道。DllNotFoundException则可能是驱动装在了非标准路径,或者安装时选了“仅安装 WinPcap 兼容层”但没装运行库。
解决:PlatformTarget明确写x64,Prefer32Bit设 false,发布的时候也用-r win-x64。部署到客户机器上时,安装包和程序位宽保持一致,32 位的采集器就配 32 位驱动,别图省事混装。日志里把Environment.Is64BitProcess打出来,出问题时省一轮沟通。
6. 二次开发:把 Demo 改成能长期挂着的采集器
Demo 和能用的工具之间差的是线程模型和落盘。回调、解析、存储三层拆开,中间用有界队列连接,是这类采集器最省心的结构:回调线程只负责拷贝入队,一个消费者线程做协议解析和统计,另一个写线程负责落盘。三层之间不共享可变状态,就不用到处加锁。
落盘有两个选择。要能用 Wireshark 复盘,就存 pcap 格式,SharpPcap 自带CaptureFileWriterDevice,writer.Write(packet)一行搞定,注意它需要原始RawCapture而不是解析后的对象,所以入队时保留原始帧是有必要的。只要业务字段,就自己序列化成 JSON Lines,一行一个消息,后面用grep或者导入数据库都方便。我一般两个都开,pcap 留最近 500MB 滚动覆盖,JSON 全量留着做统计。
改造时有几个点值得提前留接口:过滤器做成配置项而不是硬编码,现场调端口不用重新编译;会话表加超时清理,不然挂一周内存就满;Dropped计数做成定时上报,客户说“抓漏了”的时候有数据可查;时间戳统一用抓包时间而不是处理时间,否则时序分析全乱。
最后说个血泪教训。早些年我图省事,在回调里直接调Console.WriteLine,本机测试一切正常,装到现场跑了三天开始丢包,查了两天才反应过来是控制台输出在重定向到日志文件时阻塞了驱动线程。从那以后我每次写抓包回调,都强制自己先问一句:这行代码会不会阻塞?会,就挪到队列的另一头去。希望帮到你。
本文还有配套的精品资源,点击获取