@warlo

Reverse-engineering Zyxel WX3401-B0 encryption and replicating in Python

warlo
warlo

After resetting my Zyxel WX3401-B0 due to some issues with the 5GHz band dropping, the access point automatically updated itself. I had previously integrated device tracking by fetching a WLANTable endpoint and parsing the output on the AP, but suddenly the integration no longer worked. This was problematic since I prefer using device tracking on the AP level, which is very reliable and power-efficient in detecting whether there are people at home. This means I can trigger automations quite precisely, and if I want I can combine it with other tracking like GPS.

Previously the Zyxel AP just needed a simple JSON payload basically consisting of Input_Account and Input_Passwd, if those matched the AP would set a session cookie and I could probe into the authenticated endpoints, like active clients in the WLANTable. However, now the login endpoint started shipping a payload consisting of iv, key and content which immediately triggered motivation to brush the dust off my cryptography skills.

JavaScript reverse-engineering

To figure out how the AP is encrypting the content, I opened my browser's network console and scanned for some javascript being downloaded. I quickly identified app.js as the biggest bundle, which I downloaded and noticed was minified and horrible to scan through. Therefore, I simply ran prettier on the file, and thus had some source code that looked like something! Grepping around for any references to the Input_Passwd field that was used previously, indicating the payload looked similar like before. Next up I found references to an AesRsaEncrypt method by grepping for the UserLogin/ endpoint that we are calling. It looked like the UserLogin/ endpoint was encrypting some variable before sending the HTTP POST request – seemed relevant!

(n.obj &&
    (i =
      "object" == p()(n.obj)
        ? l()(n.obj)
        : n.obj),
  "None" != this.$store.state.RSAPublicKey &&
    "" != this.$store.state.RSAPublicKey &&
    ("/UserLogin" == n.url
      ? ((g = CryptoJS.lib.WordArray.random(
          32
        ).toString(CryptoJS.enc.Base64)),
        (m = CryptoJS.lib.WordArray.random(
          32
        ).toString(CryptoJS.enc.Base64)),
        localStorage.setItem("AesKey", m),
        this.AesRsaEncrypt(s, i, m, g, !0),
        (i = l()(s)))
        ...

I went into my browser's source panel and found the same AesRsaEncrypt, slapped in a breakpoint and found the arguments that it was called with:

Looking up keys in localStorage I found an AesKey, which was generated by random from CryptoJS and sent as an argument to the AesRsaEncrypt method. Additionally another argument was sent, which was the initialization vector iv generated by random. Lastly, I noticed this.#store.state.RSAPublicKey, which was the same as the one fetched from the endpoint getRSAPublickKey (publickKey typo is real 😅).

Interpreting the code, CryptoJS is used to randomly generate the AesKey and the initialization vector iv for the encryption of the payload. It is using CryptoJS.AES.encrypt for AES encryption which is documented here with CBC and PKCS7, and JSEncrypt for RSA encryption which is documented here.

The Zyxel AP seems to encrypt all payloads of other endpoints in transit, meaning we get similar JSON payloads with iv and content encrypted by the AES key provided initially in the login payload. This key needs to be exchanged, and that is what the JSEncrypt and public key are provided for. We encrypt the AES-key with the AP's RSA public key. This is done so the AP can decrypt it with its own private key, and in return can encrypt the payload with the same AES key. Then the client can decrypt again by persisting the AES key generated initially.

These pieces are the ones used to generate the payload of {"iv": <iv>, "key": <key>, "content": <content>} that we need to POST to UserLogin/. So at this point I had gathered pretty much everything I need to reproduce in my own code.

Reimplementing the encryption in Python

The (dirty hacked together) source code is found on https://github.com/warlo/hass-wx3401

I had already implemented a simple device tracking client in python for use in Home Assistant, and since the UserLogin payload looked similar like before – the major work was to start encrypting it identically to the JavaScript implementation.

AES Encryption of payload

CryptoJS uses different key-size AES depending on the key, in our case the key and IV are generated by CryptoJS.lib.WordArray.random(32). This gives 32-bytes equalling 256 bits – hence AES with key size of 256 is used. One interesting thing to note is that the IV is generated as 256-bits, but AES with CBC mode always uses 128-bits initialization vectors. It turns out CryptoJS simply just slices the input, and uses 128bits of them.

I decided to use the cryptography library in python to solve this:

import base64
import os

from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes


def encrypt(text: str) -> dict:
    aes_key = os.urandom(32)  # NOTE: persist this!
    iv_bytes = os.urandom(32)

    cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv_bytes[:16]))
    padder = padding.PKCS7(128).padder()
    padded_t = padder.update(text.encode()) + padder.finalize()
    encryptor = cipher.encryptor()
    encrypted_content = encryptor.update(padded_t) + encryptor.finalize()

    return {
        "iv": base64.b64encode(iv_bytes).decode(),
        "content": base64.b64encode(encrypted_content).decode(),
    }

This is a solution to generate the iv and the ciphertext. It works by using cryptography's Cipher primitive, defined with AES and CBC mode. Notice I opted to emulate CryptoJS behaviour, slicing the iv for encryption – but still returning the full iv. I struggled understanding this at first, since 256-bit sized iv naturally raised errors, but I felt like replicating the CryptoJS frontend code identically. Furthermore I used padding with PKCS7 and 128 bits, and then basically executed the default encryption methods from Cipher. Lastly encoding the output as a base64 string.

RSA encrypting AES key

The Zyxel AP seems to encrypt all payloads of other endpoints in transit, meaning we get similar JSON responses with iv and content encrypted by the AES provided as key in the login payload. They key-exchange happens by encrypting the AES key we generate with a RSA public key present on the AP.

import aiohttp
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding

async def rsa_encrypt():
    session = aiohttp.ClientSession()
    res = await session.get(
        f"https://<zyxel_ip>/getRSAPublickKey",
    )

    public_key_payload: dict[str, str] = await res.json()
    rsa_pem_data = public_key_payload["RSAPublicKey"]
    rsa_public_key = cast(
        rsa.RSAPublicKey,
        serialization.load_pem_public_key(rsa_pem_data.encode()),
    )
    encrypted_key = rsa_public_key.encrypt(
        base64.b64encode(key_bytes), padding=asymmetric_padding.PKCS1v15()
    )
    key = base64.b64encode(encrypted_key).decode()

First we fetch the public key from the AP. Then we use cryptography's serialization and load_pem_public_key, since the public key is provided in a PEM format. The RSAPublicKey instance gives us an encrypt method, which we call with PKCS1v15 padding. I found this padding by scanning through JSEncrypt's source-code, which had references to PKCS1 – and it is a pretty common padding to use. Providing the outputted key with base64 encoding in the {"iv": <iv>, "content": <content>, "key": key} UserLogin/ payload gives the AP the required key to start encrypt responses.

Decrypting responses

The decryption step works very similar to the encryption step. By persisting the aes_key in the client, we are able to decrypt responses from the AP. The responses come similarly on the form {"iv": <iv>, "content": <content>}, and along with the key we are able to decrypt and read the responses.

def decrypt(aes_key: str, text: str, iv: str) -> str:

    iv_bytes = base64.b64decode(iv)
    key_bytes = base64.b64decode(aes_key)

    cipher = Cipher(algorithms.AES(key_bytes), modes.CBC(iv_bytes[:16]))

    decryptor = cipher.decryptor()
    decrypted_padded_content = (
        decryptor.update(base64.b64decode(text)) + decryptor.finalize()
    )
    unpadder = padding.PKCS7(128).unpadder()
    decrypted_content = (
        unpadder.update(decrypted_padded_content) + unpadder.finalize()
    )
    return cast(str, decrypted_content.decode())

By adding these three methods to my python client and wrap the existing API calls with these, all the existing code worked out of the box 🎉

Source code: https://github.com/warlo/hass-wx3401