Linux/OpenWrt on the Alcatel HH40V
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.
Getting UART access
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
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.
Backup file format
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
Installing OpenWrt
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.
- Boot the stock firmware
- Take a configuration backup
- Modify the backup to enable SSH access
- Restore the configuration using the modified backup
- Reboot and log in via SSH - User “root”, password “oelinux123”
- Transfer the OpenWrt -factory.bin image to the router:
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
- Transfer the following script named “upgrade.sh” to the router:
scp -O -o HostKeyAlgorithms=ssh-rsa -o KexAlgorithms=diffie-hellman-group1-sha1 -o UserKnownHostsFile=/dev/null upgrade.sh root@192.168.1.1:/tmp
- On the router, make the file executable:
chmod +x /tmp/upgrade.sh
- Run the script with the new firmware to install as parameter:
/tmp/upgrade.sh /tmp/openwrt-ath79-generic-alcatel_hh40v-squashfs-factory.bin
- Wait for the router to upgrade and to boot into OpenWrt
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
Configuring the LTE modem
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).
Restoring Stock
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.
- Power off the device
- Put firwmare-system.bin to your TFTP server
- Configure a static IP of 192.168.1.112
- Hold the buttons “Power” and “WPS” and plug back the power
- Keep holding for about 10 seconds, until U-Boot has booted
- Once transfer is completed, the system starts flashing, this takes some time
- Ultimately, you are back to stock