Malware Analysis - PoisonX rootkit, Kernel driver rootkit markup in Ghidra
Watch on YouTubeVideo 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.