Reversing TP-Link EX220 from A1

Large telecoms in Bulgaria are also ISPs and there is a common practice to give out “free” Wi-Fi routers to customers. Last time I wrote about the Smartcom Ralink router and its insecure default Wi-Fi passwords. This time I will look into the TP-Link EX220 router which is being deployed by A1.

My main motivation for this research was their ridiculous policy of forcing customers to use this router and not allowing them to use their own. A1 also refuses to provide admin credentials for the router and people are forced to call their support for simple tasks like changing the Wi-Fi SSID. This is both inconvenient and terrible from privacy and security perspective.

Getting root access

First thing, of course, is to open the router and try to find UART header for serial console. The UART pinout is pretty obvious, we just need to solder a header to the PCB:

The serial console works but it ends up with a login prompt. I tried some default credentials for root and admin with no success. Fortunately, the bootloader (u-boot) is accessible and it allows loading custom firmware images. I downloaded initramfs image from OpenWRT and uploaded it using the XMODEM protocol (TFTP didn’t work for some reason). It booted successfully and I got access to the device and its flash.

The next step was to dump the flash partitions and analyze them. There are 13 partitions in total, but only 2 of them are interesting (rootfs and config):

root@OpenWrt:~# cat /proc/mtd
dev:    size   erasesize  name
mtd0: 00030000 00010000 "boot"
mtd1: 00010000 00010000 "boot-env"
mtd2: 00010000 00010000 "factory"
mtd3: 00010000 00010000 "config"
mtd4: 00010000 00010000 "isp_config"
mtd5: 00010000 00010000 "rom_file"
mtd6: 00010000 00010000 "cloud"
mtd7: 00020000 00010000 "radio"
mtd8: 00010000 00010000 "config_bak"
mtd9: 00f30000 00010000 "firmware"
mtd10: 003e0000 00010000 "kernel"
mtd11: 00b50000 00010000 "rootfs"
mtd12: 00020000 00010000 "rootfs_data"

rootfs is squashfs image with the root filesystem and config stores the configuration of the device.

The rootfs has no /etc/shadow, only /etc/passwd with the following users:

admin:I55OiXWnKr89Y:0:0:root:/:/bin/sh
dropbear:x:500:500:dropbear:/var/tmp/dropbear:/bin/sh
nobody:*:0:0:nobody:/:/bin/sh

It took me ~20min to crack the admin password with hashcat and RTX 3090 – it is spbu100e. Now I can login as admin on the serial console with the default firmware.

The next step was to find how the router configuration is stored in the config partition (rootfs is read-only). Turns out it is encrypted with AES-128-CBC and both key and IV are hardcoded in /lib/libcmm.so:

key: 1528632946736109
 IV: 1528632946736539

Dumping the partition to a file and decrypting it with openssl could be done with:

$ openssl enc -aes-128-cbc -d -in config.bin -out config.dec --nopad -K 31353238363332393436373336313039 -iv 31353238363332393436373336353339

and it almost works. We get plaintext mixed with binary data which probably means they are using some custom format after decryption. Instead of reversing this crap I thought why not just use the original library to dump the configuration for me. There is dm_dumpCfg() function in libcmm.so and it appears to be doing exactly that. All I need to do is to build a small binary which loads the library and calls this function.

Toolchain

The architecture is MIPS and they are using uClibc v1.0.14. There are several toolchains for compiling programs with uClibc but the top choice is OpenADK which is developed by the same people who created uClibc.

Building the OpenADK toolchain was surprisingly easy. I just had to specify the exact version of uClibc, patch available here.

Dumping the configuration

With some trial and error I came up with this simple program which loads the library and successfully dumps the configuration:

#include <stdio.h>
#include <dlfcn.h>

typedef int (*dm_shmInit_fun)(int);
typedef int (*dm_loadCfg_fun)();
typedef int (*dm_dumpCfg_fun)();

int main(int argc, char * argv[])
{
    void *handle = dlopen("/lib/libcmm.so", RTLD_LAZY);
    if (!handle) {
        fprintf(stderr, "dlopen failed\n");
        return 1;
    }
    dm_shmInit_fun dm_shmInit = dlsym(handle, "dm_shmInit");
    dm_loadCfg_fun dm_loadCfg = dlsym(handle, "dm_loadCfg");
    dm_dumpCfg_fun dm_dumpCfg = dlsym(handle, "dm_dumpCfg");
    if (!dm_shmInit || !dm_loadCfg || !dm_dumpCfg) {
        fprintf(stderr, "dlsym failed\n");
        return 1;
    }
    dm_shmInit(0);
    dm_loadCfg();
    dm_dumpCfg();
    return 0;
}

Build and publish on the host:

$OPENADK_ROOT/toolchain_generic-mips_uclibc-ng_mips32r2el_soft/usr/mipsel-openadk-linux-uclibc/bin/gcc -o dumpcfg dumpcfg.c -ldl
python3 -m http.server

Download and run on the router:

curl --output dumpcfg http://192.168.0.2:8000/dumpcfg
chmod +x dumpcfg
export LD_PRELOAD=/lib/libgdpr.so:/lib/libcutil.so:/lib/libmapShared.so:/lib/libos.so:/lib/libxml.so:/lib/libcmm.so
./dumpcfg

The result is a giant XML which contains the entire configuration of the router. Here are some interesting parts:

<Device>
...
  <DeviceInfo>
    <ManufacturerOUI>F0A731</ManufacturerOUI>
    <X_TP_DeviceModel>EX220(EU)</X_TP_DeviceModel>
    <SerialNumber>REDACTED</SerialNumber>
    <HardwareVersion>EX220 v1.0 00000000</HardwareVersion>
    <SoftwareVersion>A1.EX220.016</SoftwareVersion>
    <ProvisioningCode>1</ProvisioningCode>
    <UpTime>7</UpTime>
    <X_TP_ConfigVersion>1509949442</X_TP_ConfigVersion>
  </DeviceInfo>
  ...
  <X_TP_UserCfg>
    <AdminName>root</AdminName>
    <AdminPwd>aDm1n$TR8r</AdminPwd>
    <UserEnable>0</UserEnable>
    <UserRemoteEnable>0</UserRemoteEnable>
  </X_TP_UserCfg>
  ...
</Device>

User root and password aDm1n$TR8r are the default credentials for the web interface. Now even if the ISP changes the password (via TR-069), we can always dump the configuration and get it back!

This completes the quest of having full control over this router.

SSH access

Having root access on the serial console is great but it is not very convenient. SSH access would be much better. The default firmware has dropbear SSH server but it doesn’t give shell access. OK, then let’s build our own dropbear which gives shell access. Checkout my OpenADK fork and run:

$ make package=dropbear clean package

Update the rootfs image with the new dropbear binary and flash it back:

# Host side
unsquashfs -d rootfs-work partitions/rootfs.bin
cp $OPENADK_ROOT/target_generic-mips_uclibc-ng_mips32r2el_soft/usr/sbin/dropbear rootfs-work/usr/bin/dropbearmulti
mksquashfs rootfs-work rootfs-patched.squashfs -b 262144 -comp xz
python3 -m http.server

# Router side
curl --output rootfs.bin http://192.168.1.10:8000/rootfs-patched.squashfs
mtd -r write rootfs.bin rootfs

WPS PIN generation

Finally, let’s look at the WPS PIN generation. We have this page in the Web UI which allows us to generate a new WPS PIN:

Alex Stanev found this nice function in the libcmm.so library:

It initializes the random number generator with the current time and then calls rand() to get the WPS PIN (the last digit is a checksum). Let’s verify this with a simple C program:

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char * argv[]) {
    srand(atoi(argv[1]));
    printf("rand() = %d\n", rand());
    return 0;
}

I clicked the “Generate” button in the Web UI at 2025-04-06 12:40:40 and I got the WPS PIN 69636486. Converting the timestamp to seconds since epoch gives us 1743932440 which is the input for srand():

$ gcc rand.c -o rand
$ ./rand 1743932440
rand() = 669636489

The last digit is a checksum and the WPS PIN is 69636486 which is exactly what I got from the Web UI. It appears that uClibc is using the same algorithm as glibc for generating random numbers.

Oh, and one last thing. The default WiFi password is the same as the default WPS PIN. How to exploit this is left as an exercise for the reader.