Skip to main content

Breaking Docker Named Pipes SYSTEMatically: Docker Desktop Privilege Escalation – Part 2

Breaking Docker Named Pipes

In the previous blog post, we described how the Docker research started and showed how we could gain a full privilege escalation through a vulnerability in Docker Desktop. In this follow-up blog post, we will show the other vulnerable functions we were able to exploit.

TL;DR

We found and reported several privilege escalation vulnerabilities inside Docker Desktop for Windows:

We adhered to responsible disclosure guidelines, and Docker handled the notifications quickly and efficiently.

Abusing DaemonJSON through Windows Containers API to Full Privilege Escalation

We continued with our search for vulnerable API methods and found two promising methods: the start and stop functions inside the WindowsContainersController class. We can use these methods to start and stop the dockerd service from a low-privilege user. Inside the start function, we can see that it receives, as an argument, the class object WindowsContainerStartRequest (Figure 1).

The “start” API route in Docker.Backend.HttpAPI namespace.

Figure 1 : The “start” API route in Docker.Backend.HttpAPI namespace.

This class has three members, and we will focus on the two members that are being used in the Start function:

public class WindowsContainerStartRequest
{
	public Settings Settings { get; set; }
	public string DaemonJSON { get; set; }
	public string UserCertsDirectory { get; set; }
}

The DaemonJSON member holds the value of the path for the daemon.json file (Docker daemon’s configuration file), and the Settings member is a class that contains all the fields (Appendix A) that are inside the daemon JSON file.

Let’s first explain why the Settings member was not relevant to us. After a call to _windowsDockerDaemon.Start (Figure 1), there was a sequence of function calls where the settings argument was passed to three functions: RewriteOptions, GetServiceEnv and TryToStartService (Figure 2).

Figure 2 - Start API function.

Figure 2 : Start API function.

But although it was passed as an argument, it was used only in GetServiceEnv method and only for the Proxy fields (Figure 3).

GetServiceEnv function.

Figure 3 : GetServiceEnv function.

We understood that we couldn’t affect the argument settings to manipulate the service. We continued to the next argument controlled by us and checked the daemonOptions argument, and from the RewriteOptions function, we understood that it was being used as a parameter to the switch –config-file in dockerd (see code snippet below), which meant we controlled the dockerd settings.

public string RewriteOptions(string daemonOptions, Settings settings, string userName, bool useProtectedNamedPipe) {
  ...
  optionsObject = JsonConvert.DeserializeObject(daemonOptions);
  ...
  File.WriteAllText(this._jsonFilePath, optionsObject.ToString());
  return args + " --config-file \"" + this._jsonFilePath + "\"";
}

DaemonJSON Options

Fortunately, the Docker daemon JSON fields are documented in Docker, and you can see a lot of options there (Appendix B). One thing you will notice is that there are two separate versions – one for Linux and one for Windows – which might cause problems when there are fields that are supposed to work only on Linux or Windows.

We added the requested input to each field in the JSON file and checked if they returned an interesting output. We received some weird stuff, such as making the daemon create a new named pipe by changing the host’s field to something like that:

\"hosts\": [ \"npipe:////./pipe/docker_engine_windows5\", \"tcp://127.0.0.1:2376\" ]

From all these plays, we found two cases where custom settings allowed privilege escalation.

Abusing data-root to an Arbitrary File Overwrite

The first field from the JSON file that we are going to look at is data-root. It is a path to a directory containing all the container resources as described in the Docker documentation:

--data-root is the path where persisted data such as images, volumes, and cluster state are stored. The default value is /var/lib/docker. To avoid any conflict with other daemons, set this parameter separately for each daemon.

Usually, the default path for that field in Windows is C:\ProgramData\Docker, which is a protected place that we don’t have control over, but we can change it to anywhere we want.

We set the DaemonJSON variable with a JSON string containing the data-root field with the path C:\dataRoot:

container.DaemonJSON = "{ \"data-root\": \"c:\\\\dataRoot\" }";

After executing the start method, the directory C:\dataRoot was created, but we didn’t have access to the directory. We easily bypassed it by creating the directory before calling the API request, so that the daemon saw that C:\dataRoot already exists and wrote all the directories and files with SYSTEM privileges. Then we gained full access to the newly created directory because it inherited the default permissions the standard user had.

The next step was to search inside the directory for files vulnerable to symlink attacks. We found one named C:\dataRoot\network\files\local-kv.db. When we deleted the directory C:\dataRoot\network\files, followed by stopping and starting the dockerd service through the API (no privileges required), the daemon created the directory and then the file.

This is a classic case of using a TOCTOU attack by exploiting this race condition. We can make the daemon create the directory and, before it creates the file, change the directory to a junction directory, then create an object manager symlink pointing to any place we want (Figure 4).

Exploitation process of overwriting files.

Figure 4 : Exploitation process of overwriting files.

After running the exploit, it took several attempts before we succeeded in changing the directory to a junction directory so we could write the file anywhere (without controlling the content), including protected paths, which can easily cause a denial of service (DoS) if one is creative enough.

This issue was fixed in version 4.7.0 and assigned with CVE-2022-38730.

 

Abusing pidfile to Arbitrary Delete and Overwrite File

The second vulnerable field is the pidfile field, which holds a file path that stores the Docker daemon’s process ID as described in the Docker documentation:

-p, --pidfile=/var/run/docker.pid is the path where the process ID of the daemon is stored. Specify the path for your pid file here.

We set the DaemonJSON object with a JSON string containing the pidfile field with a privileged location:

{ \"pidfile\" : \"c:\\\\windows\\\\evil.txt\"}

After executing the start method, it created the target file, and if we stopped the service with the stop method, it would delete it (Figure 5).

Procmon logs of dockerd creating and deleting a file.

Figure 5 : Procmon logs of dockerd creating and deleting a file.

In such a way, we can delete any file we want:

 

From Arbitrary File Deletion to Full LPE

Deleting arbitrary files is nice, but could we leverage it to full privilege escalation? The Zero Day Initiative published an article about a technique to leverage arbitrary file delete vulnerabilities to full privilege escalation. In short, the technique exploits Windows Installer service behavior when rolling back an installation.

To do it, we needed write access to the directory C:\Config.msi, a special protected directory that low-privileged users can’t modify. But the Zero Day Initiative authors showed that if the vulnerable process calls to DeleteFileA/DeleteFileW and the directory is empty, you could delete the directory by adding the path with the following index data: C:\Config.Msi::$INDEX_ALLOCATION (check here for more details about this attribute).

In our case, dockerd used DeleteFileW to delete the file based on the pidfile field (Figure 6).

Dockerd using DelteFileW.

Figure 6 : Dockerd using DelteFileW.

But we couldn’t use this path like that since the first thing that happens is that a file is created from the pidfile field. In our case, it would create a file named C:\Config.Msi::$INDEX_ALLOCATION. That would cause a collision because the C:\Config.Msi directory already existed from old MSI installations (Figure 7).

Procmon logs of collision when trying to create a file.

Figure 7 : Procmon logs of collision when trying to create a file.

We bypassed this by passing a random file location like C:\tmp\sym\tmp.txt.
After the file was created, we deleted the file and converted the file’s directory (c:\tmp\sym) to a junction directory with an object manager symlink pointing to C:\Config.Msi::$INDEX_ALLOCATION (Figure 8).

Using CreateSymlink to create symlink.

Figure 8 : Using CreateSymlink to create symlink.

When we stopped the service, it tried to delete the directory but failed because the directory was not empty (Figure 9).

Failing to delete C:\Config.msi.

Figure 9 : Failing to delete C:\Config.msi.

An empty directory is one of the prerequisites that was mentioned in the article:
“Note that the only required exploit primitive here is the ability to delete an empty folder.”

By looking at the files inside the C:\Config.Msi directory, we saw that they were randomly generated with a name with numbers and letters. We thought about guessing the names through brute-forcing and trying to delete them, but that involved starting and stopping the service multiple times, which would be time consuming.

C:\WINDOWS\system32>dir c:\Config.Msi
 Volume in drive C is OS
 Volume Serial Number is 8CDA-914A

 Directory of c:\Config.Msi

31/07/2015  09:57        19,722,432 1011996e.rbf
31/07/2015  09:57        82,045,120 1011996f.rbf
31/07/2015  09:57         1,802,920 101199c8.rbf
16/09/2019  18:45           458,160 10119aff.rbf
31/07/2015  09:58         6,542,016 34558643.rbf
18/06/2020  08:36         1,021,048 43c89f06.rbf
31/07/2015  09:57            70,312 480e66af.rbf
31/07/2015  10:01         1,512,152 480e685b.rbf
31/07/2015  10:00         8,901,800 480e6860.rbf

The next step from our side was to look into the specific OS of Windows 11, and surprisingly, when testing it on a new fresh Windows 11, the C:\Config.msi directory didn’t exist, we created it with our permissions and the exploit worked.

This issue was fixed in version 4.7.0 and assigned with CVE-2022-37326.

*In the proof of concept, we created an empty restricted C:\Config to show it can delete it, but usually you won’t have this directory in a fresh installation.

Abusing HyperVController for Arbitrary Delete and Overwrite File

There were two more interesting API functions in the HyperVController (“hyperv”):

  1. hyperv/destroy
  2. hyperv/create

Abusing Destroy for Arbitrary File Delete

The destroy API function is implemented by the function DestroyAsync, which receives an object named settings as an argument. It then calls GetDiskPath, which returns a file path to diskpath and eventually deletes the file (Figure 10).

Docker.Backend.HyperV class from Docker.Backend.dll.

Figure 10 : Docker.Backend.HyperV class from Docker.Backend.dll.

The GetDiskPath function took the path from the settings.DataFolder (controlled by us) and concatenated it with the name DockerDesktop.vhdx (Figure 11).

GetDiskPath function.

Figure 11 : GetDiskPath function.

We were able to call the API request with any file path inside the settings.DataFolder variable (i.e C:\tmp\myvhd) and delete a file with the name DockerDesktop.vhdx (Figure 12).

Deleting DockerDesktop.vhdx

Figure 12 : Deleting DockerDesktop.vhdx

With the same method we used in the previous exploits, we used a junction directory with object manager symlink to change the name of the default file DockerDesktop.vhdx to any file we chose and deleted it (Figure 13).

Deleting a file from a privileged location.

Figure 13 : Deleting a file from a privileged location.

This issue was fixed in version 4.6.0 and assigned with CVE-2022-31647.

 

Abusing Create for Arbitrary File Overwrite

Similar to the previous vulnerability, the create API function is implemented by the function CreateOrConfigureAsync (Figure 14), which calls GetDiskPath to extract the DataFolder field from the settings object.

Creating VM function in Docker.Backend.HyperV class.

Figure 14 : Creating VM function in Docker.Backend.HyperV class.

It creates a default DockerDesktop.vhdx file based on the path in the DataFolder field. Therefore, it could be in any place we chose – for example, C:\Windows (Figure 15).

Creating DockerDesktop.vhdx in C:\Windows.

Figure 15 : Creating DockerDesktop.vhdx in C:\Windows.

In that case, the vmms.exe service was the one that created the file and not com.docker.service like in the previous examples (Figure 16).

com.docker.service and vmms being redirected.

Figure 16 : com.docker.service and vmms being redirected.

As we did in previous examples, we used the same technique to rename the file to any file we wanted in any location.

This issue was fixed in version 4.6.0 and assigned with CVE-2022-34292.

 

Conclusion

This is the second and final part of our research. In this part, we showed other vulnerable functions that we were able to exploit to gain privilege escalation. All the vulnerabilities were reported and handled quickly and efficiently by Docker.

Disclosure Timeline

23/02/22 — Initial report was sent to Docker.
14/03/22 — Docker released new version 4.6.0 with fix for CVE-2022-31647 and CVE-2022-34292.
17/03/22 — Docker advised that the fix to the other vulnerabilities will be pushed to version 4.7.0.
28/03/22 — Docker sent us an installation (version 4.7.0) to check.
31/03/22 — We checked, and it was fixed; we sent them our feedback.
07/04/22 — Docker released version 4.7.0 with a fix for CVE-2022-38730 and CVE-2022-37326.

References

Appendix A – Settings Class

public Settings()
{
    this.SettingsVersion = 10;
    this.AutoStart = false;
    this.AnalyticsEnabled = true;
    this.OpenUIOnStartupDisabled = false;
    this.AcceptCanaryUpdates = false;
    this.DisplayRestartDialog = true;
    this.DisplaySwitchWinLinContainers = true;
    this.Cpus = Math.Min(Environment.ProcessorCount, 2);
    this.MemoryMiB = 2048;
    this.SwapMiB = 1024;
    this.VpnkitCIDR = "192.168.65.0/28";
    this.OverrideProxyExclude = "";
    this.OverrideProxyHttp = "";
    this.OverrideProxyHttps = "";
    this.UseDnsForwarder = true;
    this.Dns = "8.8.8.8";
    this.DiskSizeMiB = 65536L;
    this.DataFolder = Paths.DefaultVmDataFolder;
    this.FilesharingDirectories = new List();
    this.SynchronizedDirectories = new List();
    this.DisplayedTutorial = false;
    this.DisableHardwareAcceleration = false;
    this.LifecycleTimeoutSeconds = 600;
    this.LastLoginDate = 0L;
}

Appendix B – Daemon Windows Configuration File (daemon.json)

{
  "allow-nondistributable-artifacts": [],
  "authorization-plugins": [],
  "bridge": "",
  "cluster-advertise": "",
  "cluster-store": "",
  "containerd": "\\\\.\\pipe\\containerd-containerd",
  "containerd-namespace": "docker",
  "containerd-plugin-namespace": "docker-plugins",
  "data-root": "",
  "debug": true,
  "default-ulimits": {},
  "dns": [],
  "dns-opts": [],
  "dns-search": [],
  "exec-opts": [],
  "experimental": false,
  "features": {},
  "fixed-cidr": "",
  "group": "",
  "hosts": [],
  "insecure-registries": [],
  "labels": [],
  "log-driver": "",
  "log-level": "",
  "max-concurrent-downloads": 3,
  "max-concurrent-uploads": 5,
  "max-download-attempts": 5,
  "mtu": 0,
  "pidfile": "",
  "raw-logs": false,
  "registry-mirrors": [],
  "shutdown-timeout": 15,
  "storage-driver": "",
  "storage-opts": [],
  "swarm-default-advertise-addr": "",
  "tlscacert": "",
  "tlscert": "",
  "tlskey": "",
  "tlsverify": true
}