Zeek Rogue DHCP and ARP Spoofing Detection: Why SPAN Matters

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.

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

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 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.
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:

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.

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:

@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, 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:

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 (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:

{"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:

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:

 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 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:

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.

The client's neighbor table shows the false gateway claim took effect:

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:

    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:

{"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 and Dynamic ARP Inspection 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.

Read next