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.
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
Let's review the driver we'll be covering in this article.
tmcommdriver 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.
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:
PVOID unk1; // Initialized as NULL.
PVOID unk2; // Initialized as NULL.
PVOID unk3; // Initialized as NULL.
PVOID unk4; // Initialized as NULL.
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.
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:
The config data stores the location of internal/undocumented variables in the Windows Kernel such as the
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.
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
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);
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);
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
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'
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.
|Calls NtCreateFile, all parameters are controlled by the request.
|Performs nothing, returns STATUS_SUCCESS always.
|Calls ZwClose, all parameters are controlled by the request.
|References a FileObject using HANDLE from request. Calls IofCallDriver and reads result.
|Creates a new FileObject and associates DeviceObject for requested drive.
|Deletes a file by sending an IRP_MJ_SET_INFORMATION request.
|Queries a file's size by sending an IRP_MJ_QUERY_INFORMATION request.
|Set's a file's position by sending an IRP_MJ_SET_INFORMATION request.
|Calls NtQueryInformationFile, all parameters are controlled by the request.
|Calls NtSetInformationFile, all parameters are controlled by the request.
|Creates an Oplock via IoCreateFileEx and other filesystem API.
|Calls NtCreateFile and then ZwQuerySecurityObject. All parameters are controlled by the request.
|Calls NtCreateFile and then ZwSetSecurityObject. All parameters are controlled by the request.
|Check if a file is opened exclusively.
|Forcefully close a file handle.
IoControlCode == 90004027h
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.
|Find processes via ZwQuerySystemInformation and WorkingSetExpansionLinks.
|Delete results obtained through other functions like GetProcessesAllMethods.
|Further parse results obtained through other functions like GetProcessesAllMethods.
|Completely parse results obtained through other functions like GetProcessesAllMethods.
|Returns TRUE if the system is "supported" (whether or not they have hardcoded offsets for your build).
|Attempt to stop the driver.
|Find processes via a specified method.
|Check for tampering on devices associated with physical drives.
|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:
PVOID self1; // ptr to itself.
PVOID self2; // ptr to itself.
DWORD unk4; // Initialized as NULL.
DWORD Tag; // Always 'PANS'.
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.
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
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
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
This next sub dispatch table primarily manages the TrueApi class we reviewed before.
|Retrieve pointers of functions in the TrueApi class.
|Retrieve pointers of utility functions of the driver.
|Register a function to be called on unload.
|Unload a previously registered unload function.
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.
|Read a file directly from a disk.
|Update the kernel pointers used by the Xray Api.
|Get a table of drives mapped to their corresponding devices.
IoControlCode == 900040E7h
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.
|Check a few system routines for hooks.
|Check file IO major functions for hooks.
|Check the file object type and ntoskrnl Io functions for hooks.
|Check the Io manager for hooks.
|Recursively trace a system object (either a directory or symlink).
|Copy a system object into user-mode memory.
TMXMSCheckSystemObjectByName2 is as bad as it sounds. Before looking at the function directly, here's a few reverse engineered structures used later:
TMXMSCheckSystemObjectByName2 takes in a Source pointer, Destination pointer, and a Size in bytes. The validator function called for
TMXMSCheckSystemObjectByName2 checks the following:
CheckParamsmember of the
Dstmember of the
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 + 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.
*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.
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?
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
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
ThreadQuerySetWin32StartAddress which gives you the start address of a thread.
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.
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?
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
NonPagedPoolallocation 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
.datasection. 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
- Execute your shellcode by registering an unload routine (directly in
.data) or using the several other execution methods present in the
It's really as simple as that.
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.