# Detecting Adversary-in-the-Middle (T1557) with Data Science

By Charles Givre · 2026-05-31

> Detect MITRE ATT&CK T1557 adversary-in-the-middle attacks with Python: LLMNR/NBT-NS poisoning, ARP cache poisoning, and rogue DHCP, using pandas and scapy.

[Adversary-in-the-Middle (T1557)](/mitre/T1557) is how attackers get between hosts to capture credentials and relay authentication. On internal networks the usual tools are Responder for LLMNR and NBT-NS poisoning, mitm6 for IPv6 DNS takeover, and classic ARP cache poisoning. None of these throw a malware signature. They abuse name resolution and Layer 2 mappings that are supposed to be trusted, so the durable signal is structural: one host suddenly claiming to be many others.

That structure is exactly what a few lines of `pandas` and [scapy](https://scapy.readthedocs.io/) surface well. Here is how to hunt the three most common T1557 variants.

## Where T1557 Shows Up

- **Zeek `dns.log`** captures LLMNR (UDP 5355) and NBT-NS (UDP 137) name resolution, including who answered
- **A packet capture** (or a SPAN/TAP feed) gives you ARP replies and DHCP offers that Zeek does not log by default
- **DHCP server logs** confirm which host is actually handing out leases

You do not need a SIEM. Parse the Zeek log as a DataFrame and the PCAP with scapy.

## LLMNR and NBT-NS Poisoning (T1557.001)

LLMNR and NBT-NS are fallback name resolution. A host that cannot resolve a name over DNS shouts the query to the local segment, and whoever answers wins. Normally almost nobody answers, because the name does not exist. [Responder](https://github.com/lgandx/Responder) answers everything, claiming that every queried name lives at the attacker's IP.

That is the tell. A legitimate host answers name queries for exactly one name: itself. A poisoner answers for dozens of distinct names. Load `dns.log`, keep the LLMNR and NBT-NS traffic, and count distinct names each answering IP claims:

```python
import pandas as pd

def load_zeek(path):
    cols = None
    with open(path) as f:
        for line in f:
            if line.startswith("#fields"):
                cols = line.strip().split("\t")[1:]
                break
    return pd.read_csv(path, sep="\t", comment="#", names=cols,
                       na_values=["-", "(empty)"])

dns = load_zeek("dns.log")  # id.orig_h, id.resp_h, id.resp_p, query, answers

name_svc = dns[dns["id.resp_p"].isin([5355, 137])]          # LLMNR + NBT-NS
answered = name_svc[name_svc["answers"].notna()]

# The answer value is the IP the name supposedly resolves to.
# An IP that "owns" many distinct queried names is a Responder-style poisoner.
claims = answered.groupby("answers")["query"].nunique().sort_values(ascending=False)
poisoners = claims[claims > 5]
```

Set the threshold to your environment. In a quiet segment, any IP answering for more than a handful of distinct names is worth a look. The false positive to expect is a busy print or file server, which you allowlist once and move on.

## ARP Cache Poisoning (T1557.002)

ARP poisoning works by lying about the IP-to-MAC mapping, usually telling victims that the gateway's IP is at the attacker's MAC. The structural anomaly is a one-to-many mapping: a single MAC claiming many IPs, or one IP whose MAC flaps. Parse ARP replies (`op == 2`) from a capture and build the mapping:

```python
from scapy.all import rdpcap, ARP

pkts = rdpcap("capture.pcap")
replies = [(p[ARP].psrc, p[ARP].hwsrc) for p in pkts
           if p.haslayer(ARP) and p[ARP].op == 2]            # is-at replies

arp = pd.DataFrame(replies, columns=["ip", "mac"])

# One IP mapped to multiple MACs over time = spoofing in progress
ip_flap = arp.groupby("ip")["mac"].nunique().sort_values(ascending=False)
conflicts = ip_flap[ip_flap > 1]

# One MAC claiming many IPs = a poisoner flooding the segment
mac_spread = arp.groupby("mac")["ip"].nunique().sort_values(ascending=False)
```

A gateway IP that suddenly resolves to two MACs is the canonical sign of an in-progress attack. Cross-reference the attacker MAC's vendor prefix (OUI) against your asset inventory: a poisoner is often a host that has no business speaking for the gateway.

## Rogue DHCP and Relay Setup (T1557.003)

The same primitive shows up as rogue DHCP: an attacker offers leases that point victims at a malicious gateway or DNS server. The detection is a cardinality check. There should be exactly one DHCP server per scope. Count distinct sources of DHCP OFFER messages (message type 2):

```python
from scapy.all import DHCP, IP

offer_sources = {
    p[IP].src for p in pkts
    if p.haslayer(DHCP)
    and ("message-type", 2) in [o for o in p[DHCP].options if isinstance(o, tuple)]
}

if len(offer_sources) > 1:
    print("Multiple DHCP servers offering leases:", offer_sources)
```

More than one offering source, outside a known redundant setup, means either a misconfiguration or an attacker staging a relay. Both are worth a page.

## Why the Structural View Wins

Signature tools catch the Responder and mitm6 binaries when they match a known hash. The behavior outlives the binary: rename the tool, recompile it, write your own, and it still has to answer for names it does not own or claim a MAC it should not. Counting distinct claims per host catches the technique, not the tool, which is the whole point of [hunting with data](/blog/detecting-ingress-tool-transfer-t1105) instead of waiting for a signature.

This is the approach we teach in GTK Cyber's [Threat Hunting with Data Science](/courses/threat-hunting-data-science) course: turning log and packet data into detections with `pandas` and statistics. The [T1557 reference page](/mitre/T1557) has the ATT&CK detail and sub-techniques, and the [threat hunting pipeline post](/blog/threat-hunting-pipeline-python-jupyter) shows how to run these checks on a schedule rather than by hand.

## FAQ

### How do I detect Responder LLMNR and NBT-NS poisoning without a SIEM?

Parse Zeek dns.log as a pandas DataFrame, keep the traffic on UDP 5355 (LLMNR) and UDP 137 (NBT-NS), then group the answered queries by the answering IP and count distinct names with groupby('answers')['query'].nunique(). A legitimate host answers name queries for exactly one name, itself. Responder answers for dozens. Any IP claiming more than a handful of distinct names is worth investigating. Allowlist a busy print or file server once if it shows up as a false positive.

### How do I detect ARP cache poisoning with Python?

Read the PCAP with scapy and keep the ARP is-at replies (op == 2), then build an IP-to-MAC table in pandas. Two structural anomalies give it away: one IP that maps to multiple MACs over time (the mapping flaps), and one MAC that claims many IPs (a poisoner flooding the segment). A gateway IP that suddenly resolves to two MACs is the canonical sign of an attack in progress. Cross-reference the attacker MAC's OUI vendor prefix against your asset inventory.

### How do I find a rogue DHCP server on the network?

Count the distinct source IPs sending DHCP OFFER messages (message-type 2). There should be exactly one DHCP server per scope. More than one offering source, outside a known redundant or failover setup, means either a misconfiguration or an attacker staging a relay for T1557.003. Both are worth a page.

### Why detect adversary-in-the-middle by behavior instead of tool signatures?

Signature tools catch the Responder and mitm6 binaries only when they match a known hash, so renaming, recompiling, or rewriting the tool defeats them. The structural signal outlives the binary: an attacker still has to answer for names it does not own or claim a MAC it should not. Counting distinct claims per host catches the technique rather than the tool.


---

Canonical: https://gtkcyber.com/blog/detecting-adversary-in-the-middle-t1557/