
Research note
Zyxel ZON and SecuExtender, now on Linux
Two Zyxel tools I use all the time only ran on Windows, and I work from Linux. I reverse engineered both and rewrote them for Linux, one Python file each.
Talk is cheap. Show me the code.
Summary
I manage client networks built on Zyxel equipment, and my workstation runs Linux. Two Zyxel tools I reach for all the time exist only for Windows: Zyxel One Network Utility (ZON), which finds the Zyxel devices on a LAN, and SecuExtender, the client for the SSL VPN on Zyxel firewalls. From my own machine I could not use either of them.
So I reverse engineered both and rewrote them for Linux. Each is a single Python file with nothing to install beyond the standard library.
ZON
zyxel-zon-linux runs on any Linux machine connected to the same LAN as the devices. This is a real run, with names and addresses replaced:
bashrainpwn@0xdeadspace:~$ sudo ./zon.py eth0 # MAC Device Model IPv4 Address FW Version 1 bc:cf:4f:00:00:01 XGS1930-28 192.168.1.2 V4.50(ABHT.7) | 07/30/2019 2 d8:ec:e5:00:00:02 WAX650S 192.168.1.20 V7.10(ABRM.4) 3 bc:cf:4f:00:00:03 WAC6503D-S 192.168.1.21 V6.28(AASF.3) ZON for Linux - type 'help' for commands zon> get 3 serial password for bc:cf:4f:00:00:03 (WAC6503D-S, input hidden): Serial Number S202L0000000
Discovery numbers the devices by IP, and every other command takes that number or a MAC. A few of the things I use it for:
bash# an inventory for a ticket or a spreadsheet, in one command rainpwn@0xdeadspace:~$ sudo ./zon.py eth0 --json discover > inventory.json # read any field without a password zon> query 3 uptime bc:cf:4f:00:00:03 Device Uptime 3d 01:54:54 # everything the admin password unlocks: serial, SNMP communities, the lot zon> get 3 # reboot an access point without walking to it zon> reboot 3 Reboot on bc:cf:4f:00:00:03 (WAC6503D-S)? [yes/no] yes sent; the device restarts without answering. Check later with: query bc:cf:4f:00:00:03 uptime # blink the LED to find a switch in the rack, set its location, save zon> locate 1 zon> set 1 location "rack B, unit 12" zon> save 1
Commands that change something ask before they send. help lists the rest, and --debug prints every frame in hex when a device refuses a request and you want to see why.
ZON does not use IP at all. It speaks a layer 2 protocol that the binary calls ZDP: raw Ethernet frames with EtherType 0xC1C1, sent to a Zyxel multicast address for discovery and to the device itself afterwards. Discovery needs no password. Reading protected fields, rebooting or changing settings does, and the password never travels: it keys an HMAC-SHA1 over every request.
The part that took longest was not in ZON. On site, from a Raspberry Pi on Wi-Fi, signed requests to a WAC6503D-S failed with the right password whenever they were short, and went through whenever they were long. Frames under the Ethernet minimum get padded on their way to the cable, and the padding they picked up was apparently not zeros. The access point strips padding before it checks the signature, so it no longer had the bytes I had signed. Padding with zeros on the client fixed it.
SecuExtender
zyxel-secuextender-linux logs in, opens the tunnel on a tun interface with the routes and DNS servers the firewall pushes, and logs out on Ctrl+C. Zyxel firewalls ship self-signed certificates, so the client pins the certificate by fingerprint. The first run with any value prints it:
bashrainpwn@0xdeadspace:~$ sudo ./zyxel_sslvpn.py vpn.example.com 443 alice --pin 00 Unexpected certificate, sha256=3f1c...e9 (if this is your firewall, run again with --pin 3f1c...e9) rainpwn@0xdeadspace:~$ sudo ./zyxel_sslvpn.py vpn.example.com 443 alice --pin 3f1c...e9 Password: login ok tunnel: ip 10.99.0.2 peer 10.99.0.1 dns ['10.99.0.53'] routes [('192.168.10.0', '255.255.255.0')] connected on zyvpn0, Ctrl+C to disconnect
Two options cover most of the rest. --no-dns keeps your own resolvers when you only need to reach a few hosts, and -v prints the XML the firewall pushes, which is where to look when a route is missing. For a script, the password can come from ZYXEL_VPN_PASSWORD:
bashread -s ZYXEL_VPN_PASSWORD && export ZYXEL_VPN_PASSWORD sudo -E ./zyxel_sslvpn.py vpn.example.com 443 alice --pin 3f1c...e9 --no-dns
The protocol underneath is short. An HTTPS login returns a cookie. An HTTP CONNECT reaches a service inside the firewall, which answers with the client's address and routes as XML. After that the same TLS stream carries IP packets, each one prefixed with its length.
SecuExtender's core is a C++/CLI assembly, so ILSpy turned it back into readable code. ZON is native code and needed Ghidra, plus an emulator to run its HMAC routine and compare it with mine.
What has been tested
The VPN client runs against an ATP700. ZON discovery has run against an ATP700, XGS1930 and XGS1935 switches, and WAX650S and WAC6503D-S access points; authenticated reads and reboot against a WAC6503D-S. The other write commands, password change included, have only run against a simulated device so far.
If you have other Zyxel models, try them and open an issue with what works and what does not. Both repositories carry a PROTOCOL.md with everything I recovered, marked as observed on hardware or read from the binary, for anyone who wants to write their own client.
Repositories
- ZON for Linux: github.com/rainpwn/zyxel-zon-linux
- SecuExtender for Linux: github.com/rainpwn/zyxel-secuextender-linux
If this writeup saved you an evening, you can buy me a coffee.
Buy me a coffee