That Pipe is Still Leaking: Revisiting the RDP Named Pipe Vulnerability

June 16, 2022 Gabriel Sztejnworcel

On January 11, 2022, we published a blog post describing the details of CVE-2022-21893, a Remote Desktop vulnerability that we found and reported to Microsoft. After analyzing the patch that fixed the vulnerability, we identified an attack vector that was not addressed and made the vulnerability still exploitable under certain conditions. We reported this finding to Microsoft, and they released a new fix on an April 12, 2022, security update under CVE-2022-24533.

In this post, we will review the original fix, explain the new path and review the new fix. We will dive a bit into the Windows code to see how it looks behind the scenes.

Recap

The original issue was caused by improper handling of named pipe permissions in Remote Desktop Services, which allowed non-admin users to take over RDP virtual channels in other connected sessions. The named pipe was created in such a way that it allowed every user on the system to create additional named pipe server instances with the same name.

An attacker who successfully exploited this vulnerability can view and modify data sent over the virtual channels, such as clipboard data, transferred files and smart card PIN numbers, and can gain access to the victim’s redirected devices such as hard drives, smart cards, USB devices and more.

TL; DR

The patch addressed the issue by changing the pipe permissions, preventing standard users from creating these named pipe servers. What the patch missed is the risk created when the attacker creates the first pipe server instance and can then set the permissions for all subsequent instances. You can find more details about this technique here.

Before the First Patch – Taking a Closer Look

As you can see in the following output from Sysinternals’ Accesschk tool, read-write access to the pipe was granted to the “Everyone” group:

accesschk.exe \pipe\TSVCPIPE-135594a7-de1d-4c9d-b9a9-ee2898453633

Accesschk v6.13 - Reports effective permissions for securable objects
Copyright ⌐ 2006-2020 Mark Russinovich
Sysinternals - www.sysinternals.com

\\.\Pipe\TSVCPIPE-135594a7-de1d-4c9d-b9a9-ee2898453633
  RW Everyone
  RW NT SERVICE\TermService
  RW NT AUTHORITY\SYSTEM


This part is implemented in rdpcorets.dll. We see the following call sequence:

CGlobalPipeMgr::CreatePipeHandlePair()
CGlobalPipeMgr::CreatePipeServer()
CGlobalPipeMgr::GetNpAcl()

CGlobalPipeMgr::CreatePipeHandlePair() creates both the pipe server and client. Below you can find the part that creates the server (all code samples were decompiled with IDA with minor changes to remove irrelevant parts):

v9 = CGlobalPipeMgr::CreatePipeServer((CGlobalPipeMgr *)((char *)this - 48), &hNamedPipe);
UniqueName = v9;
if ( *((_DWORD *)this + 3) && v9 == -2147024713 )
{
	for ( i = 0; i < 5; ++i )
	{
		UniqueName = CGlobalPipeMgr::CreateUniqueName((CGlobalPipeMgr *)((char *)this - 48));
		if ( UniqueName < 0 )
			break;
		UniqueName = CGlobalPipeMgr::CreatePipeServer((CGlobalPipeMgr *)((char *)this - 48), &hNamedPipe);
		if ( UniqueName != -2147024713 )
			break;
	}
}

What is important for us is that CGlobalPipeMgr::CreatePipeServer() is called. The other part can be a bit misleading; it appears like an attempt to deal with situations in which  CreateNamedPipe() fails with error -2147024713 (0x800700B7, which means “cannot create a file when that file already exists.”), by calling CGlobalPipeMgr::CreateUniqueName() to generate a new GUID for the pipe name. In our case, it does not fail, so this part is not relevant. We are not sure in which cases CreateNamedPipe() returns this error; we did not find any documentation for that. Also, there is a maximum of five attempts, hard-coded, so this is not an attempt to separate users and sessions.

CGlobalPipeMgr::CreatePipeServer() calls CGlobalPipeMgr::GetNpAcl() to create the access control list for the pipe:

  if ( AllocateAndInitializeSid(&pIdentifierAuthority, 1u, 0, 0, 0, 0, 0, 0, 0, 0, &pSid) )
    goto LABEL_14;
  LastError = GetLastError();
  v4 = LastError;
  if ( LastError > 0 )
    v4 = (unsigned __int16)LastError | 0x80070000;
  if ( v4 >= 0 )
  {
LABEL_14:
    if ( AllocateAndInitializeSid(
          &v21, 6u, 0x50u, 0x1A963466u,
          0x5CF1AAB9u, 0xF8123019, 0x7448CE95u,
          0x304EFDA0u, 0, 0, &v13) )
    {
      goto LABEL_19;
    }
    // ...
  }

This part allocates two SID (security identifier) structures. The first one evaluates to S-1-1-0, the world SID, which includes all users. The second SID is the RDS service: NT SERVICE\TermService. The function then sets the access permissions for these SIDs:

memset_0(&pListOfExplicitEntries, 0, 0x60ui64);
      pListOfExplicitEntries.Trustee.ptstrName = (LPWCH)pSid;
      pListOfExplicitEntries.grfAccessPermissions = -1073741824;
      pListOfExplicitEntries.grfAccessMode = GRANT_ACCESS;
      pListOfExplicitEntries.Trustee.TrusteeForm = TRUSTEE_IS_SID;
      v16 = 0x10000000;
      v17 = 1;
      v18 = 0;
      v19 = v13;
      v10 = SetEntriesInAclW(2u, &pListOfExplicitEntries, 0i64, &NewAcl);

The first SID, the world SID, gets -1073741824, which includes GENERIC_READ and GENERIC_WRITE. This explains the behavior we see.

Examining the First Patch

The first thing we tried after installing the patch was running our exploit. Fortunately, it did not work; we were denied access when trying to create the pipe server. We also tried running it with an administrator account, but it also did not work. Then, we ran it with the SYSTEM account, which did work. This enabled us to run Accesschk to see the pipe permissions:

accesschk.exe \pipe\TSVCPIPE-419aeaef-caa9-4bca-b0f2-9fd53cb6f370

Accesschk v6.14 - Reports effective permissions for securable objects
Copyright ⌐ 2006-2021 Mark Russinovich
Sysinternals - www.sysinternals.com

\\.\Pipe\TSVCPIPE-419aeaef-caa9-4bca-b0f2-9fd53cb6f370
  RW NT AUTHORITY\SYSTEM
  RW NT SERVICE\TermService
  RW NT AUTHORITY\LOCAL SERVICE
  RW NT AUTHORITY\NETWORK SERVICE


We no longer see the “Everyone” group, so it’s looking good. Let’s look at the relevant parts from the new GlobalPipeMgr::GetNpAcl():

LastError = RpcImpersonateClient(0i64);
// ...
CurrentThread = GetCurrentThread();
if ( !OpenThreadToken(CurrentThread, 8u, 1, &TokenHandle) )
{
  // ...
}
// ...
GetTokenInformation(TokenHandle, TokenUser, 0i64, 0, (PDWORD)TokenInformationLength.Value);

This code runs in an RPC call, initiated by the client calling WTSVirtualChannelOpen(). The function impersonates the client, then opens the impersonation token and uses it to get the calling user details. It then allocates 3 SIDs:

if ( !AllocateAndInitializeSid(&pIdentifierAuthority, 1u, 0x13u, 0, 0, 0, 0, 0, 0, 0, &pSid) )
{
  // ...
}
// ...
if ( AllocateAndInitializeSid(
      &v37, 6u, 0x50u, 0x1A963466u,
      0x5CF1AAB9u, 0xF8123019, 0x7448CE95u,
      0x304EFDA0u, 0, 0, &v32) )
{
  // ...
}
// ...
if ( AllocateAndInitializeSid(&TokenInformationLength, 1u, 0, 0, 0, 0, 0, 0, 0, 0, &v31) )
  goto LABEL_68;

The first one is the Local Service account, the second is the RDS service and the third is the calling user. Adding the calling user’s SID does not make too much sense since multiple instances of the same named pipe are created. Only the first one will get to the security descriptor; others will be ignored.

This was the main change. When looking at CGlobalPipeMgr::CreatePipeHandlePair() and CGlobalPipeMgr::CreatePipeServer(), we did not see any notable changes.

What’s Missing Here?

As we explained in the previous post, in case multiple pipe instances are created with the same name, the security descriptor passed to the first call to CreateNamedPipe() will be used for all the instances. In subsequent calls, a different security descriptor can be passed, but it will be ignored. So, in case an attacker creates the first pipe instance, they can control the permissions for other instances.

How can we deal with this attack? When calling CreateNamedPipe(), it is possible to pass the FILE_FLAG_FIRST_PIPE_INSTANCE flag in the open mode parameter. This means that the call to CreateNamedPipe() will succeed only if it creates the first instance. If there is an existing instance already, the function will fail with ERROR_ACCESS_DENIED.

As you can guess by now, FILE_FLAG_FIRST_PIPE_INSTANCE is not used in our case. So how can an attacker leverage this to perform the MiTM attack? They will need to make sure they are creating the first pipe instance. In case the attacker connects with RDP, it is not trivial. There are pipe instances of other connected users and pipe instances of the attacker session, which are created before the attacker can run any code. For other sessions, we do not have any magic; the attacker will have to sit and wait until all other sessions are closed. This still leaves the attacker pipes, and this is when session reconnection comes to the rescue (of the attacker). To recall from the previous blog, when a user disconnects from a session, the processes will continue to run on the server, allowing the user to reconnect to it later.

With this in mind, we made the following minor change to our exploit: we try to create the pipe in a loop and in case it fails with access denied, we sleep for 100 milliseconds and try again. Now we can run the code, disconnect the session, wait a second and reconnect. When the session is disconnected, there are no pipes, so the exploit code will succeed in creating the first instance. From that point everything works as before the patch. Here is a short video demonstrating this:

 

The New Patch

A new fix was released in April 2022 which addressed this issue. Let’s examine the new patch. We started by listing the open pipes:

pipelist.exe | findstr "TSVCPIPE"
TSVCPIPE-e58c3f64-1677-4760-bb10-a038e550cfcf          1               -1
TSVCPIPE-7cbace23-b8da-4a06-9b0b-74d8209aaaa1          1               -1
TSVCPIPE-b01e7639-d1f3-417c-aba3-62e5e69e2520          1               -1
TSVCPIPE-2c45f73f-0aa4-4ee9-829b-bfb1d3ea72fb          1               -1
TSVCPIPE-a46a65e4-278a-4403-ac64-997e918f5a0e          1               -1
TSVCPIPE-51617f98-5d6e-4355-b159-4b7e1e44b8f2          1               -1
TSVCPIPE-9044d140-21cf-4f4d-8ad5-2c243ccc7601          1               -1
TSVCPIPE-8d2ca510-77ce-4b78-b2e3-ad77e45e53ca          1               -1
TSVCPIPE-7cdd93b9-baf3-4e56-8497-b559272e6f6c          1               -1
TSVCPIPE-8f5b0b91-9512-4471-92ff-0addb1e67e25          1               -1
TSVCPIPE-670e271e-9b29-4dc9-8b78-49aeb15d3e68          1               -1
TSVCPIPE-86952f3b-87c4-44b5-95af-7a96e56658dc          1               -1
TSVCPIPE-40fe8844-a41b-484b-bb8c-9c0d64d08f9a          1               -1
TSVCPIPE-aa2b8a13-3654-4e81-a453-16d9bb4f3264          1               -1


We see 12 pipes with different GUIDs for two connected sessions. This means that now different pipes are created for each channel. We created additional sessions and tried disconnecting and reconnecting, and each time we got different GUIDs. This looks a lot better, since now an attacker will not be able to predict the next pipe name. Let’s look at the relevant parts from CGlobalPipeMgr::CreatePipeHandlePair():

    UniqueName = CGlobalPipeMgr::CreateUniqueName((CGlobalPipeMgr *)((char *)this - 48));
    if ( UniqueName >= 0 )
    {
      v9 = CGlobalPipeMgr::CreatePipeServer((CGlobalPipeMgr *)((char *)this - 48), &hNamedPipe);
      UniqueName = v9;
      if ( v9 >= 0 )
      {
        // ...
        if ( (ConnectNamedPipe(hNamedPipe, &Overlapped) || GetLastError() == 997)
          && (FileW = (__int64)CreateFileW((LPCWSTR)this + 20, 0xC0000000, 0, 0i64, 3u, 0x40000000u, 0i64), FileW != -1)
          && !WaitForSingleObject(*((HANDLE *)this + 4), 0xFFFFFFFF)
          && (Mode = 2, SetNamedPipeHandleState((HANDLE)FileW, &Mode, 0i64, 0i64))
          && (Mode = 0, GetNamedPipeServerProcessId((HANDLE)FileW, &Mode)) )
        {
          CurrentProcessId = GetCurrentProcessId();
          if ( !CurrentProcessId )
          {
            UniqueName = -2147467259;
            goto LABEL_40;
          }
          if ( CurrentProcessId != Mode )
          {
            // ...
          }
        }
        // ...
      }
      // ...
    }

Each time the function is called, it calls CGlobalPipeMgr::CreateUniqueName() which creates a new GUID for the pipe name. The pipe server is then created with this new unique name. Afterward, the pipe client is created using CreateFileW(). Once the pipe client is connected, the current process ID is compared to the named pipe server process ID, which is retrieved using GetNamedPipeServerProcessId(). This is an additional control guaranteeing that even if an attacker could somehow predict the GUID, the attack will not work since they will have a different process ID. In this case, the same process creates the pipe server and client (the pipe client handle is later returned to the calling process), so it is easy to perform this check. With these changes, the risks of this vulnerability have been adequately addressed.

Summary

Working with named pipes can be tricky. Though they have existed for decades and have been extensively researched, we still see security issues from time to time. We hope these posts will serve as concrete examples of how things can go wrong and encourage the security community, researchers and developers to continue to uncover similar issues to help strengthen enterprise IT environments and better protect them against risk of attack.

Disclosure Timeline

01/14/2022 – Vulnerability reported to Microsoft

01/26/2022 – Microsoft acknowledged the reported behavior

03/23/2022 – Microsoft assigned CVE-2022-24533

04/12/2022 – Patch released

We would like to thank Microsoft for working with us to get this issue fixed.

No Previous Articles

Next Article
Go BLUE! A Protection Plan for Credentials in Chromium-based Browsers
Go BLUE! A Protection Plan for Credentials in Chromium-based Browsers

In my previous blog post (here), I described a technique to extract sensitive data (passwords, cookies) dir...

Gartner Names CyberArk a Leader in the 2021 Magic Quadrant for PAM

Download Now