Implementing SysCall Detection into Fennec
Adding SysCall Detection into our PoC EDR!
Introduction
First of all, I have a EDR project called PreEmpt
. However, michaeljranaldo and I started preemptdev which is our duo on research and development, and we are currently working through a C2 Development Series over at pre.empt.dev. With that being said, a self-named project is a bit odd, so the EDR project has now been renamed to Fennec.
Below is a naïve architectural and component overview of Fennec:
Essentially, the Windows host has:
An ETWTi Agent (can be seen here)
Time and event triggered process/memory sweeps (can be seen here)
A Userland DLL which is loaded into processes to hook common offensive WinAPI Calls and is the target of this blog
An orchestration process which is used to display toasts and send information to Elastic
With the disclaimer out of the way, lets look at the topic of the blog; Over the passed few years, syscalls have became increasing more common, and in some scenarios, a requirement. This is something michaeljranaldo and I discussed in Maelstrom: EDR Kernel Callbacks, Hooks, and Call Stacks, specifically the Bypassing Userland Hooks section.
As Fennec is able identify attacks such as process injection via ETWTi and manual hooks, it only makes sense to expand this into syscalls as well.
I tend to write the introduction to the blog at the very end, and whilst looking for references to put into the blog, I found that someone has already implemented a very similar project back in February 2021. winternl released Detecting Manual Syscalls from User Mode which makes use of the same method I went for, but with a much cleaner instrumentation callback. As well as providing an in-depth blog, winternl provides great resources that expand on their ideas, highly recommend that blog if this topic is of interest to you. Their code can be seen in syscalls-detect.
As I go through my implementation, I will point out the improvements that winternl's implementation allowed me to make.
The Detection Logic
Typically, when a WinAPI called is used, the call will make its way from user-land into kernel-land. Again, this is something we discussed in User-land and Kernel-land from the Maelstrom series.
So when a call like VirtuaAlloc
is used, it will then go to NtAllocateVirtualMemory
in NTDLL. NTDLL will then do what it needs to do get it into kernel land (read the referenced post for more information).
This image sums it up:
When tools like SysWhispers3 or Tartarus Gate are used, the syscall, which is expected from NTDLL, comes from the actual executable image. So instead of it going:
It now goes:
In order to detect it, the idea should be:
If the syscall instruction comes from the executable image and not NTDLL, then its probably suspicious?
And that's the narrative this blog will focus on.
Setting up the Process Instrumentation
Again, this is something discussed in Maelstrom: EDR Kernel Callbacks, Hooks, and Call Stacks. In REcon 2015 - Hooking Nirvana (Alex Ionescu), Alex Ionescu demonstrates Process Instrumentation with NtSetProcessInformation and specifically with the ProcessInstrumentationCallback
flag. Highly recommend that talk. Not just for this particular post, but in general.
Detecting Manual Syscalls from User Mode discusses the internals of NtSetProcessInformation
very clearly, specifically this quote:
Each time the kernel encounters a scenario in which it returns to user mode code, it will check if the KPROCESS!InstrumentationCallback member is not NULL. If it is not NULL and it points to valid memory, the kernel will swap out the RIP on the trap frame and replace it with the value stored in the InstrumentationCallback field.
Lets get into the code and explain things as we go. As this is a DLL, here is DLLMain
:
SymSetOptions sets the option mask for how the symbols are to be loaded. Here, SYMOPT_UNDNAME is:
This symbol option causes public symbol names to be undecorated when they are displayed, and causes searches for symbol names to ignore symbol decorations. Private symbol names are never decorated, regardless of whether this option is active. For information on symbol name decorations, see Public and Private Symbols.
The next thing that happens is that the symbols are initialised with SymInitialize. The definition:
With fInvadeProcess
set to TRUE
, SymLoadModuleEx will be used which loads the symbol table for the specified module.
Now that the symbols are ready to go, we enter SetInstrumentationCallback()
which is where NtSetInformationProcess
is called.
The first thing to occur in this function is the declaration of PROCESS_INSTRUMENTATION_CALLBACK_INFORMATION:
This is then filled out the way Alex Ionescu shows in Hooking Nirvana:
The Version
represents the architecture:
0: x64
1: x86
The interesting part is the Callback
member. Originally, I had some very complicated assembly which just looked unstable. This is the first improvement winternl's work provided. And in homage, I've kept the function naming the same for full kudos. The assembly:
It makes sense in hindsight, but I never knew that WinAPI calls can be called from Assembly:
This is perfect because instead of passing in tons of registers into the extern
function, CONTEXT can be used to pass them all in within a clean structure.
As this callback is entirely winternl's, I won't explain their code line-by-line as I can't explain it as well as them: Detecting Manual Syscalls from User Mode
Just know that we are grabbing InstrumentationCallbackPreviousSp
, InstrumentationCallbackPreviousPc
and allocating space so that RtlCaptureContext can be called, and the CONTEXT
retrieved.
I haven't statically linked to NTDLL, which I probably should, so I just resolve the call:
Finally, call NtSetInformationProcess
:
At this point, the callback is set and InstrumentationCallbackDisabled
in KPROCESS will be set to FALSE
.
Now when an API is called, the assembly mentioned earlier will be triggered. Within that is a call to a function which will do all the parsing:
Full function to set up the callback:
The Instrumentation Hook
This is the function responsible for parsing the CONTEXT
, the function definition:
In order to identify the origins of the information gathered by the CONTEXT
, two functions are used.
SymFromAddr will retrieve symbol information for the specified address:
The HANDLE
passed in here has a requirement for the SymInitialize
to have been called, which we did in DLLMain
. The second parameter is the address to be queried, third is the displacement from the beginning of the symbol, and finally, a pointer to SYMBOL_INFO:
Microsoft have actually documented how to get the symbol from an address: Retrieving Symbol Information by Address.
A note from Microsoft on the SYMBOL_INFO
structure:
Because the name is variable in length, you must supply a buffer that is large enough to hold the name stored at the end of the SYMBOL_INFO structure. Also, the MaxNameLen member must be set to the number of bytes reserved for the name. In this example, dwAddress is the address to be mapped to a symbol. The SymFromAddr function will store an offset to the beginning of the symbol to the address in dwDisplacement
This is easily done:
The next function to set up is SymGetModuleInfo64:
This function takes the symbol address, and pulls some information on the module and stores it in IMAGEHLP_MODULE64. To set up this structure:
The first function to call is SymFromAddr
, that looks something like this:
If that succeeeds, then SymGetModuleInfo
can be used to obtain information on the module:
At this point, if everything succeeds, the module and function name are found.
Using the following loader:
Quite simple, load the DLL and try to get a HANDLE
to explorer (just so we can get some activity in the callback):
At the moment, I'm just working from symbols. If something were to happen to the symbols at runtime, then this wouldn't work. Currently, I am not aware of any impairment of symbol loading? If there is, this will likely break. Other solutions would be to work from the memory location and try to determine if it is infact inside NTDLL. But, for a proof-of-concept, I'll use symbols and something awful... string compares:
If the module name doesn't match ntdll
, or the module name isn't obtained, then we'll do some extra work. Obviously, this is kind of a horrible solution, but its enough to prove a point.
Inside this match, some information is extracted and packed into a structure:
Where Record
is:
As well as a Boolean being set:
Then the after all the checks are done, see if the bool is true:
If it is, build a json object with nlohmann/json.
Instead of just opening a handle, lets open the handle and allocate some space with Tartarus Gate:
Load the DLL
Use
OpenProcess
to get a handle to the processNtAllocateVirtualMemory
syscallNtFreeVirtualMemory
syscall
This should produce two json blobs:
The above shows the loader printing ERROR_SUCCESS
, and then a json blob:
With the logs being generated, its time to get them into Kibana.
In order to ingest json into Logstash and then Kibana, the following configuration file is used (as seen in Maelstrom: Building the Team Server):
Now the DLL just needs to send the data to the orchestration process:
In the orchestration process:
Then checking ELK:
Testing vs. SysWhispers3
As this was built against Tartarus gate, lets see how it works against SysWhispers3.
Going back to this code:
Same thing, as expected:
The json for this log:
Bypassing the Instrumentation
This is all well and good, but this is quite easy to bypass. We can take the exact same code that was used to install the callback, to NULL
it out:
Note this part:
Then its called after the DLL is loaded:
This isn't the most expressive gif, but I'll try to explain it:
In this gif, two breakpoints are set in main.c
, which is the SysWhispers loader. Then dllmain.cpp
has a breakpoint set at the top of the callback function.
The first breakpoint is hit, and the DLL is loaded. Then, the breakpoint inside the callback keep spamming me until I breakout of that and call the function to NULL
the callback, then I can step through the code without being taken to the callback function. I hope that makes sense.
Conclusion
If the DLL is loaded from user-land, like this one is then the callback can just be emptied. I've tried this on EDR products which load the DLL from the Kernel, and this does not seem to impact them. If anyone knows anything more on that particular part, I'd love to know.
Overall, though, this blog was a more of a devlog on how my EDR project is trying to handle syscalls. Again, thanks to winternl for producingDetecting Manual Syscalls from User Mode and allowing me to have a much cleaner hook!
Last updated