How to use Trend Micro's Rootkit Remover to Install a Rootkit
The opinions expressed in this publication are those of the authors. They do not reflect the opinions or views of my employer. All research was conducted independently.
For a recent project, I had to do research into methods rootkits are detected and the most effective measures to catch them when I asked the question, what are some existing solutions to rootkits and how do they function? My search eventually landed me on the TrendMicro RootkitBuster which describes itself as "A free tool that scans hidden files, registry entries, processes, drivers, and the master boot record (MBR) to identify and remove rootkits".
The features it boasted certainly caught my attention. They were claiming to detect several techniques rootkits use to burrow themselves into a machine, but how does it work under the hood and can we abuse it? I decided to find out by reverse engineering core components of the application itself, leading me down a rabbit hole of code that scarred me permanently, to say the least.
Discovery
Starting the adventure, launching the application resulted in a fancy warning by Process Hacker that a new driver had been installed.
Already off to a good start, we got a copy of Trend Micro's "common driver", this was definitely something to look into. Besides this driver being installed, this friendly window opened prompting me to accept Trend Micro's user agreement.
I wasn't in the mood to sign away my soul to the devil just yet, especially since the terms included a clause stating "You agree not to attempt to reverse engineer, decompile, modify, translate, disassemble, discover the source code of, or create derivative works from...".
Thankfully, Trend Micro already deployed their software on to my machine before I accepted any terms. Funnily enough, when I tried to exit the process by right-clicking on the application and pressing "Close Window", it completely evaded the license agreement and went to the main screen of the scanner, even though I had selected the "I do not accept the terms of the license agreement" option. Thanks Trend Micro!
I noticed a quick command prompt flash when I started the application. It turns out this was the result of a 7-Zip Self Extracting binary which extracted the rest of the application components to %TEMP%\RootkitBuster
.
Let's review the driver we'll be covering in this article.
- The
tmcomm
driver which was labeled as the "TrendMicro Common Module" and "Trend Micro Eyes". A quick overlook of the driver indicated that it accepted communication from privileged user-mode applications and performed common actions that are not specific to the Rootkit Remover itself. This driver is not only used in the Rootkit Buster and is implemented throughout Trend Micro's product line.
In the following sections, we'll be deep diving into the tmcomm
driver . We'll focus our research into finding different ways to abuse the driver's functionality, with the end goal being able to execute kernel code. I decided not to look into the tmrkb.sys
because although I am sure it is vulnerable, it seems to only be used for the Rootkit Buster.
TrendMicro Common Module (tmcomm.sys)
Let's begin our adventure with the base driver that appears to be used not only for this Rootkit Remover utility, but several other Trend Micro products as well. As I stated in the previous section, a very brief look-over of the driver revealed that it does allow for communication from privileged user-mode applications.
One of the first actions the driver takes is to create a device to accept IOCTL communication from user-mode. The driver creates a device at the path \Device\TmComm
and a symbolic link to the device at \DosDevices\TmComm
(accessible via \\.\Global\TmComm
). The driver entrypoint initializes a significant amount of classes and structure used throughout the driver, however, for our purposes, it is not necessary to cover each one.
I was happy to see that Trend Micro made the correct decision of restricting their device to the SYSTEM
user and Administrators. This meant that even if we did find exploitable code, because any communication would require at least Administrative privileges, a significant amount of the industry would not consider it a vulnerability. For example, Microsoft themselves do not consider Administrator to Kernel to be a security boundary because of the significant amount of access they get. This does not mean however exploitable code in Trend Micro's drivers won't be useful.
TrueApi
A large component of the driver is its "TrueApi" class which is instantiated during the driver's entrypoint. The class contains pointers to imported functions that get used throughout the driver. Here is a reversed structure:
struct TrueApi
{
BYTE Initialized;
PVOID ZwQuerySystemInformation;
PVOID ZwCreateFile;
PVOID unk1; // Initialized as NULL.
PVOID ZwQueryDirectoryFile;
PVOID ZwClose;
PVOID ZwOpenDirectoryObjectWrapper;
PVOID ZwQueryDirectoryObject;
PVOID ZwDuplicateObject;
PVOID unk2; // Initialized as NULL.
PVOID ZwOpenKey;
PVOID ZwEnumerateKey;
PVOID ZwEnumerateValueKey;
PVOID ZwCreateKey;
PVOID ZwQueryValueKey;
PVOID ZwQueryKey;
PVOID ZwDeleteKey;
PVOID ZwTerminateProcess;
PVOID ZwOpenProcess;
PVOID ZwSetValueKey;
PVOID ZwDeleteValueKey;
PVOID ZwCreateSection;
PVOID ZwQueryInformationFile;
PVOID ZwSetInformationFile;
PVOID ZwMapViewOfSection;
PVOID ZwUnmapViewOfSection;
PVOID ZwReadFile;
PVOID ZwWriteFile;
PVOID ZwQuerySecurityObject;
PVOID unk3; // Initialized as NULL.
PVOID unk4; // Initialized as NULL.
PVOID ZwSetSecurityObject;
};
Looking at the code, the TrueApi is primarily used as an alternative to calling the functions directly. My educated guess is that Trend Micro is caching these imported functions at initialization to evade delayed IAT hooks. Since the TrueApi is resolved by looking at the import table however, if there is a rootkit that hooks the IAT on driver load, this mechanism is useless.
XrayApi
Similar to the TrueApi, the XrayApi is another major class in the driver. This class is used to access several low-level devices and to interact directly with the filesystem. A major component of the XrayConfig is its "config". Here is a partially reverse-engineered structure representing the config data:
struct XrayConfigData
{
WORD Size;
CHAR pad1[2];
DWORD SystemBuildNumber;
DWORD UnkOffset1;
DWORD UnkOffset2;
DWORD UnkOffset3;
CHAR pad2[4];
PVOID NotificationEntryIdentifier;
PVOID NtoskrnlBase;
PVOID IopRootDeviceNode;
PVOID PpDevNodeLockTree;
PVOID ExInitializeNPagedLookasideListInternal;
PVOID ExDeleteNPagedLookasideList;
CHAR unkpad3[16];
PVOID KeAcquireInStackQueuedSpinLockAtDpcLevel;
PVOID KeReleaseInStackQueuedSpinLockFromDpcLevel;
...
};
The config data stores the location of internal/undocumented variables in the Windows Kernel such as the IopRootDeviceNode
, PpDevNodeLockTree
, ExInitializeNPagedLookasideListInternal
, and ExDeleteNPagedLookasideList
. My educated guess for the purpose of this class is to access low-level devices directly rather than use documented methods which could be hijacked.
IOCTL Requests
Before we get into what the driver allows us to do, we need to understand how IOCTL requests are handled.
In the primary dispatch function, the Trend Micro driver converts the data alongside a IRP_MJ_DEVICE_CONTROL
request to a proprietary structure I call a TmIoctlRequest
.
struct TmIoctlRequest
{
DWORD InputSize;
DWORD OutputSize;
PVOID UserInputBuffer;
PVOID UserOutputBuffer;
PVOID Unused;
DWORD_PTR* BytesWritten;
};
The way Trend Micro organized dispatching of IOCTL requests is by having several "dispatch tables". The "base dispatch table" simply contains an IOCTL Code and a corresponding "sub dispatch function". For example, when you send an IOCTL request with the code 0xDEADBEEF
, it will compare each entry of this base dispatch table and pass along the data if there is a table entry that has the matching code. A base table entry can be represented by the structure below:
typedef NTSTATUS (__fastcall *DispatchFunction_t)(TmIoctlRequest *IoctlRequest);
struct BaseDispatchTableEntry
{
DWORD_PTR IOCode;
DispatchFunction_t DispatchFunction;
};
After the DispatchFunction
is called, it typically verifies some of the data provided ranging from basic nullptr
checks to checking the size of the input and out buffers. These "sub dispatch functions" then do another lookup based on a code passed in the user input buffer to find the corresponding "sub table entry". A sub table entry can be represented by the structure below:
typedef NTSTATUS (__fastcall *OperationFunction_t)(PVOID InputBuffer, PVOID OutputBuffer);
struct SubDispatchTableEntry
{
DWORD64 OperationCode;
OperationFunction_t PrimaryRoutine;
OperationFunction_t ValidatorRoutine;
};
Before calling the PrimaryRoutine
, which actually performs the requested action, the sub dispatch function calls the ValidatorRoutine
. This routine does "action-specific" validation on the input buffer, meaning that it performs checks on the data the PrimaryRoutine
will be using. Only if the ValidatorRoutine
returns successfully will the PrimaryRoutine
be called.
Now that we have a basic understanding of how IOCTL requests are handled, let's explore what they allow us to do. Referring back to the definition of the "base dispatch table", which stores "sub dispatch functions", let's explore each base table entry and figure out what each sub dispatch table allows us to do!
IoControlCode == 9000402Bh
Discovery
This first dispatch table appears to interact with the filesystem, but what does that actually mean? To start things off, the code for the "sub dispatch table" entry is obtained by dereferencing a DWORD
from the start of the input buffer. This means that to specify which sub dispatch entry you'd like to execute, you simply need to set a DWORD
at the base of the input buffer to correspond with that entries' **OperationCode**
.
To make our lives easier, Trend Micro conveniently included a significant amount of debugging strings, often giving an idea of what a function does. Here is a table of the functions I reversed in this sub dispatch table and what they allow us to do.
OperationCode | PrimaryRoutine | Description |
---|---|---|
2713h | IoControlCreateFile | Calls NtCreateFile, all parameters are controlled by the request. |
2711h | IoControlFindNextFile | Returns STATUS_NOT_SUPPORTED. |
2710h | IoControlFindFirstFile | Performs nothing, returns STATUS_SUCCESS always. |
2712h | IoControlFindCloseFile | Calls ZwClose, all parameters are controlled by the request. |
2715h | IoControlReadFileIRPNoCache | References a FileObject using HANDLE from request. Calls IofCallDriver and reads result. |
2714h | IoControlCreateFileIRP | Creates a new FileObject and associates DeviceObject for requested drive. |
2716h | IoControlDeleteFileIRP | Deletes a file by sending an IRP_MJ_SET_INFORMATION request. |
2717h | IoControlGetFileSizeIRP | Queries a file's size by sending an IRP_MJ_QUERY_INFORMATION request. |
2718h | IoControlSetFilePosIRP | Set's a file's position by sending an IRP_MJ_SET_INFORMATION request. |
2719h | IoControlFindFirstFileIRP | Returns STATUS_NOT_SUPPORTED. |
271Ah | IoControlFindNextFileIRP | Returns STATUS_NOT_SUPPORTED. |
2720h | IoControlQueryFile | Calls NtQueryInformationFile, all parameters are controlled by the request. |
2721h | IoControlSetInformationFile | Calls NtSetInformationFile, all parameters are controlled by the request. |
2722h | IoControlCreateFileOplock | Creates an Oplock via IoCreateFileEx and other filesystem API. |
2723h | IoControlGetFileSecurity | Calls NtCreateFile and then ZwQuerySecurityObject. All parameters are controlled by the request. |
2724h | IoControlSetFileSecurity | Calls NtCreateFile and then ZwSetSecurityObject. All parameters are controlled by the request. |
2725h | IoControlQueryExclusiveHandle | Check if a file is opened exclusively. |
2726h | IoControlCloseExclusiveHandle | Forcefully close a file handle. |
IoControlCode == 90004027h
Discovery
This dispatch table is primarily used to control the driver's process scanning features. Many functions in this sub dispatch table use a separate scanning thread to synchronously search for processes via various methods both documented and undocumented.
OperationCode | PrimaryRoutine | Description |
---|---|---|
C350h | GetProcessesAllMethods | Find processes via ZwQuerySystemInformation and WorkingSetExpansionLinks. |
C351h | DeleteTaskResults* | Delete results obtained through other functions like GetProcessesAllMethods. |
C358h | GetTaskBasicResults* | Further parse results obtained through other functions like GetProcessesAllMethods. |
C35Dh | GetTaskFullResults* | Completely parse results obtained through other functions like GetProcessesAllMethods. |
C360h | IsSupportedSystem | Returns TRUE if the system is "supported" (whether or not they have hardcoded offsets for your build). |
C361h | TryToStopTmComm | Attempt to stop the driver. |
C362h | GetProcessesViaMethod | Find processes via a specified method. |
C371h | CheckDeviceStackIntegrity | Check for tampering on devices associated with physical drives. |
C375h | ShouldRequireOplock | Returns TRUE if oplocks should be used for certain scans. |
These IOCTLs revolve around a few structures I call "MicroTask" and "MicroScan". Here are the structures reverse-engineered:
struct MicroTaskVtable
{
PVOID Constructor;
PVOID NewNode;
PVOID DeleteNode;
PVOID Insert;
PVOID InsertAfter;
PVOID InsertBefore;
PVOID First;
PVOID Next;
PVOID Remove;
PVOID RemoveHead;
PVOID RemoveTail;
PVOID unk2;
PVOID IsEmpty;
};
struct MicroTask
{
MicroTaskVtable* vtable;
PVOID self1; // ptr to itself.
PVOID self2; // ptr to itself.
DWORD_PTR unk1;
PVOID MemoryAllocator;
PVOID CurrentListItem;
PVOID PreviousListItem;
DWORD ListSize;
DWORD unk4; // Initialized as NULL.
char ListName[50];
};
struct MicroScanVtable
{
PVOID Constructor;
PVOID GetTask;
};
struct MicroScan
{
MicroScanVtable* vtable;
DWORD Tag; // Always 'PANS'.
char pad1[4];
DWORD64 TasksSize;
MicroTask Tasks[4];
};
For most of the IOCTLs in this sub dispatch table, a MicroScan is passed in by the client which the driver populates. We'll look into how we can abuse this trust in the next section.
Exploitation
When I was initially reverse engineering the functions in this sub dispatch table, I was quite confused because the code "didn't seem right". It appeared like the MicroScan
kernel pointer returned by functions such as GetProcessesAllMethods
was being directly passed onto other functions such as DeleteTaskResults
by the client. These functions would then take this untrusted kernel pointer and with almost no validation call functions in the virtual function table specified at the base of the class.
Taking a look at the "validation routine" for the DeleteTaskResults
sub dispatch table entry, the only validation performed on the MicroScan
instance specified at the input buffer + 0x10
was making sure it was a valid kernel address.
The only other check besides making sure that the supplied pointer was in kernel memory was a simple check in DeleteTaskResults
to make sure the Tag
member of the MicroScan
is PANS
.
Since DeleteTaskResults
calls the constructor specified in the virtual function table of the MicroScan
instance, to call an arbitrary kernel function we need to:
- Be able to allocate at least 10 bytes of kernel memory (for vtable and tag).
- Control the allocated kernel memory to set the virtual function table pointer and the tag.
- Be able to determine the address of this kernel memory from user-mode.
Fortunately a mentor of mine, Alex Ionescu, was able to point me in the right direction when it comes to allocating and controlling kernel memory from user-mode. A HackInTheBox Magazine from 2010 had an article by Matthew Jurczyk called "Reserve Objects in Windows 7". This article discussed using APC Reserve Objects, which was introduced in Windows 7, to allocate controllable kernel memory from user-mode. The general idea is that you can queue an Apc to an Apc Reserve Object with the ApcRoutine
and ApcArgumentX
members being the data you want in kernel memory and then use NtQuerySystemInformation
to find the Apc Reserve Object in kernel memory. This reserve object will have the previously specified KAPC
variables in a row, allowing a user-mode application to control up to 32 bytes of kernel memory (on 64-bit) and know the location of the kernel memory. I would strongly suggest reading the article if you'd like to learn more.
This trick still works in Windows 10, meaning we're able to meet all three requirements. By using an Apc Reserve Object, we can allocate at least 10 bytes for the MicroScan
structure and bypass the inadequate checks completely. The result? The ability to call arbitrary kernel pointers:
Although I provided a specific example of vulnerable code in DeleteTaskResults
, any of the functions I marked in the table with asterisks are vulnerable. They all trust the kernel pointer specified by the untrusted client and end up calling a function in the MicroScan
instance's virtual function table.
IoControlCode == 90004033h
Discovery
This next sub dispatch table primarily manages the TrueApi class we reviewed before.
OperationCode | PrimaryRoutine | Description |
---|---|---|
EA60h | IoControlGetTrueAPIPointer | Retrieve pointers of functions in the TrueApi class. |
EA61h | IoControlGetUtilityAPIPointer | Retrieve pointers of utility functions of the driver. |
EA62h | IoControlRegisterUnloadNotify* | Register a function to be called on unload. |
EA63h | IoControlUnRegisterUnloadNotify | Unload a previously registered unload function. |
Exploitation
IoControlRegisterUnloadNotify
This function caught my eye the moment I saw its name in a debug string. Using this sub dispatch table function, an untrusted client can register up to 16 arbitrary "unload routines" that get called when the driver unloads. This function's validator routine checks this pointer from the untrusted client buffer for validity. If the caller is from user-mode, the validator calls ProbeForRead
on the untrusted pointer. If the caller is from kernel-mode, the validator checks that it is a valid kernel memory address.
This function cannot immediately be used in an exploit from user-mode. The problem is that if we're a user-mode caller, we must provide a user-mode pointer, because the validator routine uses ProbeForRead
. When the driver unloads, this user-mode pointer gets called, but it won't do much because of mitigations such as SMEP. I'll reference this function in a later section, but it is genuinely scary to see an untrusted user-mode client being able to direct a driver to call an arbitrary pointer by design.
IoControlCode == 900040DFh
This sub dispatch table is used to interact with the XrayApi. Although the Xray Api is generally used by scans implemented in the kernel, this sub dispatch table provides limited access for the client to interact with physical drives.
OperationCode | PrimaryRoutine | Description |
---|---|---|
15F90h | IoControlReadFile | Read a file directly from a disk. |
15F91h | IoControlUpdateCoreList | Update the kernel pointers used by the Xray Api. |
15F92h | IoControlGetDRxMapTable | Get a table of drives mapped to their corresponding devices. |
IoControlCode == 900040E7h
Discovery
The final sub dispatch is used to scan for hooks in a variety of system structures. It was interesting to see the variety of hooks Trend Micro checks for including hooks in object types, major function tables, and even function inline hooks.
OperationCode | PrimaryRoutine | Description |
---|---|---|
186A0h | TMXMSCheckSystemRoutine | Check a few system routines for hooks. |
186A1h | TMXMSCheckSystemFileIO | Check file IO major functions for hooks. |
186A2h | TMXMSCheckSpecialSystemHooking | Check the file object type and ntoskrnl Io functions for hooks. |
186A3h | TMXMSCheckGeneralSystemHooking | Check the Io manager for hooks. |
186A4h | TMXMSCheckSystemObjectByName | Recursively trace a system object (either a directory or symlink). |
186A5h | TMXMSCheckSystemObjectByName2* | Copy a system object into user-mode memory. |
Exploitation
Yeah, TMXMSCheckSystemObjectByName2
is as bad as it sounds. Before looking at the function directly, here's a few reverse engineered structures used later:
struct CheckSystemObjectParams
{
PVOID Src;
PVOID Dst;
DWORD Size;
DWORD* OutSize;
};
struct TXMSParams
{
DWORD OutStatus;
DWORD HandlerID;
CHAR unk[0x38];
CheckSystemObjectParams* CheckParams;
};
TMXMSCheckSystemObjectByName2
takes in a Source pointer, Destination pointer, and a Size in bytes. The validator function called for TMXMSCheckSystemObjectByName2
checks the following:
ProbeForRead
on theCheckParams
member of theTXMSParams
structure.ProbeForRead
andProbeForWrite
on theDst
member of theCheckSystemObjectParams
structure.
Essentially, this means that we need to pass a valid CheckParams
structure and the Dst
pointer we pass is in user-mode memory. Now let's look at the function itself:
Although that for loop may seem scary, all it is doing is an optimized method of checking a range of kernel memory. For every memory page in the range Src
to Src + Size
, the function calls MmIsAddressValid
. The real scary part is the following operations:
These lines take an untrusted Src
pointer and copies Size
bytes to the untrusted Dst
pointer... yikes. We can use the memmove
operations to read an arbitrary kernel pointer, but what about writing to an arbitrary kernel pointer? The problem is that the validator for TMXMSCheckSystemObjectByName2
requires that the destination is user-mode memory. Fortunately, there is another bug in the code.
The next *params->OutSize = Size;
line takes the Size
member from our structure and places it at the pointer specified by the untrusted OutSize
member. No verification is done on what OutSize
points to, thus we can write up to a DWORD each IOCTL call. One caveat is that the Src
pointer would need to point to valid kernel memory for up to Size
bytes. To meet this requirement, I just passed the base of the ntoskrnl
module as the source.
Using this arbitrary write primitive, we can use the previously found unload routines trick to execute code. Although the validator routine prevents us from passing in a kernel pointer if we're calling from user-mode, we don't actually need to go through the validator. Instead, we can write to the unload routine array inside of the driver's .data
section using our write primitive and place the pointer we want.
Really, really bad code
Typically, I like sticking to strictly security in my blog posts, but this driver made me break that tradition. In this section, we won't be covering the security issues of the driver, rather the terrible code that's used by millions of Trend Micro customers around the world.
Bruteforcing Processes
Let's take a look at what's happening here. This function has a for loop from 0 to 0x10000, incrementing by 4, and retrieves the object of the process behind the current index (if there is one). If the index does match a process, the function checks if the name of the process is csrss.exe
. If the process is named csrss.exe
, the final check is that the session ID of the process is 0. Come on guys, there is literally documented API to enumerate processes from kernel... what's the point of bruteforcing?
Bruteforcing Offsets
EPROCESS ImageFileName Offset
When I first saw this code, I wasn't sure what I was looking at. The function takes the current process, which happens to be the System process since this is called in a System thread, then it searches for the string "System" in the first 0x1000 bytes. What's happening is... Trend Micro is bruteforcing the ImageFileName
member of the EPROCESS
structure by looking for the known name of the System process inside of its EPROCESS
structure. If you wanted the ImageFileName
of a process, just use ZwQueryInformationProcess
with the ProcessImageFileName
class...
EPROCESS Peb Offset
In this function, Trend Micro uses the PID of the csrss
process to brute force the Peb
member of the EPROCESS
structure. The function retrieves the EPROCESS
object of the csrss
process by using PsLookupProcessByProcessId
and it retrieves the PebBaseAddress
by using ZwQueryInformationProcess
. Using those pointers, it tries every offset from 0 to 0x2000 that matches the known Peb
pointer. What's the point of finding the offset of the Peb
member when you can just use ZwQueryInformationProcess
, as you already do...
ETHREAD StartAddress Offset
Here Trend Micro uses the current system thread with a known start address to brute force the StartAddress
member of the ETHREAD
structure. Another case where finding the raw offset is unnecessary. There is a semi-documented class of ZwQueryInformationThread
called ThreadQuerySetWin32StartAddress
which gives you the start address of a thread.
Unoptimized Garbage
When I initially decompiled this function, I thought IDA Pro might be simplifying a memset
operation, because all this function was doing was setting all of the TrueApi
structure members to zero. I decided to take a peek at the assembly to confirm I wasn't missing something...
Yikes... looks like someone turned off optimizations.
Cheating Microsoft's WHQL Certification
So far we've covered methods to read and write arbitrary kernel memory, but there is one step missing to install our own rootkit. Although you could execute kernel shellcode with just a read/write primitive, I generally like finding the path of least resistance. Since this is a third-party driver, chances are, there is some NonPagedPool
memory allocated which we can use to host and execute our malicious shellcode.
Let's take a look at how Trend Micro allocates memory. Early in the entrypoint of the driver, Trend Micro checks if the machine is a "supported system" by checking the major version, minor version, and the build number of the operating system. Trend Micro does this because they hardcode several offsets which can change between builds.
Fortunately, the PoolType
global variable which is used to allocate non-paged memory is set to 0 (NonPagedPool
) by default. I noticed that although this value was 0 initially, the variable was still in the .data
section, meaning it could be changed. When I looked at what wrote to the variable, I saw that the same function responsible for checking the operating system's version also set this PoolType
variable in certain cases.
From a brief glance, it looked like if our operating system is Windows 10 or a newer version, the driver prefers to use NonPagedPoolNx
. Good from a security standpoint, but bad for us. This is used for all non-paged allocations, meaning we would have to find a spare ExAllocatePoolWithTag
that had a hardcoded NonPagedPool
argument otherwise we couldn't use the driver's allocated memory on Windows 10. But, it's not that straightforward. What about MysteriousCheck()
, the second requirement for this if statement?
What MysteriousCheck()
was doing was checking if Microsoft's Driver Verifier was enabled... Instead of just using NonPagedPoolNx
on Windows 8 or higher, Trend Micro placed an explicit check to only use secure memory allocations if they're being watched. Why is this not just an example of bad code? Trend Micro's driver is WHQL certified:
Passing Driver Verifier has been a long-time requirement of obtaining WHQL certification. On Windows 10, Driver Verifier enforces that drivers do not allocate executable memory. Instead of complying with this requirement designed to secure Windows users, Trend Micro decided to ignore their user's security and designed their driver to cheat any testing or debugging environment which would catch such violations.
Honestly, I'm dumbfounded. I don't understand why Trend Micro would go out of their way to cheat in these tests. Trend Micro could have just left the Windows 10 check, why would you even bother creating an explicit check for Driver Verifier? The only working theory I have is that for some reason most of their driver is not compatible with NonPagedPoolNx
and that only their entrypoint is compatible, otherwise there really isn't a point.
Delivering on my promise
As I promised, you can use Trend Micro's driver to install your own rootkit. Here is what you need to do:
- Find any
NonPagedPool
allocation by the driver. As long as you don't have Driver Verifier running, you can use any of the non-paged allocations that have their pointers stored in the.data
section. Preferably, pick an allocation that isn't used often. - Write your kernel shellcode anywhere in the memory allocation using the arbitrary kernel write primitive in
TMXMSCheckSystemObjectByName2
. - Execute your shellcode by registering an unload routine (directly in
.data
) or using the several other execution methods present in the90004027h
dispatch table.
It's really as simple as that.
Conclusion
I reverse a lot of drivers and you do typically see some pretty dumb stuff, but I was shocked at many of the findings in this article coming from a company such as Trend Micro. Most of the driver feels like proof-of-concept garbage that is held together by duct tape.
Although Trend Micro has taken basic precautionary measures such as restricting who can talk to their driver, a significant amount of the code inside of the IOCTL handlers includes very risky DKOM. Also, I'm not sure how certain practices such as bruteforcing anything would get through adequate code review processes. For example, the Bruteforcing Processes code doesn't make sense, are Trend Micro developers not aware of enumerating processes via ZwQuerySystemInformation
? What about disabling optimizations? Anti-virus already gets flak for slowing down client machines, why would you intentionally make your driver slower? To add insult to injury, this driver is used in several Trend Micro products, not just their rootkit remover. All I know going forward is that I won't be a Trend Micro customer anytime soon.