Since February 2022 was reported a new ransomware that appears to be
using a Windows 0-day vulnerability, according to the research conducted
by Trend Micro.
More information about this ransomware can be found at this
link.
According to analysis by Kaspersky, the Nokoyawa ransomware group has
used other exploits targeting the Common Log File System (CLFS) driver
since June 2022, with similar but distinct characteristics, all linked
to a single exploit developer.
In April 2023 when Microsoft released the patch, the
CVE-2023-28252
as assigned.
Previously, in 2022 a similar bug in the same component was researched
by us, and documented in this
blogpost
To face the analysis, it’s necessary to know the .blf file format, that is handled by the vulnerable Common Log File System driver called CLFS.sys and that is in driver’s folder within system32.
More information about this filetype can be found in the links below:
https://github.com/ionescu007/clfs-docs/blob/main/README.md
https://www.coresecurity.com/core-labs/articles/understanding-cve-2022-37969-windows-clfs-lpe
This analysis is made for Windows 11 21H2, clfs.sys version 10.0.22000.1574 although it also works on Windows 10 21H2, Windows 10 22H2, Windows 11 22H2 and Windows server 2022.
In previous Windows versions, it’s necessary to adjust some values, otherwise we would produce a BSOD.
Microsoft Patch Tuesday april de 2023.
You can check the driver version as shown
When the vulnerability was published, in April 2023 I started with Esteban Kazimirow to perform the reversing of the CLFS.sys driver, although in this case, just analyzing the patch was very difficult to deduce where the bug was and how to trigger it, since the exploitation is very complex.
Later, a blogpost came out whose author, from a sample of a malware, showed some parts of the code decompiled by HexRays and some information that guided where the exploitation had to be faced.
Obviously the provided info was not complete, but without this help it would have been unlikely to have come to build the PoC and later a functional exploit.
To make it easier to understand, we will first explain how to build the PoC and then we will do the vulnerability analysis.
This blogpost contains two sections:
Building the PoC:
1-Get the kernel addresses we need for exploitation
2-Preparing the Path to create the .blf files:
3-Create the "trigger blf" file using the CreateLogFile() function
4-Crafting the “trigger blf” file
5-Getting the kernel address of the BASE BLOCK of trigger blf
6-Calling AddLogContainer with the handle of trigger blf
7-Preparing the spray blf files
8-Preparing the memory to perform the spray
9-Triggering the bug
Debugging:
1-Checking the memory spray
2-Looking at the RecordOffset[12] of trigger blf
3-Looking at the iFlushBlock value in spray blf file
4-Why does it read from BLOCK 1 SHADOW instead of BLOCK 0 CONTROL ?
5-Why the checksum is equal to zero in blf spray files ?
6-Ending the exploitation.
7-The real patch
I’ll create a function named InitEnvironment to obtain some necessary Kernel addresses.
Get the EPROCESS address of my process and store it in the g_EProcessAddress variable, then the EPROCESS address of the SYSTEM process, and store it in system_EPROCESS, then the EHTREAD address of the main thread of my process, and I store it in g_EThreadAddress and finally the address of the PREVIOUS MODE that in this version of the PoC will not be used.
This method is well known, the GetObjectKernelAddress function, calls NtQuerySystemInformation twice with the first argument SystemExtendedHandleInformation, the first call is passed with an incorrect size and returns error, but also returns the correct size that is used in the second call and obtains the information of all the handles, then going through in a loop the information of each handle and in the field Object of the correct handleinfo gets the address searched in kernel.
I also need the kernel addresses of the following functions exported by CLFS.sys:
• ClfsEarlierLsn
• ClfsMgmtDeregisterManagedClient
And the exported functions from NTOSKRNL.exe
• RtlClearBit/PoFxProcessorNotification
• SeSetAccessStateGenericMapping
To get these addresses uses a similar method that is used to get the kernel base of both modules, by calling NtQuerySystemInformation twice, but in this case the first argument will be *SYSTEM_INFORMATION_CLASS (*in the PoC we use the FindKernelModulesBase function for this purpose).
Then it loads CLFS.sys and NTOSKRNL.exe as normal modules in user mode by calling to LoadLibrary, obtains the addresses in user mode with GetProcAddress and then subtracts the imagebase from each one, which obtains the offset of the function and finally adds each offset to the corresponding kernel bases and thereby obtains the kernel addresses of all the necessary functions.
I create a function called createInitialTriggerBlfFile which will generate and write a .blf file.
The path that is used as an argument in the CreateLogFile is different from a normal path, for example to open the file 1280.blf located in the C:\Users\Public folder, we must set the path LOG:C:\Users\Public\1280. This will be saved in the stored_name_CreateLog variable.
I do this by using wsprintfW() since stored_env stores the path C:\Users\Public, previously obtained from the environment variables. To this string I will prepend the string LOG: and a random name at the end, without the .blf extension.
This will be the path to my initial file that I’ll call "trigger blf". Of course, I also must save the normal path to the same file without the LOG: in front and with the BLF extension to open it and modify it with CreateFile(), WriteFIle() as any other file, this path will be, for example: C:\Users\Public\1280.blf, and it will be stored in the stored_name_fopen variable**.**
Of course, both paths correspond to the same file, and I must use one or the other as appropriate.
The CreateLogFile function fulfills a function quite similar to CreateFile() (creates new files or open existing files and get their handle), even some arguments are similar, but CreateLogFile() only works with blf files.
In addition, when it opens an existing file, it verifies that the format is ok, even if each block has a checksum and if this is not correct it will return an error.
I’ll create 2 kinds of BLF files:
-
The Trigger blf
-
The Spray blf
Both are blf files but modified in a different way.
In this way the PoC first creates the "trigger blf" file, using CreateLogFile, with the path for example: LOG:C:\Users\Public\1280 that I have set up before, and was stored in the stored_name_CreateLog variable.
The fifth argument fCreateDisposition, as in CreateFileA(), can take the following values:
In this case I’ll use the OPEN_ALWAYS argument, so the file will be created if it does not exist and if it exists it will be opened. Since the file doesn't exist yet, it will be created with a random name.
logFile = CreateLogFile(stored_name_CreateLog, GENERIC_READ | GENERIC_WRITE, 1, 0, 4, 0);
CreateLogFile() will create our "trigger blf" file with its 6 blocks and their corresponding checksums and will return the handle that will be stored in the logFile variable.
Each block will have from the offset showed at left column, a header whose size is 0x70 bytes.
So, for example, the header of the CONTROL BLOCK goes from offset 0x0 to 0x70.
All headers of all blocks have the same structure called _CLFS_LOG_BLOCK_HEADER.
This is the header structure:
At offset 0xC of the header I can find the checksum, so as the CONTROL BLOCK starts at offset 0, the checksum will be in the offset 0xC of the file and so each block will have its checksum at 0xC from the beginning of its block.
To modify the trigger blf file, I must open it as a normal file either with CreateFileA or with fopen and then modify it with WriteFile or fwrite respectively, I perform this at the beginning of the fun_prepare function of the PoC.
Remember that the normal path is stored in the stored_name_fopen variable, so I use it to open the file with wfopen_s (which is a variant of fopen that supports Unicode strings).
The file is modified in the craftTriggerBlfFile function called from fun_prepare.
Then I call fseek to point to the offset to be changed and then with fwrite the file is modified.
The changes to be made to the "trigger blf" file are as follows:
After making these changes, the FixCRCFile is called to calculate the new checksum and fix the checksums of the first 4 blocks. The next two blocks do not have any changes, so it is not necessary to recalculate their checksums.
The CLFS.sys driver reads the six blocks of the file, and to store their content makes an allocation in the Kernel pool.
There’s a very important structure of size 0x90 that in the previous blogpost of CVE-2022-37969, through reversing I found some fields and called it pool_0x90. After much more reversing, now I know that its real name is m_rgBlocks and as the controller goes allocating memory to copy from the file the contents of each block, there it saves the size of each block, the start offset, and the kernel address where it was stored.
It has six CLFS_METADATA_BLOCK that correspond to each block by its number.
Each structure CLFS_METADATA_BLOCK is 0x18 bytes long. (0x18*6=0x90)
In offset 0 there is a union, but at least in this exploit only the pbImage field is used, so simplifying it would be:
The allocation of that structure can be done from two different places of CLFS.sys driver, according to the creation of a new file or if an existing one is opened. In the case of when a new file is created, the driver allocates the 0x90 bytes from CClfsBaseFilePersisted::CreateImage+28A, while in the case of an existing file it allocates from CClfsBaseFilePersisted: ReadImage+6E.
After that, I’ll get the start address of block 2 that corresponds to the trigger blf file, called BASE BLOCK that begins at offset 0x800 and its length is 0x7a00.
Inside the fun_prepare function below this address will be found in kernel using this piece of the code.
First, the getBigPoolInfo function finds all the allocations in the pool that have the "Clfs" tag and a size of 0x7a00, then stores them in an array.
After that it opens again the trigger blf file previously modified by using CreateLogFile with the OPEN_EXISTING argument, so it opens an existing file, this will perform the allocation of its BASE BLOCK.
When getBigPoolInfo is called again, there’ll be one new “Clfs” pool of size 0x7a00, and its address is retrieved by calling NtQuerySystemInformation twice.
The address of the BASE BLOCK of trigger blf file is stored in the CLFS_kernelAddrArray variable.
Note that if the modified trigger blf file does not have the correct checksum, the CreateLogFile() function will fail.
The last part of the fun_prepare function, calls the AddLogContainer api using the handle of the trigger blf file.
In the last function of the PoC called to_trigger a second type of blf file will be created,
I’ll name it spray blf.
This kind of file will be used to fill a memory space (spray), 10 equals of this kind are needed, but initially only one is created.
Three arrays will be created to store the random names of this files:
stored_log_arrays: store ten new random names of .blf files that will be used with CreateLogFile.
stored_container_arrays: store random names to create ten new container files.
stored_fopen_arrays: store the log files names of the first array (stored_log_arrays variable), but with their normal path (without the “LOG:” string) and with the .blf extension.
On each iteration the blf file is copied using CopyFileW, the names that are stored in the arrays are assigned.
The fun_trigger function calls craftSprayBlfFile where modifications are made to each file and FixCRCFile will fix the CRCs.
Summarizing, I’ve created 10 similar files (spray blf) with random names with the following modifications:
The last change is to copy the entire block 0 (CONTROL BLOCK) to block 1 (CONTROL BLOCK SHADOW)
The effect of these changes, plus those made to the trigger blf file, will be explained later in the debugging chapter.
Some of these changes are those that produce vulnerability, while others are only necessary to bypass the driver checks.
At this point the files are already created and modified, ready to perform the spray, then when they are opened with CreateLogFile, they will be located in the memory area that we want, as will show later.
In the to_trigger function, an array of 12 elements is created, containing the address of the BASE BLOCK of trigger blf file plus 0x30.
Then, in the fun_pipeSpray function, the memory is filled with a spray of pipes, inside there’s a loop that calls to CreatePipe and creates the number of pipes that is passed as a first argument, the second argument is an array that will store the handles of all the pipes created.
Within a loop, it calls to CreatePipe creating read-write pipes.
In this way first 0x5000 pipes will be created and then call again to create other 0x4000 pipes.
Then uses WriteFile to write to the first 5000 pipes, the array recently created with the addresses of BASE BLOCK + 0x30 of the trigger blf file**.**
Now already has a compact block created in memory, it will release 0x667 pipes from the number 0x2000 and up to the 0x2667, since in memory the pipes are not in the same order as were created, what will happen is that there will be free spaces in this memory block.
Note that the allocations of the pipes have as user size of 0x90 bytes, so when be released we’ll have
It frees the memory spaces of size 0x90 between the memory full of
pipes.
Then it loops to call CreateLogFile with the 10 spray blf
files.
When CreateLogFile is called to open existing files, the allocation
of 0x90 bytes is performed for the m_rgBlocks one for each spray
blf file**,** so these allocations will occupy gaps that were left
when releasing the pipes since they are the same size.
Then repeat the process of writing in the final 0x4000 pipes the array that has the address of BASE BLOCK +0x30 of trigger blf.
All these manipulations creates a controlled memory space, I will show you how it is when is being debugged, but the idea is that the m_rgBlocks of each spray blf file occupy the 0x90 byte gaps that were released.
Then already in the final part, the bug is triggered within a while( 1 ) using a call to AddLogContainer to the spray blf files.
Within this while the bug is triggered:
This while will exit when it finds the System token, using the NtFsControlFile function that will read the pipes attributes.
Then using CreateLogFile, again overwrites the token of our process with the recently found System Token and in this way we achieve the elevation of privilege.
Then restore some values, close the handles of the pipes and the blf files, and run a Notepad as System to verify that we have raised correctly.
Note the blf files created on the PUBLIC folder. Remember that if you want to do another try, you must first delete the created files. some will be locked and cannot be deleted, but the PoC will still work.
Before I start with the effect of changes to trigger blf and spray blf files to perform the exploitation, I must verify that m_rgBlocks of spray blf files are located in holes that occur in memory distribution, after performing the pipe spray and the subsequent release of a fixed number of pipes.
When this procedure ends, a pipe should be located under the 0x90 bytes of m_rgBlocks, so when m_rgBlocks is used, an OUT OF BOUNDS will occur and it will read from that pipe that is below.
The PoC has an ideal point to place a breakpoint:
At this point, the opening of spray blf files is complete and the AddLogContainer function is still not called.
To debug in user mode, I will use x64dbg and for kernel mode, IDA with the Windbg plugin.
At this point the memory should already be prepared, and I can see the distribution.
I’ll pause IDA to find an interesting point to put a breakpoint.
I’ll set up a breakpoint at CClfsBaseFilePersisted::AddContainer, which is called from AddLogContainer and at the beginning, it has the RCX register pointing to CClfsBaseFilePersisted structure and at offset 0x30 there’s a pointer to m_rgBlocks.
When the breakpoint is reached, I check on call stack that AddLogContainer is being called from my PoC.
the RCX register points to:
The first field is the pointer to a vtable (CLFS! CClfsBaseFilePersisted::'vftable') and at offset 0x30 is the pointer to m_rgBlocks.
The blocks 0, 1, 4 and 5 have not saved the pbImage yet, while blocks 2 (BASE BLOCK) and 3 (SHADOW BLOCK) have.
Each block in m_rgBlocks table has its cbOffset which is the offset where the block starts in file, cbImage is the block size, and eBlockType is the block type.
If the spray is correct, below the m_rgBlocks there should be a pipe and within, the pointers to BASE BLOCK + 0x30 of trigger blf.
The "!pool" command on windbg displays the memory distribution:
Each m_rgBlocks has a “Clfs” tag and its size is 0xa0 because it is the 0x90 user size plus 0x10 header and below there’s a pipe with the “NpFr” tag that has the same 0x90 user size + 0x10 header.
Since distribution isn't an exact science, some “Clfs” were placed continuously, which is undesirable, but the one I'm working with, is correctly placed followed by a pipe.
One of the first changes that affects is the one made in trigger blf file at offset 0x858, where the value 0x369 is stored.
The BASE BLOCK starts at the offset 0x800 in the file.
Inside the _CLFS_LOG_BLOCK_HEADER at offset 0x800+0x58 (0x58 from the beginning of BASE BLOCK header).
At offset 0x28 the array RecordOffsets (DWORD) begins.
Moving 0x30 bytes forward, at offset 0x58 (0x828+0x30=0x858 from the beginning), is field 12 of RecordOffsets.
I run the PoC to CreateLogFile as shown in the image below:
Before I enter to CreateLogFile I'm going to put a breakpoint in a place where value 0x369 hasn't been used yet.
In a case that CreateLogFile opens an existing file, the m_rgBlocks structure is allocated here:
CClfsBaseFilePersisted::ReadImage+6E
So, I’ll set a breakpoint on IDA right here:
When breakpoint is triggered**:**
In m_rgBlocks there is still some garbage because it’s still uninitialized, but as soon as pbImage of block 2 is allocated, the address will be saved in offset 0x30 from the start, since the first field inside each CLFS_METADATA_BLOCK is pbImage.
Now I set up a hardware breakpoint on write: ba w1 ffffd003'7f5bea30
After initializing to zero, it stops when it saves pbImage.
The analysis says that it corresponds to block0, because it does not consider the constant r14*8 which is 0x30 afterwards, as a result is really writing the pbImage of block 2.
Note that CClfsBaseFilePersisted::ReadMetadataBlock is used to allocate any of the blocks, using the size passed as argument.
Now set a read/write breakpoint at 0x58 from the base block, to see when it uses the value 0x369.
ba r1 FFFF978A'16ECF000+0x58
When the breakpoint is hit, reads the value 0x369 located at the RecordOffset[12], adds it to a weird pointer on r14 and increments the contents of RAX+r14.
A few lines above in the code, ESI has the value 0x13 and multiplies by 0x18, which is the size of each block in m_rgBlocks.
WINDBG>? 0x18*0x13
Evaluate expression: 456 = 00000000'000001c8
If I add the value of r8= 0x1c8 that is greater than 0x90, to the initial address of m_rgBlocks, it’ll be reading OUT OF BOUNDS.
Below m_rgBlocks, is the pipe with the pointer to BASE BLOCK + 0x30, it reads this pointer that was strategically placed inside the pipe.
The current position in the code was called from the while(1) statement of main module.
Inside spray blf file I’ve strategically placed the value 0x13 at offset 0x48a (iFlushBlock).
At offset 0x8a of spray blf file, iFlushBlock of BLOCK 0 is located, whose value is 4, while offset 0x48a belongs to iFlushBlock of BLOCK 1, and its value is 0x13
Now I have to find out why it reads iFlushBlock = 0x13 from BLOCK 1 instead of iFlushBlock = 4 from BLOCK 0.
If I look back to find out where the 0x13 came from, I see on call stack that WriteMetadataBlock is called from CClfsBaseFilePersisted::ExtendMetadataBlock+416, there the second iFlushBlock argument is EDX=0x13, which comes from r9w.
A couple of lines before, CClfsBaseFile::GetControlRecord was called to retrieve the address of BLOCK 0, maybe the problem is here, so I'll reboot and put a breakpoint on it.
GetControlRecord calls CClfsBaseFile::AcquireMetadataBlock who should fill the m_rgBlocks table with the address of block 0, when I step over this function gets the address of block 1, so, the problem occurs inside CClfsBaseFile::AcquireMetadataBlock.
By adding 0x8A to the address retrieved, I can confirm that the 0x13 value that belongs to BLOCK 1 is present.
I will reboot and set a breakpoint there:
CClfsBaseFile::GetControlRecord+27 call CClfsBaseFile::AcquireMetadataBlock
The second argument passed to AcquireMetadataBlock is zero, it corresponds to block 0, it is going to copy from the file and store its address in m_rgBlocks .
In _CLFS_METADATA_BLOCK_TYPE block type enumeration, they have different names than I used, but they are the same 6 blocks.
After checking that the block type is less than the maximum m_cBlocks=6, it saves a reference value to avoid reading the same block two times.
ReadMetadataBlock is called, the problem of reading block 1 instead of block 0 would be inside this function.
If everything is fine, it allocates using cbImage as size and it stores the address in field block 0-> pbImage in m_rgBlocks.
The !pool command displays the tag and size allocated.
So, I already have the address of pbImage of block 0 stored in m_rgBlocks, so I need to see why it copies the bytes of block 1 there instead of bytes of block 0.
I get to a call to CClfsContainer::ReadSector where a pointer to a variable containing pbImage is passed, to write the bytes.
Notice the changes made in pbimage content when stepping over ReadSector.
Adding 0x8a to pbImage I can find the value 4 which is correct value, instead of 0x13, so the problem must occur later.
After calling ClfsDecodeBlock It returns an error 0x0C01A000A.
CClfsBaseFilePersisted::ReadMetadataBlock+153 calls to ClfsDecodeBlock
After this error, it adds 1 to the type and calls CClfsBaseFilePersisted::ReadMetadataBlock again but with type 1 to read block 1.
In CClfsBaseFilePersisted::ReadMetadataBlock It allocates and stores a new pbImage in m_rgBlocks for block 1.
Blocks 0 and 1 have different addresses, now if I add 0x8a to the address of block 1 its value is 0x13.
Maybe since block 0 returned an error, it uses block 1 and returns it to GetControlRecord as Control Block.
As shown before, when it uses 0x13 value instead of 4, it goes outside the bounds of m_rgBlocks and reads the pipe spray values controlled by me.
Then it frees the pbImage from block 0 and it copies the pointer from block 1 to block 0.
It would be necessary to find the value that causes the error 0x0C01A000A inside ClfsDecodeBlock.
Inside ClfsDecodeBlock the checksum of the first block is zero, this is the error 0xC01A000A.
Before calling to AddLogContainer, opening any spray blf file with a hexadecimal editor, the checksum was changed to zero.
it should have been changed before when it was opened with CreateLogFile.
For some reason spray blf files end up after exiting CreateLogFile with checksum of block 0 equal to 0 and return a valid handle, let's see why this happens.
I stop at CreateLogFile before opening some spray blf file.
Note that before calling CreateLogFile, spray files have the correct checksum in block 0 and after completing the function, the checksum value changes to zero.
So, I set a breakpoint on CClfsBaseFile::GetControlRecord, to look inside.
After passing CClfsContainer::ReadSector the checksum is not zero.
Before entering to calculate the CRC32, it puts the checksum field to zero in memory to calculate the CRC, and the result is correct.
Then it checks the value of eExtendState =2 and it goes to WriteMetadataBlock.
Here the checksum is still zero in memory, I just need to see when this value is written in the file.
It checks some values that are crafted in blf spray file to reach CClfsBaseFilePersisted::ExtendMetadataBlock.
After a loop to read blocks that have not been read yet, block 0 continues with checksum = 0.
Arriving at WriteMetadataBlock.
Since I'm running before it replaces block 0 with 1, the iFlushBlock value of the blf spray file is still 4 the correct value.
Now it’s working with block 4, and it will write block 4 in file, here is not the problem yet.
Then it comes to CClfsBaseFilePersisted::FlushControlRecord
Inside it reaches WriteMetadataBlock, but with argument 0, to write block 0 to file**.**
Then the ClfsEncodeBlock returns error 0xC01A000A, although it will write the file with the bad block 0 in CClfsContainer::WriteSector, just below.
The variable var_54 stores the 0xC01A000A error value and will be checked before exiting the function.
But after calling CClfsContainer::WriteSector which returns no error, the content of var_54 is overwritten with zero.
So, the function returns zero with no error and it continues working since CreateLogFile will return a handle instead of an error value.
The value 0x13 in iFlushBlock causes it to go out of bounds and it will read the pointer that is in the pipes that points to the Base Block +30 of trigger blf.
Then it adds 0x28 to that pointer, ( 0x58 from the beginning of the base block of the trigger blf) that has the value 0x369.
The INC instruction will increase the value 0x14 by 1 and repeats 4 times, so 0x14 ends to 0x18.
WINDBG>db r14+369
ffffcb82'091e7397 14 00 00 00
After that, CreateLogFile is called, and reads the 0x1858 value.
GetSymbol checks if the fake block previously created in trigger blf, pointed by the offset 0x1858, has the correct values.
if the pointer had not been incremented several times, it would have the original value 0x1458 and will point to the right block.
After exit GetSymbol, it will use that fake block here.
Then it will read the value of offset 0x18 of fake block where I place 0x05000000 and jump to content of what is there.
WINDBG>dps 0x5000000
00000000'05000000 00000000'05001000
It reads the content of 0x05000000 and its 0x05001000 and there it is ClfsEarlierLsn.
This function is used to return the value 0xFFFFFFFF in RDX although this first time that value is not used.
The second call occurs here, it calls PoFxProcessorNotification which was on 0x501000 +8
WINDBG>dps 00000000**'05001000**
00000000'05001000 fffff805'7ab13220 CLFS! ClfsEarlierLsn
00000000'05001008 fffff805'769dc3b0 nt! PoFxProcessorNotification
in this function RCX = 0x05000000 , it checks that 0x40 bytes later must be nonzero
WINDBG>dps rcx+40
00000000'05000040 00000000'05000000
The address to jump will be 0x68 later.
WINDBG>dps rcx+68
00000000'05000068 fffff805'7ab2bfb0 CLFS! ClfsMgmtDeregisterManagedClient
And the argument will be 0x48 bytes later.
WINDBG>dps rcx+48
00000000'05000048 00000000'05000400
The ClfsMgmtDeregisterManagedClient, it's a convenient function because I can control the argument and I also have two jumps to functions controlled by me.
The first call is again to ClfsEarlierLsn that returned in RDX=0xFFFFFFFF.
it will take the source to write from the content of RDX=0xFFFFFFFF.
WINDBG>dps rdx
00000000'ffffffff ffff8005'3a4ee000
At address 0xFFFFFFFF I had stored the system_EPROCESS & 0xfffffffffffff000.
The destination is the pointer located at 0x5000400 +0x48
*(UINT64*)0x5000448 = para_PipeAttributeobjInkernel + 0x18;
The PipeAttribute pointer in kernel that points to a buffer filled with “A” will be overwritten with the high part of the SYSTEM EPROCESS pointer.
This pointer was created when I previously called _NtFsControlFile with a buffer full of “A” .
The content of that attribute can be read using NtFsControlFile.
Now the pipe attribute no longer points to the buffer with “A” but to system_EPROCESS & 0xffffffffffffff000.
This code will be repeated until the system token is retrieved.
On windows 11 the system token is at offset 0x4b8 of the EPROCESS structure recently read.
I only need to write that system token in my process by calling CreateLogFile.
To do this job, just repeat the step used to read the system token.
In the double call, it first calls ClfsEarlierLsn to return 0xFFFFFFFF in RDX and then calls nt_SeSetAccessStateGenericMapping.
I check that the value pointed by RDX is the System Token.
The token of my process is:
It’s going to write there.
WINDBG>dps rax+8
ffff9b8b'fc446578 ffffc402'f601c06c
WINDBG>dps rax+8
ffff9b8b'fc446578 ffffc402'ef841919
Now my process is System I can run a Notepad to verify.
BINDIFF shows a lot of changed functions
The vulnerable function is here:
The primary is the patched version, the secondary is the vulnerable version.
The patch tests the return value of CflsEncodeBlock, which is 0xC01A000A, stores it into the variable var_54, and since it is negative, checks it and avoids the WriteSector.
The patch, in addition to not writing the file, the function returns correctly 0xc01a000a, with which CreateLogFile does not return any handle and the exploitation cannot continue.
Only if ClfsDecodeBlock is not negative, it goes to WriteSector but leaves returning the negative value 0xC01A000A.
This is the actual patch that really prevents the exploitation using the PoC that I just attached.
At this point we have explained how the bug was exploited, it leads to controlling the functions that allows us to read the SYSTEM token and write it in our own process to achieve the local privilege escalation. You can find the functional PoC at Fortra’s GitHub.
We hope you find it useful, if you have any doubt can contact us: