当网络里混入“假网关”:用 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、端口安全和集中化日志能把风险降到最低。
相关推荐
千里镜宵烛4 小时前
Lua-编译,执行和错误
开发语言·lua
赵谨言4 小时前
基于python二手车价值评估系统的设计与实现
大数据·开发语言·经验分享·python
NiKo_W4 小时前
Linux 网络初识
linux·网络·网络协议
java1234_小锋5 小时前
PyTorch2 Python深度学习 - 初识PyTorch2,实现一个简单的线性神经网络
开发语言·python·深度学习·pytorch2
胡萝卜3.05 小时前
C++面向对象继承全面解析:不能被继承的类、多继承、菱形虚拟继承与设计模式实践
开发语言·c++·人工智能·stl·继承·菱形继承·组合vs继承
Violet_YSWY5 小时前
将axios、async、Promise联系在一起讲一下&讲一下.then 与其关系
开发语言·前端·javascript
KevinLyu5 小时前
PHP内核详解· 内存管理篇(三)· 分配大块内存
php
luoganttcc5 小时前
用Python的trimesh库计算3DTiles体积的具体代码示例
开发语言·python·3d
延迟满足~5 小时前
Kuboard部署服务
网络