Submind YouTube summaries
Thumbnail for Malware Analysis - PoisonX rootkit, Kernel driver rootkit markup in Ghidra

Malware Analysis - PoisonX rootkit, Kernel driver rootkit markup in Ghidra

Watch on YouTube

Video summary

The video provides a detailed walkthrough of analyzing the PoisonX rootkit kernel driver using Ghidra to reverse engineer its malicious functionality. The presenter begins by explaining that while many drivers use obfuscation techniques like packing or encryption, this specific sample has a relatively rigid structure typical of Windows drivers, making static analysis feasible without heavy unpacking. To understand how the malware operates within the kernel, it is essential to grasp basic driver architecture: when a user-mode client attempts to communicate with a kernel-mode driver, they cannot do so directly and must instead interact through a device object created by the driver. The driver establishes this communication channel via a symbolic link that maps the internal device object to an accessible path in user mode, allowing applications to send requests known as IRPs (I/O Request Packages) which are then dispatched based on major function codes like Create or DeviceIOControl. Upon loading the binary into Ghidra and Cutter, the analysis reveals several key indicators of a rootkit, including imports from `ntoskrnl.exe` and specific debug paths in the PDB file that explicitly name "PoisonX" alongside functionality intended to hide processes. The presenter identifies encrypted strings within the code which are decrypted using an XOR operation with a constant value before being converted into Unicode structures for use by the driver's handlers. To facilitate deeper analysis, custom data types from Ghidra's NTDDK archive are imported and manually adjusted to correctly define complex kernel structs like `IRP` and device objects, particularly handling union fields that often confuse automated type inference tools. A specialized tool called "ktrace" is also utilized to automate the decryption of strings during emulation and export trace data back into Ghidra as comments and labels, significantly speeding up the reverse engineering process by highlighting relevant API calls without manual intervention for every string. The core malicious behavior of the rootkit involves two primary capabilities: terminating arbitrary processes and hiding network connections via NSI Proxy hooking. The first handler is shown to read an integer from a system buffer representing a Process ID, which it then uses to locate and kill specific user-mode processes before cleaning up handles. The second and more sophisticated function hooks into the `NSI Proxy` driver's device control dispatcher to intercept network traffic requests. By replacing the original completion routine with its own handler, the rootkit can inspect incoming connection data against a global list of hidden ports; if a request matches an entry in this list—identified by IP addresses and port numbers—the legitimate NSI proxy is instructed to delete that specific TCP connection from the system's active table before responding. This mechanism effectively allows malware to establish persistent network connections while remaining invisible to standard monitoring tools, demonstrating how kernel rootkits manipulate low-level networking stacks to achieve stealth persistence on Windows systems.
Read the full video transcript
Welcome to map analysis for hedgehogs. Today we will be analyzing a kernel mode rootkit and we will do so by marking up the code in Ghidra. That's exactly the way I would do it at work as well because a lot of those drivers are not obfuscated that much. So static analysis relatively easy since they have a relatively rigid structure as well. If you want to understand every detail that we go through today, you will probably also need some additional knowledge about how Windows drivers work. So this is a very basic overview of how drivers work. Keep in mind I'm not a driver developer and I'm not an expert on drivers. This is just so you have an idea what I'm talking about when I'm analyzing the driver. So very basic and simplified view. So let's assume down below we have the kernel mode and in the upper part the user mode. Now here sits our driver and when the driver receives task to do it receives a so-called request package also called IRP and it receives this package from the object manager. When the driver initializes itself, it will set up a major functions table. This is an array consisting of function pointers and every index in this array has a fixed meaning. So common ones, not all of them, but common ones are create or write or device IO. Each of these functions is a dispatcher. Why? Because commonly you have multiple possible requests you can do. So, that fall into the category of creating or writing something. So, depending on the operation that you want to do, the dispatcher will decide which kind of handler to call and it calls one of many IOCTL handlers. So, handler one, handler two, handler three, for instance. Now, let's assume we have a client in user mode. This could be our malware which wants to use a rootkit driver to hide itself. Now, the client cannot directly talk to the driver because the driver is in kernel mode. There needs to be some form of communication. And for that reason, the driver needs to initialize two things when it does its uh setup. So, firstly, it creates a device object which is managed by the object manager. And you will commonly see names like this one. Now, the client also cannot directly talk to the device object. So, the driver will create a symbolic link which links the device object with user mode. So, here we have a symbolic link to the device object. And the client wants to access the device object, it will use the symbolic link to do that, and it will refer to the link like this. So, this part of the path here means or tells Windows this is not a file, this is a symbolic link. However, when the driver sets up the symbolic link, you will see this. So, this is my basic understanding how this works. But, the most important part for you is what do these handlers here do? So, most of the time these will do the interesting work that the driver does. So, when we traverse the code, we will look at the initialization of the driver, how it sets up the major functions table, and the dispatchers, and then we can follow the code to see the handlers and what they do. Since most drivers are built up like that, that's what I meant with rigid structure when I said drivers often look the same way. If you haven't already, join our Discord server for discussions about this sample, for instance. And if you are interested in learning malware analysis, check out my courses at malwareanalysisforhedgehogs.learnworlds.com. This is our driver here. Let's take a look with the usual suspects. And going to start with Detected Easy. And detected easy already tells us this is Visual Studio 2019. We have a little bit debug data and the debug path inside that we can look at. And what do we find? Also the debug entries and the debug entry says that there is this path in here called build poison x hide x64 release hide PDB. So this sounds like a rootkit named poison x just from the naming here and it's supposed to hide something. So probably it's a rootkit. Now how do you know that this is a driver in the first place? In the optional header in the Windows fields there is a subsystem value and this subsystem value in this case means device drivers and native Windows processes. You can also check it here so it's a one. And if that's the case then you have a drive. Furthermore with drivers you will see typically imports from ntoskrnl.exe. So driver that runs in kernel mode needs to use different APIs than user mode code. So nothing much interesting apart from that. This driver's also pretty small if you check the properties. It's only 20 kilobytes. >> Yes. >> Now let's take a look at the strings. And we see here our debug pass. But apart from that, there is nothing there. No strings that the driver probably would use. So, that's already a little bit odd. Now, I'm going to load this driver into Cutter. So, let's open this, analyze it. We have Now, we are directly at the entry point. And when we scroll down, you may already notice here's an a huge byte array that's initialized. It's not a typical the ASCII string since there are a lot of values that are just too small for that. We have here is a memset. So, this array here is 54 bytes or 55, 55. And we here set double the value for another variable. That usually happens if something like this is turned into Unicode. So, likely what's going on is just based on this information that this is a string that's somehow going to be decrypted and then turned into some sort of Unicode structure. And yeah, we also see this confirmed with this function here RTL init Unicode string. However, it doesn't have the right parameters. We will need some data structures that are typical for drivers. So, there is one that I can recommend here. Uh Ghidra Windows data types. Let's get them. And we need the NTDDK 64 GDT. So, let's download this. I see I already downloaded it. Uh and now we open the archive here. And now we have the types available. So, next let's look for this function. Also, you probably want to be with English. All right. So, P Unicode string, PCW string. So, this is a conversion from a wide string to the kernel Unicode string. Yeah. So, we see that this Visual Studio already has these types here. Uh which was provided by Ghidra itself. And this is void. Let me say okay. And now we have this definition right here. So, this looks like it could be decrypting something. And let's take a look inside. So, this is the size, right? Size. This must be the the white char array. This is the byte array. This is the in input string. And this is the uh output string. Since we get this array as an input, so this must be the output. And that's the size decrypts string. Doesn't have a status. It has a status. It's return int. Okay. So, yeah, we can already see this. So, it's decrypting with XOR 3A. And then uh checks the success like that. And here's another function call. So, you can see this is decrypting in place, right? So, this is is the size. Let's do it like this. Uh that the current char the the index bytes to go bytes to decrypt, something like this. So, what I'm saying is so it decrypts this in place, right in this array here. It's going to be decrypted. And afterwards, the output, we already know that is wide char array. So, this is probably just turning the string into a wide char array. So, if we go inside of here, all that's a U truncation. But, you don't need to look into this so closely. Just decrypt one string, see if this theory is correct, and then uh that should work out. Don't need to look into this right now. And let's save this. Yeah, let's test this theory and decrypt one of the strings. So, how are we best going to do this? I think So, we need to make sure this is the right order of characters, and it's not. So, here we have a different order of these. So, it's supposed to be like that. Okay, let's try this. And we get this. So, we got to have to re-code. It's not working for some reason. But this is working. So, that's this string here. We now verify that this is actually happening. So, it's really just XOR with 3A and then turning it into Unicode. So, we don't need to analyze this other function in there. So, let's check this down here. See what's happening. We have another function that needs a better definition. So, let's check for create device. Okay, now. And this is the definition here. So, what I'm going to do is I'm just copy-pasting this to key draw. We need to be careful. We need this this star here as well. Okay. So, I'm just copy this and then paste it in here. And now thanks. Okay, it doesn't recognize device type. Let me just check. So, device type file device constants blah blah blah. So, this is like uh just four bytes long object just replaces with few long as well. colon Okay. And you can see P the driver object is known thanks to our archive that we imported. So, this was already useful. And this is NT status. Okay. And uh it's fast call. Okay. Now we got this. And already some parts of this seem to be quite better. Let's turn this into P driver object. And this is already a device object. And now we can see that it's accessing the major function table of this driver. This is expected. So, the driver places the dispatchers for each major function in the major function table. And that's what's happening here. And now let's think about how we are going to move forward. We need those strings. So, we could manually decrypt them all. That's possible. It's not that many. But, I also have created a tool that will aid us with the static analysis here. And that's using emulation with speak easy. And it's able to export the traced functions so that we can then use the information in the Ghidra database. Let's do this here as well. And then we may not need to decrypt all of the strings here. So, the way this works, so this is my program. I'm not sure if I will name it like this when I publish it, but uh for now, that's the work name. K trace for kernel trace. So, So, we activate uh I activate the um virtual environment. I say K trace and input our driver, which is here. And then I say uh Ghidra export. And what I want is to export this to It's called poison x Java. Um I'm not going to show you this right now because we are using the output here. So, here you can see it generated a JavaScript Java space script a script for Java, which will add comments to the Ghidra database and also add some labels here for certain addresses. So, let's use this. Let me just go to the script manager. Uh I'm just going to create a new one poison x. Okay. And we are copy pasting this here. Saving this, running it. And we can see applied 22 comments and four labels. And now you see here the output of the tracer. And because those strings are used in the API calls, we also see the decrypted strings in here. We don't need to do this ourselves. And it also is able to show us the meaning for some of these values like here in IO create device, it recognizes that the type is file device unknown. And similarly this one means file device secure open. That will help us and we don't need to decrypt everything. What is happening here? This will initialize the major functions table with this function here with this dispatcher. And the dispatcher returns this. Try to find this. And this is status not supported. All right. So, we go here. It says status not supported. So, all that this means is we put the function in here or the driver puts a function in here that just returns not supported because this function we need to initialize it, the dispatcher, but with returning not supported, it means hey, uh this dispatcher isn't doing anything. So, that's all there is. But, we have one major function which is IRP_MJ_DEVICE_CONTROL. So, the major functions table here IRP It's not in there as uh I'm not quite sure why. But, uh it should be the MJ device control. Let me check. Let me just Google for this one. IRP_MJ Major function codes. That's the one I was looking for. So, here you can see all of the major function codes. So, every enum here corresponds to an index in the major functions table. Unfortunately, it doesn't show me which value these have. Um But, apparently, it's this one according to the tracer. We can probably also find the value like if we check for wine. Yeah, like this one. This looks good. Look at this. So, here we have all of the values in wine defined the in indices in the the major function table and is there meaning here. So, we could implement an enum like this. We can click press E and enter this. And now we have this information. So, I will create symbolic link. Let's look this up at as well. It's ensure. We have symbolic link name and device name. So, on both are P unicode string. And here we see the link and the device name. So, what's important now? Where to look for more code in the driver? It's here because um this is now the dispatcher that was defined for the device control major function and the unloader. So, in both of these we have additional code that we should take a look at. Now, the driver unload is less likely to contain interesting code because usually it just does what it says. It unloads everything. So, we can see here it calls Ke Delay Execution Thread. And there are again the encoded strings, but this is just the um symbolic link again. So, it's all it does, right? So, we have delete symbolic link, delete device. Nothing much happening here that would be interesting. Not sure what this is, but for that we have to analyze the other part here, the dispatch for MJ create device uh device control. So, now one important thing you need to know about the dispatcher is that this always receives the same uh types. Here. That's what I was looking for. So, this is the driver dispatch function and it always has this um definition. So, it's a device object, pointer to a device object, and a pointer to an IRP. And we can just use the very same uh thing here. So, what was it? Device object and IRP pointer. So, P device object, P IRP, IRP. So, like this, and I think this was NT status. Yes. Was it K? And now this is better. Albeit not perfect. So, the problem I have the IRP struct is really enormous and it has a lot of unions where well, the first choice of Ghidra isn't necessarily the correct union. This seems to be correct here, so uh stack. The first thing what the dispatcher would do is get the current stack location. Um and afterwards, the dispatcher needs to decide which functions to call or which handlers to call. And to do that, it usually gets the IOCTL code first. That's what we see here. This is a a code. So, these IOCTL control codes are described So, here's an introduction. I'm not sure if we see that much here. So, here we see some codes and what they mean. But, they do have a certain structure that can just be decoded. Can say That's control codes uh Con Brother. So, here's this one for instance where we can input an IOCTL code there and get back what this means. So, let's do this. Write this. And now you can see this means file device unknown uh with read and write access. And 802 is a user-defined function ID. And then it's using method buffered for read and write access. So, this is kind of cool. And this is almost the same here. Except for the function bits. So, yeah. See here it changed to 804. And that's just a different function ID. The rest is the same. So, we have here two IOCTL codes. IOCTL codes, right? Uh now this isn't accessing the IOCTL code. Why? Because we chose the wrong Julian, as I said before. So, if we should use the device IO control. However, this doesn't return the right field. And that has to do with this structure here. Let me try to explain. IOCTL Um IRP struct. Let me do it. Let me check this. So, this is the IRP struct. The IO stack location struct union with lots and lots and lots of unions. And the one we are looking at is this one here. Wait, no, I'm in the wrong one. This is the right one. So, the IO control code, this is the actual value we are accessing here. And you can see here in the structure definition, it says ULONG pointer alignment and here as well. Whereas, if you check this in Ghidra, there is no such possibility to define that this is a pointer alignment. Like, I can go to here edit data type, can see here the struct, where is it? Device IO control. It's a struct, so let's search for this struct. Go to edit. So, these are correctly defined, but the alignment, we cannot really define the alignment in here, because it depends on the structs and the outer struct that is using this union. And we cannot really tell Ghidra, well, to abide this by this alignment, or I don't know of a way how to do this, because I really tried, and it's kind of not working. But, you can infer based on the knowledge that this is a dispatcher. And the first thing it does is gets a current stack location and then probably checks the uh IOCTL control codes. That's basically all you need to know to know what what's happening. So, let's ignore this and just go with this. And here we can now see this driver is doing uh it two possible functions that it functions that it executes based on this control code. We are going to start with the lower one. The reason is that this one is easier to analyze. So, I obviously looked at this before. Um, Oh, yeah. We have one of those status codes. I fear I have closed the uh one very quick that So, driver and T status codes. And this is uh status unsuccessful. And this one is a status buffer too small. We know uh based on the dispatcher what kind of types should be in here. So, we know this is the IRP pointer and this is the stack. Uh, this one is IO stack location pointer. And this is the I am what is this? I'm not sure what this is. Okay. So, PIRP and uh IO stack location pointer. Now, the same thing is happening here with the union fields which are not correct. In this case, we are using so the the function that was requested requested read and write access. So, most commonly when a function wants to read and write, it needs a buffer and check check the buffer for input and output. And here, same thing as before, we are in device IO control. And this makes sense, right? So, it checks the output buffer length if it's smaller than three and if it's smaller than three, it returns status buffer too small. So, some error checking. Um this is the status that's returned. And this was the sec and this was the IRP. So, what does it do? It reads the system buffer. So, uh system buffer, right? It turns it into an integer. It's expecting an integer in form of characters like and then converts them into an int. I'm just calling it input int. We can see here the tracer just supplied a an empty buffer. So, this wasn't doing that much here. And that calls this function. So, let's see what this function does. What's a what what's three That's the integer, right? So just some int. And here we see it opens a process and then terminates the process. Why is this happening? We can see here so proc _something something this is a a fake process generated by the tracer. And this is the the process ID here used in the name. So that's how you know it's a fake process. We can see uh yeah, this is the PID here as well. They have the process handle here. This seems to be a process all access attributes. Actually, let's look up this function so we choose the right types. Client ID P object attributes. So what was the last one? Client ID. We name this to client ID. And now yeah, we get those uh the meaning of these attributes here. Okay, let's [clears throat] take a look at this client ID struct. So, this says it's a process identifier. So, this is a PID, right? This input int is process ID. Which identifies the process. So, based on a process ID, this will terminate a process. Terminate process by process ID. So, now we know that this here also contains the process ID. Okay. And that's the purpose of this function. This driver is able to kill processes. Now, let's take a look at the other function. And I told you before it's a bit more complicated than this one. We are also not going to go into every detail here. Um but just get the gist of it. And yeah, same thing as before, we need to set the types correctly. So, it's IRP and it's IO stack location pointer. We force the right field, which is the system buffer again. Same reason as before. We again have some input in form of a number string. So this was system buffer inputs. Wait. Look at int. I think we have seen this before. Yeah, this is status status unsuccessful. Um so the system buffer here is also used as output buffer. So it returns okay in this case. Can see this below. But what we what we know is this is a global array of some sort. And this is saving the input integer into this global array. G some sort of list or something. Let's just say index. So what this is doing it looks for the first entry of this array that is zero. When it finds it, it puts in the integer. So just appends the integer to this list, some kind of let's let's call it integer list actually. Integer list. Now it again checks our buffer. If it's too small, so in this case the read length is correct. Now the most important part is here. Here is something else is happening if the input int is zero. Again we see some strings being decoded. And this string is driver NSI proxy. NSI proxy is a driver that's used for network communication. Let's check this call here. Amazingly, it knows all of these structs. I didn't expect that. And of course, it's NT status. So, what this get is it it obtains the handle to this driver or to the object of this driver based on the name that it has. And this is the name here, NSI proxy. And we know this is NSI object path. And the object pointer I think this isn't exactly the right type here. Is it? Oh, yeah. Now, it doesn't say so, but this is again driver objects. P driver object Um you can see this here. So, it's again IRP MJ device control which it saves in this global data structure pointer. So, original NSI proxy MJ device control dispatcher. Okay? Whereas this here is a new one. So, this is the NSI proxy device control dispatcher hook. hook So, it obtains NSI proxy and saves its dispatcher and then it sets a new one. Now, let's take a look at the dispatcher. Here's our hook function for NSI proxy and since this is a dispatcher, we already know this has the same uh definition as the other ones. Here it is. So, it's P device object and P IRP. P device object and P IRP. Now, again, first thing it probably does, it obtains a stack and then it checks the control code, right? It's uh on this stack. And here we have to force the field for device IO control. Same here. Now, this doesn't look right. I/O control code supposed to start at minimum at 800 hex. So, this doesn't look quite right, and I have tried to figure out if it did like if something in the types is wrong. But, I believe the most plausible explanation for me is that this is some secret code for the rootkit, so that it doesn't accidentally get this for legitimate requests. But, we'll get this uh We set up this structure this way just for the rootkit part. Let's call this one here hook context. So, this kind of saves the routine in the context. We can do auto create structure and say this is completion routine, and this is the context. And we have here uh the current process and some control value. So, what this does, it calls the original dispatcher with these values. And if the this weird uh conditions here are true, which is probably the case if the malware wants to hide something, then it will do this. And this will set the completion routine. The completion routine, well, you can infer this from its name, but it's called after everything's done. And this sets another function here, completion routine function. So, that's going to be the interesting one. And we check this out here. Does this have Let me Google for completion routine. We're going to need the dispatch thingy. Com- pletion routine. This is the struct, right? So, let's look for this struct. On just I/O completion routine. Much better. And here we have our types. So, it's PDEVICE_OBJECT, PIRP, and then context. And now there's There's this function that is a little bit difficult to identify what it does. But what we see here and that's I think the most important part. So, the problem is to to figure out what struct this is accessing here with Ivar 7. Um you need to analyze analyze proxy. There are various structs that are possible here. We know there is an element size of 38 for each of these. The feel like the element index This is the element size. Let's call this element size. We access our integer list. Now, the question is what is done with it? And let's call it current and Anyhow, the important part is this is an NSI proxy. NSI proxy handles network requests. We have some sort of integer list that determines which request gets deleted. And if it finds that this is matching, it deletes this request from the list. We don't exactly know what kind of request this is, but how you can figure this out without a lot of effort is by searching other rootkits that also hook NSI proxy. And here is one. And these rootkits, they have these structures already defined. So, this is an open source rootkit. It hides TCP network connections. And when you check the driver component here, there is a hideport.cpp. And this one looks pretty similar to what we have here. So, it might not be the exact same one, but people usually don't reinvent the wheel. They look at open source code and then modified to their purposes. So, we have here this hook completion routine. And it's uh keeping a vectors of hidden ports. So, this is our int array, you know, the one here. The hidden port vector in hook completion. So, where is this used? Let me check here. So, here is the uh hook device IO control, and there you see exactly what's happening in our code as well. That the completion routine gets a separate function. And uh otherwise, it calls the original NSI device AO. So, this is happening. You also see here the size of 38 bytes for this structure. And what it does is it tests if P internal TCP table entry. Let's see where we can find this one. I think it should be in hideport.h. And there it is. So, this could be our structure here because it's also 38 bytes, and there's first a local, then a remote entry, and each one has a port and IP, and the other bytes are unknown. So, that's why it says 8 bytes for uh they probably play some role that's just not known and not so important for this rootkit. And that's it already. If you have any more questions, please ask in the comment section below, or join our YouTube channel. You find everything in the video description below. Also, samples are in the video description below.