Skip to content

Instantly share code, notes, and snippets.

@husjon
Last active June 21, 2024 18:37
Show Gist options
  • Save husjon/251cd1e12009449aecca413a2a6a56de to your computer and use it in GitHub Desktop.
Save husjon/251cd1e12009449aecca413a2a6a56de to your computer and use it in GitHub Desktop.
#!/usr/bin/env python
import math
import os
import random
import sys
import warnings
import requests
from urllib3.exceptions import InsecureRequestWarning
def usage():
print(f"Usage: {sys.argv[0]} <PATH_TO_CERTIFICATE>")
print()
print("Environment variables:")
print(" * URL Switch url (e.g.: https://switch.local)")
print(" * DISABLE_VERIFY_SSL In case SSL should be verified (default True)")
print(" * USERNAME Switch Username ")
print(" * PASSWORD Switch Password ")
print(" * KEY_PASSWORD Certificate Private Key Password")
exit(1)
if len(sys.argv) < 2:
usage()
URL = os.environ.get("URL")
DISABLE_VERIFY_SSL = bool(os.environ.get("DISABLE_VERIFY_SSL", False))
USERNAME = os.environ.get("USERNAME")
PASSWORD = os.environ.get("PASSWORD")
KEY_PASSWORD = os.environ.get("KEY_PASSWORD")
KEY_PATH = sys.argv[1]
MISSING_VARIABLES = []
def encode(input):
# Python implementation of the Zyxel GS1900 password encoder
text = ""
possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
length = len(input)
length2 = len(input)
for i in range(1, 321 + 1):
if 0 == i % 5 and length > 0:
length -= 1
text += input[length]
elif i == 123:
if length2 < 10:
text += "0"
else:
text += str(math.floor(length2 / 10))
elif i == 289:
text += str(length2 % 10)
else:
# text += possible[math.floor(random.random() * len(possible))]
text += possible[math.floor(random.random() * len(possible))]
return text
class ZyxelGS1900:
_session = requests.session()
def __init__(self, url, verify=True) -> None:
self.url = url
self.host = url.split("://")[1]
self.verify = verify
if not verify:
# Disable the screaming about Insecure request when not verifying the certificate
warnings.simplefilter("ignore", InsecureRequestWarning)
def login(self, username, password):
# The Zyxel GS1900 uses a two-step login process
# 1. We pass in our credentials and retrieves an Auth ID
# 2. We let the switch verify that we provided the correct Auth ID
login_form = {
"username": username,
"password": encode(password),
"login": "true",
}
# Initial login to get AuthID
res = self._session.post(
f"{self.url}/cgi-bin/dispatcher.cgi",
data=login_form,
verify=self.verify,
)
auth_form = {"authId": res.content.strip(), "login_chk": "true"}
res = self._session.post(
f"{self.url}/cgi-bin/dispatcher.cgi",
data=auth_form,
verify=self.verify,
)
if res.content.strip() != b"OK":
raise Exception("Invalid Auth ID, check username and password")
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.
# 1. The form boundary needs to be prefixed with `----`
# 2. The certificate needs to come before the key password
request = requests.Request(
method="POST",
url=f"{self.url}/cgi-bin/httpuploadcert.cgi",
files={
"http_file": (
path.split("/")[-1],
open(f"./{path}", "rb"),
"application/pkcs12",
),
# password is sent as a file to maintain the order,
# since **requests** adds the data properties prior to the files properties when building the body
"pwd": password,
},
)
# prepare the request
prepared_request = self._session.prepare_request(request=request)
# update the boundary to include the `----` prefix
content_type = prepared_request.headers["Content-Type"]
boundary = content_type.split("boundary=")[-1].strip()
new_boundary = f"----{boundary}"
# update boundary in header (boundary in header includes another `--` prefix)
prepared_request.headers["Content-Type"] = content_type.replace(
boundary, f"--{new_boundary}"
)
# update boundary in body
prepared_request.body = prepared_request.body.replace(
boundary.encode(), new_boundary.encode()
)
# ship ip
response = self._session.send(
prepared_request,
verify=self.verify,
)
if "success" in response.content.decode():
print("Certificate uploaded successfully")
else:
print("Failed to upload certificate")
print("-" * 20)
print(response.content.decode())
if not URL:
MISSING_VARIABLES.append("URL")
if not USERNAME:
MISSING_VARIABLES.append("USERNAME")
if not PASSWORD:
MISSING_VARIABLES.append("PASSWORD")
if not KEY_PASSWORD:
MISSING_VARIABLES.append("KEY_PASSWORD")
if MISSING_VARIABLES:
print("Missing Environment variable(s):")
for var in MISSING_VARIABLES:
print(f" * {var}")
print("\n\n")
usage()
exit(1)
switch = ZyxelGS1900(
# url="https://gs1900.husjon.xyz",
url=URL,
verify=not DISABLE_VERIFY_SSL,
)
switch.login(
username=USERNAME,
password=PASSWORD,
)
switch.upload_certificate(KEY_PATH, KEY_PASSWORD)

Zyxel GS1900 certificate installer

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

19. June 2024:

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.

20. June 2024:

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

21. June 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment