ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java+Jnetpcap网络嗅探器实战:抓包解析与TCP流重组

Java+Jnetpcap网络嗅探器实战:抓包解析与TCP流重组 简介一份基于Java与Jnetpcap库实现的网络嗅探器完整项目面向希望学习网络协议分析与抓包技术的小白及进阶者适用于毕设、课程设计或工程实训。项目实现了运行主机网卡选择、链路层至应用层的全协议数据包捕获并逐层解析包头信息内置Ethernet、IP、ARP、ICMP、UDP、TCP、HTTP七种协议的过滤分析支持源/目的IP及包内容关键字过滤以及基于IPPort的TCP流追踪分析结果可保存。资源共72个文件以Java源码、class字节码、XML配置和17张运行截图为主附带jar库与说明文档压缩包整体仅1.85MB便于快速部署与代码研读。目前已有467人学习适合需要完整可运行示例以参考抓包核心流程与界面设计的学习者。1. 用Java和Jnetpcap写网络嗅探器抓包程序到底在抓什么做Java后端的人几乎都遇过这种局面联调时接口报错两边都坚持“我发了/我没收到”能一锤定音的只有一台抓包程序。网络嗅探器这个名字听着高本质就是抓包程序加封包分析程序。Jnetpcap在Java界比较特殊它是把libpcap这套底层抓包能力通过JNI暴露给Java的封装也就是说你不用碰C/C就能拿到网卡上的原始帧。这篇文章把“基于JavaJnetpcap的网络嗅探器设计与实现”这条路走通从环境配置讲到抓包循环、封包解析、TCP流重组再讲JNI崩溃和回环流量这些绕不开的坑。适合正在做课程设计、流量分析工具或想给团队搭一个内网抓包小工具的从业者。如果你只是想抓自己进程发出的应用层数据那用nio就够了抓包程序的价值在于连别人发给你的包也能看见。2. Jnetpcap的抓包链路为什么Socket硬写不行环境三件套怎么配2.1 Java工程师的第一道坎Socket只能看见自己进程的数据Java标准库的Socket是应用层编程接口数据到达网卡之前已经被协议栈消化完了程序只能被动接收发给当前进程的包。而网络嗅探器要把网卡设置成混杂模式让驱动把链路上所有帧都交一份上来。这一层能力在操作系统里通常由libpcap提供。Jnetpcap就是在JNI边界上把libpcap的函数映射成Java对象的库核心类是JpcapCaptor。有人会问为什么不直接用JNI调libpcap可以但头文件、指针、内存释放这些事会让开发成本翻几倍。Jnetpcap把这些封装成Packet、Header、PcapIf一套对象模型Java工程师上手成本低很多。它和早期jpcap项目相比封包分析的类型体系更完整Ethernet、IP、TCP、UDP这些头部都有对应描述。这里有个很容易被想当然的点网卡驱动把帧拷贝到内核缓冲再通过Pcap库交到用户空间这里有两次拷贝和一次系统调用。所以网络嗅探器从来不做“抓一个包解析一个包”这种串行活而是先批量收包再异步解析。这个思路会贯穿后面所有代码。2.2 环境三件套JDK版本、Npcap、jnetpcap.dll的匹配关系Jnetpcap是早年项目对新的JDK适配比较慢。我一般锁JDK 8而不是追最新版。JDK 9以后模块化对ClassPath访问有限制Jnetpcap这种靠JNI加载native库的组件容易出幺蛾子。如果你电脑里装了多个JDK启动时用java -version确认一下当前版本再决定要不要切环境变量。第二件是抓包驱动。Windows上装Npcap安装时勾选“WinPcap API兼容层”Jnetpcap才能通过wpcap.dll找到Pcap函数族。Linux上直接装libpcap-dev即可macOS自带的libpcap也能用。第三件是jnetpcap的jar和dll这两个文件要放到classpath和java.library.path里位数必须和JDK一致——64位JDK配64位dll否则运行时报“Wrong architecture”。启动命令这么写java -Djava.library.path./lib \ -cp ./lib/jnetpcap.jar:./out \ com.example.sniffer.Main-Djava.library.path指定native库目录jnetpcap的dll和jar都放在./lib下。classpath用冒号分隔Windows下要改成英文分号经常有人在这里配置完却报“no jnetpcap in java.library.path”然后发现dll根本没放进java.library.path指向的目录。JNI查找native库依赖的是java.library.path不是classpath这个区别排查时最容易被忽略。2.3 先写个能跑的枚举网卡并打印MAC地址第一步不急着抓包先把本机网卡设备列表枚举出来。这块没有技术深度但能验证环境配置是否到位项目里习惯叫“设备发现”import org.jnetpcap.Pcap; import org.jnetpcap.PcapIf; public class ListDevices { public static void main(String[] args) { StringBuilder errbuf new StringBuilder(); java.util.ListPcapIf alldevs new java.util.ArrayList(); int r Pcap.findAllDevs(alldevs, errbuf); if (r Pcap.ERROR || alldevs.isEmpty()) { System.err.println(设备枚举失败: errbuf); return; } for (PcapIf dev : alldevs) { System.out.println(设备名: dev.getName()); System.out.println(描述: dev.getDescription()); byte[] mac dev.getMacAddress(); if (mac ! null) { StringBuilder sb new StringBuilder(); for (byte b : mac) { if (sb.length() 0) sb.append(:); sb.append(String.format(%02X, b)); } System.out.println(MAC地址: sb); } for (java.net.InetAddress addr : dev.getAddresses()) { System.out.println(IP地址: addr.getHostAddress()); } } } }Pcap.findAllDevs把本机所有网卡装进List失败信息写入errbuf。getMacAddress方法对虚拟网卡、回环网卡经常返回null所以必须先判空再格式化。有一类常见报错是“找不到wpcap.dll”这通常是Npcap没装兼容层或者装了32位版本而JDK是64位优先从这两个方向查。能把IP和MAC打出来说明Jnetpcap的native层已经通了可以进入下一步。2.4 打开网卡的最小代码openLive的四个参数设备枚举通过后用Pcap.openLive打开指定网卡开始收包String device alldevs.get(0).getName(); StringBuilder errbuf new StringBuilder(); int snaplen 65535; int promisc Pcap.MODE_PROMISCUOUS; // 1混杂模式 int timeout 10; // 10 毫秒 Pcap pcap Pcap.openLive(device, snaplen, promisc, timeout, errbuf);第一个参数是网卡名称来自findAllDevs返回的PcapIf.getName在Windows上通常是\Device\NPF_开头的GUID字符串不是给人看的网卡名。snapLen控制每帧最多拷贝多少字节给应用层promisc设置为1代表混杂模式0代表只收本机流量timeout是内核缓冲向应用提交数据的时间间隔单位毫秒。这几个参数的具体权衡放下一章展开先记住一点pcap对象创建成功后要判断是否为空openLive失败时pcap为null而不是抛异常错误信息在errbuf里。3. 把报文不间断捞上来抓包循环与三个必调参数3.1 两种抓包循环loop回调与nextExPacket阻塞式抓包循环有两种写法各有适用场景。第一种是回调式调用pcap.loop然后由libpcap的分发线程逐帧调用你的handlerpcap.loop(-1, new JPacketHandlerObject() { Override public void nextPacket(JPacket packet, Object user) { // 回调运行在Jnetpcap的native分发线程禁止在这里做耗时解析 int caplen packet.getCaptureHeader().caplen(); System.out.println(捕获长度 caplen); } }, null);loop的第一个参数是要抓的包数-1表示无限抓包直到主动中断。回调在每个包到达时立刻触发实时性好适合做流量监听。但千万注意回调执行期间后续包会在内核缓冲里积压如果回调里做了数据库写入、正则匹配这类耗时操作丢包率会直线上升。第二种是阻塞式JPacket packet pcap.nextExPacket(); if (packet ! null) { // 拿到一帧交给工作线程处理 }nextExPacket每次取一帧没有包时阻塞在native方法里适合自己控制消费节奏的场景。我一般用nextExPacket作为主循环的取包方式把帧投递到独立线程池做解析这样抓包线程和解析线程互不阻塞。3.2 三个必调参数snapLen、promisc、timeout的权衡openLive里那三个参数是抓包程序最核心的调优点我按实际项目里的经验列一张参数表参数典型值作用踩坑点snapLen65535每帧最多拷贝给应用的字节数设太小会截断帧尾部TCP payload拿不全promisc1混杂模式收链路上所有帧部分虚拟网卡驱动会忽略此标志timeout10~50内核缓冲向用户态提交的时间间隔设0在部分平台变成非阻塞循环空转以太网默认MTU是1500字节但加上VLAN Tag、巨型帧单帧可能超过9000字节snapLen设65535是Pcap官方的通用建议。设小了最典型的现象是“包能抓到但TCP payload永远是1448字节”因为超过snapLen的部分被内核丢了。这个参数只影响拷贝长度不影响线路上实际的帧所以调大到65535没有副作用。timeout影响着实时性和吞吐量的平衡。抓交互式调试流量设10毫秒让包尽快到达应用层抓大流量做离线分析设长到50毫秒让内核把一批帧合并提交减少用户态唤醒次数。timeout设为0时要特别小心在Windows上这代表非阻塞模式没有包时nextExPacket立即返回null循环空转CPU直接跑满。3.3 后台抓包线程与解析解耦队列缓冲怎么做抓包程序一旦需要做界面展示或复杂解析就必须把“收包”和“处理包”拆到两个线程。回调线程收到帧后把数据拷贝成独立对象投进阻塞队列消费线程负责解析。这是最常见的生产级抓包结构BlockingQueuePcapPacket queue new LinkedBlockingQueue(1024); pcap.loop(-1, (packet, user) - { // PcapPacket必须拷贝回调里的packet是复用的native缓冲区 PcapPacket copy new PcapPacket(packet); if (!queue.offer(copy)) { copy.clear(); // 队列满说明消费跟不上宁可丢帧也不能内存堆积 } }, null);这里的核心是PcapPacket的拷贝。Jnetpcap为了性能会复用同一块native内存回调对象在处理完下一帧后内容就被覆盖所以必须new PcapPacket(packet)做一次深拷贝。这个native内存管理机制对Java工程师来说像个黑匣子最典型的表现就是“放到List里的包循环跑几轮后全变成最后一帧的数据”根因就是没拷包。队列设置上限是保护性设计。消费线程一旦卡住队列满后新帧直接丢弃而不是让抓包线程无限堆积导致内存溢出。实际生产里建议队列容量设为2048到4096之间太大浪费内存太小容易丢帧。4. 把帧一层层剥开Ethernet、IP、TCP头部解析与HTTP流重组4.1 解析方式选型用Header类型还是手动按偏移读Jnetpcap自带Ethernet、Ip4、Tcp等Header类型理论上可以packet.getHeader(new EthernetHeader())直接拿结构化字段。但我在实际项目里更常用手动偏移解析原因有两个一是默认Header类型在解析前要做类型匹配性能开销比直接读字节高一个量级二是抓包程序经常要处理畸形帧、非标准帧Header框架对长度校验不通过的情况容易抛异常或返回null。协议帧结构是公开且固定的。以太网帧头部14字节目标MAC占6字节、源MAC占6字节、类型字段占2字节类型0x0800代表IPv4。IP头紧跟其后第一个字节的高4位是版本号低4位是IHLIHL乘以4就是IP头长度。TCP头在IP头之后源端口和目的端口各占2字节。这一套偏移规则掌握后解析程序就变成一个字节读取问题可控性最强。4.2 手写一个解包方法从原始帧里读TCP四元组下面这个方法从JPacket原始字节里解析出IP层跟TCP层的核心字段public void dissect(JPacket packet) { int capLen packet.getCaptureHeader().caplen(); if (capLen 14) { return; // 以太网头都不完整直接丢弃 } // 以太网类型字段偏移12占2字节0x0800表示IPv4 int ethType (packet.getUByte(12) 8) | packet.getUByte(13); if (ethType ! 0x0800) { return; // 先只处理IPv4IPv6是0x86DD } int ipStart 14; byte versionIhl packet.getUByte(ipStart); int ipHeaderLen (versionIhl 0x0F) * 4; // IHL字段单位是4字节 byte proto packet.getUByte(ipStart 9); // 协议字段6TCP if (proto ! 6) { return; } // IP源地址和目的地址各占4字节手动拼成整数 long srcIp ((long) (packet.getUByte(ipStart 12) 0xFF) 24) | ((packet.getUByte(ipStart 13) 0xFF) 16) | ((packet.getUByte(ipStart 14) 0xFF) 8) | (packet.getUByte(ipStart 15) 0xFF); int tcpStart ipStart ipHeaderLen; int srcPort (packet.getUByte(tcpStart) 8) | packet.getUByte(tcpStart 1); int dstPort (packet.getUByte(tcpStart 2) 8) | packet.getUByte(tcpStart 3); // TCP数据偏移字段偏移12高4位乘以4就是TCP头长度 int tcpHeaderLen (packet.getUByte(tcpStart 12) 4) * 4; long seq ((long) (packet.getUByte(tcpStart 4) 0xFF) 24) | ((packet.getUByte(tcpStart 5) 0xFF) 16) | ((packet.getUByte(tcpStart 6) 0xFF) 8) | (packet.getUByte(tcpStart 7) 0xFF); System.out.printf(%s:%d - %s:%d seq%d tcpHeaderLen%d%n, ipToString(srcIp), srcPort, ipToString(dstIp), dstPort, seq, tcpHeaderLen); }代码里用的是packet.getUByte(index)逐字节读取再做位移拼装。这里有个必须注意的细节网络字节序是大端而Java的int原样读取字节数组会按小端解释所以源端口不能直接读整型必须手动把高字节左移8位再或上低字节。这个位运算的思路其实就是java基础里原码补码和位运算在实际协议解析中最常见的用法。ipToString工具方法把整数型IP按字节拆回点分十进制private String ipToString(long ip) { return (ip 24 0xFF) . (ip 16 0xFF) . (ip 8 0xFF) . (ip 0xFF); }4.3 简化版TCP流重组按四元组聚合payload抓包程序不能只展示单帧还得能把属于同一条TCP连接的报文拼在一起才能看到完整的HTTP请求或响应。生产级的TCP重组要处理序列号比对、乱序、重传、窗口更新工程量很大。抓包分析工具里一般先做简化版按四元组聚合把顺序到达的payload连续写入缓冲区MapTcpKey, ByteBuffer streams new HashMap(); // 收到TCP packet后假设已有srcIp、srcPort、dstIp、dstPort和payload偏移 TcpKey key new TcpKey(srcIp, srcPort, dstIp, dstPort); ByteBuffer buf streams.computeIfAbsent(key, k - ByteBuffer.allocate(64 * 1024)); int payloadOffset tcpStart tcpHeaderLen; int payloadLen capLen - payloadOffset; if (payloadLen 0) { byte[] payload packet.getByteArray(payloadOffset, payloadLen); buf.put(payload); }TcpKey这个类需要实现equals和hashCode字段就是四元组里的四个值。这种写法能还原“顺序到达、没有丢包”的连续TCP流对HTTP这种文本协议已经够用了。但如果链路有重传或乱序简化版会把重复段重复写入所以生产环境的完整实现必须校验序列号连续性。缓冲区设为64KB是保守值承载一次HTTP请求和响应通常足够超长响应可以设计成滚动写入磁盘。拿到完整流之后HTTP解析就简单了因为HTTP/1.1头是文本协议。把payload转成String按\r\n\r\n切分第一段是请求行或状态行后面是Header列表\r\n\r\n之后是body。这一步建议单独抽一个HttpParser类不要和抓包循环混在一起。4.4 落盘自检用JpcapWriter写lcap文件抓包程序做完解析后第一件要做的验证工作是落盘。Jnetpcap提供JpcapWriter可以把原始帧直接写成lcap文件用Wireshark打开对照检查JpcapWriter writer JpcapWriter.openDumpFile(pcap, capture.lcap); // 每抓到一个包后 writer.writePacket(packet);lcap文件格式和Wireshark的pcap格式兼容这样你自己的解析逻辑和Wireshark解析的结果就能逐帧对比。我习惯抓包循环里同时做两件事一是解析帧结构打日志二是把原始帧全量落盘。这样一旦解析结果和预期不符可以直接打开lcap查证而不是重新抓一遍。4.5 减少无效帧setFilter先过滤再抓流量大的环境中全量抓包再逐帧解析会浪费大量CPU在无关流量上。Jnetpcap支持Pcap过滤器直接在内核态完成初筛pcap.setFilter(tcp port 80, true);setFilter的第二个参数是优化开关传true让libpcap优化过滤表达式。常见过滤写法有“host 192.168.1.1”“tcp port 443”“udp”“net 192.168.1.0/24”组合用“and”“or”。在抓包程序里我一般会把这部分做成可配置项用命令行参数传入过滤表达式因为实际使用中抓HTTP和抓DNS的过滤条件完全不同。5. Jnetpcap抓包程序避坑指南JNI崩溃、环回流量、内存泄漏5.1 JVM直接崩溃异常栈都没有现象抓包程序运行一会儿控制台没有任何异常信息进程直接消失系统日志里只留一个hs_err_pid*.log文件。原因Jnetpcap的native层崩溃。JNI代码里出现段错误时Java异常机制捕获不到属于C语言层面的崩溃表现形式就是JVM直接退出。最常见的根因是jnetpcap.dll位数与JDK不匹配或者Npcap版本与jnetpcap不兼容。解决先到进程工作目录找hs_err_pid文件用文本编辑器打开重点看“Native frames”段落崩溃栈通常指向jnetpcap的native函数。确认dll位数以后再把Npcap卸载重装安装时勾选完整兼容层选项。这类问题不要试图用try-catch兜住JNI崩溃不是Java异常。5.2 抓不到本机回环流量现象程序访问本机服务时抓不到包抓别的机器流量正常。原因回环流量不经过物理网卡WinPcap时代根本没有回环网卡抽象Npcap虽然提供Npcap Loopback Adapter但默认配置下并不启用回环捕获。解决安装Npcap时选择支持回环流量设备枚举时单独找名为“Npcap Loopback Adapter”的网卡用这个接口抓包。要注意的是请求本机服务时流量直接发到本机IP网卡层面的过滤规则和物理网卡不一样过滤表达式需要放宽到tcp port 8080这种端口级条件。5.3 内存占用持续上涨现象抓包程序运行数小时后内存从几百MB涨到几个GB最后卡死。原因典型情况是回调里的Packet没有拷贝就直接存入容器或者拷贝后没有释放。Jnetpcap的JPacket引用的是native内存Java的GC管不到这块区域对象不被引用时native内存也不一定立刻归还。另一个原因是阻塞队列无界增长消费线程跟不上。解决回调里一律new PcapPacket(packet)深拷贝队列设置上限并加丢帧策略。PcapPacket提供clear方法确认帧不再使用后调用clear释放native内存。排查时用jcmd GC.class_histogram看PcapPacket实例数量如果实例数持续增长就是容器引用没释放如果实例数不变但内存增长问题出在native内存。5.4 网卡能枚举openLive也成功但收不到包现象设备列表打印正常openLive没有报错循环里却一直拿不到包。原因权限不足是第一嫌疑。Windows下捕获原始帧需要管理员权限普通用户运行时Pcap调用看起来正常但驱动层拒绝交出数据。其次是Npcap服务未启动或者混杂模式被网卡驱动忽略。虚拟机和无线网卡的驱动对混杂模式支持最差经常静默失败。解决先以管理员身份重启程序再看Npcap服务状态sc query npcap服务未启动就在管理员终端执行sc start npcap。如果程序和Npcap都正常换一块物理有线网卡试。无线网卡在Windows上默认不允许混杂模式捕获这是驱动层的限制不是代码问题。6. 进阶玩法按会话聚合流量交叉验证抓包结果6.1 连接级聚合统计把单帧解析做成了还不能叫嗅探器得把海量包聚合成“连接视图”才能看出流量规律。我常用的做法是维护一个四元组到统计对象的映射MapTcpKey, ConnStat stats new HashMap(); // 每解析完一个TCP包 ConnStat st stats.computeIfAbsent(key, k - new ConnStat()); st.packets; st.bytes payloadLen;循环结束后按bytes排序输出立刻能看出哪条连接在跑大流量哪条连接包数多但数据量小。这种聚合视图就是抓包程序的核心输出比单帧列表直观得多。课程设计或团队内网工具做到这一步已经具备实用价值。6.2 三种方法交叉验证抓包结果要验证抓包程序正确与否我习惯三个手段一起上第一开Wireshark同时抓同一张网卡对比两侧的TCP四元组和序列号是否一致第二本机用curl访问一个公网HTTP站点确认抓到的帧里能看到完整的GET请求行和Host头第三ping网关地址检查ICMP请求和应答的标识字段是否对应。这三个手段能分别验证链路层、TCP解析和IP层处理。有一次排查一个TCP流重组问题抓到的HTTP请求总是缺尾部数据最后发现是snapLen设成了1500巨型帧的payload被截断导致payloadLen为负。从那以后我把snapLen、过滤表达式、队列消费速度这三个参数写进了程序启动日志每次跑起来先确认环境再抓包。这个习惯帮我少走了很多弯路希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进