stefan-gloor.ch

Hacking a VoIP Phone

Voip phone

In this project, I analyzed and reverse engineered the firmware, file formats and custom cryptography of a business IP phone. As it turns out, many of the phone’s software components are customized, which made this a very interesting and insightful project. Finally, I wrote tools to craft my own update images, in order to install arbitrary software on the phone.

A Look at the Hardware

First of all, I took the phone apart to get an idea with what kind of device I am dealing with. There are 3 PCBs, two of them mostly for the buttons on the front. The PCB in the center contains the main CPU (under the heatsink), its DRAM and a NAND flash (on the back). Interestingly, the central PCB also contains a small 8-bit OTP microcontroller manufactured by Holtek, likely used as a co-processor for real-time tasks.

internals of the phone
Internals of the phone.

Connectivity-wise the phone offers a USB port (e.g., for Bluetooth dongles), two Ethernet jacks (labeled “PC” and “Internet”), an “EXT” phone port, and two analog connectors for the headset and receiver.

Although I can’t read Chinese, the unpopulated 3-pin header looked suspiciously like a serial port to me. Attaching a serial cable to it quickly confirmed this:

UBL version: 1.1.0.8,Jul 20 2016,15:36:49
UBL chipid : 0x00011188
UBL boot   : NAND


version: 2.5.0.3-T4XS (Sep 28 2016,09:00:44)
code   : 03B00000 -> 03B3ACD4
bss    : 03B3ACD4 -> 03B88140
DRAM  :60 MiB
holtek:new style with 16+32bits
HWVER :66.0.0.128.0.0.0
Video :bcm111xx fb
NAND  :128 MiB
In    :serial
Out   :serial
Err   :serial
Net   :bcm111xx emac

scan key=-1
Hit any key to stop autoboot:  1  0 

Loading from NAND 128MiB 3,3V 8-bit, offset 0x15a0000
   Image Name:   Linux-2.6.27.47
   Image Type:   ARM Linux Kernel Image (uncompressed)
   Data Size:    1718092 Bytes = 1.6 MiB
   Load Address: 0e000000
   Entry Point:  0e000000
Automatic boot of image at addr 0x02680000 ...
## Booting kernel from Legacy Image at 02680000 ...
   Image Name:   Linux-2.6.27.47
   Image Type:   ARM Linux Kernel Image (uncompressed)
   Data Size:    1718092 Bytes = 1.6 MiB
   Load Address: 0e000000
   Entry Point:  0e000000
   Verifying Checksum ... OK
   Loading Kernel Image ... OK
OK

Starting kernel ...

Uncompressing Linux to 0x00408000 done, booting the kernel.

From this boot log we can learn a few things: the phone uses an ARM processor, uses U-Boot as a bootloader and runs on Linux 2.6.27. Although the serial console seems to be disabled for Linux, we can try to get into the U-Boot console, which would allow us to flash new firmware or potentially change the kernel command line. Note that the bootloader seems to be aware of the co-processor, as “holtek” probably refers to the Holtek microcontroller.

Pressing any key during boot does indeed drop us to the bootloader shell!

However, the shell does not seem to be functional. The prompt is shown, and is printed on a new line after pressing “Enter”, but any other character is not echoed on the serial console. I tried entering a few commands anyway, but couldn’t see any output from them. I made sure that the serial cable and its connection were good, but I couldn’t get it to work. Because of this, I tried to attack the device in another way.

Analyzing the Firmware

To perform a firmware update, you can download an update file from the manufacturer’s website and download that to the phone either via web interface, USB or TFTP. However, the firmware files are encrypted multiple times, so they cannot easily be analyzed.

Lucky for me, Tristan Pourcelot at Synacktiv already did some great reverse engineering of firmware for these kinds of phones. In his research, he found that some of the firmware files are not signed and are only encrypted using very weak ciphers and hard-coded keys. Using his scripts, I was able to decipher the first “layer” of the firmware image: I could unpack the vendor .rom image into two files: app.bin and version.bin.

diagram showing how the vendor rom consists of parts which can be decrypted using cypher3

However, I was unable to further unpack these intermediate files, because they were partially encrypted as well. Hence, I started to reverse engineer the encryption scheme myself.

app.bin seems to have the same cleartext header as its parent .rom file. The header indicates that the payload is encrypted by cipher type 0x83, which does not seem to be supported by the phone’s upgrade library present in previous firmware versions:

Decompiled function in libupgrade.so which selects the decryption function applied to a new firmware image before it is installed. There is no support for the cipher identifier I encountered (0x83)
Decompiled function in libupgrade.so which selects the decryption function applied to a new firmware image before it is installed. There is no support for the cipher identifier I encountered (0x83).

This is weird, because it is possible to update from an old version to this new one. This implies that the code required to decrypt this firmware must be included within itself, i.e., it must be self-decrypting.

Self-Decrypting Firmware

This led me to analyze the other file, version.bin. An analysis using binwalk showed that it consists of an ELF binary, but most of the file is encrypted or compressed (high entropy). I loaded the ELF binary into Ghidra and started to reverse engineer it.

From this, I found that the ELF in version.bin is actually a decryptor for the rest of the file. The payload consist of two executables, uptool and libupgrade.so which are encrypted using AES-CBC-256.

diagram showing how version.bin decrypts itself to reveal libupgrade.so and uptool

So, during an update, version.bin decrypts itself, yielding libupgrade.so which then overwrites the existing upgrade library on the phone. This introduces support for the cipher used in the new firmware, allowing app.bin to be decrypted.

But what key does version.bin use to decrypt itself?

“Hardware-Assisted” AES

Instead of hard-coding the key, the engineers at Yealink got creative. By analyzing the decryptor binary in Ghidra, I found out that they use a special key derivation algorithm. They use the hardware ID (which is 65 on the model I have) as a seed, which they pass on to another function. The result of this function is stored as part of the final key. On every iteration, 16 bits are added to the AES key.

custom AES derivation function
Custom AES key derivation function

This mistery function issues an ioctl system call to a custom kernel module. This kernel module talks to the small Holtek microcontroller!

So, the microcontroller is used to generate the AES key in a challenge-response fashion. For each 16-bit challenge sent to the microcontroller (generated by the function above), it would respond with a different 16-bit value. This is repeated 16 times to assemble the full 256 bits of the AES key.

To get the key, I attached a logic analyzer to the serial bus to eavesdrop on the communication between the main CPU and the microcontroller. The serial protocol seems to be similar to SPI, as it uses a separate clock and chip select signal, but there is only one wire for the bidirectional data channel. Also, the bit width seems to be different for each packet, but I think it is always a multiple of four bits.

measurement setup to eavesdrop on the communication between the main CPU and the microcontroller
Measurement setup to eavesdrop on the communication between the main CPU and the microcontroller.

By manually looking for a pattern that repeats 16 times, I was able to identify the relevant data exchange. One packet is 20 bits long and carries a 16 bit payload. The first nibble is either 0 or 1 and indicates whether the packet is sent by the main CPU or the microcontroller.

Logic analyzer output showing the challenge packet being send from the main CPU and the response coming from the microcontroller, reveiling the first 16 bits of the key
The challenge packet sent by the main CPU and the corresponding response from the microcontroller to get 16 bits of the key. This repeats 16 times to get the full AES key.

This allowed me to recover the full AES key, and fully decrypt version.bin! Now, I can continue to analyze the decrypted libupgrade.so to find out how to decrypt app.bin.

Decrypting the Root File System

By reverse engineering libupgrade.so, I figured out that app.bin is encrypted using AES-ECB-128 and a hard-coded key. However, this key is first encoded by a simple cipher before it is registered with the AES engine.

cypher4 function
The AES key is encoded using cipher 4 before use.

I extracted the hard-coded key and applied the cipher to it to get the AES key that is used for decryption. However, I wasn’t able to properly decrypt the image using OpenSSL or pycryptodome. I tried several mutations, such as using different endianness and byte order, but to no avail.

Eventually, I extracted the decompiled AES engine from Ghidra and integrated it in my own decryptor tool. With this, the decryption worked! This lead me to believe that the AES implementation might also be modified, but I haven’t verified this.

At this point, I was able to extract multiple images from app.bin, including the kernel and the YAFFS root filesystem. Of course, the YAFFS filesystem was encrypted again, but luckily did not use a new method, so I could use the existing decryption script.

diagram showing all components of the vendor rom image and their encryption scheme
Overview of the update file components and their respective encryption or compression schemes.

Repacking

With the firmware completely decrypted and unpacked, the next goal is to add modifications to the firmware (e.g., add binaries to the root file system), repack the image, and install it on the phone. For this, I wrote a C program to do all the decryption, decompression and unpacking described above in reverse, while updating all headers and checksums.

I did eventually manage to create a repackaged update file that would be accepted by the phone and not throw any errors, however, the phone would not boot anymore after the update. Inspecting the serial port revealed that the phone was entirely bricked, not even UBL (first bootloader executed after boot ROM, before U-Boot) seemed to run, so there was no way of flashing another image. It is weird that the bootloader seemingly got corrupted, because in my understanding, the bootloaders are not contained in the update image, and should therefore not be overwritten.

Flash Dump Recovery

In an attempt to investigate what went wrong, I desoldered the NAND flash and dumped it. In hindsight, I obviously should have done this first, in order to have a backup.

Telephone PCB with desoldered NAND flash and flash programmer
Underside of main PCB with desoldered NAND flash put into a Flashcat NAND flash programmer to dump it.

Using this flash dump and the information from the decrypted firmware, I was able to reverse engineer the flash layout:

Offset Partition name Use Mount point
0x0 ubl UBL bootloader
0x20000 uboot U-Boot bootloader
0x120000 env U-Boot environment
0x220000 userenv User environment
0x320000 logo Pictures, splash screens
0x420000 smkern Safe mode kernel
0x6a0000 smrfs Safe mode rootfs
0x15a0000 kernel Linux kernel
0x1820000 rfs Root file system /
0x2720000 app App file system /phone
0x7200000 cfg Config file system /config
0x7600000 data Data file system /data

I took a closer look at the flash dump of the bricked phone and indeed, it seems like the UBL bootloader partition was accidentally overwritten by an encrypted YAFFS filesystem, presumably app or rfs. So I used a second phone and copied the UBL partition over, which was enough to revive the phone!

After comparing the headers of the original update file with the one generated by my tool once again, I noticed my mistake. The reason that the filesystem was being copied to the wrong flash partition was a missing byte in the block headers. This one byte in the header identifies to which partition the blocks payload should be copied to, but it was always set to 0. After fixing this mistake, I was able to successfully update the phone using my generated update file.

Code Execution Attempt 1

The easiest way to achieve simple code execution (without persistence) is to replace the ELF (the decryptor code) in version.bin, as it is not encrypted by AES (only the container is ciphered) and is automatically executed on update.

I first tried a simple reverse shell and reverse-ssh as payloads, but both of them resulted in the error “ROM block invalid”, which first led me to believe my repacker was incorrect. But strangely, there was no error when my payload was a simple endless loop. As expected, the updater would download the image and then hang. This is why I think the error is a side effect of the binary crashing.

Next, I tried to craft some shell code in assembly. My goal was to spawn a telnet server to which could connect to get shell access. As the system uses busybox, I need to call busybox with argv[0]=telnetd to achieve this.

.data
.section .rodata

command:
        .string "/phone/bin/busybox\0"
arg0:
        .string "/bin/telnetd\0"

arg1:
        .string "-F\0"

arg2:
        .string "\0"

args:
        .word arg0
        .word arg1
        .word arg2

.text
.global _start

_start:
        ldr r0, =command
        ldr r1, =args
        mov r2, #0
        mov r7, #11
        svc #0
loop:
        b loop
        

However, this also resulted in an error. If I remove the system call (svc), the updater hangs as expected and no error is shown. First, I thought this might be seccomp preventing the execution of the execve syscall, but then I noticed it does not crash if instead of invoking busybox, I pass a non-existing path. This means that the crash must either be related to busybox somehow, or the way I do the syscall is wrong for this version of Linux. In hindsight, the reason might have been mismatching ABI versions of the assembler and the target system. Anyway, I abandoned this idea and tried to gain shell access in another way.

Code Execution Attempt 2

My next idea to get a backdoor executed on the phone was to tamper with the root file system. For this, I would need to add my payload binary or script to the YAFFS2 filesystem, repack and encrypt it again and include it in the update file I can load over the web interface. I could also directly flash the rootfs to the NAND partition for testing.

The YAFFS2 file systems in the Yealink update file are in “raw OOB” format, i.e., they include the NAND flash spare areas, so they will be copied to flash in a 1:1 fashion, unlike the kernel or applications, which are copied on partition or filesystem level, respectively. This implies that I need to know how to populate these spare areas which are interleaved with the actual filesystem data.

Reversing The NAND ECC Scheme

The NAND chip in the phone uses 2048 byte pages, followed by 64 byte spare areas. The stored data is encoded in the main page area, whereas the spare area holds metadata (such as bad block markers) and error correction (ECC) information.

While YAFFS2 comes with its own ECC algorithm and spare layout, referred to as “YAFFS tags”, it seems like the phone uses a non-standard method of encoding the ECC information. Instead of appending the ECC bytes to the YAFFS tag fields, bytes 9-15, 25-31, 41-47 and 57-63 are used to hold the check bits for four 512 byte chunks that make up the 2048 byte page. The remaining bits hold metadata used by the YAFFS2 filesystem.

Hexdump of the NAND spare area, with the ECC bytes highlighted
Hexdump of a NAND spare area, with the ECC bytes highlighted.

The higher nibble of the last ECC byte is always zero, so actually only 52 check bits are used to check a 512 byte chunk. This strongly suggests the use of a BCH code, in particular with a degree 13 polynomial and an error correction capability of 4, as 4 x 13 = 52. This is in contrast to the expected hamming codes, as suggested by the YAFFS2 filesystem implementation.

As I couldn’t find any hints in the decompiled firmware, I started to suspect that the SoC uses a hardware ECC controller. This is backed by this project, which reverse engineered the ECC scheme of other Broadcom NAND controllers, and found the same 52 bit layout. Unfortunately, the ECC scheme is not exactly the same for the SoC used in the phone, which is part of the BCM111xx family, so I wasn’t able to reproduce the ECC bits using the existing BCH tool.

I decompiled the kernel driver for the NAND controller to verify that my assumptions about polynomial degree (13) and error correction capability (4) were correct. However, I could not find the generator polynomial in the software (neither decompiled nor from mainline Linux), so I am assuming it is hard-wired. I iterated through all possible minimal polynomials of degree 13 over GF(2), but I could not reproduce the ECC bytes. I tried different endianness, reverse bit and byte order, and compared the hamming weights. I even plotted a 256 x 256 confusion matrix for each ECC byte to see whether there is a 1:1 mapping applied, but to no avail.

Code Execution Attempt 3

In my final attempt, I focused again on the update process. Instead of completely replacing the decryptor binary in version.bin, I exploited a system() function call I discovered while decompiling the binary.

decompiled function showing the system() call

It should be fairly easy to patch the binary to replace the static string with custom shell code. And in fact, after replacing it with

telnetd -l /bin/sh -F

I could connect over telnet and get a root shell!

Getting Persistence and Installing DOOM

Thanks to the root shell, I could now remount the relevant partition as writeable and have my own shell scripts run on boot. This allowed me to persistently have a telnet server running whenever the device is powered on, which I can use to get root shell access.

Next, I tried running my payloads again that I tried to inject into the update decryptor earlier. As expected, they immediately segfault, which explains the errors and reboots I was seeing. I also compiled some simple “Hello world” programs, which would also immediately crash, regardless of whether they were compiled statically or whether they used system calls. I guess this happened because of an incompatible ABI version, so I need to retry with a toolchain that is compiled for the kernel version the phone uses.

The firmware that I was working on uses a 2.6.27 kernel from 2008. I first tried to get a toolchain for this kernel working on my modern machine, but I realized after a few days of trying that this was way too much trouble. So, I travelled back in time and set up an Ubuntu 10.04 VM and installed crosstool-ng from around the same time. I bootstrapped the build system by using a mix of archived deb packages and compiling some GNU build tools locally. Once crosstool-ng was working, I could use it to compile a standard arm-unknown-linux-gnueabi toolchain for the 2.6.27 kernel. It uses GCC 4.3 and glibc 2.9, all released around 16 years ago. Using this toolchain, my programs now run fine on the phone.

After getting a persistent shell on a device with a color screen, it is mandatory to run DOOM. For this, I used fbdoom, which is designed to only require a Linux framebuffer device, e.g., /dev/fb0. I patched the code to use the correct screen orientation and scaling for my device. The final binary is kept on a USB drive plugged into the phone’s back, launched by a shell script on boot.

GIF showing doom running on the phone.

So yes, of course it runs DOOM.