A simple script to install a certificate (specifically PKCS#12
) for the Zyxel GS1900 switch using the v2.80V2.80(AAHK.0) | 10/16/2023
firmware.
Firmware release notes can be found here: https://download.zyxel.com/GS1900-24E/firmware/GS1900-24E_2.80(AAHK.0)C0_2.pdf
The idea for it is to allow us to update the certificate automatically when needed (hands-free) with for example certbot.
After some investigation I found that the switch was very stubborn about how it wants the request sent.
In short:
- The form-data boundary requires a
----
prefix - The certificate needs to come before private key password.
The script is now fully working, allowing us to do what we set out to do.
A summary of how I figured all of this out can be found below.
Script usage:
python ./upload_cert.py <PATH_TO_CERTIFICATE>
Environment variables:
* URL Switch url (e.g.: https://switch.local)
* DISABLE_VERIFY_SSL In case SSL should be verified (default True)
* USERNAME Switch Username
* PASSWORD Switch Password
* KEY_PASSWORD Certificate Private Key Password
Currently it is in a proof-of-concept state, however authentication and retrieval of the CSRF token for uploading all works.
I am currently havning some issue getting it to recognize the certicate password or at least that's what I get from the response I'm getting: Upload certificate failed. Invalid SSL private key
.
I am not able to replicate the issue or the response in a browser as when I'm using the wrong password for a key I get the following response: Upload certificate failed. Invalid PKCS file.
I've tried inspecting the requests from both the browser and the script whereas the time of the request, the form boundaries and browser specific headers are the only differences.
Even after adding in some of the headers, it still reports the above error.
After banging my head at this for a few hours, I opened up Burp Suite and started looking at the differences between a browser request and Pythons requests library, I found that requests does not use the 4 ----
prefix to the form boundary which most browsers do, hence the Zyxel switch blankly refuses the request since it's not able to read the segments in the form.
Different form boundaries from the different browser types:
- Webkit browsers
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarySZM9ObnCj4YPwCwl
- Firefox:
Content-Type: multipart/form-data; boundary=---------------------------259477228521696883621690493288
- Python's requests:
Content-Type: multipart/form-data; boundary=675b3c098bec1d1719a567ae721f7f9e
After using Burp Suite to manipulate the request from my script until all I was left with was the form boundaries, I facepalmed.
I added one -
after another until I got a successful request, I found that 4 was the magic number (same as Webkit).
The following form boundary is accepted:
Content-Type: multipart/form-data; boundary=----DUMDUM
While the following is not (notice the missing -
):
Content-Type: multipart/form-data; boundary=---DUMDUM
Not only did the request need to have the correct boundary, the order of the form data is paramount.
However, the CSRF token etc is not (you still need to be authenticated).
So the requirement to update the certificate is for the form boundary to include the ----
prefix and that the certificate comes before the password in the body.
Once that's all prepared we can send the request and the certificate is updated on the switch.
I hadn't looked into this since my last update but, today I did.
What I remembered from last time was remembering the encoding function being weird.
I decided to pull in the encoding function and set a few breakpoints to step through it and after just a few iterations it clicked.
What I found made me chuckle and I'm going to say that they could just as well have used something like base64.
The encoding function works as follows:
- It generates a string that is exactly 321 characters long.
- All characters are randomized based on the
possible
characters (with a few exceptions)- The password is literally written in plain text in reverse with a 4 character padding.
- The length of the password is available in the encoded string at position 123 (10s position) and 289(1s position), this is using 0-indexing.
Assumptions: We have a password password123!
(12 characters long) and the randomized characters are replaced with _
.
The output of the encoding function would be just like the following.
(first line is the randomized version, second is the fixed version)
jis6!AW2A3uJJT2HgzW14imxdtJ3DrNofLo2IDDwSSFhsxEITsYhPfaB4A1pnm6zhtYdxZudoOtirbwSdDDm5svCoVwOVU5CNNiB8KQ5b9rr1Lwz6H2YnR5qx71Vr8hSSNpjnW39OYqSNQzv3pyMblGCQa7aBFpRVPBO39lq8p3TuluaWmV1FRzZwkb8oMOtEzdwq5w8E4IfykNpp9tPxX5HjeVWk4pAWjp4xR6tCGi8tafpWuuuEur3OsNFVovY4LmGSg93kir4QTdz5zgEKA4uCp3MQScU2egAzhTLLTpt6mBhL4deCuaAib3rEbuoc
____!____3____2____1____d____r____o____w____s____s____a____p______________________________________________________________1_____________________________________________________________________________________________________________________________________________________________________2________________________________
^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^
\ ---------- our padded password in reverse ---------- / ^ the password length 10s position ^ the password length 1s position
The funny thing is that you can just use this string as the "password" in formData
and it will be treated as valid.
Here's a quick example showing the formdata sent to the switch including the login reponse with both the randomized and fixed padding character.
Zyxel_GS1900_certificate_installer on ξ decode-login [!?] via ξ v20.18.3 via π v3.12.9 via βοΈ impure (nix-shell-env) took 2s
β― bash upload.sh
{'username': 'test', 'password': 'jis6!AW2A3uJJT2HgzW14imxdtJ3DrNofLo2IDDwSSFhsxEITsYhPfaB4A1pnm6zhtYdxZudoOtirbwSdDDm5svCoVwOVU5CNNiB8KQ5b9rr1Lwz6H2YnR5qx71Vr8hSSNpjnW39OYqSNQzv3pyMblGCQa7aBFpRVPBO39lq8p3TuluaWmV1FRzZwkb8oMOtEzdwq5w8E4IfykNpp9tPxX5HjeVWk4pAWjp4xR6tCGi8tafpWuuuEur3OsNFVovY4LmGSg93kir4QTdz5zgEKA4uCp3MQScU2egAzhTLLTpt6mBhL4deCuaAib3rEbuoc', 'login': 'true'}
AuthID:
2BE97C2D19D95F1FE0EF2D917ACB2C5A
Login response: OK
done
Zyxel_GS1900_certificate_installer on ξ decode-login [!?] via ξ v20.18.3 via π v3.12.9 via βοΈ impure (nix-shell-env) took 2s
β― bash upload.sh
{'username': 'test', 'password': '____!____3____2____1____d____r____o____w____s____s____a____p______________________________________________________________1_____________________________________________________________________________________________________________________________________________________________________2________________________________', 'login': 'true'}
AuthID:
BC823AA1CD6C048037BB0634C5DA9265
Login response: OK
done
Zyxel_GS1900_certificate_installer on ξ decode-login [!?] via ξ v20.18.3 via π v3.12.9 via βοΈ impure (nix-shell-env) took 2s
β―
The above example (fixed padding character version) was run with the following patch applied.
diff --git a/upload_cert.py b/upload_cert.py
index e9c5499..7e4132b 100644
--- a/upload_cert.py
+++ b/upload_cert.py
@@ -57,7 +57,7 @@ def encode(input):
text += str(length2 % 10)
else:
- text += possible[math.floor(random.random() * len(possible))]
+ text += "_"
return text
@@ -84,6 +84,7 @@ class ZyxelGS1900:
"password": encode(password),
"login": "true",
}
+ print(login_form)
# Initial login to get AuthID
res = self._session.post(
@@ -91,6 +92,7 @@ class ZyxelGS1900:
data=login_form,
verify=self.verify,
)
+ print('AuthID:', res.content.decode())
auth_form = {"authId": res.content.strip(), "login_chk": "true"}
@@ -99,9 +101,11 @@ class ZyxelGS1900:
data=auth_form,
verify=self.verify,
)
+ print('Login response:', res.content.decode())
if res.content.strip() != b"OK":
raise Exception("Invalid Auth ID, check username and password")
+ print("done")
def upload_certificate(self, path, password):
# The switch is extremely particular in how it accepts the payload hence we need to prepare the request to make a few modifications.