起因
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 | 子网掩码 | 44 | NetBIOS 名字服务器 |
| 3 | 默认网关(路由器) | 46 | NetBIOS 节点类型 |
| 6 | DNS 服务器 | 47 | NetBIOS 作用域 |
| 15 | 域名 | 119 | 域名搜索列表 |
| 31 | 路由器发现 | 121 | 无类静态路由 |
| 33 | 静态路由 | 249 | 微软私有:无类静态路由 |
| 43 | 厂商特定信息 | 252 | WPAD(代理自动发现) |
看这份单子能反推出一件事:这台脚本的作者是照着 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 和一张租约表。