Reverse-engineering Zyxel WX3401-B0 encryption and replicating in Python
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