information regarding copyright ownership.
"""
-import os
-import signal
-import socket
+from collections.abc import AsyncGenerator
+
import struct
-import sys
-import threading
-# DNS constants
+import dns.flags
+import dns.rcode
+import dns.rdatatype
+
+from isctest.asyncserver import (
+ AsyncDnsServer,
+ BytesResponseSend,
+ DnsProtocol,
+ DnsResponseSend,
+ QueryContext,
+ ResponseAction,
+ ResponseHandler,
+)
+
+# DNS constants used by raw wire builder functions below
DNS_TYPE_SOA = 6
DNS_TYPE_A = 1
DNS_TYPE_NS = 2
DNS_RCODE_NOERROR = 0
DNS_RCODE_SERVFAIL = 2
+ZONE_NAME = "ixfr-race."
+NUM_RECORDS = 400
+
def encode_name(name):
"""Encode a DNS name in wire format (no compression)."""
return result
-def encode_name_compressed(offset):
- """Encode a DNS name using compression pointer."""
- return struct.pack("!H", 0xC000 | offset)
-
-
def build_soa_rdata(
mname, rname, serial, refresh=3600, retry=900, expire=604800, minimum=86400
):
return struct.pack("!HHHHHH", qid, flags, qdcount, ancount, nscount, arcount)
-def parse_dns_query(data):
- """Parse incoming DNS query, return (qid, qname, qtype, qclass)."""
- if len(data) < 12:
- return None
- qid, _, _ = struct.unpack("!HHH", data[:6])
-
- # Parse question
- offset = 12
- labels = []
- while offset < len(data):
- length = data[offset]
- offset += 1
- if length == 0:
- break
- if length >= 0xC0:
- # Compression pointer
- offset += 1
- break
- labels.append(data[offset : offset + length].decode("ascii"))
- offset += length
-
- qname = ".".join(labels) + "."
-
- if offset + 4 > len(data):
- return None
-
- qtype, qclass = struct.unpack("!HH", data[offset : offset + 4])
- return qid, qname, qtype, qclass
-
-
def build_ixfr_message1(qid, zone_name, num_records):
"""
Build IXFR Message 1: A valid IXFR diff that triggers ixfr_commit().
header = build_dns_header(qid, flags, 1, ancount)
msg = header + question + answer
- print(
- f"[*] Message 1: {len(msg)} bytes, {ancount} RRs "
- f"(diff 1: {num_records} DEL + {num_records} ADD)"
- )
-
return msg
header = build_dns_header(qid, flags, 1, 0)
msg = header + question
- print(
- f"[*] Message 2 (bad-rcode): {len(msg)} bytes, "
- "rcode=SERVFAIL -> triggers try_axfr -> xfrin_reset()"
- )
-
return msg
header = build_dns_header(qid, flags, 1, ancount)
msg = header + question + answer
- print(
- f"[*] AXFR response: {len(msg)} bytes, {ancount} RRs "
- f"(serial={serial}, {num_records} A records)"
- )
-
return msg
-def tcp_send_message(sock, msg):
- """Send a DNS message over TCP with 2-byte length prefix."""
- length = struct.pack("!H", len(msg))
- sock.sendall(length + msg)
-
-
-def tcp_recv_message(sock):
- """Receive a DNS message over TCP with 2-byte length prefix."""
- length_data = b""
- while len(length_data) < 2:
- chunk = sock.recv(2 - len(length_data))
- if not chunk:
- return None
- length_data += chunk
- length = struct.unpack("!H", length_data)[0]
-
- data = b""
- while len(data) < length:
- chunk = sock.recv(length - len(data))
- if not chunk:
- return None
- data += chunk
- return data
-
-
-def handle_client(conn, addr, zone_name, num_records, axfr_done_event):
- """Handle a single TCP connection from a BIND secondary."""
- print(f"[+] Connection from {addr}")
-
- try:
- while True:
- data = tcp_recv_message(conn)
- if data is None:
- print(f"[-] Connection closed by {addr}")
- break
-
- parsed = parse_dns_query(data)
- if parsed is None:
- print(f"[-] Failed to parse query from {addr}")
- break
-
- qid, qname, qtype, qclass = parsed
- print(f"[*] Query: {qname} type={qtype} class={qclass} id={qid}")
-
- if qtype == DNS_TYPE_SOA:
- # SOA query over TCP (initial or pre-transfer check)
- # Respond with serial=1 if initial AXFR not done yet,
- # serial=3 to trigger IXFR after initial load
- if axfr_done_event.is_set():
- serial = 3
- else:
- serial = 1
- print(f"[*] Responding with SOA serial={serial}")
- response = build_soa_response(qid, zone_name, serial)
- tcp_send_message(conn, response)
-
- elif qtype == DNS_TYPE_AXFR:
- # Initial AXFR to load the zone with serial=1
- print("[*] AXFR request - sending initial zone (serial=1)")
- response = build_axfr_response(qid, zone_name, 1, num_records)
- tcp_send_message(conn, response)
- axfr_done_event.set()
- print(
- "[+] Initial AXFR complete. Zone loaded with "
- "serial=1. Next SOA will return serial=3 to "
- "trigger IXFR."
- )
+class IxfrRaceHandler(ResponseHandler):
+ """
+ Handle SOA, AXFR, and IXFR queries to trigger the IXFR->AXFR race condition.
- elif qtype == DNS_TYPE_IXFR:
- print("[*] IXFR request received")
+ Phase 1: Respond to SOA with serial=1 and serve an AXFR to load the zone.
+ Phase 2: After AXFR, respond to SOA with serial=3 to trigger IXFR.
+ On IXFR, send a valid large diff (msg1) followed immediately by a
+ SERVFAIL response (msg2) to race ixfr_commit() against xfrin_reset().
+ """
- # Message 1: Valid IXFR diff -> triggers ixfr_commit()
- msg1 = build_ixfr_message1(qid, zone_name, num_records)
+ def __init__(self) -> None:
+ self._axfr_done = False
- print(f"[*] Sending Message 1 ({len(msg1)} bytes)...")
- tcp_send_message(conn, msg1)
+ async def get_responses(
+ self, qctx: QueryContext
+ ) -> AsyncGenerator[ResponseAction, None]:
+ qid = qctx.query.id
- # Message 2: Trigger xfrin_reset() while worker is running
- msg2 = build_bad_rcode_message2(qid, zone_name)
+ if qctx.qtype == dns.rdatatype.SOA:
+ serial = 3 if self._axfr_done else 1
+ yield BytesResponseSend(build_soa_response(qid, ZONE_NAME, serial))
- print(f"[*] Sending Message 2 ({len(msg2)} bytes) - triggers race!")
- tcp_send_message(conn, msg2)
+ elif qctx.qtype == dns.rdatatype.AXFR:
+ yield BytesResponseSend(build_axfr_response(qid, ZONE_NAME, 1, NUM_RECORDS))
+ self._axfr_done = True
- print(
- "[+] IXFR response sent. If BIND9 is built with "
- "TSAN, expect data race reports on "
- "xfr->ixfr.journal and xfr->ver"
- )
- else:
- print(f"[*] Ignoring query type {qtype}")
-
- except (ConnectionResetError, BrokenPipeError) as e:
- print(f"[-] Connection error: {e}")
- finally:
- conn.close()
- print(f"[-] Connection to {addr} closed")
-
-
-def udp_server(listen_addr, port, zone_name, axfr_done_event):
- """UDP server for SOA queries (BIND sends SOA queries over UDP first)."""
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- sock.bind((listen_addr, port))
- print(f"[+] UDP listening on {listen_addr}:{port} (for SOA queries)")
-
- while True:
- try:
- data, addr = sock.recvfrom(4096)
- parsed = parse_dns_query(data)
- if parsed is None:
- continue
-
- qid, qname, qtype, qclass = parsed
- print(f"[UDP] Query from {addr}: {qname} class={qclass} type={qtype}")
-
- if qtype == DNS_TYPE_SOA:
- # Return serial=1 initially (matches zone), then serial=3
- # after AXFR to trigger IXFR
- if axfr_done_event.is_set():
- serial = 3
- else:
- serial = 1
- print(f"[UDP] Responding with SOA serial={serial}")
- response = build_soa_response(qid, zone_name, serial)
- sock.sendto(response, addr)
- elif qtype == DNS_TYPE_IXFR:
- # IXFR over UDP gets truncated response to force TCP
- print("[UDP] IXFR over UDP, sending TC=1 to force TCP")
- flags = DNS_FLAG_QR | DNS_FLAG_AA | 0x0200 # TC bit
- header = build_dns_header(qid, flags, 0, 0)
- sock.sendto(header, addr)
+ elif qctx.qtype == dns.rdatatype.IXFR:
+ if qctx.protocol == DnsProtocol.UDP:
+ # Force TCP retry by setting the TC bit
+ qctx.response.flags |= dns.flags.TC
+ yield DnsResponseSend(qctx.response)
else:
- print(f"[UDP] Ignoring query type {qtype}")
- except Exception as e: # pylint: disable=broad-except
- print(f"[UDP] Error: {e}")
-
-
-def sigterm(*_):
- print("SIGTERM received, shutting down")
- os.remove("ans.pid")
- sys.exit(0)
-
-
-def main():
- signal.signal(signal.SIGTERM, sigterm)
- signal.signal(signal.SIGINT, sigterm)
- with open("ans.pid", "w", encoding="utf-8") as pidfile:
- print(os.getpid(), file=pidfile)
-
- listen = sys.argv[1]
- port = int(sys.argv[2])
- zone_name = "ixfr-race."
- num_records = 400
+ # Message 1: Valid IXFR diff -> triggers ixfr_commit()
+ yield BytesResponseSend(
+ build_ixfr_message1(qid, ZONE_NAME, NUM_RECORDS)
+ )
+ # Message 2: SERVFAIL -> triggers xfrin_reset() while
+ # ixfr_apply worker from Message 1 is still running -> UAF
+ yield BytesResponseSend(build_bad_rcode_message2(qid, ZONE_NAME))
- # Shared event: set after initial AXFR, before IXFR
- axfr_done_event = threading.Event()
- # Start UDP server in background (for SOA queries)
- udp_thread = threading.Thread(
- target=udp_server, args=(listen, port, zone_name, axfr_done_event)
- )
- udp_thread.daemon = True
- udp_thread.start()
-
- # Set up TCP server (for AXFR initial load + IXFR attack)
- server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- server.bind((listen, port))
- server.listen(5)
- print(f"[+] TCP listening on {listen}:{port}")
- print()
- print("[*] Phase 1: Initial AXFR to load zone with serial=1")
- print("[*] Phase 2: SOA refresh will return serial=3 -> IXFR -> race")
- print()
-
- while True:
- conn, addr = server.accept()
- t = threading.Thread(
- target=handle_client,
- args=(conn, addr, zone_name, num_records, axfr_done_event),
- )
- t.daemon = True
- t.start()
- server.close()
+def main() -> None:
+ server = AsyncDnsServer(default_rcode=dns.rcode.NOERROR, default_aa=True)
+ server.install_response_handler(IxfrRaceHandler())
+ server.run()
if __name__ == "__main__":