Everything started when I was researching Windows containers. It required installing Docker Desktop for Windows, and I couldn’t help but notice that there were many Docker processes. Since some of the processes were privileged, the communication between them is of particular interest, which led me to explore further. I found the processes were using named pipes for communication, and one of them was a way to forward API calls from a low-privileged user to a privileged service. Understanding the API was the key to finding vulnerabilities.
In this two-part blog series, we will discuss the details of six privilege escalation vulnerabilities we found in Docker Desktop for Windows and a new tool named “PipeViewer” that we developed to help us scan for Windows named pipes with weak permissions. In the first part, we will focus on how everything started and show the specific vulnerability that led us to a full privilege escalation on Windows.
In the second part, we will focus on the rest of the vulnerabilities, some of which also led to full privilege escalation.
TL;DR
In this blog post, we will share the process of finding the following vulnerabilities inside Docker Desktop for Windows:
- Full privilege escalation – CVE-2022-25365 (fix for incomplete fix CVE-2022-23774).
Why So Many Processes?
If you are a container fan like me, you will be thrilled to hear that you can run containers in Windows, not just in Linux like in the old days. So why not play with it?
We started by installing Docker Desktop for Windows, a software written in C#, made by Docker to support containers in the Windows environment. It provides you with the means of creating, editing, stopping, altering the configuration and more. After the installation (from Docker’s website), we saw that it was using many processes (Figure 1).
Figure 1 – Docker Desktop process list
By looking over the process list, we saw two services, dockerd and com.docker.service, that seemed worthy of a closer examination since these two services ran with SYSTEM privileges and communicated with many other resources. We thought it would be worthwhile to check if we could affect them for our needs. But before we get to that, let’s understand the relationship between these two services.
The main process of Docker Desktop (Docker Desktop.exe) is a GUI application with low privileges. It communicates with the service com.docker.service, which forwards some of the requests to the dockerd service, and eventually, dockerd communicates with the container (Figure 2).
Figure 2 – Docker Desktop diagram with its services
In every vulnerability research, we must understand the means of communication between entities that have different privileges, as that is a great place to find EoP bug channels between different processes. Here, we wished to understand the communication between Docker Desktop to com.docker.service and the rest of the background processes. The short answer was pipes, lots of pipes.
Pipes Here, Pipes There, Pipes Everywhere
We decided to check and see what was going on under the hood. From a brief look over the code of Docker Desktop and its dependencies libraries, we saw that it is using Windows named pipes (Figure 3).
Figure 3 – Class PipeNames from Docker.Core.dll
A named pipe is a way of communication between a server pipe and one or more client pipes. Any process can access named pipes, making them an easy form of communication between related or unrelated processes.
We searched for more named pipes, but covering the rest of the processes code was time consuming and unnecessary, especially since some of them were not written in C#. Therefore, we monitored every created named pipe through IO Ninja, an all-in-one terminal emulator, sniffer and protocol analyzer. When we started capturing named pipes and ran Docker Desktop, we received a lot of information (Figure 4).
Figure 4 – Monitoring for Docker pipes with IO Ninja
If we take a closer look, we can see that there was an event called “Server file opened,” which means that the process, which appeared in the “Process:” field, created the pipe.
After the above analysis, we mapped all the named pipes to a table (Appendix A – Table 1) — or at least most of them (approximately 40 pipes) — allowing us to see the origin of every named pipe and check its privileges.
From the many pipes out there, we narrowed it down to about three that were created by privileged services and looked promising. Eventually, we focused on one specific named pipe called dockerBackendV2.
PipeViewer – Named Pipe Viewer Tool
While doing this research, we built a tool that can help us view all the current named pipes on the system and their permissions. We named this tool “PipeViewer.”
PipeViewer allowed us to filter all the running Docker named pipes. From there, we could see that we, as part of the low privileged docker-users group, had read and write permissions on the named pipe, meaning we could communicate with that named pipe (Figure 5).
Figure 5 – PipeViewer show we have Read & Write permissions on a named pipe
This tool can help in other cases when you want to check if there is a vulnerable named pipe that you can communicate with. Now let’s go back to investigate our named pipe.
Exposing dockerBackendV2 Undocumented API
The named pipe dockerBackendV2 was created by the service com.docker.service.
By taking a closer look at Docker.Service class in com.docker.service.exe, we saw that it started the dockerBackendV2 server (Figure 6).
Figure 6 – Starting the dockerBackendV2 server
The main code of the service didn’t seem to be of much interest, but it used interesting libraries (Figure 7): Docker.Core.dll and Docker.Backend.dll.
Figure 7 – Docker Libraries
If we look at the class BackendAPIPipeResolver in Docker.Core.dll (Figure 8), it seems that the named pipe dockerBackendV2 was implemented as an HTTP REST API, giving us the first clue that we could communicate with it.
Figure 8 – Creating dockerBacnendV2 named pipe over Http
Continuing to the next library Docker.Backend.dll, we found that there are seven classes (based on version 4.11.1) under the namespace Docker.Backend.HttpAPI. Each class had a route prefix and sub-routes, each of which is a REST API method that will be executed by com.docker.service.exe with SYSTEM privileges. We mapped the REST API methods to get a better overview (Appendix B – Table 2), and with that, we saw the main route, how it was being called and its usage.
The Problem with docker-users Group
Fortunately, we could call any REST API method from a low-privilege user! However, there is a requirement that the user needs to be part of the docker-users group (as we saw earlier with PipeViewer).
This group doesn’t have special privileges, as it exists only for the users to be able to use Docker, but in fact, it has high privileges indirectly.
Having this group allows low-privilege users to create a container with a mount to the host “C:\Windows”, even if the users don’t have access to the path.
They will be able to access the container and from the container access the protected path like that:
docker run --rm -v C:\Windows:C:\Windows2 mcr.microsoft.com/windows/servercore:ltsc2022 cmd.exe /c "echo 1 > C:\Windows2\pwn.txt"
In this way, you can elevate your privileges to SYSTEM easily (you will understand it later after reading the exploitation process). Docker is aware of this issue as they mentioned:
“This is a known issue with Windows containers. Unfortunately, it’s outside our control (it’s an operating system feature), but we have documented it at https://docs.docker.com/desktop/windows/permission-requirements/#windows-containers, and also provided a flag for administrators to disable Windows containers for their users at installation time if they wish.”
Note that the documentation was created after we reported the vulnerabilities and they added a new flag (–no-windows-containers) from version 4.11 that can prevent creating Windows containers but still be part of the docker-users group. But the vulnerabilities we found were still bypassing it because we were able to communicate with the API directly.
After this clarification, all we needed to do now was look at the table (Appendix B – Table 2) and search for the first API method that could be abused. From there, we got a clue for the first candidate.
From move-data-folder API to Full Privilege Escalation
One of the more peculiar methods that caught my eye was move-data-folder (Figure 9) under the HyperVController class (Docker.Backend.HttpAPI namespace), which defines the hyperv controller. You can probably understand from the name that the method moves directory files to different places. We already knew it was being executed with high privileges. The only thing left was to call it and see if we could move files to protected locations. But how could we call the method?
Figure 9 – move-data-folder API under HyperVController class in Docker.Backend.HttpAPI namespace
We didn’t need to search far. A class named ServiceAPIClient (inside Docker.Core.dll) under the Docker.CoreBackendAPI namespace shows exactly how to call the method (Figure 10). Since this is C# code and we could see how it was called, we could use the same classes and call this method.
Figure 10 – Calling the “hyperv/move-data-folder” API from Docker.Core.BackendAPI.ServiceAPIClient (Docker.Core.dll)
Now we have a sense of what the method does, so it’s time to understand better how the logic works.
The move-data-folder API Logic
The move-data-folder method receives two arguments: the old\source directory and new\target directory. It then moves everything from the old directory to the new directory, with some exceptions:
- The root directory and sub-directories (if any) must have a file named DockerDesktop.vhdx.
- From the root directory, only the file named DockerDesktop.vhdx will be moved, but from the sub-directories, all the files will be moved.
We executed the method with two directories created by us and made a diagram of how the control flow works (Figure 11).
Figure 11 – Flow chart of the MoveDataFolder logic
You probably remember that the service com.docker.service runs with SYSTEM privileges, so you can see where this is going.
The next step was to call the move-data-folder function again, but this time on a privileged target location like C:\Windows. It worked, of course, but to our surprise, it didn’t inherit the permissions from C:\Windows, meaning we had control (Figure 12) over the copied file! Better than what we were expecting.
Figure 12 – Having control over the copied file
The reason behind this is that, according to Microsoft, moving files to a different directory in the same volume (NTFS in our case), retains the original permissions. In this case, the move-data-folder method moves the file by using the function File.Move to a different location in the same volume, allowing us to edit the file as we like. We can see this by looking at the SetRenameInformationFile operation in Procmon (Figure 13).
Figure 13 – Renaming the file with SetRenameInformationFile
From there, it was quite easy. We only needed to change the name of the file so we could use a simple known DLL hijack to get SYSTEM shell. But even when we had full control over the file, it was in a protected location, and Windows File Protection (WFP) prevented us from renaming it (Figure 14).
Figure 14 – Windows File Protection (WFP) protect from renaming a file
Bypassing the Windows File Protection Obstacle
To bypass this restriction, we decided to use a combination of a junction directory and an object manager symlink. Instead of moving the file directly to C:\Windows, which would prevent us from changing the name of the file, we used a proxy directory and moved the file to a junction directory, which we named “Jumper”. This directory had a link from DockerDesktop.vhdx to a DLL that we could later exploit — for example, a known vulnerable PrinterSpooler DLL hijack binary path C:\Windows\system32\ualapi.dll. In this way, we could control the file’s name, and move it to the junction directory, where it would be redirected to a path with the name we chose.
The final step (Figure 15) was calling move-data-folder with C:\tmp\ABC as the old directory argument containing our malicious file DockerDesktop.vhdx, and C:\tmp\Jumper as the new directory argument, a junction directory with object manager symlink that points to C:\Windows\system32\ualapi.dll (known DLL hijack path). The service moved C:\tmp\ABC\DockerDesktop.vhdx to C:\tmp\jumper\DockerDesktop.vhdx which redirected to \RPC Control\DockerDesktop.vhdx and then to \??\C:\windows\system32\ualapi.dll.
Figure 15 – High level chart of the exploit
We can see the process described above by using Procmon (Figure 16). It calls SetRenameInformationFile that moves the file DockerDesktop.vhdx to a junction directory C:\tmp\jumper that redirects to an object manager symlink (C:\Windows\System32\ualapi.dll), but Procmon doesn’t handle it correctly and doesn’t show the file name destination. You can see that the FileName under the Detail column is empty (at the end of the list).
Figure 16 – Procmon logs of the exploitation process
Triggering the Exploit Without a Computer Restart
That’s it; now we just needed to restart the computer so the Printer Spooler service would load the hijacked DLL and get a SYSTEM shell. But restarting the computer is not a realistic scenario in a real attack because attackers know that most people would notice when their computer restarts, and restarting the machine can take a long time. I remembered reading an article named “Faxing Your Way to SYSTEM” by Yarden Shafir and Alex Ionescu, which mentioned triggering a DLL hijack in a privileged service from a low-privilege user. We used this technique to trigger our malicious DLL hijack without restarting the computer.
Docker released a fix for this issue in Docker Desktop version 4.5 and assigned it with CVE-2022-23774. However, it only prevented moving files directly to protected locations, which our exploit was still bypassing because it used an unprivileged junction directory that indirectly points (using object manager symlink) to the privileged location. We notified Docker about that, and they released a complete fix in version 4.5.1 and assigned it with CVE-2022-25365.
Conclusion
It is interesting to see the mere existence of the large number of named pipes that led us to start this research while doing something else altogether. We checked and found that Docker uses a named pipe with REST API that allowed us to call its methods from a low-privilege user, with the actions done by a privileged service. In this research, we also developed a new open-source tool named “PipeViewer” to help scan for Windows named pipes and show their permissions. The vulnerability was reported and handled quickly and efficiently by Docker.
In the next part, we will show other methods we used to exploit other vulnerabilities.
Disclosure Timeline
January 1, 2022 — Initial report to Docker; they acknowledged on the same day.
January 25, 2022 — Docker released version 4.5.0 with a fix for CVE-2022-23774, still incomplete.
February 3, 2022 — Docker release a new patched installation that fixed the vulnerability.
February 15, 2022 — Docker released version 4.5.1 with a fix for CVE-2022-25365.
References
- A Docker Desktop privilege escalation vulnerability through named pipe.
- Windows named pipes explanation.
- An article by Yarden Shafir and Alex Onescu about a technique to trigger a hijacked DLL to SYSTEM through the fax service.
- James Forshaw — Symbolic link tools.
- An article by Eran Shimony about symbolic links.
Appendix A – Table of Docker Named Pipes
Process | Named Pipes | Integrity |
---|---|---|
com.docker.service | dockerBackendV2 dockerBackendV2Debug |
System |
dockerd | docker_engine_windows | System |
com.docker.backend | com.docker.backend dockerBackendApiServer dockerBackendApiServerForGuest dockerDNSInternalGRPC dockerDNSSystemGRPC dockerHubProxy dockerExport dockerFilesystem dockerNTPUDP dockerSOCKS dockerVolume dockerVpnKitControl dockerVpnkitData docker_cli |
Medium |
com.docker.dev-envs | dockerDevEnvApiServer dockerDevEnvVolumes |
Medium |
DockerDesktop | dockerWebApiServer dockerFrontendApiServer |
Medium |
com.docker.proxy | docker_engine dockerDesktopWindowsEngine dockerDesktopLinuxEngine dockerDesktopEngine dockerAPIProxyControl docker_engine_linux |
Medium |
vpnki-bridge | dockerDebugShell dockerDiagnosticd dockerDNSForwarder dockerLifecycleServer dockerMemlogdq dockerProcd dockerVolumeContents dockerWsl2BootstrapExposePorts dockerWSLCrossDistroService dockerMutagen |
Medium |
vpnkit | dockerVpnkit dockerVpnKitDiagnostics |
Medium |
docker | dockerCliApi | Medium |
com.docker.desktop-extensions | dockerExtensionManageAPI | Medium |
Table 1 – Docker named pipes map
Appendix B – Table of API Calls
Controllers | Route | Usage | Description |
---|---|---|---|
DnsController (“dns”) | POST “refresh-hosts” | /dns/refresh-hosts | Update the hosts. |
HyperVController (“hyperv”) | POST “create” | /hyperv/create Body: CreateVMRequest class |
|
POST “start” | /hyperv/start Body: Settings class |
||
POST “stop” | /hyperv/stop | ||
GET “check-virtualization” | /hyperv/check-virtualization | ||
GET “bootloader” | /hyperv/bootloader | ||
POST “destroy” | /hyperv/destroy Body: DestroyVMRequest class |
||
GET “vhdx-size” | /hyperv/vhdx-size | Looking for the size of “DockerDesktop.vhdx”. |
|
POST “move-data-folder” | /hyperv/move-data-folder Body: MoveDataFolderRequest class |
Moves the content from the input OldDir to NewDir. |
|
PingController (“ping”) | GET “ping” | /ping | Send ping request. |
VersionController (“version”) | GET “version” | /version | Get version. |
WindowsContainersController (“windowscontainers”) |
POST “start” | /windowscontainers/start Body: WindowsContainerStartRequest class |
Starts docker daemon |
POST “stop” | /windowscontainers/stop | Stops docker daemon. | |
POST “destroy” | /windowscontainers/destroy Body: Settings class |
||
GET “is-running” | /windowscontainers/is-running | Return result if the docker daemon is running. |
|
WindowsFeaturesController (“windowsfeatures”) |
POST “check” | /windowsfeatures/check Body: WindowsFeature array class |
|
RegistryAccessController (“registryaccess”) |
POST “download” | /registryaccess/download | Download policy from Hub registry through a privileged binary com.docker.admin.exe. |
Table 2 – Docker API map