I needed a cheap LTE router for some quick tests and bought a used Alcatel HH40V for cheap. It's based on a QCA9531 SoC with 128MiB RAM and 16MiB flash and it's running an old, customized version of OpenWrt on this SoC. The LTE module is based on a Qualcomm module and is running its own, customized Android stack.
Getting access to the LTE module is documented here: https://github.com/froonix/HH40V/wiki
However, getting access to the SoC is neither documented nor easily possible - up to now.
There are two pads for UART on the PCB - orange is Rx, green is Tx and blue is GND (that's not a pad):
The serial console is then available with 115200 baud and no password.
Getting SSH access is required in order to install OpenWrt without having to solder to the pads. I poked around in the filesystem and on the Wiki and found out that it is possible to download a configuration backup - unfortunately, it is encrypted. Someone in the Wiki mentioned it's based on OpenSSL, so I searched for OpenSSL in the file core_app
, one of the biggest applications of the OEM firmware. It contained the hardcoded password for the for the backup file - yeah!
Long story short, with the information in this core_app
, I could come up with a Python script that modifies the backup in a way that it enabled the Dropbear SSH server upon startup.
This is the script (also available at https://gist.github.com/andyboeh/d295c80a57d62379b926640762f3d5dd), run it as enable_sshd.py configure.bin
. Then, upload the modified backup file (configure.bin_mod.bin
) to your router, reboot and you have SSH access. Credentials: User “root”, Password “oelinux123”.
#!/usr/bin/env python import os import sys import subprocess import tempfile import struct import shutil import hashlib class SSHEnabler(object): def __init__(self, filepath, directory): self.openssl = None self.filepath = filepath self.directory = directory self.check_openssl() def decrypt_file(self): if not os.path.exists(self.filepath): print(f"Input file does not exist: {self.filepath}") return False ret = subprocess.run([self.openssl, "aes-128-cbc", "-d", "-k", "oacgnahsnauyilil", "-base64", "-in", self.filepath, "-out", self.directory + os.path.sep + "decrypted.tar.gz", "-md", "md5"]) if ret.returncode != 0: print("Error decrypting file") return False ret = subprocess.run(["tar", "zxf", self.directory + os.path.sep + "decrypted.tar.gz", "-C", self.directory]) if ret.returncode != 0: print("Error extracting file") return False if not os.path.exists(self.directory + os.path.sep + "configure.bin"): print("Extracted file configure.bin does not exist") return False os.remove(self.directory + os.path.sep + "decrypted.tar.gz") with open(self.directory + os.path.sep + "configure.bin", "rb") as f: head = f.read(45) # The first 45 bytes contain ALCATEL BACKUP HEAD, the filename of the extracted file and the lenght of it if head[0:24] != b"ALCATEL BACKUP FILE HEAD": print("Head does not start with ALCATEL BACKUP FILE HEAD") return False length, = struct.unpack(">h", head[43:45]) ret = subprocess.call(["dd", "if="+self.directory + os.path.sep + "configure.bin", "of="+self.directory + os.path.sep + "backup.tar.gz", "bs=1", "skip=45", "count=" + str(length)]) ret = subprocess.call(["dd", "if="+self.directory + os.path.sep + "configure.bin", "of="+self.directory + os.path.sep + "configure.bin.head", "bs=1", "count=43"]) ret = subprocess.call(["dd", "if="+self.directory + os.path.sep + "configure.bin", "of="+self.directory + os.path.sep + "configure.bin.tail", "bs=1", "skip=" + str(45 + length)]) os.remove(self.directory + os.path.sep + "configure.bin") os.remove(self.directory + os.path.sep + "configure.md5") ret = subprocess.run(["tar", "zxf", self.directory + os.path.sep + "backup.tar.gz", "-C", self.directory]) if ret.returncode != 0: print("Error extracting backup.tar.gz") return False if not os.path.exists(self.directory + os.path.sep + "backup_dir" + os.path.sep + "sysconfig.tar.gz"): print("sysconfig.tar.gz in backup file not found") return False os.remove(self.directory + os.path.sep + "backup.tar.gz") ret = subprocess.run(["tar", "zxf", self.directory + os.path.sep + "backup_dir" + os.path.sep + "sysconfig.tar.gz", "-C", self.directory + os.path.sep + "backup_dir"]) if ret.returncode != 0: print("Error extracting sysconfig.tar.gz") return False if not os.path.exists(self.directory + os.path.sep + "backup_dir" + os.path.sep + "etc" + os.path.sep + "rc.local"): print("rc.local not found") return False os.remove(self.directory + os.path.sep + "backup_dir" + os.path.sep + "etc" + os.path.sep + "rc.local") with open(self.directory + os.path.sep + "backup_dir" + os.path.sep + "etc" + os.path.sep + "rc.local", "w") as f: f.write("# Put your custom commands here that should be executed once\n") f.write("# the system init finished. By default this file does nothing.\n") f.write("\n") f.write("/etc/init.d/dropbear start\n") f.write("\n") f.write("exit 0\n") os.remove(self.directory + os.path.sep + "backup_dir" + os.path.sep + "sysconfig.tar.gz") ret = subprocess.run(["tar", "zcf", self.directory + os.path.sep + "backup_dir" + os.path.sep + "sysconfig.tar.gz", "-C", self.directory + os.path.sep + "backup_dir", "etc"]) if ret.returncode != 0: print("Error creating sysconfig.tar.gz") return False shutil.rmtree(self.directory + os.path.sep + "backup_dir" + os.path.sep + "etc") ret = subprocess.run(["tar", "zcf", self.directory + os.path.sep + "backup.tar.gz", "-C", self.directory, "backup_dir"]) if ret.returncode != 0: print("Error creating backup.tar.gz") return False shutil.rmtree(self.directory + os.path.sep + "backup_dir") size = os.path.getsize(self.directory + os.path.sep + "backup.tar.gz") with open(self.directory + os.path.sep + "configure.bin.head", "ab") as f: f.write(struct.pack(">h", size)) with open(self.directory + os.path.sep + "configure.bin", "wb") as f: with open(self.directory + os.path.sep + "configure.bin.head", "rb") as g: f.write(g.read()) with open(self.directory + os.path.sep + "backup.tar.gz", "rb") as g: f.write(g.read()) with open(self.directory + os.path.sep + "configure.bin.tail", "rb") as g: f.write(g.read()) os.remove(self.directory + os.path.sep + "configure.bin.head") os.remove(self.directory + os.path.sep + "configure.bin.tail") os.remove(self.directory + os.path.sep + "backup.tar.gz") md5 = hashlib.md5(open(self.directory + os.path.sep + "configure.bin", "rb").read()).hexdigest() with open(self.directory + os.path.sep + "configure.md5", "w") as f: f.write(md5 + "\n") ret = subprocess.run(["tar", "zcf", self.directory + os.path.sep + "decrypted.tar.gz", "-C", self.directory, "configure.bin", "configure.md5"]) if ret.returncode != 0: print("Error creating final .tar.gz file") return False ret = subprocess.run([self.openssl, "aes-128-cbc", "-e", "-k", "oacgnahsnauyilil", "-base64", "-in", self.directory + os.path.sep + "decrypted.tar.gz", "-out", self.filepath + "_mod.bin", "-md", "md5"]) def which(self, program): def is_exe(fpath): return os.path.isfile(fpath) and os.access(fpath, os.X_OK) fpath, fname = os.path.split(program) if fpath: if is_exe(program): return program else: for path in os.environ["PATH"].split(os.pathsep): path = path.strip('"') exe_file = os.path.join(path, program) if is_exe(exe_file): return exe_file return None def check_openssl(self): self.openssl = self.which("openssl") if self.openssl: ret = subprocess.run([self.openssl, "version"], stdout = subprocess.PIPE, universal_newlines = True) if ret.returncode == 0: version = ret.stdout.replace('\n', '') return version return False if len(sys.argv) < 2: print("Usage: enable_sshd.py configure.bin") sys.exit(1) with tempfile.TemporaryDirectory() as tempdir: enabler = SSHEnabler(sys.argv[1], tempdir) enabler.decrypt_file()
Once you have SSH access, you can proceed with installing OpenWrt.
Very brief overview of the file format after decryption.
String: ALCATEL BACKUP FILE HEAD 0x00 0x00 0x00 0xzz length of filename (without 0 terminator - maybe it's a ushort with the previous 0x00) String: filename 0x00 0x00 0xzz file 0xzz length (ushort) TAR GZ ARCHIVE 0x00 0x00 0x00 0xzz length of filename (without 0 terminator - maybe it's a ushort with the previous 0x00) Binary: file content String: filename 0x00 0x00 0xzz number of bytes remaining in file (ushort) 0xzz String: ALCATEL BACKUKP FILE HEAD 0xf7 Lenght of file, different endian (originates from modem) 0x3b 0x00 0x00 Binary: file content Binary: Some sort of trailer, didn't attempt to decode
OpenWrt support to snapshot was added on 2023/04/23 with commit https://github.com/openwrt/openwrt/commit/097f350aebc542963c7208af4973ff17e01ce76e.
Running OpenWrt on this device requires a slightly different partition layout, because the kernel partition is fixed with 1.5MB and thus too small to run a recent kernel. However, there is enough space and initial installation requires a small modification of one U-Boot variable. As long as the system boots normally, there are no problems with this approach. Installing via TFTP (recovery) restores the variable back to default, forcing the installation of a stock firmware.
NB: Although the systems is running a variation of OpenWrt, the sysupgrade process is heavily modified and cannot be used as-is to install OpenWrt.
scp -O -o HostKeyAlgorithms=ssh-rsa -o KexAlgorithms=diffie-hellman-group1-sha1 -o UserKnownHostsFile=/dev/null openwrt-ath79-generic-alcatel_hh40v-squashfs-factory.bin root@192.168.1.1:/tmp
scp -O -o HostKeyAlgorithms=ssh-rsa -o KexAlgorithms=diffie-hellman-group1-sha1 -o UserKnownHostsFile=/dev/null upgrade.sh root@192.168.1.1:/tmp
chmod +x /tmp/upgrade.sh
/tmp/upgrade.sh /tmp/openwrt-ath79-generic-alcatel_hh40v-squashfs-factory.bin
Contents of /tmp/upgrade.sh
#!/bin/sh IMAGE_NAME="$1" if [ ! -e ${IMAGE_NAME} ]; then echo "Image file not found: ${IMAGE_NAME}" exit 1 fi . /lib/upgrade/common.sh fw_setenv bootcmd "bootm 0x9f150000" kill_remaining TERM sleep 3 kill_remaining KILL run_ramfs mtd write ${IMAGE_NAME} firmware sleep 2 reboot -f
The LTE modem is an RNDIS device and works out-of-the-box if it was properly set up for the stock firmware. Limited configuration can be achieved using AT commands over USB (/dev/ttyUSB1).
To restore stock, you need a system image. Unfortunately, you can't backup without disassembling and soldering serial headers. However, a firmware upgrade can be downloaded from https://alcatelfirmware.com/alcatel-onetouch-hh40v. Look for the file Firmware/DownloadImage/uprade_hh40v_all.tar.gz. Inside, you will find the file “sysupgrade/firmware-system.bin”. This firmware-system.bin can be installed via TFTP recovery. The TFTP recovery also resets any U-Boot variables so it will boot normally.