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 and started to write tools to craft my own update images for it (work in progress).

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, it also contains a small 8-Bit OTP microcontroller manufactured by Holtek, which is probably used as some sort of 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:,Jul 20 2016,15:36:49
UBL chipid : 0x00011188
UBL boot   : NAND

version: (Sep 28 2016,09:00:44)
code   : 03B00000 -> 03B3ACD4
bss    : 03B3ACD4 -> 03B88140
DRAM  :60 MiB
holtek:new style with 16+32bits
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-
   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-
   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

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 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 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 lead 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 which are encrypted using AES-CBC-256.

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

So, during an update, version.bin decrypts itself, yielding 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 mistery 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 to find out how to decrypt app.bin.

Decrypting the Root File System

By reverse engineering, 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.

Packing Up

With the firmware completely decrypted and unpacked, I can add modifications to the firmware (e.g., add binaries to the root file system), repack the image and install it on the phone.

The easiest way to achieve simple code execution (without persistence) is to replace the ELF in version.bin, as it is not encrypted by AES (only the container is ciphered) and is automatically executed on update. For this, I wrote a simple tool which assembles the final .rom image from its two components, version.bin and app.bin. It was a bit tricky to get the checksums and headers right, but eventually I managed to craft correct images.

I first tried a simple reverse shell and reverse-ssh as a payload, 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.

.section .rodata

        .string "/phone/bin/busybox\0"
        .string "/bin/telnetd\0"

        .string "-F\0"

        .string "\0"

        .word arg0
        .word arg1
        .word arg2

.global _start

        ldr r0, =command
        ldr r1, =args
        mov r2, #0
        mov r7, #11
        svc #0
        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 be related to busybox somehow.

Please come back later, I am still working on this!