当网络里混入“假网关”:用 Scapy 写一个 DHCP 欺骗检测器(附完整代码与讲解)

摘要

本文围绕DHCP 欺骗(DHCP spoofing)这一网络威胁,给出一个贴近实战、以被动检测 + 警报/记录 为目标的工具设计与实现。文章先用口语化的方式把场景讲清楚,再给出一个完整的 Python 实现(基于 Scapy),并对每个模块、每段代码逐行解释,最后用示例演示如何运行与解读输出、并分析时间/空间复杂度与实用性。强调:本文只给出防御/检测工具;任何主动利用 DHCP 伪造去攻击他人的行为都是不被支持的。

描述

在很多企业或校园网络里,终端通常通过 DHCP 自动获得 IP、网关(默认路由)、DNS 等信息。一旦网络中出现伪造 DHCP 服务器,它就能把"默认网关"设成攻击者的主机,从而把本应发往外网的数据先发给攻击者------攻击者可以监听、篡改或转发这些流量(中间人)。这个攻击现实中常见的场景包括:

  • 公共场所(学校食堂、会议中心、商场)的无线接入点旁边有人插入了恶意设备。
  • 公司分支网络没有物理隔离或端口安全措施,某个插槽被人接入一台伪造 DHCP 服务器。
  • 网络部署复杂、管理员未维护可信 DHCP 列表,导致终端随机接受第一个 OFFER。

现实中,我们希望的是:既不需要主动更改网络中的 DHCP 流量,也能尽早发现网络中是否存在可疑/冲突的 DHCP OFFER,从而报警、记录并通知管理员采取人工检查或自动化隔离(在网络设备上)。

因此本文实现一个实用的工具 dhcp_watchdog.py,它的功能包括:

  1. 被动监听局域网内的 DHCP/BOOTP 报文(DISCOVER/OFFER/REQUEST/ACK)。
  2. 解析每个 OFFER 中的关键选项(提供者硬件地址 / DHCP Server Identifier / Router(网关)/ lease time / DNS 等)。
  3. 在短时间内检测到同一子网/同一请求客户端收到来自多个不同 DHCP 服务器并提供不同默认网关时,判定为"可能的 DHCP 欺骗"并触发警报。
  4. 将警报写入日志文件、可选择发送邮件或通过 HTTP webhook(伪代码/接口留空,示例展示如何扩展)。
  5. 支持配置"可信 DHCP 服务器列表"(白名单)以减少误报。

下面给出设计答案与完整代码,然后逐块讲解。

题解答案

  • 工具名:dhcp_watchdog.py

  • 运行方式:在需要监控的网段上以 root 权限运行(因为需要抓包):sudo python3 dhcp_watchdog.py -i eth0

  • 主要依赖:scapy(用于抓包与解析 DHCP),argparseloggingthreadingjson

  • 工作流程:

    1. 启动后在指定接口上被动抓取 UDP 67/68(DHCP)的数据包。
    2. 对 OFFER 包(及含 server identifier 的包)提取:DHCP Server Identifier(IP)、你的目标客户端 MAC/IP(yiaddr/ciaddr)、Router(默认网关)等。
    3. 用短时窗口(例如 30 秒)为单位聚合来自不同 DHCP server 的 OFFER,若对同一客户端出现多个不同 "默认网关" 提供且提供服务器不在白名单时,生成警报。
    4. 日志记录所有可疑事件,输出到控制台并写文件。可选:扩展 webhook/邮件告警。

题解代码

注意:运行前请在具有抓包权限的环境中安装 scapy:pip3 install scapy。出于安全与合规考虑,本工具仅用于检测/防御,不包含任何攻击或伪造 DHCP 响应的代码。

python 复制代码
#!/usr/bin/env python3
# dhcp_watchdog.py
"""
被动 DHCP 欺骗检测工具(只读/检测)
运行: sudo python3 dhcp_watchdog.py -i <iface> --window 30 --threshold 2
"""

import argparse
import logging
import threading
import time
import json
from collections import defaultdict, deque
from datetime import datetime

from scapy.all import sniff, BOOTP, DHCP, UDP, IP, Ether

# ----------------------
# 配置与日志
# ----------------------
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.StreamHandler(),
        logging.FileHandler("dhcp_watchdog.log", encoding="utf-8")
    ]
)

# ----------------------
# 全局参数
# ----------------------
# 默认短时窗口(秒),在该时间窗口内聚合 OFFER
DEFAULT_WINDOW = 30
# 当同一客户端在窗口内被 >= threshold 个不同 server 提供不同默认网关则报警
DEFAULT_THRESHOLD = 2


# ----------------------
# 工具函数:解析 DHCP options
# ----------------------
def parse_dhcp_options(dhcp_options):
    """
    把 scapy 的 DHCP options 列表转换成 dict 方便使用
    scapy 把 options 抽成像 [('message-type', 2), ('server_id', '192.168.1.1'), ... , 'end']
    """
    opts = {}
    for o in dhcp_options:
        if isinstance(o, tuple) and len(o) >= 2:
            key, val = o[0], o[1]
            opts[key] = val
    return opts


# ----------------------
# DHCP 事件存储结构
# ----------------------
class EventWindow:
    """
    基于短时滑动窗口收集 DHCP OFFER 信息。
    存储结构:
      key = client_mac (str, 小写)
      value = deque of (timestamp, server_ip, offered_gateway_list)
    """
    def __init__(self, window_seconds=DEFAULT_WINDOW):
        self.window = window_seconds
        self.lock = threading.Lock()
        self.data = defaultdict(deque)

    def add_offer(self, client_mac, server_ip, router_list, raw_packet_summary):
        """
        添加一条 OFFER 记录
        router_list: list of router IPs (可能为空)
        raw_packet_summary: 简要字符串描述包(便于日志记录)
        """
        ts = time.time()
        entry = (ts, server_ip, tuple(router_list), raw_packet_summary)
        with self.lock:
            q = self.data[client_mac]
            q.append(entry)
            # 清理过期
            while q and (ts - q[0][0] > self.window):
                q.popleft()

    def snapshot(self, client_mac):
        with self.lock:
            return list(self.data.get(client_mac, []))

    def clients(self):
        with self.lock:
            return list(self.data.keys())


# ----------------------
# 检测器
# ----------------------
class RogueDetector:
    def __init__(self, window_seconds=DEFAULT_WINDOW, threshold=DEFAULT_THRESHOLD, trusted_servers=None):
        self.window_seconds = window_seconds
        self.threshold = threshold
        self.window = EventWindow(window_seconds)
        self.trusted = set(trusted_servers or [])

    def process_offer(self, client_mac, server_ip, router_list, raw_summary):
        """
        每收到一个 OFFER 调用一次
        """
        self.window.add_offer(client_mac, server_ip, router_list, raw_summary)
        # 做检测
        self._analyze(client_mac)

    def _analyze(self, client_mac):
        """
        分析 client_mac 在窗口内的所有提供记录
        如果发现 >= threshold 个不同 server 且这些 server 提供的 router 有差异并且 server 不都是可信的 -> 报警
        """
        records = self.window.snapshot(client_mac)
        if not records:
            return

        # 收集不同 server 提供的 router 集合
        server_to_routers = {}
        servers_seen = set()
        for ts, server_ip, routers, summary in records:
            servers_seen.add(server_ip)
            # routers 是 tuple
            server_to_routers.setdefault(server_ip, set()).update(routers)

        # 如果可见 server 数量不达阈值,暂不报警
        if len(servers_seen) < self.threshold:
            return

        # 如果所有 server 都在可信白名单中,则忽略
        untrusted_servers = [s for s in servers_seen if s not in self.trusted]
        if not untrusted_servers:
            return

        # 判断 router 值是否出现冲突(不同 server 提供的 router 集合不完全相同)
        unique_router_sets = set()
        for s, rset in server_to_routers.items():
            # 将 rset 转成排序的元组便于比较
            unique_router_sets.add(tuple(sorted(rset)))
        if len(unique_router_sets) <= 1:
            # 所有 server 提供的 router 集合一致,误报可能性大
            return

        # 满足条件:触发报警
        self._alert(client_mac, servers_seen, server_to_routers, records)

    def _alert(self, client_mac, servers_seen, server_to_routers, records):
        alert = {
            "time": datetime.utcnow().isoformat() + "Z",
            "client_mac": client_mac,
            "servers": list(servers_seen),
            "server_router_map": {s: list(r) for s, r in server_to_routers.items()},
            "recent_records": [
                {"ts": datetime.utcfromtimestamp(ts).isoformat()+"Z", "server": s, "routers": list(r), "summary": summary}
                for ts, s, r, summary in records
            ]
        }
        # 写日志与输出
        logging.warning("可能的 DHCP 欺骗检测到: %s", json.dumps(alert, ensure_ascii=False))
        # 这里可以扩展:发送 webhook / 邮件等
        # send_webhook(alert)  # 留空,交由运维根据需要实现


# ----------------------
# 抓包回调:解析并调用检测逻辑
# ----------------------
def dhcp_packet_callback(pkt, detector):
    # 只关心 UDP 67/68 的 DHCP 包
    if not (pkt.haslayer(UDP) and pkt.haslayer(BOOTP) and pkt.haslayer(DHCP)):
        return

    bootp = pkt[BOOTP]
    dhcp = pkt[DHCP]
    # 客户端 MAC(使用 chaddr)
    client_mac = ":".join("{:02x}".format(x) for x in bootp.chaddr[:6]).lower()
    dhcp_opts = parse_dhcp_options(dhcp.options)
    msg_type = dhcp_opts.get("message-type")
    server_id = dhcp_opts.get("server_id")  # DHCP Server Identifier, IP
    router = dhcp_opts.get("router")  # 可能是单个 IP 或列表
    # scapy 有时把 router 返回为单个字符串或单个元素的 list
    router_list = []
    if router:
        if isinstance(router, list) or isinstance(router, tuple):
            router_list = list(router)
        else:
            router_list = [router]

    # 只处理 OFFER (2) 和 ACK (5) 和 OFFER-like server responses(为了检测)
    if msg_type in (2, 5):  # 2: OFFER, 5: ACK
        summary = f"msg_type={msg_type} server_id={server_id} yiaddr={bootp.yiaddr} routers={router_list}"
        detector.process_offer(client_mac, server_id or pkt[IP].src, router_list, summary)


# ----------------------
# 主函数:入口与参数
# ----------------------
def main():
    parser = argparse.ArgumentParser(description="被动 DHCP 欺骗检测 (只读)")
    parser.add_argument("-i", "--iface", required=True, help="监听接口,例如 eth0")
    parser.add_argument("--window", type=int, default=DEFAULT_WINDOW, help="短时窗口(秒),默认30")
    parser.add_argument("--threshold", type=int, default=DEFAULT_THRESHOLD, help="触发报警的不同 server 数量阈值,默认2")
    parser.add_argument("--trusted", type=str, default="", help="可信 DHCP server 列表,逗号分隔的 IP")
    args = parser.parse_args()

    trusted_list = [ip.strip() for ip in args.trusted.split(",") if ip.strip()]
    detector = RogueDetector(window_seconds=args.window, threshold=args.threshold, trusted_servers=trusted_list)

    logging.info("启动 DHCP Watchdog,在接口 %s 上监听 (window=%ds, threshold=%d)", args.iface, args.window, args.threshold)
    if trusted_list:
        logging.info("可信 DHCP 服务器白名单: %s", trusted_list)

    # 使用 scapy 抓包(过滤 UDP 67/68)
    bpf = "udp and (port 67 or port 68)"
    sniff(iface=args.iface, filter=bpf, prn=lambda pkt: dhcp_packet_callback(pkt, detector), store=False)


if __name__ == "__main__":
    main()

题解代码分析

下面我们把代码拆成模块来逐段解释,帮助你读懂每一步在干什么,并指出为什么这样设计。

依赖与日志配置

python 复制代码
from scapy.all import sniff, BOOTP, DHCP, UDP, IP, Ether
logging.basicConfig(...)
  • 使用 scapy 来抓包并解析 DHCP 报文(BOOTP/DHCP 是同一协议族)。
  • logging 配置了控制台和文件输出,文件名为 dhcp_watchdog.log,便于长期审计。

parse_dhcp_options

python 复制代码
def parse_dhcp_options(dhcp_options):
    ...
  • Scapy 将 DHCP options 解析成列表(含 'end' 等),函数把它转换为字典以便按 key 访问(比如 message-type, server_id, router)。

EventWindow

  • 目的:在一个短时间窗口(默认 30 秒)内记下来自不同 DHCP servers 对同一客户端的 OFFER。
  • defaultdict(deque) 存储:deque 有快速从左端弹出旧记录的能力,适合滑动窗口。
  • add_offer 用时间戳记录每条 OFFER 并清理过期记录。

设计理由:DHCP 欺骗可表现为在极短时间里有多个不同的 DHCP server 提供不同的配置,因此短时聚合是合理的检测方法。

RogueDetector

  • 初始化时可以给出 trusted_servers(白名单),避免已知合法的 backup DHCP 服务器引起误报。

  • process_offer:对每个 OFFER 调用 add_offer 并触发 _analyze

  • _analyze 的判定规则(相对保守):

    1. 统计在窗口内出现的不同 server 数量,若小于阈值则不报警(避免偶发)。
    2. 从白名单中剔除可信服务器,若剩余无 server 则不报警。
    3. 判断不同 server 提供的 router(默认网关)集合是否不一致,如果一致(即所有 server 都给出相同网关),不报警(可能是冗余 DHCP 服务的合法情况)。
    4. 否则生成警报并写日志(包含最近记录详情)。
  • 这样既可探测恶意伪造(提供不同网关),也能尽量避免误报。

为什么不直接对"不同 server 出现"就报警?因为在一个大网络里可能存在容错配置或多台 DHCP server 为同一子网服务并且配置一致:我们只在"不同 server 提供不同 router(默认网关)"时提高警告级别。

抓包回调 dhcp_packet_callback

  • 只处理含有 UDPBOOTPDHCP 的包。
  • 取出 client_mac(BOOTP.chaddr)、message-type(是否是 OFFER/ACK)、server_id(DHCP option: server_id)、router(DHCP option: router)。
  • 对于 OFFER (message-type==2) 与 ACK (5) 触发 detector.process_offer。我们把 ACK 也纳入检测是因为有些设备在短时间内也可能收到 ACK,从检测角度看也有价值。

注意:chaddr 是原始 bytes,代码把前 6 个字节格式化为 MAC 字符串。

命令行与运行

  • 支持 --window--threshold--trusted 三个参数,运行时可以调整灵敏度。
  • 用 BPF 过滤器 udp and (port 67 or port 68) 以减少抓包量。
  • sniff(..., store=False) 是被动监听,不保存包到内存,避免内存占用增大。

示例测试及结果

下面给出如何在实验室环境演示该工具并解读输出。注意:请在你被允许的网络环境中测试,不要在生产环境盲目运行抓包或进行攻击测试。

环境准备

  • 在 Ubuntu / Linux 上安装 scapy:pip3 install scapy
  • 需要 root 权限运行抓包:sudo
  • 选定监听接口,例如 eth0 / ens33 / wlan0(取决于你的机器)

启动

假设你要监听 eth0,设置窗口 30 秒,阈值 2:

复制代码
sudo python3 dhcp_watchdog.py -i eth0 --window 30 --threshold 2 --trusted 192.168.1.1

输出示例(控制台):

复制代码
2025-10-28 20:40:00,123 [INFO] 启动 DHCP Watchdog,在接口 eth0 上监听 (window=30s, threshold=2)
2025-10-28 20:40:12,456 [INFO] 发现 DHCP OFFER: msg_type=2 server_id=192.168.1.1 yiaddr=192.168.1.100 routers=['192.168.1.1']
2025-10-28 20:40:13,011 [INFO] 发现 DHCP OFFER: msg_type=2 server_id=192.1.1.253 yiaddr=192.168.1.100 routers=['192.1.1.253']
2025-10-28 20:40:13,012 [WARNING] 可能的 DHCP 欺骗检测到: {"time": "2025-10-28T12:40:13.012345Z", "client_mac": "aa:bb:cc:dd:ee:ff", "servers": ["192.168.1.1","192.1.1.253"], "server_router_map": {...}, ...}

解释:

  • 第一个 OFFER 来自可信服务器 192.168.1.1(在白名单),第二个来自未在白名单的 192.1.1.253 且提供的 routers 不同,触发警报。
  • 工具会在 dhcp_watchdog.log 写入完整 JSON 结构。

离线测试

如果没有真实环境,也可用另一个测试主机产生模拟的 DHCP OFFER(只作本地实验)。或在本机上用 pcaps 回放(tcpreplay)来验证分析器对记录的解读能力。工具本身是被动的,只要 pcap 中包含 DHCP OFFER/ACK,它就能处理。

时间复杂度

  • 每收到一个 DHCP 包,process_offer 做的主要工作是:

    • 在对应客户 MAC 的 deque 末尾插入记录(O(1))。
    • 从 deque 左侧弹出过期记录直到窗口内(每个记录在生命周期中被弹出一次),摊还复杂度 O(1) 每条记录。
    • 分析时把当前 deque 中的记录遍历一次(假设窗口内最多 k 条记录),构建映射与集合:O(k)。
  • 因此单个包的平均时间复杂度为 O(k)(k 是窗口内同一客户端的记录数)。在常见网络下,k 很小(通常个位数),因此工具能高效运行。

空间复杂度

  • 主存占用由 EventWindow.data 决定:它保存窗口内的每个客户端的记录。
  • 假设短时窗口内共有 C 个不同客户端生成记录,每个客户端平均保存 k 条记录,则空间复杂度为 O(C·k)。
  • 因为我们定期清理过期记录,且不保存完整 packet payload(只保存 summary 字符串),内存使用通常很小。即便在大型网络,可通过缩短窗口或限制每个客户端队列最大长度来控制内存。

扩展与实际部署建议

在真实企业/校园场景里,单靠一个主机运行检测脚本还不够------推荐的综合措施:

在网管层阻断

  • 在交换机上开启端口安全(Port Security),限制单端口 MAC 地址数量;对于办公桌面,锁定只允许授权的 MAC。
  • 启用 DHCP snooping(大多数厂商交换机支持),并在交换机上配置可信端口/不可信端口,配合动态 ARP inspection(DAI)可以在交换机层面阻断伪造 DHCP 报文。

将检测工具作为 SIEM 的数据源

  • dhcp_watchdog 的日志或 webhook 集成到企业的 SIEM 或告警系统(如 Splunk、ELK),当检测到可疑事件自动创建工单或触发 ACL 策略。

在关键网段部署多点监控

  • 在多个分支或楼层的关键交换机旁部署轻量级探测虚拟机/容器,集中上报到守护中心,使得检测覆盖性更好。

白名单维护

  • 在工具运行时维护可信 DHCP server 列表(比如中心 DHCP、容灾 DHCP 的 IP),减少误报。工具可定期从配置管理数据库(CMDB)拉取可信服务器列表以实现自动化。

告警分级

  • 将"不同 server 但 router 一致"的情况作为 Info 级别日志,"不同 server 且 router 不一致"的情况提升到高优先级并自动封锁端口(需和网管设备集成并经过审批)。

总结

  • 本文把 DHCP 欺骗的问题放到实际场景中:公共场合、分支网点或校园网的风险是现实的。我们实现了一个被动的检测工具(dhcp_watchdog),它基于 Scapy 抓包、短时窗口聚合、并通过对比不同 DHCP server 提供的 router(默认网关)来判断可疑事件。
  • 设计原则是保守报警、减少误报、可扩展到告警/自动化运维。代码既能作为独立检测工具运行,也易于嵌入更大运维体系(比如将警报发送到 SIEM 或 webhook)。
  • 最后给出部署建议:结合交换机层的 DHCP snooping、端口安全和集中化日志能把风险降到最低。
相关推荐
ServBay11 小时前
垃圾堆里编码?真的不要怪 PHP 不行
后端·php
用户9623779544814 小时前
CTF 伪协议
php
BingoGo3 天前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php
JaguarJack3 天前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php·服务端
BingoGo4 天前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php
JaguarJack4 天前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php·服务端
JaguarJack5 天前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
后端·php·服务端
BingoGo5 天前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
php
JaguarJack6 天前
告别 Laravel 缓慢的 Blade!Livewire Blaze 来了,为你的 Laravel 性能提速
后端·php·laravel
郑州光合科技余经理6 天前
代码展示:PHP搭建海外版外卖系统源码解析
java·开发语言·前端·后端·系统架构·uni-app·php