rainpwn

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

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:

bash
rainpwn@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:

bash
rainpwn@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:

bash
read -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

If this writeup saved you an evening, you can buy me a coffee.

Buy me a coffee