Hacking a 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.
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
.
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:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
So yes, of course it runs DOOM.