起因

source\repos\新建文件夹\1.py,20 行,from scapy.all import *。

它做的事:构造一个 DHCP Discover 包,用本机 MAC 加 2 的地址当客户端 MAC,从 WLAN 网卡发出去,然后打印收到的回应。

这个脚本我第一次看的时候觉得平平无奇,直到注意到一行:

client_mac = "02:00:00:00:00:5c"  # 本机已经有IP了,为了模拟,所以我们随机产生一个MAC地址

02:00:00:00:00:5a 是那台机器的 MAC,...:5c 是它 +2。这不是随机,这是「保证不撞车」的伪随机。

代码已整理到公开仓库:cnb.cool/t1mmoc/tspace/pwngore/net-reverse-snippets(dhcp-discover/)。仓库里的 MAC、主机名、期望地址、网卡名都换成了占位值,本文引用的是同一套占位值。

一、DHCP 最反直觉的一点:我还没有地址,却要发一个 IP 包

四步走(DORA)谁都背得出:

步骤方向大意
DISCOVER客户端 → 广播谁是 DHCP 服务器?我要地址
OFFER服务器 → 客户端我这有这些,给你这个
REQUEST客户端 → 广播我确定要这个
ACK服务器 → 客户端确认,附租期和配置

但第一步就有个鸡生蛋的问题:IP 包需要源地址,而客户端还没有地址。

解法是把地址字段填成「无意义但合法」的值:

IP_Discover = IP(src='0.0.0.0', dst='255.255.255.255')
UDP_Discover = UDP(sport=68, dport=67)
  • 源 0.0.0.0 是约定的「本机尚无地址」。
  • 目的 255.255.255.255 是本地广播,因为此刻还不知道服务器在哪。
  • 端口 68(客户端)/ 67(服务器)是 BOOTP 时代留下的固定值。

二层也必须是广播:

Ether_Discover = Ether(src=client_mac_id, dst='ff:ff:ff:ff:ff:ff')

这就是为什么 DHCP 包不能只靠 send(),得用 srp()——srp 是「发二层帧并收二层回应」,需要 Npcap 和指定网卡:

ans = srp(Discover, iface='WLAN', timeout=1)

iface='WLAN' 是那台机器的无线网卡名,换机器就得改。

二、服务器凭什么认出「我」

既然源 IP 是 0.0.0.0,服务器没法靠 IP 识别客户端。它靠三样东西:

1)chaddr:链路层地址。 这是 BOOTP 里就有的字段,硬编码在包体里,不是从 Ether 头里推的:

BOOTP_Discover = BOOTP(chaddr=client_mac_id, xid=client_xid, flags=0x8000)

2)xid:事务 ID。 随机生成,服务器在 OFFER 里原样带回,客户端用它匹配「这个回应是给谁的」:

client_xid = randint(1, 9999999)

一个 DHCP 服务器同时服务几十台机器,全都在广播域里抢答,xid 就是唯一的对话标识。写死它或者用 0,多个客户端之间会互相吃掉对方的 OFFER。

3)flags=0x8000:广播标志位。 BOOTP 的 16 位 flags 里最高位是 broadcast。置了它,就是在说「我没有 IP,没法接单播,请把回应广播出来」。现代客户端一般也能接单播,但在没有地址的阶段置这个位最稳——尤其当你想在非标准环境(虚拟机、抓包实验)里收回应时。

三、options 才是 DHCP 的本体

BOOTP 的固定头只有 236 字节,剩下的全塞在 options 里。DHCP 就是「BOOTP + options」:

options = [
    ("message-type", "discover"),
    ("client_id", b"\x01" + client_mac_id),
    ("requested_addr", "192.168.1.100"),
    ("hostname", "myhost"),
    ("vendor_class_id", 'MSFT 5.0'),
    ("param_req_list", [1, 3, 6, 15, 31, 33, 43, 44, 46, 47, 119, 121, 249, 252]),
    "end",
]

逐条:

  • message-type: discover(option 53)。DHCP 报文类型,这一步是 1。
  • client_id(option 61)= 0x01 + MAC。0x01 是硬件类型(以太网)。这个字段比 chaddr 更「正式」——有些环境(交换机端口安全、IPAM)按 client_id 记租约,改了它等于换了一台新设备。
  • requested_addr(option 50)= 192.168.1.100。这只是「我想要」,不是「我要」。服务器完全可以给别的,甚至不理。它的真实用途是「续租时提醒服务器还给我上次那个」。
  • hostname(option 12)。会出现在服务器的租约表和路由器的设备列表里。这里最该改的就是它——MAC 换了还是同一台机器,主机名往往直接把「是谁」写上去了。
  • vendor_class_id = MSFT 5.0。这是 Windows 客户端的招牌。很多家用路由器和企业 DHCP 会按这个字段给不同的池或不同的策略(比如把 Windows 设备分到特定 VLAN)。冒充它就等于冒充一台 Windows 机器。
  • param_req_list(option 55):我要服务器告诉我这些参数。这份清单几乎是 Windows 客户端的标准配置,13 个代码逐个对上:
代码含义代码含义
1子网掩码44NetBIOS 名字服务器
3默认网关(路由器)46NetBIOS 节点类型
6DNS 服务器47NetBIOS 作用域
15域名119域名搜索列表
31路由器发现121无类静态路由
33静态路由249微软私有:无类静态路由
43厂商特定信息252WPAD(代理自动发现)

看这份单子能反推出一件事:这台脚本的作者是照着 Windows 的模板抄的,而不是按自己需求挑的。46、47、249、252 这四个在家庭网络里基本用不上,纯粹是同模板带出来的指纹。

  • "end"(option 255)。必须收尾,不然服务器解析可能读越界。

四、那几个「为什么」

为什么 MAC 要 +2,而不是真随机?

因为 randint 生成的 MAC 有一定概率撞到网络上已存在的设备(尤其是虚拟机、IoT 设备密集的环境)。基于自己的 MAC 改最后一字节,是「不撞自己、也几乎不撞别人」的最小代价方案。 十行脚本里最聪明的一行。

为什么只发 DISCOVER,不发 REQUEST?

因为目标是看谁在答,不是真拿地址。收到 OFFER 就够了——ans.summary() 会把来源 IP 打出来。真要走完 DORA,就得解析 OFFER 里的 yiaddr 和 server identifier,再发 REQUEST,代码量翻三倍。

为什么不报错就说明成功了?

timeout=1 只等 1 秒。如果网络里没有 DHCP 服务器,或者被 VLAN 隔离,ans 就是空——「没回应」和「有回应但我不认识」在这个脚本里长得一样。这是这类一次性脚本的常态,值得记住。

五、这个脚本反过来证明了什么

DHCP 是一个完全没有认证的协议。

服务器不认识客户端,客户端也不验证服务器。谁的 OFFER 先到,客户端就用谁的。这条性质有三层后果,方向完全不同:

1)它是排查工具。 网络里偶尔有人拿到错误网段的地址(比如 169.254.x.x 那种自动专用地址,或者根本不该出现的 192.168.0.x),最直接的办法就是自己发一个 DISCOVER,看 OFFER 从哪个 IP 回来。回来两个不同的源,就说明广播域里有两台 DHCP 服务器——一台正式的,一台私接的路由器。这个脚本就是干这个用的。

2)它是攻击原理的起点。 换一个 MAC、重复发 DISCOVER,服务器就会一直分配地址出去。地址池耗尽后,真实客户端拿不到地址。这就是 DHCP 饿死(starvation),第二步通常是起一台假服务器接管网关。这次我没有写循环,但**「换 MAC + 重复发」这两个动作已经全在这 20 行里了**。

3)对策不在客户端。 DHCP 层的认证方案(RFC 3118)基本没人部署。实际防线全在交换机上:

手段挡住什么
DHCP Snooping非信任端口来的 OFFER/ACK 直接丢弃 → 挡假服务器
端口安全 / MAC 限制一个端口只允许 N 个 MAC → 抬高换 MAC 的成本
每端口限速 DHCP让耗尽需要很久
IP Source Guard挡住拿假地址直接发包

家用场景里最常见的版本其实是「关掉路由器的 DHCP,只留一台」——最土也最有效。

收尾

20 行代码,能拆出来的东西比预想多:

  • DHCP 的核心矛盾是「没有地址却要发包」,所以 0.0.0.0 和 255.255.255.255 不是随便填的,是唯一合法解。
  • 服务器认客户端靠 chaddr + client_id,认对话靠 xid,认「能不能单播」靠 flags。
  • param_req_list 这 13 个代码是一份可以反推设备类型的指纹——抄模板会留下痕迹。
  • requested_addr 只是建议,vendor_class_id 才是真正影响服务器决策的字段之一。
  • 协议没有认证这件事,既是排查的入口,也是攻击的入口,区别只在循环写不写。

最后一个观察:这个脚本发完包不检查结果,print(ans.summary()) 就结束。当时我要的只是「能发出去」,没打算「看懂回来的是什么」——而这两件事之间,隔着整套 DORA 和一张租约表。