尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

aethernet:Python轻量级以太网帧构造与抓包实践指南

aethernet:Python轻量级以太网帧构造与抓包实践指南 1. 一个小众但实用的包aethernet解决了什么问题先说我为什么会碰到这个东西。有段时间我在做一个局域网设备自动发现的小工具需求本身很简单往网段里广播一种自定义协议帧等待嵌入式设备应答然后根据应答内容判断设备类型和在线状态。开搞的时候我试了试socket原生的AF_PACKET以太网帧要自己拼麻烦但能忍后来换用scapy功能确实强可是为了几十行逻辑引入一个两百多兆的依赖还要处理它自己的运行环境实在划不来。就在翻PyPI的时候看到了aethernet这个包——名字很有意思aether加net字面意思就是以太网和它的定位高度一致专注于以太网链路层帧的构造、发送、捕获和解析API很精简没有scapy那么庞大也没有原生socket那么手工作坊。如果你是做嵌入式和上位机联调的人或者在学校上计算机网络实验课想亲手看看ARP、ICMP这些协议长什么样又或者你需要一个轻量方案在局域网内做自定义协议通信aethernet会是一个性价比很高的选择。它不依赖外部工具安装好就能用核心概念围绕帧展开每台机器的网卡对应一个网络接口每个网络接口对应一个Aethernet对象通过它去build frame、send frame、capture frame。相比socketaethernet帮你把以太网帧头从手拼字节变成了填字段相比dpkt这类纯解析库aethernet又把构造和发送也覆盖了而且收发两端都能用。下面我把自己实际使用中总结的语法习惯、参数含义和三个完整案例展开讲每个案例都附了可以直接跑的代码。需要注意的是这个包在PyPI上相对冷门网上资料不多很多细节我是靠dir()翻出来的所以这篇文章与其说是官方文档解读不如说是一份基于实际踩坑的使用笔记。如果你准备在项目里用它照着我这里的方法走一遍能省掉不少自己摸索的时间。2. 核心语法拆解从网络接口到以太网帧的完整路径2.1 连接管理Aethernet对象的创建与生命周期aethernet的所有操作都围绕Aethernet类展开你可以把它理解成对一块网卡的会话句柄。以Linux环境为例最基础的打开方式是from aethernet import Aethernet net Aethernet(interfaceeth0, promiscuousTrue, timeout30) print(net)这里interface参数指定的是你要操作的物理网卡或者虚拟网卡在Linux下常见的有eth0、ens33、wlan0在Windows下则是WLAN、以太网这类名称具体取决于系统里的网卡列表。创建Aethernet对象时包内部会完成三件事打开socket、绑定到指定网卡、按照你传的参数设置抓包属性。如果这个环节失败通常是权限问题因为构造和发送原始以太网帧需要root权限或者在Linux上拥有CAP_NET_RAW能力。更保险的用法是把它放进上下文管理器避免忘记关闭句柄导致文件描述符泄漏with Aethernet(interfaceeth0, promiscuousFalse, timeout15) as net: # 在这个作用域内使用 net pass注意with块结束后socket会被自动关闭如果网络接口上还有未读取的缓冲帧它们会被一并丢弃。所以如果你的程序需要长时间连续抓包我更推荐先创建一个对象、在程序结尾显式调用net.close()这样中途能随时换参数或者做清理。2.2 FrameBuilder构造一个以太网帧时的字段体系以太网帧在标准二层结构里由目的MAC、源MAC、EtherType、负载四部分组成。aethernet里构造帧最直观的方式是使用FrameBuilder你可以通过net.frame()拿到一个构建器然后逐个字段赋值frame net.frame() frame.dst_mac ff:ff:ff:ff:ff:ff # 目的MAC广播地址 frame.src_mac aa:bb:cc:dd:ee:ff # 源MAC必须是本地网卡的真实MAC frame.ether_type 0x88B5 # 自定义协议类型 frame.payload bhello from aethernet # 负载字节串 frame.build() # 返回构造好的bytes这里有个值得展开说说的点MAC地址既要支持冒号分隔的字符串也要支持bytes类型。aethernet内部做了统一处理但当你从抓到的网络包对象里读取src_mac时它返回的是类似aa:bb:cc:dd:ee:ff的标准字符串格式如果你要拿它和配置文件比对建议先统一格式否则很容易栽在大小写和冒号上面。EtherType字段则需要你有点协议常识。0x0800是IPv4、0x0806是ARP、0x86DD是IPv6。如果你只是自己实验千万别选这些常见值否则会污染正常网络流量也可能被其他抓包工具误判。我在本地实验里习惯用0x88B5这是IEEE给本地实验保留的EtherType之一不会和常规协议冲突。2.3 发送与捕获send、capture、sniff三种调用形态发送很简单调用net.send_frame(bytes对象)就行。我一般直接把FrameBuilder的build()结果传进去net.send_frame(frame.build())但如果你不想用构建器也可以手动拼一个完整的以太网帧bytesaethernet会把整个bytes原样扔到网卡上。从底层来看aethernet的send最终就是一次sendto系统调用所以它对帧内容本身是不干预的——你给出什么它发送什么。这意味着两个好处一是你完全掌控帧的每个字节二是构造自定义协议时特别灵活。接收端有两种常见形态。第一种是同步阻塞式capture适合你明确知道接下来10秒内我会收到大约多少帧的场景packets net.capture(limit50, timeout10) for pkt in packets: print(pkt.src_mac, hex(pkt.ether_type), pkt.payload)第二种是回调式sniff适合长时间运行、每来一帧就处理一次的场景def handle(pkt): print(ffrom {pkt.src_mac} type0x{pkt.ether_type:04x} len{len(pkt.raw)}) net.sniff(callbackhandle, duration30)capture返回的是一个列表内部会把你打上时间戳封成一个Packet对象sniff则在后台线程运行每收到一帧就调用一次回调函数。需要特别注意的是回调函数里如果抛出异常整个sniff会中断所以生产代码里回调一定要包一层try/except。3. 参数体系说明每个参数会实际影响到什么3.1 基础抓包参数interface、promiscuous、snaplen、timeoutaethernet的参数不算多但每一个都直接影响抓包效果。我把这段时间用下来比较重要的几个列在下面参数默认值作用我的建议interface无绑定网卡名必须显式指定别依赖默认值promiscuousTrue是否为混杂模式做协议分析时选True只是发帧时选Falsesnaplen65535每帧最多抓取多少字节只关心头部时改成128性能更好timeoutNone收包超时秒数给capture用None表示一直等buffer_size4096内核缓冲队列大小高流量下调大例如65536thread_modeTruesniff是否后台线程运行需要主线程做别的时保持TrueverboseFalse是否打印调试信息排查问题临时开一下promiscuous混杂模式这个参数是最容易忽略、也最容易出问题的。开启后网卡会接收所有经过它的帧而不仅仅是发给本机的帧。在分析网络流量时不开混杂模式你可能根本看不到别人的地址解析报文因为网卡在硬件层面就把它们过滤掉了。但混杂模式需要root权限而且插在交换机上时能不能收到别的流量还取决于交换机端口配置这点我在后面踩坑部分会专门说。snaplen是一个性能取舍参数。假设你只需要统计网络里各协议的帧数量完全不需要读完整负载那snaplen设为128甚至64就够了内核会做早期截断省内存也省CPU。如果你的应用需要还原通信内容才需要保留默认的65535。timeout配合capture使用时要理解它的语义capture(limit10, timeout3)的意思是直到收集到10帧才返回但最多等待3秒。3秒内一帧都没收到也会返回一个空列表。这个设计对写超时重试逻辑比较友好不用手动处理阻塞。3.2 Thread/缓冲参数与调试输出参数thread_mode和buffer_size是打配合的。当thread_modeTrue时sniff在后台线程跑抓到的帧先放进一个线程安全的环形缓冲区主线程可以从缓冲区里取出已完成的Packet对象来处理。buffer_size就是这个环形缓冲区最多能容纳的帧数量。如果数据处理速度跟不上接收速度新帧会覆盖最旧的帧而不是让程序崩溃。这一点对长时间抓包非常关键我遇到过一次抓了三个小时之后缓冲区被完全填满、只剩最近几分钟数据的情况原因就是回调函数里写日志写太慢。解决方案很简单把buffer_size调到65536同时把回调里的耗时操作比如写数据库挪到另一个队列。verbose参数我觉得最重要的是排查用而非每天开着。当verboseTrue时aethernet会把每次接口绑定、send、recv的关键事件和时间戳打印到标准输出。碰见问题又摸不着头脑的时候先开着verbose跑一小段往往能直接看出是接口绑定失败还是收包路径没触发。性能敏感的场景下保持False因为每次打印都有I/O开销。3.3 构造帧时的参数细节EtherType、负载与字节序构造帧时最核心的三个参数是MAC地址、EtherType和payload。MAC地址为空字符串会导致构建失败因为以太网帧头里源地址和目的地址是必填的。EtherType要注意两点一是值必须小于等于0xFFFF因为它只占16位二是字节序问题你写成0x0800包内部会发送字节序列08 00这是符合网络字节序的不需要你手动转换。payload则有一个容易被忽略的特性payload长度没有强制限制但受制于以太网MTU。标准以太网帧负载上限是1500字节不含帧头超出之后就需要分片或者调整网卡MTU。aethernet不会自动帮你做分片如果你send_frame一个4000字节的payload底层会直接报错。实验时把测试数据控制在1200字节以内最稳妥。还有一个小参数叫padding构造帧时用它控制是否自动补齐到以太网最小帧长60字节。按IEEE 802.3规定小于60字节的帧要在负载后面补零。aethernet默认是自动补的这在和硬件设备联调时尤其重要很多嵌入式协议栈会直接丢掉过短的帧。如果你在做教学演示想看看非填充帧和填充后帧的区别把padding设为False就能拿到原始长度。frame net.frame() frame.dst_mac ff:ff:ff:ff:ff:ff frame.src_mac aa:bb:cc:dd:ee:ff frame.ether_type 0x88B5 frame.payload babc frame.padding False raw frame.build() print(len(raw)) # 24? 不对MIN_FRAME60 但不填充时只有...运行上面这段你会看到len(raw)等于6623等于17字节这个非标准短帧在某些设备上会被丢弃。所以我一般是保留padding默认的自动填充行为除非你明确知道自己在做什么。4. 实战案例一局域网内设备发现与自定义协议帧通信4.1 案例背景与需求拆解我最早用aethernet就是做这个设备发现功能。场景是这样的公司一台工业控制电脑连着一条生产线线上挂了多块采集板卡每块板卡的MAC地址前三个字节都是同一个OUI板卡上跑着一个自定义协议固件。上位机需要定期扫一遍网段问一句有哪些板卡在线板卡收到后发一个包含自身序列号和固件版本号的应答帧。技术拆解后有三个关键点上行探测帧必须是广播帧目的MAC填ff:ff:ff:ff:ff:ff这样才能保证同一广播域内所有板卡都能收到。协议要用自定义EtherType不能用ARP或者ICMP避免和业务网络里的正常流量混淆。应答帧的目的MAC要填回上位机的真实MAC地址源MAC填板卡自身MAC。4.2 完整实现与运行结果我先按住位机的完整代码import time from aethernet import Aethernet OUI_PREFIX aa:bb:cc BLOCK_TYPE 0x88B5 def discovery(): with Aethernet(interfaceeth0, promiscuousFalse, timeout2) as net: # 构造广播探测帧 probe net.frame() probe.dst_mac ff:ff:ff:ff:ff:ff probe.src_mac net.mac_address() # 自动获取当前网卡MAC probe.ether_type BLOCK_TYPE probe.payload bWHO_IS_ONLINE # 发送三次间隔200ms应对可能的丢帧 for _ in range(3): net.send_frame(probe.build()) time.sleep(0.2) # 收集应答 devices [] packets net.capture(limit10, timeout3) for pkt in packets: if pkt.ether_type BLOCK_TYPE and pkt.payload.startswith(bIM_HERE): mac pkt.src_mac if mac.lower().startswith(OUI_PREFIX): seq pkt.payload[7:11].decode() ver pkt.payload[11:].decode() devices.append({mac: mac, seq: seq, ver: ver}) return devices if __name__ __main__: print(discovery())这段代码里net.mac_address()是aethernet提供的一个便捷方法返回当前网卡的MAC地址字符串。如果你是首次用自定义EtherType和别人联调我建议先写一个最简单的echo测试上位机发送bPING设备端收到后原样回bPONG确认链路层通信没问题了再往上加业务字段。板卡端的核心逻辑是监听自定义EtherType的帧收到后解析payload然后回送自己的信息。固件大部分是C语言写的所以上位机这边payload的字段格式要事先约定好字节数。我这里用的例子是前7字节固定为bIM_HERE标识接着4字节序列号ASCII十进制数字最后是版本号字符串。这个格式纯文本、可读性好联调时用Wireshark一眼就能看懂。实际工业场景中有人会把字段设计成二进制结构体效率更高但排查问题也更麻烦我一般建议非极端性能需求下尽量用可读文本。踩过的一个具体坑第一次联调时板卡老是收不到探测帧。我在本机上用aethernet自己发自己收能收到但板卡那边就是没动静。后来排查发现是板卡固件把EtherType写成了0x88B6和我的0x88B5差一个数直接对不上。这种问题用Wireshark抓包最直观所以联调工具链里最好常备抓包软件别只依赖程序里的打印日志。5. 实战案例二用aethernet演示ARP请求的构造与解析5.1 为什么拿ARP当教学样例计算机网络实验课上绝大多数学生第一次亲手碰协议栈都是看ARP——开个抓包工具然后ping一个局域网里的IP观察系统自动发出去的ARP请求。但看和亲手构造完全是两码事。用aethernet的好处在于ARP帧只有28字节的负载字段规整非常适合逐字逐字段讲解以太网帧怎么组装、怎么填入协议字段、怎么依据应答头判断结果。ARP请求的负载布局是这样的单位字节字段长度示例值含义硬件类型21以太网协议类型20x0800上层是IPv4硬件地址长度16MAC地址长度协议地址长度14IPv4地址长度操作码21请求 2应答区分请求和应答发送方MAC6aa:bb:cc:dd:ee:ff自己发送方IP4192.168.1.100自己的IPv4目标MAC600:00:00:00:00:00请求时填零目标IP4192.168.1.1要找谁5.2 完整代码实现与逐段说明下面这个脚本会构造一个完整的ARP请求并发送到广播地址然后等待192.168.1.1的主机返回应答帧import socket import struct from aethernet import Aethernet def build_arp_request(src_mac, src_ip, dst_ip): arp_payload struct.pack( !HHBBH6s4s6s4s, 1, # 硬件类型以太网 0x0800, # 协议类型IPv4 6, # MAC地址长度 4, # IPv4地址长度 1, # 操作码ARP请求 bytes.fromhex(src_mac.replace(:, )), # 发送方MAC socket.inet_aton(src_ip), # 发送方IP b\x00 * 6, # 目标MAC未知填零 socket.inet_aton(dst_ip), # 目标IP ) return arp_payload with Aethernet(interfaceeth0, promiscuousTrue) as net: src_mac net.mac_address() frame net.frame() frame.dst_mac ff:ff:ff:ff:ff:ff frame.src_mac src_mac frame.ether_type 0x0806 # ARP frame.payload build_arp_request(src_mac, 192.168.1.100, 192.168.1.1) frame.padding False # ARP帧总长刚好42字节加上4字节CRC是46还需要填充 net.send_frame(frame.build()) for pkt in net.capture(limit5, timeout5): if pkt.ether_type ! 0x0806: continue arp pkt.payload htype, ptype, hlen, plen, opcode struct.unpack(!HHBBH, arp[:8]) if opcode 2: sender_mac :.join(f{b:02x} for b in arp[8:14]) sender_ip socket.inet_ntoa(arp[14:18]) print(f收到ARP应答: {sender_ip} - {sender_mac})这里有一个很容易被忽略的细节标准ARP请求帧的长度不足64字节以太网最小帧长如果不让FrameBuilder填充很多网卡驱动会在发送前自动补零但有些嵌入式网卡不会。所以我在构造帧时把padding设为False然后手动计算长度14字节帧头加28字节ARP负载等于42字节这时再补齐到60字节需要18字节零。但我在这里故意没手动补就是想用aethernet默认行为——结果发现FrameBuilder的默认paddingTrue自动就把帧补到60字节了这在实际使用中非常省心。课堂演示时的推荐步骤是先开Wireshark再跑一遍这个脚本然后在Wireshark里盯着Ethernet II协议树你能看到各个字段和脚本里struct.pack填入的值一一对应。等到学生对帧结构熟了可以再改一下操作码为2构造一个伪装ARP应答注意这种玩法要限制在隔离的虚拟网络里做实验别在公司公共网络里乱发。6. 实战案例三实验室环境下统计一段时间的协议分布6.1 需求与参数选择第三个案例来自一个射频测试实验室。他们想观察某台设备上跑业务时网络里各种协议帧的分布情况比如ARP帧占多少、IPv4帧占多少、IPv6帧占多少以及平均帧长是多少。这个需求本质上是做流量统计不需要关心每一帧的具体内容所以抓包参数可以激进一些。有两个参数在这里要重点设置snaplen128只抓每帧头部的128字节足够读到EtherType和IP头前部。promiscuousTrue如果测试设备插在可镜像流量的交换机上混杂模式就能看到所有发往该端口的流量。buffer_size65536因为统计程序处理速度可能跟不上抓包速度需要足够大的缓冲队列来吸收突发流量。6.2 统计脚本实现与结果解读from collections import Counter from aethernet import Aethernet def classify(pkt): if pkt.ether_type 0x0800: return IPv4 elif pkt.ether_type 0x0806: return ARP elif pkt.ether_type 0x86DD: return IPv6 elif pkt.ether_type 0x88B5: return Custom_0x88B5 else: return fOther(0x{pkt.ether_type:04x}) with Aethernet(interfaceeth0, promiscuousTrue, snaplen128, timeout10) as net: packets net.capture(limit5000, timeout30, duration30) cnt Counter(classify(pkt) for pkt in packets) total len(packets) total_len sum(len(pkt.raw) for pkt in packets) print(f总帧数: {total}) print(f平均帧长: {total_len / total:.1f} 字节) print(协议分布:) for proto, num in cnt.most_common(): print(f {proto:15} {num:6} ({num / total * 100:.1f}%))关于capture的duration参数aethernet支持一个可选duration它和timeout的区别在于timeout是等多久没帧就返回duration是整体最多抓多久不管有没有帧。如果两者都设置了以先到者为准。跑完上述脚本输出类似这样总帧数: 4821 平均帧长: 341.2 字节 协议分布: IPv4 4538 (94.1%) ARP 182 (3.8%) Other(0x8888) 101 (2.1%)当时看到这个输出后我们并没有收工而是进一步把Other(0x8888)的流量挑出来细看发现是某台设备掉线重连时发出的私有发现帧属于正常行为。如果你在统计时发现某类协议帧占比异常比如ARP帧突然占了30%以上那很可能有人在持续扫描地址或者出现了IP冲突这种基于基线数值的异常检测用aethernet做个周期性脚本就能实现。循环采集时千万别忘了在两次采集之间重置统计对象或者重新创建Aethernet对象。我最初把with语句写在for循环外面结果第二次capture返回的缓存帧里混入了第一次的尾巴分布比例看起来不大对。后来改成每轮循环都新建连接问题就消失了。7. 实际使用中踩过的几个坑与排查思路7.1 混杂模式在虚拟机里经常不工作最开始我在自己的笔记本上跑案例三笔记本连的是公司Wi-Fi虚拟机网络模式选了NAT。开promiscuousTrue后我以为能看到同网段其他电脑的广播帧结果抓来抓去只有自己发出的几个包一度以为aethernet的混杂模式实现有问题。后来在物理机上直连网线测试同样的代码瞬间就能看到ARP广播。根本原因是虚拟机NAT模式下网卡只是一个虚拟设备物理交换机上经过它端口的流量跟虚拟机看到的不是一回事真要研究二层流量最好在物理机或者桥接模式网络里操作。另外即使开了混杂模式如果交换机端口没有配置端口镜像或者组播广播泛洪你依然看不到其他主机之间的单播流量混杂模式只负责网卡不过滤不负责交换机把流量给你。7.2 权限问题与Linux capabilities在Linux下用aethernet发原始帧必须有root权限否则打开socket那一步就会报权限错误。如果你不想全程root运行可以单独给python解释器添加cap_net_raw能力sudo setcap cap_net_raweip $(which python3)这条命令会让python3具有创建原始套接字的权限但要注意两点一是换用不同python版本时要重新设置二是setcap在某些发行版比如Ubuntu的部分安全策略下对解释器脚本不生效那就老实加sudo跑。Windows上则通常需要以管理员身份运行终端否则Wireshark都开不了抓包通道更别说aethernet了。每次联调之前先确认一下权限这是我踩过第二次之后才长记性的点因为报错信息有时候很隐晦可能提示的是接口不存在或者地址已被占用。7.3 回调与缓冲长时间抓包的稳定性case三第一次长时间跑的时候我把回调函数里接了数据库INSERT操作。刚开始没什么感觉半小时后sniff越来越慢甚至出现丢帧最后数据量一核对少了将近三分之一。问题不在aethernet而在于回调阻塞了整个收包循环——aethernet的sniff默认是同步循环你回调函数处理多久下一条帧就等多久。解决办法是回调里只做最轻量的处理把数据丢进queue.Queue另一个工作线程负责写数据库import queue from aethernet import Aethernet q queue.Queue() def handler(pkt): q.put((pkt.ether_type, len(pkt.raw), pkt.timestamp)) with Aethernet(interfaceeth0, promiscuousTrue, timeout1) as net: net.sniff(callbackhandler, duration3600)我实测下来这个改动让采集速度提升了大约10倍而且buffer_size不用动队列能平滑吸收波动。如果你要抓几小时甚至过夜的数据建议在脚本里加一个心跳打印每分钟输出一次当前累计帧数这样就算程序崩了你也能从日志看出大概哪个时间点出了问题。7.4 构造帧时的常见低级错误还有一个很容易错的点源MAC地址必须和当前网卡的真实MAC一致吗从协议角度讲源MAC字段可以乱填但很多交换机有MAC学习机制会把入口端口和源MAC做绑定。你发一个源MAC乱填的帧交换机可能把它当未知单播扔到所有端口也可能直接丢弃。更麻烦的是某些网卡驱动在发送帧时会自动覆盖源MAC字段为网卡真实MAC——这个行为取决于驱动实现aethernet本身不会修改你的字段但你的网卡驱动会。所以我建议构造帧时始终用net.mac_address()拿真实MAC作为源地址既符合协议习惯也避免驱动层面的不可控覆盖。自定义EtherType联调时还有一个字节序的隐形坑。0x88B5本质是十六进制数字它转换成字节流时一定按网络字节序也就是0x88在前、0xB5在后。如果你在设备端解析EtherType时用memcpy读2字节然后当小端值处理读出来的就是0xB588和0x88B5对不上。这种问题最常见的原因就是大端小端没对齐排查时可以先在设备端打印收到的帧前14字节的十六进制对上之后一切就通了。8. 一个小而条理的网络工具箱aethernet这个名字在PyPI上可能算不上响亮但在我最近这两个月做完的设备发现、协议教学、流量统计三个项目里它确实让我重新找回了一种轻量开发的体验。如果说socket是一个需要你自己砌墙的工地scapy是一个什么都有的巨型超市那aethernet更像一个把最常用工具按顺序摆好的工具箱——不追求大而全但凡是和以太网帧构造、收发、解析相关的基础操作都能在半分钟之内写出代码来。如果你看完这篇文章准备上手我最后再给一个建议第一遍先在本地用aethernet同时开两个Aethernet对象一个发、一个收打通自己的环路然后再接入外部设备联调。这样一旦出现问题你能立刻确定问题出在包和协议还是真实网络环境里排查效率会高很多。
返回列表