← Back to list

DNS Spoofing at Two Speeds: From Python Prototype to Optimised C Injector

A deep technical dive into DNS race-condition spoofing, TXID-based detection, and the engineering journey from a Scapy prototype to a…

Asif Iqbal · 2026-03-25 01:51 · 0 claps · 7.8 min read
#dns #dnssec #dns-spoofing #libpcap #pcap-analysis
Open on Medium ↗
Wiki topics: UX · UI/UX Design

DNS Spoofing at Two Speeds: From Python Prototype to Optimised C Injector

A deep technical dive into DNS race-condition spoofing, TXID-based detection, and the engineering journey from a Scapy prototype to a precomputed, stack-allocated C implementation that reliably wins the race.

Why DNS Spoofing Still Matters in 2025

DNS spoofing is not a new attack. It was documented in the 1990s, and the theoretical fix — DNSSEC — has existed since 1997. Yet in 2025, the majority of enterprise networks still run unsigned DNS zones, most resolvers don’t validate DNSSEC signatures, and DNS-over-HTTPS/TLS adoption remains uneven outside of consumer browsers.

The result: DNS cache poisoning and on-path spoofing remain viable attack techniques in internal network penetration tests, red team engagements, and adversary simulations — particularly in environments where an attacker has already achieved LAN access or ARP poisoning capability.

This article covers two related projects that approach the problem from different angles:

ProjectLanguageRoledns-sentinelPython / ScapyRed team injector + Blue team detectordns-snifferC / libpcapOptimised injector with precomputed responses

The Python toolkit was the prototype — fast to build, easy to iterate on, and good enough to demonstrate the attack. The C implementation came later, driven by a concrete engineering problem: winning the race more reliably. Understanding what changed between the two, and why, is the core of this article.

The Attack Surface: DNS as a Race Condition

The Protocol Weakness

DNS over UDP has no session layer, no connection state, and no cryptographic authentication. A resolver sends a query packet and accepts the first response that arrives with a matching Transaction ID (TXID) — a 16-bit value chosen pseudo-randomly per query.

The TXID was never intended as a security control. With only 65,536 possible values and no rate limiting on responses, it provides minimal protection against a determined attacker who can observe traffic on the local network.

The Race

An on-path attacker — one who can observe DNS queries leaving the victim via ARP spoofing, rogue AP, switched port mirroring, or physical LAN access — can execute the following:

Victim ──[UDP/53 Query: A? example.com TXID=0x3A1F]──▶ Upstream Resolver
           │
           └──────────────────────────────────────────▶ Attacker (sniffing)
                                                              │
                                                              │ Craft forged response:
                                                              │  TXID: 0x3A1F (copied)
                                                              │  QR=1, AA=1
                                                              │  AN: example.com → evil-ip
                                                              ▼
Victim ◀──[Forged UDP/53 Response TXID=0x3A1F]───────────────┘
  │
  └── Accepts forged answer (first to arrive wins)

       [Real resolver response arrives later — silently discarded]

The attack succeeds when the forged packet reaches the victim before the legitimate resolver response. On a LAN, a well-optimised attacker can respond within a few hundred microseconds of observing the query — well within the typical upstream resolver RTT of 1–50ms.

The key insight: the attacker’s margin is the upstream resolver RTT minus the attacker’s processing and injection latency. Every microsecond saved in crafting and sending the forged packet directly increases the attack’s reliability.

Part 1: dns-sentinel — The Python Prototype

Architecture

dns-sentinel is a two-tool toolkit built on Scapy:

  • dnsinject.py — sniffs DNS A-record queries and races the upstream resolver with a forged response
  • dnsdetect.py — monitors for duplicate responses on the same TXID and alerts on IP mismatches

The Injector

The injector uses a Scapy AsyncSniffer with a BPF filter (udp port 53). For each intercepted A-record query, it builds and sends a forged response:

forged = (
    IP(dst=pkt[IP].src, src=pkt[IP].dst) /
    UDP(dport=pkt[UDP].sport, sport=53) /
    DNS(
        id=pkt[DNS].id,       # Copy TXID
        qr=1,                 # Response flag
        aa=1,                 # Authoritative
        qd=pkt[DNS].qd,       # Echo question section
        an=DNSRR(
            rrname=pkt[DNS].qd.qname,
            ttl=300,
            rdata=spoofed_ip
        )
    )
)

To minimise send latency, the forged packet is transmitted via a raw Layer-3 socket (socket.SOCK_RAW) rather than Scapy's send(), which bypasses Scapy's routing and interface selection overhead.

The Detector

The detector exploits the same protocol property the attacker relies on: each DNS TXID should produce at most one response. Two different responses for the same TXID is unambiguous evidence of a race — one of them is forged.

For each DNS response packet:
    key = (src_ip, TXID)
    if key not in table:
        table[key] = answer_ip        # Store first-seen answer
    else if table[key] != answer_ip:  # Different answer = spoofing
        ALERT(domain, TXID, table[key], answer_ip)

Output:

[!] Spoofing detected for corp-sso.example.com
    TXID     : 0x3A1F
    Answer 1 : 192.168.1.100   ← Forged (arrived first)
    Answer 2 : 203.0.113.42    ← Legitimate resolver response

The detector also supports offline PCAP analysis via -r capture.pcap — useful for post-incident forensic work without requiring live traffic.

Usage

# Inject — spoof specific domains from a hostfile
sudo python dnsinject.py -i eth0 -h hosts.txt
# Detect — live monitoring
sudo python dnsdetect.py -i eth0
# Detect — forensic PCAP analysis
sudo python dnsdetect.py -r capture.pcap

Where Python Falls Short

The Python prototype works well in a lab. But there’s a fundamental performance ceiling: every time a query is intercepted, Scapy allocates objects, builds the DNS layer from Python objects, serialises it to bytes, and hands it to the OS. That pipeline — Python interpreter overhead, GC pressure, Scapy’s internal processing — adds up. In a tight race against an upstream resolver with a low RTT, that overhead is measurable and sometimes costs the win.

This is what motivated the C implementation.

Part 2: dns-sniffer — The Optimised C Injector

The C project (dns-sniffer) contains two source files that tell the story of an iterative optimisation:

FileVersionKey characteristicdns_spoofer_static.cv1 — naivemalloc per packet, full response built on every querydns_spoofer.cv2 — optimisedPrecomputed payloads, stack-allocated buffers, only TXID patched at runtime

Both use libpcap for capture and pcap_inject for Layer-2 packet injection.

v1: The Naive Baseline (dns_spoofer_static.c)

The first version is a clean, correct implementation. When a matching DNS query arrives, send_dns_spoof_response() does the following every single time:

  1. malloc() a new response buffer
  2. Build the full Ethernet, IP, UDP, and DNS headers from scratch
  3. Copy and encode the question section from the original query
  4. Build the answer section: compression pointer, RR fixed part, spoofed IP bytes
  5. Compute the IP checksum
  6. pcap_inject() the packet
  7. free() the buffer

This is correct and readable. But there’s significant redundant work: for any given domain in the hostfile, the DNS payload — the question section name, the QTYPE/QCLASS bytes, the compression pointer, the RR type/class/TTL/rdlength, the spoofed IP — is identical on every query. Only the TXID and the RD flag bit change between packets.

v2: Precomputed Payloads (dns_spoofer.c)

The optimised version addresses this with a single structural change: the DomainMapEntry struct now carries a precomputed DNS payload buffer alongside the domain and IP:

typedef struct {
    char    domain[DNS_MAX_NAME_LENGTH + 1];
    char    ip[INET_ADDRSTRLEN];
    int     precomputed_dns_payload_len;
    uint8_t precomputed_dns_payload[DNS_MAX_PAYLOAD_SIZE]; // Pre-built at startup
} DomainMapEntry;

At startup, when the hostfile is loaded, precompute_single_dns_response() runs once per domain entry. It builds the complete DNS payload — header (with placeholder TXID=0), question section, answer section with spoofed IP — and stores it in precomputed_dns_payload. This work happens once, not on every packet.

int precompute_single_dns_response(DomainMapEntry *entry) {
    // DNS header with placeholder TXID
    dns_resp_hdr->id      = 0;  // Updated per-packet
    dns_resp_hdr->flags   = htons(DNS_FLAG_QR | DNS_FLAG_AA | DNS_FLAG_RA);
    dns_resp_hdr->qdcount = htons(1);
    dns_resp_hdr->ancount = htons(1);
    // Question section — encode domain name in wire format
    hostname_to_dns_format(entry->domain, ...);
    // Answer section — compression pointer + RR + spoofed IP
    uint16_t name_ptr = htons(0xC000 | DNS_HDR_SIZE);  // 0xC00C
    inet_pton(AF_INET, entry->ip, &spoofed_ip_addr);
    entry->precomputed_dns_payload_len = curr_offset;
}

At packet-handling time, the hot path is now minimal:

// 1. Copy the precomputed payload — one memcpy
memcpy(dns_resp_payload_ptr,
       spoof_entry->precomputed_dns_payload,
       spoof_entry->precomputed_dns_payload_len);
// 2. Patch only what changes per-packet: TXID and RD flag
dns_resp_hdr->id = ori_dns_header->id;  // Copy TXID from query
uint16_t flags = ntohs(dns_resp_hdr->flags);
if (ori_flags & DNS_FLAG_RD_MASK)
    flags |= DNS_FLAG_RD_MASK;
else
    flags &= ~DNS_FLAG_RD_MASK;
dns_resp_hdr->flags = htons(flags);

The entire DNS payload is ready — just stamp the TXID, conditionally flip one bit, and inject.

The Second Optimisation: Stack Allocation

In v1, send_dns_spoof_response() calls malloc() for the response buffer on every packet. Memory allocation is not free — it involves a syscall or heap lock contention under load. In v2, the response buffer is stack-allocated in the packet handler:

void dns_packet_handler(...) {
    // Stack allocation — no malloc, no free, no heap contention
    uint8_t response_packet_buffer[MAX_SPOOF_PACKET_SIZE];
    // ...match domain, then:
    send_dns_spoof_response(handle, eth_hdr, ip_hdr, udp_hdr,
                            ori_dns_header, &domain_map[i],
                            response_packet_buffer);  // Pass buffer in
}

The buffer lives on the stack for the duration of dns_packet_handler's call frame. No allocation, no deallocation, no heap fragmentation over time.

Layer-2 Injection via pcap_inject

Both versions use pcap_inject() for packet transmission rather than a raw IP socket. This is an important distinction: pcap_inject operates at Layer 2, writing a complete Ethernet frame directly to the wire. The implementation therefore builds the full Ethernet header (swapping src/dst MACs from the original query), which gives it two advantages:

  • No kernel routing involvement — the packet bypasses the IP stack entirely and goes straight to the NIC driver
  • MAC-level precision — the forged response is addressed directly to the querying host’s MAC address, not broadcast
// Swap MAC addresses from the original query
memcpy(eth_resp_hdr->ether_dhost, ori_eth_hdr->ether_shost, ETHER_ADDR_LEN);
memcpy(eth_resp_hdr->ether_shost, ori_eth_hdr->ether_dhost, ETHER_ADDR_LEN);

The Performance Difference: Why It Matters

To understand why these optimisations move the needle, consider what happens in the hot path for each version when a DNS query arrives:

Python (dns-sentinel):

pcap callback → Python object allocation → Scapy layer construction
→ DNS serialisation → socket.SOCK_RAW send

C v1 (dns_spoofer_static):

pcap callback → malloc() → full header construction → memcpy (question)
→ build answer section → IP checksum → pcap_inject → free()

C v2 (dns_spoofer):

pcap callback → memcpy (precomputed payload) → patch 4 bytes (TXID)
→ patch 2 bytes (flags) → IP checksum → pcap_inject

The v2 hot path reduces to a single memcpy of the precomputed DNS payload, two field patches, an IP checksum over 20 bytes, and the pcap_inject call. Everything that can be moved outside the hot path has been moved outside the hot path.

Putting It Together: Choosing the Right Tool

RequirementRecommended toolQuick lab demo, no compilationdns-sentinel (Python)Dual red+blue in one toolkitdns-sentinelForensic PCAP detectiondns-sentinel (dnsdetect.py -r)Maximum injection speed, reliably winning tight racesdns-sniffer (C v2)Understanding the optimisation journeyBoth — read v1 then v2

Installation

dns-sentinel (Python):

git clone https://github.com/Asif-Iqbal-Gazi/dns-sentinel
cd dns-sentinel
pip install -r requirements.txt
sudo python dnsinject.py -i eth0 -h hosts.txt

dns-sniffer ©:

git clone https://github.com/Asif-Iqbal-Gazi/dns-sniffer
cd dns-sniffer
make
sudo ./bin/dns_spoofer -i eth0 -f hosts.txt         # Optimised v2
sudo ./bin/dns_spoofer_static -i eth0 -f hosts.txt  # Naive v1 (baseline)

Requirements: libpcap (apt install libpcap-dev), GCC, Linux or macOS, root privileges.

Operational Notes

ARP poisoning as a prerequisite. On a switched network, neither tool sees traffic from other hosts by default. You need to be on-path first — ARP spoofing is the standard technique.

IPv6 is out of scope. Both implementations handle A records (IPv4) only. Dual-stack hosts querying AAAA records won’t be spoofed; modern browsers may fall back to a legitimate IPv6 address.

TTL tradeoffs. The C implementation uses TTL=60 seconds; the Python version uses TTL=300. Lower TTL reduces how long a spoofed entry persists in the victim’s resolver cache after the attack ends. Choose based on whether persistence or reduced forensic footprint matters more for your engagement.

DNSSEC is the real fix. Both tools detect and exploit the symptom. DNSSEC eliminates DNS spoofing entirely by cryptographically signing zone data. The reason tools like these remain relevant is that DNSSEC deployment, particularly for internal enterprise zones, is still low.

Responsible Use

Both projects require root privileges and raw network access. Use exclusively on networks you own or have explicit written authorisation to test. Unauthorised DNS spoofing is illegal under computer fraud statutes in most jurisdictions.

Intended uses: penetration testing engagements, red/blue team exercises, CTF environments, network security training, and forensic PCAP analysis.

Part of an ongoing series on network security tools and CTF write-ups. Previous entries cover VBScript deobfuscation, SSHD coredump forensics, 3D maze reversing, RSA cryptanalysis, and AWS traffic interception — all linked on my profile.


메타데이터
post_id
d9e29943e24e
slug
dns-spoofing-at-two-speeds-from-python-prototype-to-optimised-c-injector-d9e29943e24e
url
https://medium.com/@asif.iqbal.gazi/dns-spoofing-at-two-speeds-from-python-prototype-to-optimised-c-injector-d9e29943e24e
canonical_url
https://medium.com/@asif.iqbal.gazi/dns-spoofing-at-two-speeds-from-python-prototype-to-optimised-c-injector-d9e29943e24e
author_url
https://medium.com/@asif.iqbal.gazi
status
ok
fetched_at
2026-07-07 04:41:59