Pwning 3CX Phone Management Backends from the Internet

11 min readMar 30, 2022


After an unplanned journey with Microsoft Exchange the month before, I started to look for new interesting vulnerability research targets for February: widely used, usually reachable from Internet perimeter and hopefully not so much targeted by other security researchers.

What I found was the 3CX Phone Management System. According to the vendor’s website, this piece of software can be deployed On-premise. Supported operating systems range from Linux and Windows to Raspberry Pi, so I (randomly) chose the Windows version. This software is used as an one-for-all solution of collaborative communication. All the company names referenced at their website made me feel confident that it is widely used indeed. Let’s check Shodan:

Well, this seems to be a good candidate. Even though, according to the vendor (private communication) a majority of systems run the Linux version, I didn’t really change my target operating system since it was all set up already. On top of this, a short scan over a random sample revealed quite a number of Windows installations, too (Update 1st April, 2022: vendor speaks of tens of thousands of Windows instances). Another Update 10/2022: excerpt from my personal communication with 3CX.

Let’s hunt for some Pre-Auth Remote Code Execution bugs.

The next two screenshots show my recently set up 3CX Phone Management System with the web interface(s) easily detectable and fingerprinted. First, the management console itself

and also a user-friendly web client for the end user (there is also a vulnerability here, an exercise for the reader)

First, let’s check the technology stack for this product. The default installation on Windows goes to C:\Program Files\3CX Phone System\. Seeing several nginx (worker) processes running, I search for the nginx.conf which is found in Bin\nginx\conf\. Two upstream definitions catch our eyes:

upstream gateway {
keepalive 100;
upstream mc {
keepalive 100;

The processes listening on these ports are hit by every incoming HTTP request dispatched according to their definitions typically based on their route (mapped by the URI path). 3CXManagementConsole.exe and 3CXGatewayService.exe can be easily identified with the SysInternals TCPView tool.

Also nice to see that these processes are running in the context of NT AUTHORITY\SYSTEM giving us maximum impact if successful.

The nginx.conf directive proxy_pass http://mc belonging to the location @proxy definition therefore targets the process listening on 3CXManagementConsole.exe. We target this first because the @proxy variable is also used as fallback processor for all kinds of URI path matches. Therefore, we assume the attack surface could be maximized by first focusing on this part of the code.

Attaching to the process and loading all modules in dnSpy, let’s look where we can find the routing definitions with its corresponding business logic. The 3CXManagementConsole.dll holds a lot of easily identifiable Controller classes.

Searching for .asp or .aspx files on the other hand will fail because we’re dealing with an ASP .NET Core MVC web application here (keep this in mind for the RCE part). The RouteAttribute tells you which URI path is handled by which code in the .NET assemblies. The [controller] placeholder simply means that the controller class name (without the word controller) has to match. E.g. the ActivityLogController matches for URIs like /api/ActivityLog/.

Targeting unauthenticated endpoints in .NET often means enumeration of route definitions annotated by the AllowAnonymousAttribute. There are several (such as for login of course) but we’re especially interested in the class ManagementConsoleJS.Provisioning.ElectronController.

The Download method reachable via /download/{platform}/{file} has two user-controlled variables. As the name ElectronController in combination with /download might already indicate, there exist fat client apps for different Desktops which could be downloaded via this API call, so you don’t have to rely on the web interface exclusively to use 3CX functions.

There exists a directory C:\ProgramData\3CX\Instance1\Data\Http\electron which holds e.g. a Windows MSI installer file 3CXDesktopApp-18.7.10.msi as expected.

The interesting code part is shown next where your user-controlled parameters are processed further:

The platform parameter ends in ManagementConsoleJS.Services.ElectronService.GetEAppPlatform(string platform) choosing the proper root directory by matching your input accordingly and assigning an Enum value. That’s the boring part.

The preceding GetUpdatePackagePath(string platfrom, string fileName) in the same namespace uses a Path.Combine(…) call to retrieve the final file name on disk.

This should immediately ring your alarm bells. ”Relative Path Traversal” you might think and indeed it is. And this is why it becomes really handy working on the Windows installation, but why? Because nginx in default configuration might bring you in trouble with getting your path traversal payloads through the reverse proxy component if using / as URI segment separator. You could try this with encoding, double encoding, different encoding schemes, ..; (I know this doesn’t make sense here!) tricks etc. but you will probably fail in this case.

Luckily, Windows uses \ as directory character separator and nginx kindly forwards this for you “as is”. The proof-of-concept is therefore easy.

But there are restrictions. We can traverse up to C:\ProgramData\3CX\Instance1\Data but not any further because nginx will return a 400 Bad Request if you try (don’t ask me why). Being restricted to this directory (with all its subdirectories of course) is not cool enough but still you’re able to read credentials (as shown in the screenshot above), chat logs, listen to call recordings or even download full backups of the 3CX installation. This is bad enough for sure!

So, I submitted the bug as Pre-Auth Relative Path Traversal File Read to the vendor which was fixed a few days after in 3CX Version 18, Update 2 Security Hotfix, Build February 2022 (see change log).

Let’s see how they fixed it.

They introduced a check named Utilities.IsVulnerablePath(file) on our controllable parameter file. What does it do?

The classical checks for .. but also Absolute Path Traversal for strings containing :\\ etc. So I wonder what Path.Combine is really doing under the hood:

Looking deeper into the branch with Path.IsPathRooted(…)

there is a check for PathInternal.IsDirectorySeparator((char)(*path[0]))) which would result in a rooted path segment evaluated to true. Then only the second parameter to the Path.Combine method is returned. This should work then with a path segment like \Windows\win.ini, shouldn’t it? It did.

We had a bypass for the patch. But this time, we want it all: remote code execution.

After getting rid of the relative path traversal depth restriction by abusing an absolute path traversal and the process still running as SYSTEM, we should basically be able to read all files on the file system, right?

The underlying database is PostgreSQL and database files are located at C:\Program Files\3CX Phone System\Data\DB. We connect to the database and check for valuable data such as credentials. There seems to be a customers table which holds the admin credentials, in cleartext. Great!

A simple grep for the credentials on the file system reveals the corresponding file with customers table content. Since this table is not part of continuous processing, no file-based locking has to be expected most of the time. Therefore, using our new pre-auth absolute path traversal we try to simply read the database file.

Access granted! But do we have to brute-force the directory and file name IDs? Let’s have a look in the official documentation of PostgreSQL instead. The base directory indeed seems to be the right place to search for database table content.

The directory and file names are related to so called OIDs generated by PostgreSQL during operation. Do you spot the 16384 in the screenshot below? This sounds familiar, doesn’t it? 16384 exactly matches the directory name.

And because the customers table was created during installation in a probably early stage, the range of OIDs we’d have to guess is probably really, really, really small. Or maybe even static, since we completed several independent installations with the same OID outcome every time, namely 16393 for the customers table. Not a lot to guess here probably.

Now, we got administrator credentials for the web interface. We could focus on finding a post-auth RCE from now on. And there are plenty of solutions for it but I only show you one in this blog post. Here is some extra information if you want to find alternative RCEs.

Alright, we’re logged in

and search for diagnostic functions, upgrade possibilities, anything which could help us to achieve remote code (or command) execution.

Clicking through various functions, the Call Flow Apps menu catches our attention. This function allows something like pre- and post-processing of incoming calls, forwarding, playing recorded messages etc. And for customization we of course can upload a ZIP file containing such a Call Flow App.

So let’s again look into the code with dnSpy targeting the CallFlowAppsController.

We can find code for POST request handling which basically processes a multipart form upload request.

It checks for things like .zip file extension, a properly structured manifest.xml and more. I won’t go through all the code but one can “reverse engineer” the expected file structure easily from there.

In the end, we have an ExtractToFile call with (you might guess it) a Path.Combine operating on the ZIP file entries. Why not finding another path traversal issue for RCE as well? Sounds like a ZipSlip thing.

Alright, without checking any further code we build a dummy zip file with relative path segment entries and all the expected directories and files to be called a Call Flow App. Since we’re lazy, bash is our friend on constructing the zip file with a final pinch of ghex to adjust the directory character separator accordingly.

We are SYSTEM, right? So let’s try to write a HACKED.txt file into C:\Windows\system32. Obviously you’d have to adjust the paths from the screenshot above accordingly to get the result shown next.

It worked! This basically means “Game Over”. Since we want to stay within the 3CX application to avoid any operating system specific conditions, a search for calls on various prototypes of System.Diagnostics.Process.Start is done next.

The RenewCertificates method starts a Windows binary named pbxconfigtool.exe with some parameters we’re not interested in. Short remark: you might spot the operating system specific if-else part. And yes, the .NET code also runs on Linux installations!

The CloudServicesWatcher.exe process is running by default and the code indicates that the binary will be executed every 6 hours.

The chain for Pre-Auth RCE as SYSTEM ends with overwriting the PbxConfigTool.exe with a binary (beacon please?) packed into the uploaded zip file thanks to the ZipSlip vulnerability described above.

From a red team perspective, seemingly unrelated prolonged chronological (malicious) actions are harder to correlate. Also, this works as a kind of (temporary) persistence mechanism as long as the .exe file won’t be overwritten again (e.g. during an upgrade procedure).

The patch bypass was fixed by 3CX in the 3CX Version 18, Update 3 FINAL, Build February 2022 release with a corresponding blog post published on March 1st, 2022.

That’s it for this blog post. I hope you enjoyed it and happy pwning/bug hunting…oh wait a minute.

We didn’t check the patch for the latest version, yet. A few weeks from now, I created this blog post in draft mode and it was ready to be published any time. But did they really follow my recommendation by using something like Path.GetFullPath(location).StartsWith to resolve the full path first before doing any other operations? Let’s check again.

IsVulnerablePath(string path) still exists and it was extended by || path.StartsWith(“\\”, StringComparison.Ordinal). This indeed kills my latest Patch Bypass.

What about other cases where we might achieve the second part of Path.Combine being interpreted as rooted path? Let’s look again at the .NET system library code: what you already know and what we abused for the first Patch Bypass was PathInternal.IsDirectorySeparator((char)(*path[0])).

What I didn’t mention (and didn’t pay attention to first) was the second operator of the logical or term being

So beginning with a drive character followed by a colon also leads to an “is rooted” condition. Comparing this to the 3CX IsVulnerablePath function, we only have partial matches, e.g. path.Contains(“:\\”, StringComparison.Ordinal). So we googled for other “fancy Windows path formats” and found this in the Microsoft .NET documentation.

It describes exactly one special case for which we can use drive letter + colon without a following backslash. This case is even described in more detail in the blue box of the documentation with a clear warning:

Use of the second form when the first is intended is a common source of bugs that involve Windows file paths.

So let’s check the current directory because this is a relative path format.

Does this mean we can again bypass the latest patch with this trick? Turns out, yes, we can.

So, we’re able to read any file under C:\Windows\System32 (and its subdirectories) as NT AUTHORITY\SYSTEM from an unauthenticated context which is bad…again. At least I cannot think of using this for the same RCE chain described above. This was fixed with 3CX Version 18, Hotfix 1, Build March 2022 and at the time looks good to me.

Finally, the blog post ends, for now. No CVE(s), no logo, no website…just like that. ¯\_(ツ)_/¯