> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Zeek Rogue DHCP and ARP Spoofing Detection: Why SPAN Matters
- URL: https://www.pinglabz.com/zeek-rogue-dhcp-arp-spoofing-detection/
- Published: 2026-10-21T21:51:15.000Z
- Updated: 2026-10-21T21:51:15.000Z
- Description: Built in my home lab: a 51-line Zeek script flags rogue DHCP and gateway ARP spoofing, but only after SPAN sends it the unicast attack frames.
- Author: Jaime
- Tags: Network Security, Blue Team, Monitoring, Packet Analysis, DHCP, Layer 2

Zeek can flag a rogue DHCP server and ARP spoofing with a short custom script, but only if the sensor actually receives the attack frames. Built in my home lab on an IOL-L2 switch, an attacker sent the client 6 rogue DHCP replies and 3 false gateway ARP replies. With no SPAN session, Zeek saw none of them and wrote no notices. Mirroring the client port in both directions to the sensor produced both notices from the same script.

SPAN restored visibility, not protection: the client's ARP cache was still poisoned.

## Lab topology: IOL-L2, a DHCP server, an attacker and a Zeek sensor

The client connects to sw1 Ethernet0/1, the attacker to Ethernet0/2, the legitimate DHCP/gateway host to Ethernet0/3, and Zeek eth1 to Ethernet1/0\. All four switch ports are in VLAN 162\. In the baseline and fix phases, a [SPAN](https://www.pinglabz.com/span-rspan-erspan-configuration/) session mirrors the client port in both directions to the sensor port.

![Lab diagram: Three panels built in my home lab show one IOL-L2 switch on VLAN 162 with a client, a legitimate DHCP server, an attacker and a Zeek sensor. With SPAN on, Zeek sees the client's DHCP and ARP. With no SPAN, the client still receives 6 rogue DHCP replies and 3 false gateway ARPs while Zeek sees none of them. With the client port mirror restored, Zeek logs Rogue_DHCP_Server and Gateway_ARP_Conflict notices. SPAN restores visibility but does not stop the attack.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/10/zeek-rogue-dhcp-arp-spoofing-detection-topology.png)

| Role                    | Data address           | Fixed data MAC    |
| ----------------------- | ---------------------- | ----------------- |
| Client                  | DHCP: 10.62.162.100/24 | 02:62:00:00:01:00 |
| Legitimate DHCP/gateway | 10.62.162.1/24         | 02:62:00:00:00:01 |
| Attacker                | 10.62.162.66/24        | 02:62:00:00:00:66 |
| Zeek capture interface  | Unnumbered eth1        | Run-specific      |

The run used IOL-L2 IOS 17.18.2, Zeek 9.0.0 and three Alpine 3.24.2 endpoints. The DHCP host ran dnsmasq 2.92rel2\. Management used a separate path and plays no part in the captures.

The gateway host answers local ARP and ping. It doesn't forward traffic anywhere else, and DNS wasn't tested. The DHCP options advertise .1 as router and DNS server; that advertisement alone doesn't prove either service.

## The SPAN session, dnsmasq and the Zeek detection script

The baseline switch configuration contains this mirror session:

```text
monitor session 1 source interface Et0/1
monitor session 1 destination interface Et1/0

```

With no direction keyword, the source is mirrored in both directions. The dnsmasq configuration limits allocation to one address for one hour and binds DHCP to eth1\. `port=0` disables its DNS listener, which keeps the test focused on DHCP and ARP.

```ini
interface=eth1
bind-interfaces
port=0
dhcp-authoritative
dhcp-range=10.62.162.100,10.62.162.100,255.255.255.0,1h
dhcp-option=3,10.62.162.1
dhcp-option=6,10.62.162.1
dhcp-leasefile=/lab/evidence/dnsmasq.leases
log-dhcp
log-facility=/lab/evidence/dnsmasq.log

```

Zeek has no default ARP log, so the script writes its own `l2_evidence.log` from the `arp_request` and `arp_reply` events, alongside DHCP OFFER and ACK replies. This is the complete script the sensor ran:

```zeek
@load base/frameworks/notice
@load base/protocols/dhcp

module L2Evidence;

export {
    redef enum Notice::Type += { Rogue_DHCP_Server, Gateway_ARP_Conflict };
    redef enum Log::ID += { LOG };
    type Info: record {
        ts: time &log;
        kind: string &log;
        sender: addr &log;
        mac: string &log;
        detail: string &log;
    };
    const trusted_dhcp = 10.62.162.1 &redef;
    const gateway = 10.62.162.1 &redef;
    const gateway_mac = "02:62:00:00:00:01" &redef;
}

event zeek_init()
    {
    Log::create_stream(LOG, [$columns=Info, $path="l2_evidence"]);
    }

event dhcp_message(c: connection, is_orig: bool, msg: DHCP::Msg, options: DHCP::Options)
    {
    if ( msg$m_type != 2 && msg$m_type != 5 ) return;
    local server = c$id$orig_h;
    if ( options?$serv_addr ) server = options$serv_addr;
    local detail = fmt("type=%d yiaddr=%s xid=%s", msg$m_type, msg$yiaddr, msg$xid);
    Log::write(LOG, [$ts=network_time(), $kind="DHCP_SERVER_REPLY", $sender=server, $mac=msg$chaddr, $detail=detail]);
    if ( server != trusted_dhcp )
        NOTICE([$note=Rogue_DHCP_Server, $msg=fmt("Unapproved DHCP server %s: %s", server, detail),
                $src=server, $identifier=fmt("dhcp-%s", server)]);
    }

event arp_reply(mac_src: string, mac_dst: string, SPA: addr, SHA: string, TPA: addr, THA: string)
    {
    Log::write(LOG, [$ts=network_time(), $kind="ARP_REPLY", $sender=SPA, $mac=SHA,
                    $detail=fmt("target=%s target_mac=%s", TPA, THA)]);
    if ( SPA == gateway && SHA != gateway_mac )
        NOTICE([$note=Gateway_ARP_Conflict, $msg=fmt("Gateway %s claimed by %s; expected %s", SPA, SHA, gateway_mac),
                $src=SPA, $identifier=fmt("arp-%s-%s", SPA, SHA)]);
    }

event arp_request(mac_src: string, mac_dst: string, SPA: addr, SHA: string, TPA: addr, THA: string)
    {
    Log::write(LOG, [$ts=network_time(), $kind="ARP_REQUEST", $sender=SPA, $mac=SHA,
                    $detail=fmt("target=%s", TPA)]);
    }

```

The DHCP check compares option 54 (server identifier) with the approved server address when that option is present, and falls back to the packet's source address otherwise. The ARP check compares any claim for gateway .1 with the pinned gateway MAC. `L2Evidence::Rogue_DHCP_Server` and `L2Evidence::Gateway_ARP_Conflict` are this script's notice types; they aren't built-in Zeek detections. Stock Zeek won't raise these notices without a script like this one. Like the rest of the [Zeek logs](https://www.pinglabz.com/pcap-to-zeek-connection-dns-logs/), the custom log and notices are written in the directory Zeek runs from.

Each phase restarted the unchanged script in a fresh directory. The sensor ran live on eth1, with a capture filter for ARP and UDP ports 67/68 and `-C` to skip checksum validation in the virtual capture environment:

```sh
zeek -C -i eth1 -f "arp or (udp and (port 67 or port 68))" /lab/l2.zeek LogAscii::use_json=T

```

ICMP was outside that filter, so ping results come from the client's own output.

## Baseline: Zeek sees DHCP DORA and gateway ARP

The client completed [DHCP DORA](https://www.pinglabz.com/dhcp-dora-packet-capture/) (DISCOVER, OFFER, REQUEST, ACK), installed .100, resolved the legitimate gateway MAC and got two ping replies. Zeek's native `dhcp.log` recorded the whole exchange:

```text
{"ts":1792609318.435469,"uids":["Ca0kUb3m6WGquEc9we","Cig8Iq3nuaQd4mF6Y6"],"client_addr":"10.62.162.100","server_addr":"10.62.162.1","mac":"02:62:00:00:01:00","requested_addr":"10.62.162.100","assigned_addr":"10.62.162.100","lease_time":3600.0,"msg_types":["DISCOVER","OFFER","REQUEST","ACK"],"duration":0.020615100860595703}

```

The `mac` field is the client hardware address from the DHCP message (chaddr), not the server's Ethernet source. The client confirmed connectivity:

```text
2 packets transmitted, 2 packets received, 0% packet loss

```

The sensor captured six filtered packets: four DHCP messages and the gateway ARP request and reply. No notice was expected, and no `notice.log` was written.

## Fault: without SPAN, Zeek misses unicast rogue DHCP and ARP replies

The fault removed monitor session 1 and left Ethernet1/0 as an ordinary VLAN 162 access port. The switch confirmed the session was gone:

```text
 No SPAN configuration is present in the system for session [1]. 

```

The switch had already learned the client's MAC before the attack, so frames addressed to the client were known unicast and weren't flooded to other ports (the opposite of what my [MAC flooding lab](https://www.pinglabz.com/cisco-port-security-mac-flooding-lab/) forces). The attack generator then sent three unicast OFFER/ACK pairs and three false gateway ARP replies, every one addressed to the client's MAC.

Counting the attacker's frames in the client and sensor captures answers the visibility question. The filters were `ether src 02:62:00:00:00:66 and udp src port 67` for rogue DHCP and `ether src 02:62:00:00:00:66 and arp and arp[6:2] = 2 and arp src host 10.62.162.1` for false gateway ARP replies:

```text
fault client rogue_dhcp=6
fault client false_gateway_arp=3
fault zeek rogue_dhcp=0
fault zeek false_gateway_arp=0

```

The sensor still captured some broadcast traffic, so it wasn't silent. That trickle could make a quick health check look fine, but it didn't include a single attack frame, so the unchanged script had nothing to alert on. It's the same blind spot as [the same SPAN fault with Suricata](https://www.pinglabz.com/suricata-span-wrong-direction-no-alerts/).

The client's [neighbor table](https://www.pinglabz.com/ip-neigh-arp-linux/) shows the false gateway claim took effect:

```text
10.62.162.1 lladdr 02:62:00:00:00:66 REACHABLE 

```

Some captures count four attacker ARP replies. The gateway-specific filter counts three false claims for .1; the fourth is the attacker's honest reply for its own .66 address. Keeping those separate avoids inflating the poisoning count.

## Fix: mirror the client port and Zeek raises both notices

The fix restored Ethernet0/1 as a source in both directions and Ethernet1/0 as the destination. `show monitor session 1` confirmed:

```text
    Both                 : Et0/1
Destination Ports        : Et1/0

```

The unchanged script then saw six rogue DHCP replies and three false gateway claims, and wrote two notices:

```text
{"ts":1792609374.895725,"note":"L2Evidence::Rogue_DHCP_Server","msg":"Unapproved DHCP server 10.62.162.66: type=2 yiaddr=10.62.162.100 xid=1644167168","src":"10.62.162.66","actions":["Notice::ACTION_LOG"],"email_dest":[],"suppress_for":3600.0}
{"ts":1792609375.096705,"note":"L2Evidence::Gateway_ARP_Conflict","msg":"Gateway 10.62.162.1 claimed by 02:62:00:00:00:66; expected 02:62:00:00:00:01","src":"10.62.162.1","actions":["Notice::ACTION_LOG"],"email_dest":[],"suppress_for":3600.0}

```

Zeek suppresses repeat notices per identifier for 3600 seconds by default, so six DHCP packets and three gateway claims produced one notice of each type. Don't count notices as packets.

## Verification across all three phases

| Phase                       | Client rogue DHCP / false gateway ARP | Sensor rogue DHCP / false gateway ARP | Sensor filtered packets          | Custom notices   |
| --------------------------- | ------------------------------------- | ------------------------------------- | -------------------------------- | ---------------- |
| Baseline                    | 0 / 0                                 | 0 / 0                                 | 6                                | 0; no notice.log |
| Fault, ordinary access port | 6 / 3                                 | 0 / 0                                 | broadcasts only; 0 attack frames | 0; no notice.log |
| Fix, SPAN restored          | 6 / 3                                 | 6 / 3                                 | 17                               | 2                |

All final client and sensor captures reported zero kernel drops. The fixed phase also poisoned the client's neighbor entry: SPAN repaired observation, not enforcement.

## What this detection doesn't cover

The forged DHCP replies arrived after the real client had finished its exchange. I saw all six reach the client, but whether a client would accept the rogue lease wasn't tested, and neither were starvation, MITM forwarding or IPv6\. The script pins one gateway and trusts DHCP option 54 when it's present, so an attacker who spoofs the approved server identifier can evade the DHCP check. SPAN doesn't prevent poisoning. To block these attacks, use [DHCP snooping](https://www.pinglabz.com/dhcp-starvation-rogue-dhcp-snooping-lab/) and [Dynamic ARP Inspection](https://www.pinglabz.com/dynamic-arp-inspection-ip-source-guard/) on the switch; Zeek is the detection layer.

## Key takeaways

- Check the capture point before changing detection logic. Here the fault was an ordinary access port that received broadcasts but none of the known-unicast attack traffic.
- A sensor that's receiving packets isn't necessarily receiving the packets that matter. Count the attacker's frames on both the victim and the sensor.
- Keep native protocol logs, custom detections and endpoint effects separate: `dhcp.log` showed the exchange, the script produced the notices, and the neighbor table showed the poisoning.
- Notice suppression means one notice per identifier per hour, not one per attack packet.