Sysmon Internals - From File Delete Event to Kernel Code Execution
Sysmon File Delete Event Internals and Kernel Code Execution
Introduction
On April 2020, Mark Russinovich announced the release of a new event type for Sysmon version 11.0: event ID 23, File Delete. As indicated by the name, it logs file delete events that occur on the system. In addition to this, another functionality came alongside allowing files marked for deletion to be archived, enabling defenders to track tools being dropped by malware to better understand the actor's capabilities and develop signatures. This article will discuss the internals of Sysmon version 11.11's to gain insight into how it operates and its limitations. I have briefly checked Sysmon version 12.0 as it was released (September 17, 2020) during the writing of this article and the code for this event looks almost identical so the information should still be mostly relevant.
Before beginning the article, a huge thank you to Samir for the collaborative effort on this journey.
File Delete Conditions
Let's first understand the conditions for which the file delete event will be triggered. The file events are handled almost entirely by the SysmonDrv.sys
driver through the minifilter component. Specifically, it monitors for three I/O request packets (IRP) IRP_MJ_CREATE
, IRP_MJ_CLEANUP
, and IRP_MJ_WRITE
for file creates, complete handle closes (reference count on a file object reaching zero), and writes respectively.
IRP_MJ_CREATE
The purpose of this IRP in the context of file deletes is to handle two situations: file overwrites and file deletes using the FLAG_FILE_DELETE_ON_CLOSE
option.
Sysmon will check first whether the file was opened with FLAG_FILE_DELETE_ON_CLOSE
, and if it is, the configuration's filter rules will be matched against. If the conditions are met, the delete event will be created and the file will be archived in the IRP_MJ_CLEANUP
request when all the handles to the file are closed.
On the other hand, if the file already exists and is being opened with overwrite intent (disposition value is either FILE_OVERWRITE
or FILE_OVERWRITE_IF
), the driver will attempt to open the file with FltCreateFile
. If the function fails with STATUS_OBJECT_NAME_NOT_FOUND
, the file doesn't exist and Sysmon will pass it on to be handled as a file create event. Otherwise, if it returns successfully, the target file exists and will be archived immediately, creating the delete event in tandem.
IRP_MJ_CLEANUP
As mentioned before, this request is sent when all of the referenced handles to a file have been closed. Sysmon handles this request by checking the DeletePending
flag in the file object and archiving the file if it's set. In the case of FLAG_FILE_DELETE_ON_CLOSE
, Sysmon will explicitly set the file's delete disposition to true using FltSetInformationFile
with FileDispositionInformation
before checking the DeletePending
flag.
IRP_MJ_WRITE
Sysmon also considers file content overwrites instead of just classic file deletes. To meet this condition, the write must originate from user mode, the write size must be greater than or equal to the size of the target file and the write should start at the zeroth offset.
Sysmon will then read the first byte of the file before iterating through each byte of the buffer that will be written. To trigger the file archiving and delete event, all of the bytes must match the first recorded byte. Sysmon refers to this as shredding where all bytes of the overwrite are the same, e.g. AAAAAAA....
There is one more condition under IRP_MJ_WRITE
which triggers the archive and delete event however, I was unable to trigger or trace the conditions required. Perhaps this would be an upcoming feature?
Internals
Now that the conditions of file delete events have been covered, let's investigate the implementation details.
File Delete Requirements
The first function to cover is one I call CheckAndQueueFileDeleteEvent
and its main purpose is to check if the target file should be archived and if a delete event should be logged. It achieves this in several ways with a few initial checks. If any of the following conditions are met, the file delete event will be bypassed:
- The operation is made by the registered service process (should be
Sysmon64.exe
), - The delete event was not set in the configuration,
- The target file is a device or directory,
- The file is empty,
- The file's parent directory is the archive directory.
After these initial checks, the function will retrieve the file's extension using FltParseFileNameInformation
and also check if the file is an executable by using a few FltReadFile
calls to read the MZ
and PE
signatures at offset 0 and 0x3C
respectively. These values are returned back to the caller along with the image file name responsible for the operation.
Finally, it makes a call to a function I labelled QueueFileDeleteEvent
to perform a final check if the file should be archived and logged.
Reporting File Delete Events
The following represents the proprietary file delete event structure:
The event is allocated and set in QueueFileDeleteEvent
. But besides the obvious purpose of reporting file delete event data, it has another important, secondary purpose. The tenth argument to this function is a pointer that represents the boolean value of whether the target file should be archived. Although this pointer is optional, if it is a valid, non-zero value, this function serves to pass the event data to Sysmon64.exe
via an event queue to be checked against the filter conditions provided in the configuration file.
This explains the existence of the Event
, IsArchivedAddress
, and the ServiceProcessHandle
members of the FILE_DELETE_EVENT
structure. After intialising these values and the structure, the event is queued in a function I labelled QueueEvent
and the thread is blocked using KeWaitForMultipleObjects
waiting on either the Event
or the Sysmon64.exe
process.
To understand this further, we need to know how events are reported from the driver to Sysmon64.exe
.
QueueEvent
Sysmon utilises a doubly linked list to queue up events. In this article, I will refer to it as g_EventReportList
. This function is relatively straightforward, if the event size is not greater than 0x3FCB8 + 0x348
(40000) it will be appended to g_EventReportList
, otherwise, an incorrect event size error will be created. Since we are not concerned about the error event, we'll skip it for brevity.
An allocation for a new data structure is created to wrap the event argument:
typedef struct _EVENT_REPORT {
/* 0 */ LIST_ENTRY ListEntry;
/* 10 */ ULONG EventDataSize;
/* 18 */ PVOID EventData;
} EVENT_REPORT, *PEVENT_REPORT;
The pointer to the event is pointed to by EventData
and its size is stored in EventDataSize
. After filling this structure, the EVENT_REPORT
is appended in g_EventReportList
if the number of entries is less than 50000. If there are 50000 entries, the first item in the queue is removed and deallocated.
Retrieving Events
Once events are queued, Sysmon64.exe
can read them one by one through the driver's device control dispatch with the I/O control code 0x83400004
.
In the context of the file delete event, Sysmon64.exe
will check for a valid Event
member.
If it's valid, a filter check will be performed to determine if the target file object should be archived and a file delete event launched. The following data structure will be initialised and sent to the driver:
typedef struct _SET_ARCHIVED_INFO {
/* 0 */ BOOLEAN IsArchived;
/* 8 */ PEPROCESS ServiceProcessHandle; // Service process.
/* 10 */ PKEVENT Event;
/* 18 */ PBOOLEAN IsArchivedAddress;
} SET_ARCHIVED_INFO, *PSET_ARCHIVED_INFO;
The IsArchived
value is set depending on the return value of the function that checks for filter matching. Using DeviceIoControl
, Sysmon will send the above structure back to the driver with the 0x83400010
I/O control code.
Back in the driver's device control dispatch, the value in IsArchivedAddress
will be set to IsArchived
(!) before signalling the event to unblock QueueFileDeleteEvent
.
Once QueueFileDeleteEvent
is signalled and unblocked, it will return the IsArchived
value back through the tenth argument which is then returned again from CheckAndQueueFileDeleteEvent
.
The return value is used in two locations: the IRP_MJ_CREATE
with FLAG_FILE_DELETE_ON_CLOSE
and in the ArchiveFile
function. The former triggers the file delete event and archiving in the IRP_MJ_CLEANUP
request by setting the CompletionContext
value to 1
so that it can be handled in the minifilter's post operation.
The post operation routine simply allocates and sets a stream handle context with a size of two bytes. If the CompletionContext
value is 1
, the stream handle context will be set to the value of 0
.
When the IRP_MJ_CLEANUP
request is sent on handle close, it will check the stream handle context for 0
and set the target file's delete disposition.
File Archiving
When CheckAndQueueFileDeleteEvent
returns to indicate that the file delete event should be logged, the ArchiveFile
function will perform a second round of checks that must be passed if the target file should be archived. Using the subroutine that I have called GetFileInfo
, Sysmon queries the state of the file for information such as the file's hash, the first byte, if the file is the same repeated byte (shredded), and if "kernel crypto" is supported - it should always be. In addition to this, ArchiveFile
also queries for the available disk space. The target file will not be archived if any of the following is true:
- The file is shredded,
- "Kernel crypto" is not supported,
- The remaining disk space is less than 10 MB.
If archiving is appropriate, it will call one of two functions. If the current IRP is IRP_MJ_WRITE
, it will call the function I named CopyFile
, else it will call the other I named RenameFile
. The reason for why this happens is unknown to me. Both of these take the archive file name which is the file's hash (from GetFileInfo
) and its extension (from CheckAndQueueFileDeleteEvent
) that's generated by ArchiveFile
.
CopyFile
This function is pretty self explanatory and simple. The size of the file is queried and a buffer created with a max size of 0x10000
. The new archived file is created with FltCreateFile
and chunks of the target file are copied over using FltReadFile
and FltWriteFile
. If, for some reason, the copy fails, Sysmon will report the error. The file delete event will be generated using QueueFileDeleteEvent
specifying a NULL
value for the tenth IsArchived
parameter.
RenameFile
ArchiveFile
will set this function as a delayed work item with which KeWaitForSingleObject
is immediately called to trigger it. Unfortunately, I do not have an answer to this behaviour. Sysmon will simply rename the file from its original to the archived file name (replacing any existing file) before reporting the file delete event using QueueFileDeleteEvent
specifying a NULL
value for the tenth IsArchived
parameter.
Bugs and Bypasses
Throughout my research of Sysmon - and to my surprise - I uncovered a few bugs and bypasses. In this section, I'll detail the findings and example implementations of how they can be abused. A special thank you to Samir for verifying these bugs as well as providing constant motivation.
File Shredding Bypass
Now that we understand how Sysmon identifies shredding and that it can archive files on such events, we can easily bypass it. Since shredding is defined by repeated bytes that fill the size of the file or greater, the solution is trivial. Just simply modifying the data overwrite with alternating or random non-repeated bytes. Even overwriting everything with the same repeated byte except the final character - obviously cannot be the same byte as the overwrite - will suffice.
Given the abundance of permutations for overwriting data, it's safe to assume that it's virtually impossible for Sysmon to detect these alternate methods. While it may be useful, I question its practicality.
File Delete and Overwrite Bypass
Sysmon has a small issue that seems to originate in the update from version 11.0 to 11.10 with the introduction of the CheckAndQueueFileDeleteEvent
conditional in the specific FLAG_FILE_DELETE_ON_CLOSE
case. The IoQueryFileDosDeviceName
function call in CheckAndQueueFileDeleteEvent
is provided to the file delete event reported to Sysmon64.exe
where it is checked against the filter. However, dynamic analysis reveals that the resulting file object name is always C:
. This means that the filter match will always fail unless the target file name is something like
<TargetFilename condition="begin with">C:</TargetFilename>
or
<FileDelete onmatch="exclude"/>
For some unknown reason, the following rule does not seem to capture the file delete event with FLAG_FILE_DELETE_ON_CLOSE
:
<TargetFilename condition="is">C:</TargetFilename>
In addition to file deletes, this also works in the case of file overwrites with the FLAG_FILE_DELETE_ON_CLOSE
option. Since Sysmon has precedence for this flag, targeting already existing files will never trigger the code that is supposed to perform the file overwrite check. The result is that the file archiving for overwritten files is never logged.
To demonstrate this, del
on the command prompt deletes a file using the FLAG_FILE_DELETE_ON_CLOSE
option. In the following image, the file creation is logged however, the file delete event isn't.
Arbitrary Kernel Write
I mentioned earlier that the Sysmon driver returns data to its service process to determine if a file delete event should be logged. The service process communicates back a boolean value to the driver which is written to a stack address. However, both the boolean value and the target stack address is controlled by the service process which effectively means that there is a write-what-where primitive from usermode.
Service Process Registration
To abuse this, we need to take over Sysmon64.exe
and register our own from the perspective of the driver. To connect to a driver, usually a call to CreateFile
is made with the path specifying the symbolic link to its device object. In this case, Sysmon's driver's device name is \\.\SysmonDrv
.
This results in the IRP_MJ_CREATE
request being sent to the driver which is handled in its driver dispatch routine registered for the IRP_MJ_CREATE
IRP. On examination of this code, it performs a privilege check on the requesting process to see if it has PRIVILEGE_SET_ALL_NECESSARY
which effectively means we require debug privileges.
Once a handle to the device is opened, the next step is to register the process with Sysmon. This means that Sysmon must set its global service process to that of our process. Sysmon supports the 0x83400000
I/O control code that's handled by the driver dispatch under the IRP_MJ_DEVICE_CONTROL
request which can be made via the DeviceIoControl
function using the handle to the device. If we follow this control code in the function I labelled DriverDispatchDeviceControl
, there is a length check for the input and output buffers.
The input buffer size can be either 0 or 4 but the output buffer size must be 4. If the input buffer exists, it is checked against the value 1111
(0x457
) - this seems to be the version of Sysmon, in this case, it is v11.11, in Sysmon v12.0, it is 1200
. If the input value is incorrect, it will return with the STATUS_REVISION_MISMATCH
error and fail the registration. If the input buffer is not supplied, it will ignore the revision check so providing the input buffer is optional. The output buffer will always return the version.
Once this requirement has been fulfilled, the driver will register the requesting process as the service process. in its g_ServiceProcessHandle
variable.
The driver does not contain any other mechanisms like certificate checks to verify the service process so any executable is compatible.
Reading Events
To abuse the write, we need to be able to read the file delete events reported by the driver to obtain valid Event
and ServiceProcessHandle
values. As aforementioned, the driver supports another I/O control code, 0x83400004
, which reads events from the g_EventReportList
queue. The only requirement here is that the output buffer needs to be able to fit the size of the event. To be able to support all of the events, we can either dynamically resize the buffer with the returned size or we can simply allocate a buffer with the maximum event size (40000 from the QueueEvent
function).
We can use DeviceIoControl
again with the handle to the Sysmon device. The output buffer will return the details of all queued events so we will have to differentiate them by their ID - this is the first member of the event structure. We are only interested in file delete events so we need to filter for 0xF0000000
. The last check is if the event data contains a valid Event
member (as checked by Sysmon64.exe
so we will follow its convention).
Once all of the conditions have been fulfilled, we can finally send a request back to the driver to perform the write using the 0x83400010
I/O control code. The IsArchivedAddress
member points to where the single IsArchived
byte will be written. The Event
and ServiceProcessHandle
members will just be copied from the delete event that was read earlier.
It should be noted that there may be some queued events that contain a ServiceProcessHandle
from Sysmon64.exe
so it may take a few event reads to remove those from the queue before newer events start to contain our process's.
The result of the above example code is a bug check for writing to an invalid address.
Arbitrary Kernel Write to Kernel Code Execution
To be able to execute code through Sysmon requires a few stars to align, some luck, and some hacks. We'll go over what we can do with an arbitrary kernel write, what issues we have to solve, what is available to us from Sysmon, and finally, how to combine everything together to create a solution that allows us to inject and execute code.
What We Have
Arbitrary kernel writes allow us to write a ROP chain into the stack, bypassing any stack cookie mitigations that might be present. This effectively allows us to gain control over the execution of code. But since the kernel is a volatile space with regards to unhandled exceptions, if we overwite too much of a target stack, we may end up destroying any critical data and potentially end up crashing the system. To be able to restore normal code execution, the solution I have come up with is to perform a stack pivot to another writable section in kernel and write the ROP chain there.
Now that there is execution control, where can the stack pivot and the other gadgets be written reliably and safely to guarantee that they are all there before execution? We can guarantee the gadgets if they are written somewhere that isn't touched such as a data section cave. For the stack pivot, we don't want to have the target function finish before it's written.
Since we want to inject and execute code, we can first start off with allocating a pool that has RWX permissions for writing and then executing the code. Windows allows allocation of a NonPagedPool
(we want the page to always be available for code execution) through the ExAllocatePoolWithTag
function which contains the permissions we need. This also benefits us by reducing the complexity of needing to reprotect the code section. This is relatively easy to achieve and the ROP chain would look something like so:
Injecting code into the pool is trivial but this is useless if we can't read the address of the pool. And how do we get the address of ExAllocatePoolWithTag
? If we can somehow read the pool address and inject code, how can we execute the code?
Problem Solving
We have the basic requirements for getting code execution but some issues stand in the way. To summarise:
- How do we reliably and safely write the stack pivot to control execution?
- How do we get the address of
ExAllocatePoolWithTag
? - Once we allocate the pool, how do we read it back in usermode?
- After the code is written into the pool, how can we execute it?
Starting with the first problem, we can abuse the thread blocking done in the QueueFileDeleteEvent
used to wait for the reply from the service returning the boolean value to determine whether a target file should be archived. This provides both reliability and safety of writing the stack pivot gadget. But we can only write a single byte before the event is signalled, continuing execution of QueueFileDeleteEvent
. This means we cannot write the eight bytes needed to pivot the stack. To verify this assumption, I had a look into the KeSetEevent
function that is called after the single byte write.
Looking at the disassembly, there is actually an early return that doesn't seem to do anything that would affect the state of the kernel as there are only reads and comparisons. So we have hope of writing more bytes before unblocking QueueFileDeleteEvent
. If we set the pointer of the Event
member to an address that contains the byte array 00 00 00 00 01 00 00 00
, the first two checks will turn towards the early return. Since KeSetEvent
is always called with a FALSE
third argument, dil
will always be 0.
This can be further abused to write multiple bytes in quick succession because we don't need to wait for more delete events to provide valid Event
addresses. The first delete event's Event
value can be stored until the stack pivot and other gadgets have been written. The saved Event
can be passed to unblock QueueFileDeleteEvent
and execute the ROP on demand.
We now have the requirements to bypass the thread unblocking to write more than a single byte. But how do we find these values in ntoskrnl.exe
with ASLR?
To solve the problem of ASLR and finding the address of ExAllocatePoolWithTag
, Windows provides the EnumDeviceDrivers
function which returns an array of the current base addresses of all drivers loaded into the kernel. The following code snippet demonstrates how this can be done.
To get offsets of a driver, LoadLibrary
can be used to load them into the usermode process. Subtracting the base of the user-loaded module and then adding the kernel base address from EnumDeviceDrivers
, we can calculate the correct kernel addresses.
The next problem is reading the allocated pool. Sysmon provides only one way of providing controllable data back to usermode and that is through event reporting. We can abuse this by writing the pool address to an event and then read it in the usermode process. The first thing I tried to do was to ROP into the QueueEvent
using a custom event passed into rcx
however, there were two issues. Calling QueueEvent
will trigger a path that will call ExFreePool
which will eventually cause a bug check with a stack issue. The reason for this is unknown to me but I could not get it to work. The alternative to this is to skip the beginning of the function and ROP into the code that queues the new event. Unfortunately, acquiring the mutex to manipulate g_EventReportList
throws another bug check so this route won't work either.
To force this to work, a hack was needed. Since accessing g_EventReportList
through QueueEvent
was not feasible, directly accessing the event queue would still work but with some small issues: an event must already be queued and it must not be removed. Since a process does not need to be required to be "registered" by the driver to read events, Sysmon64.exe
can still pull them from g_EventReportList
despite our own process already interacting with the driver. To combat this, we can simply suspend the threads of Sysmon64.exe
. If an event is not queued and the write of the pool address is attempted, the system will bug check with a corrupted list error. Because of this hack, it can be a point of failure.
Because we are writing to an existing event, we need to choose a member to overwrite. Sysmon events are usually formatted with a four-member header. Here's what the file delete event looks like as an example:
In all events that isn't the report event type, Unk1
and Unk2
are always 0. If we place them here, it can easily be determined if the pool was written or not simply by checking for a value. The usermode process can now read the allocated pool through the event list.
Injecting code into the pool is trivial. The data can be written quickly using the KeSetEvent
bypass. The final problem we need solved is how the code is executed. The first few solutions involved spawning a thread using functions like PsCreateSystemThread
, IoCreateSystemThread
, IoQueueWorkItem
, ExQueueWorkItem
, and IoCreateDriver
however, these all end up as bug checks either caused directly by the function or as a side effect from calling ExFreePool
. No matter, we have an arbitrary write so we can simply take advantage of Sysmon's use of dynamic function resolutions to call our code. For example, Sysmon dynamically resolves ZwOpenProcessTokenEx
and so we can just overwrite the value in the data section to point to our code's entry point. Luckily for us, Sysmon does not use Control Flow Guard.
Again, we need to handle the return safely so that restoration can be done to stop the kernel from panicking. We can simply return an erroneous NTSTATUS
value or we can jump to ZwOpenProcessTokenEx
from the shellcode after it finishes.
This concludes the issues that we had. We were able to transform a write-what-where primitive combined with a read provided by Sysmon allowing us to inject arbitrary data into the kernel. From there, we took advantage of dynamic function resolution to execute our code. It should be noted that if stack pivoting is used, there is a chance that the kernel may bug check on an invalid stack pointer location. It can be increased each time the pool fails to be read and requires more attempts.
Demonstration
As a proof-of-concept, I've developed some shellcode that disables LSASS PPL:
Notes for Defenders
This section was written by Samir with advice for defenders.
With the BYOV (Bring Your Own Vulnerability) option an attacker with the need to execute code or evade some kernel mode protection can leverage this technique and install a vulnerable Sysmon driver version. Thus it’s recommended to prevent the installation of those versions (as of writing this, versions before Sysmon v11.00 are not impacted).
Hashes of Impacted SysmonDrv versions:
Although detection of these kind of techniques is hard (using Sysmon itself or Windows native logging), below is a listing of some suspicious key events prior to the exploitation completion.
Suspicious process attempting to access the Sysmon Service: note the PROCESS_SUSPEND_RESUME
(0x0800
) requested access (excludes generic access rights and alerts on sensitive ones).
Suspicious Process loading NT OS Kernel (should be rare and limited to the System virtual process):
YARA Signature
rule Sysmon_KExec_KPPL {
meta:
date = "30-09-2020"
author = "SBousseaden"
description = "hunt for possible injection with Instrumentation Callback PE"
reference = "https://undev.ninja/p/9af8ac08-4879-4d87-a92b-ff4abc778908/"
strings:
$sc1 = {90 51 B9 00 48 8D 0D DB 1F 00 00 44 89 7C 24 48 41 8B F7 4C 89 BD F0 01}
$sc2 = {65 C7 85 B8 01 00 00 48 8B 04 25}
$sc3 = {C7 85 BC 01 00 00 88 01 00 00 C7 85 C0 01 00}
$sc4 = {DC 01 00 00 EA C6 80 ?? C7 85 E0 01 00 00 ?? 00 00 00 48}
$sc5 = {C7 85 E4 01 00 00 48 B8 00 00 C7 85 EC 01 00 00 00 00 48 B9}
$sc6 = {48 89 01 59 66 C7 85 FC 01 00 00 FF E0}
$sc7 = {65 48 8B 04 ?? ?? ?? 25 88 01 00}
$sc8 = {48 8B 04 25 C7 85 4C 02 00 00 88 01}
$sc9 = {48 89 01 59 66 C7}
$ioc1 = {30 45 33 C9 C7 44 24 28 B8 FC 03 00 45 33 C0 BA 04 00 40 83 48 89 5C 24 20 48 8B}
$ioc2 = {4C 89 74 24 30 BA 10 00 40 83 44 89 74 24 28 48 8B CE 4C 89}
$sdrv1 = "SysmonDrv" wide
$sdrv2 = "SysmonDrv"
condition: uint16(0) == 0x5a4d and 1 of ($sdrv*) and (2 of ($sc*) or 1 of ($ioc*))
}
Conclusion
This marks the end of the article. The file delete functionality was documented to the best of my ability and hopefully it is useful to someone. It would be great if someone more knowledgable could explain some of the things I couldn't.
Along with the internals, I think the bonus kernel code execution in SysmonDrv
is pretty hilarious and ironic, repurposing Microsoft's own product that was built as a defensive tool for offensive and malicious intent. Ultimately, it does require administrative privileges to be abused and so it isn't a crtitical issue from a purely technical perspective (administrator to kernel isn't a security boundary!). However, because it is a Microsoft product and it's used as a trusted, core defensive component that's widely deployed, I personally feel like the impact is much greater. Having an issue like this in a security product may damage the reputation of Microsoft in some eyes. But as the product begins to pick up more functionality and increase in complexity, it's inevitable that security issues will be introduced. Microsoft should perform more internal testing for potential security issues and other bugs in future releases.
Anwyay, I hope that this can benefit and serve others more than it does to me, and that the reliability and capability of it can be improved (apologies for my amateur skill in exploit development). As always, my code can be found on my GitHub: https://github.com/NtRaiseHardError/Sysmon