The Hunt for CVE-2023-28771 & Friends, Part 1 - Patch Diffing ZyXEL Firmware.

Just hammering publish on this one quickly to get the notes out there, this post is mostly intended to simply cover the methods of extraction of the ZyXEL firmwares and some basic diffing across the filesystems to locate potentially interesting things.

This story begins with this tweet, followed by reading this advisory, followed by this advisory.

So it seemed to me that getting the most recent, and an immediately previous version of the firmware of a ZyXEL USG firewall would be a good way to spend a weekend.

I decided to focus on the pre-auth RCE by Trapa Security as my "intended target", but I would probably get side tracked along the way.

So lets read the advisory.

"Improper error message handling in some firewall versions could allow an unauthenticated attacker to execute some OS commands remotely by sending crafted packets to an affected device."

Ok, interesting. What does this even mean. I have no idea. ZyXEL advisories often are a bit vague. Let us go seek an answer.

I probably won't beat VulnCheck to the punch on this one, and I think they are already on the ball, but I figured I'd copy paste my terminal into a text file and outline my process as much as possible in this blog entry.

We go to the ZyXEL website and download the patched, and previous firmware images. They are zip files containing an encrypted zipfile with some unpacker, etc.

We could go all hardcore like some heroic Italians, whose efforts you can read here, however I am feeling a bit lazy, so instead I decide to use a well known cryptanalytic attack on the firmware - the known plaintext pkzip cracking attack.

We are interested in unpacking the 535ABAQ0C0.bin file. We can use a partial known plaintext attack by using the config file and the tool "pkcrack",

$ cd firmware-before-patch/
$ ls
535ABAQ0C0.bin				535ABAQ0C0.db				535ABAQ0C0.ri				
535ABAQ0C0.conf				535ABAQ0C0.pdf				USG FLEX 50_V5.35(ABAQ.0)C0-foss.pdf	firmware-before-patch.zip
files in the zipfile of the firmware.

We now start to prepare for the attack, by shoving the config file in a ZIP file...

$ zip -9 pre.tmp.zip 535ABAQ0C0.conf 
  adding: 535ABAQ0C0.conf (deflated 83%)

We now run the extract utility to prepare our known-plaintext file for the attack.

$ ../pkcrack/bin/extract pre.tmp.zip 535ABAQ0C0.conf 535ABAQ0C0.plaintext

Run the attack...

$ ../pkcrack/bin/pkcrack -C 535ABAQ0C0.bin -c db/etc/zyxel/ftp/conf/system-default.conf -p 535ABAQ0C0.plaintext -d cracked.zip -a
Files read. Starting stage 1 on Sat Apr 29 05:15:56 2023
Generating 1st generation of possible key2_4814 values...done.
Found 4194304 possible key2-values.
Now we're trying to reduce these...
Lowest number: 973 values at offset 833
Lowest number: 969 values at offset 693
Lowest number: 912 values at offset 685
Done. Left with 912 possible Values. bestOffset is 685.
Stage 1 completed. Starting stage 2 on Sat Apr 29 05:16:13 2023
Ta-daaaaa! key0=efdba847, key1=3b53e5f1, key2=d72d2e63
Probabilistic test succeeded for 4134 bytes.
Ta-daaaaa! key0=efdba847, key1=3b53e5f1, key2=d72d2e63
Probabilistic test succeeded for 4134 bytes.
Stage 2 completed. Starting zipdecrypt on Sat Apr 29 05:16:20 2023
Decrypting compress.img (40595aa6d26ebe404c64969d)... OK!
Decrypting compress.img.sig (31b2460f2c5b61f95bac979d)... OK!
Decrypting db/ (4c477bf79b71ce1e218876a1)... OK!
Decrypting db/etc/ (c7eaa98d732a12d4c36976a1)... OK!
Decrypting db/etc/zyxel/ (87fccc73b6472171692876a1)... OK!
Decrypting db/etc/zyxel/ftp/ (d9cba2c284e72fbe00ab76a1)... OK!
Decrypting db/etc/zyxel/ftp/conf/ (d1c0240b013106e8e5d676a1)... OK!
Decrypting db/etc/zyxel/ftp/conf/system-default.conf (ab1f4ae66fd7cc467e18f193)... OK!
Decrypting etc_writable/ (7619ead094c2a36651f076a1)... OK!
Decrypting etc_writable/ModemManager/ (0050e8145d162becce8f76a1)... OK!
Decrypting etc_writable/ModemManager/libmm-plugin-altair-lte.so (b3b04ecf51a96347a933ba96)... OK!
Decrypting etc_writable/ModemManager/libmm-plugin-anydata.so (436ea5e3159409689c25ba96)... OK!
Decrypting etc_writable/ModemManager/libmm-plugin-cinterion.so (63f8ad2097cd31261880b996)... OK!
Decrypting etc_writable/ModemManager/libmm-plugin-generic.so (c3617e1cf4f88fbac93cb896)... OK!
Decrypting etc_writable/ModemManager/libmm-plugin-gobi.so (87d3b3b244378681dacdb996)... OK!
Decrypting etc_writable/ModemManager/libmm-plugin-hso.so (44a56968e1aa7a8d522eba96)... OK!
Decrypting etc_writable/ModemManager/libmm-plugin-huawei.so (f7fd71c7386a49a50be8bb96)... OK!
Decrypting etc_writable/ModemManager/libmm-plugin-iridium.so (18f9404d3eccbde680b2b996)... OK!
Decrypting etc_writable/ModemManager/libmm-plugin-linktop.so (81eeb58e124edad31090bb96)... OK!

We can repeat the same process on the patched firmware, of course, to obtain the decrypted firmware files for reverse engineering process. I won't bother showing that here, its the same shit over again.

This method is somewhat widely known and works on most of the USG firmwares that I've looked at in the past. Hopefully ZyXEL never fix it, the idea of having to do some real work sounds tiresome.

We can now unpack the "compress.img" file to get at the rootfs, using unsquashfs, and use the Meld tool to compare file changes.

using meld to compare the firmware.

Look, at this point I stumbled down a few blind alleys and did some useless bindiffing. "ios.cgi" differed in Meld, but not in radiff2, and I spent two hours comparing them in Ghidra before realising they were not what I was seeking.

What I was seeking was handler.py in the ztp cgi-bin, which had a new and exciting check added.

$ diff ./firmware-before-patch/cracked/compress.img.uns/usr/local/zyxel-gui/htdocs/ztp/cgi-bin/handler.py ./firmware-patched/cracked/compress.img.uns/usr/local/zyxel-gui/htdocs/ztp/cgi-bin/handler.py
69a70,78
> def check_user(ipaddr):
>     cmd = ["/bin/zysh", "-p", "100", "-e", "debug show users role by " + ipaddr]
>     p1 = subprocess.Popen(cmd, stdout=subprocess.PIPE)
>     ret_strs = p1.communicate()[0]
>     if "username=support  user_role" in ret_strs:
>         return 1
>     else:
>         return 0
> 
1582a1592,1597
>         ipaddr = os.getenv("REMOTE_ADDR")
>         if ipaddr == None:
>             raise Exception("no ip")
>         res = check_user(ipaddr)
>         if res == 0:
>             raise Exception("invalid users")
maybe the diff we are looking for?

What this check does, is before the JSON body of the request to /ztp/cgi-bin/handler gets processed, it checks if there is an existing login from the support user from that IP, in an attempt to prevent unauthorised users from sending shit to the handler.

There is also this very interesting check just after that exception, in both the patched and unpatched versions:

        js = sys.stdin.read()
        if ";" in js or "\u" in js or "configure terminal" in js:
            raise Exception("invalid req")
        req = json.loads(js)

This, to me, looks like patching for previous exploits - blocking unicode injections, injections of "configure terminal", and shell command injections using ";" to append commands.

Has there been such vulnerabilities before? Apparently so, back in May 2022 Jake Baines at Rapid7 found such an injection. That blog post/advisory also gives us a really good idea as to how we can abuse the ZTP system - its just a CGI that takes some simple JSON and calls functions that are largely built of shell command calls.

I'd suspect that character check was added as a quick fix for those issues - perhaps I can go do some necromancy of older patches and prove this sometime, but not today.

To me, it looks like this new mitigation is to block all attempts at unauthenticated injections using the ZTP system. It tries to check if there is a login session by a support user from the calling IP address, and uses this as a kind of authentication.

I didn't spot any other differences in the handler file, or other Python imports to it in that directory.

So what I suspect is happening here is there is another bug, somewhere in handler or one of its imports, that can be exploited to execute commands.

And more than likely, instead of playing bug whackamole, ZyXEL decided to kill the entire attack surface off with this authenticity check.

Given the advisory mentions error message handling, its plausible that something doesn't error right and lets us execute something?

Breaking down all the myriad functions in the ZTP system is going to take a while, the code is spaghetti. I wonder if we can find the bug?

That is all for now, the next post will cover blind alleys and wrong trees barked up during the process of looking for an exploitable bug in the handler functions, and maybe a diversion to looking at some other bugs fixed in these patches.

Hopefully this post was helpful in documenting some stuff about reversing ZyXEL USG firmware update files.