Press enter or click to view image in full size
We’ve all been there. A simple question pops into your head. “I wonder how that works?” Before you know it, your browser has 50 tabs open, your coffee is cold, and your entire weekend has vanished into a coding rabbit hole. That was me, trying to figure out how EDRs — Endpoint Detection and Response tools — actually do their magic.
An EDR is like a super-powered security guard for your computer. It doesn’t just check a list of known troublemakers at the door; it watches what every program does, looking for suspicious behavior. But the internet, while full of information, was short on simple, step-by-step guides showing how these complex beasts are built.
So, I decided to build one myself. This is the story of how I built “RottenTomato,” a journey from a simple Windows driver to a (mostly) working security tool. To truly understand what a program is doing, we can’t just look at it from the outside; we need to get access to the heart of the operating system.
Think of your computer’s operating system as having two areas. There’s user space, where your applications like Discord, Chrome, and Word live. Each runs in its own little sandbox. If Discord crashes, Word doesn’t care.
Then there’s kernel space. This is the OS’s control room. It’s where the core system operations, hardware drivers, and critical services run. The kernel has access to everything and is the ultimate source of truth for what’s happening on the system. These two spaces are isolated from each other for security and stability.
So, if a security tool wants to monitor for new processes or file modifications, it can’t do it from the user space sandbox. It needs a way to run code inside the kernel space. The most common way to do that? A kernel driver.
To understand how security tools first got this kernel-level view, let’s trace what happens when you do something as simple as opening a file.
CreateFileA
.CreateFileA
calls a more complex, lower-level function in a library called ntdll.dll
. This library is the bridge between user space and kernel space.ntdll.dll
uses a special instruction called a syscall
. This is the formal request to switch from user mode to kernel mode.But how does the kernel know which function to run? It uses the SSDT (System Service Dispatch Table). Think of the SSDT as the kernel’s private phone directory. The syscall
comes with a number, and the CPU looks up that number in the SSDT to find the exact memory address of the kernel function it needs to execute (like the kernel's version of NtCreateFile
).
Security vendors realized they could modify this table. They would use a driver to sneak into the kernel and change the address for, say, NtCreateFile
to point to their own monitoring function first. Their function would inspect the request, decide if it was safe, and then pass it along to the original kernel function. This was called SSDT hooking.
This was powerful, but it was also dangerous. A buggy security driver could crash the whole system. Worse, malware authors realized they could use the exact same technique (called a rootkit) to hide their own malicious activity!
To stop this, Microsoft introduced Kernel Patch Protection (KPP), better known as PatchGuard. This feature periodically checks critical kernel structures like the SSDT. If it detects any unauthorized modifications, it immediately crashes the system with a Blue Screen of Death (BSOD). Just like that, SSDT hooking was dead.
Security vendors were furious, but Microsoft eventually provided a new, official way for them to monitor the system: Callbacks.
A callback is essentially a notification system. A driver can now officially register with the kernel and say, “Hey, please notify me every time a certain event happens.” The kernel keeps a list of these registered drivers, and when an event occurs — like a new process being created — it dutifully notifies every driver on the list.
Some of the most useful callbacks for an EDR are:
PsSetCreateProcessNotifyRoutine
: Get notified when a process is created or terminated.PsSetLoadImageNotifyRoutine
: Get notified when a DLL is loaded.CmRegisterCallback
: Get notified of changes to the Windows Registry.This is the mechanism we’ll use for our EDR. No more sketchy patching, just a clean, Microsoft-approved subscription to system events.
Okay, theory time is over. Let’s start building. This is the part where the weekend starts to slip away. To develop a kernel driver, you’ll need to set up a specific environment.
bcdedit /set testsigning on
bcdedit -debug on
Now we have a blank slate, ready for our driver code.
A kernel driver, like any program, has a main entry point. Here, it’s called DriverEntry
. When our driver is loaded, this is the first code that runs.
In our initial DriverEntry
, we need to do two basic things:
IoCreateDevice
: This officially creates a "device object" for our driver, putting it on the map for the OS.IoCreateSymbolicLink
: This creates a user-friendly name, or a "symlink," so that other programs can find and communicate with our driver.We also need to define an Unload
routine. This is the cleanup crew. When we stop the driver, this function deletes the device and symbolic link we created.
RottenTomato.c
#include <Ntifs.h>
#include <ntddk.h>
#include <wdf.h>// Define the device name and symbolic link for the RottenTomato driver
UNICODE_STRING DEVICE_NAME = RTL_CONSTANT_STRING(L"\\Device\\RottenTomato"); // Kernel-visible device name
UNICODE_STRING SYM_LINK = RTL_CONSTANT_STRING(L"\\??\\RottenTomato"); // User-visible symbolic link
// Unload routine called when the driver is being unloaded
void UnloadRottenTomato(_In_ PDRIVER_OBJECT DriverObject) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "RottenTomato: Unloading driver...\n");
// Delete the device object created by the driver
IoDeleteDevice(DriverObject->DeviceObject);
// Remove the symbolic link to the device
IoDeleteSymbolicLink(&SYM_LINK);
}
// Entry point for the driver — called when the driver is loaded
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
UNREFERENCED_PARAMETER(RegistryPath); // Not used in this driver
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "RottenTomato: Initializing driver...\n");
NTSTATUS status; // Status variable for function return codes
PDEVICE_OBJECT DeviceObject; // Pointer to created device object
// Create a device object for communication
status = IoCreateDevice(
DriverObject, // Driver object passed by system
0, // No additional device extension space needed
&DEVICE_NAME, // Name of the device
FILE_DEVICE_UNKNOWN, // Device type is unspecified
0, // No special characteristics
FALSE, // Not exclusive; multiple handles allowed
&DeviceObject // Output: created device object
);
// Check if device creation failed
if (!NT_SUCCESS(status)) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "RottenTomato: Failed to create device\n");
return status;
}
// Create a symbolic link so user-mode apps can access the driver
status = IoCreateSymbolicLink(&SYM_LINK, &DEVICE_NAME);
// Check if symbolic link creation failed
if (!NT_SUCCESS(status)) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "RottenTomato: Failed to create symbolic link\n");
IoDeleteDevice(DeviceObject); // Clean up created device
return status;
}
// Set the driver's unload routine
DriverObject->DriverUnload = UnloadRottenTomato;
return status; // Return the final status (should be STATUS_SUCCESS)
}
After compiling this and loading it with a few command-line instructions, we have a running kernel driver! It does absolutely nothing useful yet, but it’s alive. Success!
sc.exe create rottenTomato type=kernel binPath=rottenTomato.sys
sc.exe start rottenTomato
Press enter or click to view image in full size
Now let’s give our spy a mission. We’ll use the PsSetCreateProcessNotifyRoutineEx
callback. This function lets us register our own custom function that will be executed every single time a new process is about to be created.
The real power here is in the information the kernel gives our callback function. For every new process, we get a structure (PPS_CREATE_NOTIFY_INFO
) containing its command line, parent process ID, and, most importantly, a variable called CreationStatus
.
This is our chance to act as judge, jury, and executioner. After analyzing the process info, we can set CreationStatus
:
STATUS_SUCCESS
: The process looks fine. Let it run.STATUS_ACCESS_DENIED
: This looks suspicious. Block the process from ever starting.Let’s implement a simple rule: if the process command line contains “mimikatz”, block it. Otherwise, allow it.
RottenTomato.c
#include <Ntifs.h>
#include <ntddk.h>
#include <wdf.h>// Global variables
UNICODE_STRING DEVICE_NAME = RTL_CONSTANT_STRING(L"\\Device\\rottenTomato"); // Internal device name
UNICODE_STRING SYM_LINK = RTL_CONSTANT_STRING(L"\\??\\rottenTomato"); // Symlink
// Handle incoming notifications about new/terminated processes
void CreateProcessNotifyRoutine(PEPROCESS process, HANDLE pid, PPS_CREATE_NOTIFY_INFO createInfo) {
UNREFERENCED_PARAMETER(process);
UNREFERENCED_PARAMETER(pid);
// Never forget this if check because if you don't, you'll end up crashing your Windows system ;P
if (createInfo != NULL) {
// Compare the command line of the launched process to the notepad string
if (wcsstr(createInfo->CommandLine->Buffer, L"notepad") != NULL) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "rottenTomato: Process (%ws) allowed.\n", createInfo->CommandLine->Buffer);
// Process allowed
createInfo->CreationStatus = STATUS_SUCCESS;
}
// Compare the command line of the launched process to the mimikatz string
if (wcsstr(createInfo->CommandLine->Buffer, L"mimikatz") != NULL) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "rottenTomato: Process (%ws) denied.\n", createInfo->CommandLine->Buffer);
// Process denied
createInfo->CreationStatus = STATUS_ACCESS_DENIED;
}
}
}
void UnloadrottenTomato(_In_ PDRIVER_OBJECT DriverObject) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "rottenTomato: Unloading routine called\n");
// Unset the callback
PsSetCreateProcessNotifyRoutineEx(CreateProcessNotifyRoutine, TRUE);
// Delete the driver device
IoDeleteDevice(DriverObject->DeviceObject);
// Delete the symbolic link
IoDeleteSymbolicLink(&SYM_LINK);
}
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
// Prevent compiler error in level 4 warnings
UNREFERENCED_PARAMETER(RegistryPath);
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "rottenTomato: Initializing the driver\n");
// Variable that will store the output of WinAPI functions
NTSTATUS status;
// Setting the unload routine to execute
DriverObject->DriverUnload = UnloadrottenTomato;
// Initializing a device object and creating it
PDEVICE_OBJECT DeviceObject;
UNICODE_STRING deviceName = DEVICE_NAME;
UNICODE_STRING symlinkName = SYM_LINK;
status = IoCreateDevice(
DriverObject, // our driver object,
0, // no need for extra bytes,
&deviceName, // the device name,
FILE_DEVICE_UNKNOWN, // device type,
0, // characteristics flags,
FALSE, // not exclusive,
&DeviceObject // the resulting pointer
);
if (!NT_SUCCESS(status)) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "rottenTomato: Device creation failed\n");
return status;
}
// Creating the symlink that we will use to contact our driver
status = IoCreateSymbolicLink(&symlinkName, &deviceName);
if (!NT_SUCCESS(status)) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "rottenTomato: Symlink creation failed\n");
IoDeleteDevice(DeviceObject);
return status;
}
// Registers the kernel callback
PsSetCreateProcessNotifyRoutineEx(CreateProcessNotifyRoutine, FALSE);
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "rottenTomato: Driver created\n");
return STATUS_SUCCESS;
}
We recompile, load the driver, and try to run a file named mimikatz.exe
. Access Denied. We try to run notepad.exe
. It opens just fine. It works! Our EDR has taken its first breath.
Press enter or click to view image in full size
Our current logic is trivial to bypass (just rename the file). Real detection logic is complex. And running complex code in the kernel is a terrible idea — one small bug and you get a BSOD.
This is why every real EDR has two parts:
Our driver’s job will be to forward process creation events to our user-space agent. To make things interesting for our RottenTomato EDR, we’ll build two agent components: a Static Analyzer and a Remote Injector.
This agent receives the filename of a new process from our driver. Before the process runs, it does a quick background check:
OpenProcess
, VirtualAllocEx
, WriteProcessMemory
, and CreateRemoteThread
is highly suspicious, as this is the classic recipe for injecting code into another process.SeDebugPrivilege
is often used by malware to get powerful permissions.If an unsigned binary trips one of these checks, our analyzer will tell the driver to block it.
staticAnalyzer.cpp
#include <stdio.h>
#include <windows.h>
#include <dbghelp.h>
#include <wintrust.h>
#include <Softpub.h>
#include <wincrypt.h>#pragma comment (lib, "wintrust.lib")
#pragma comment(lib, "dbghelp.lib")
#pragma comment(lib, "crypt32.lib")
#define MESSAGE_SIZE 2048
BOOL VerifyEmbeddedSignature(const wchar_t* binaryPath) {
LONG lStatus;
WINTRUST_FILE_INFO FileData;
memset(&FileData, 0, sizeof(FileData));
FileData.cbStruct = sizeof(WINTRUST_FILE_INFO);
FileData.pcwszFilePath = binaryPath;
FileData.hFile = NULL;
FileData.pgKnownSubject = NULL;
GUID WVTPolicyGUID = WINTRUST_ACTION_GENERIC_VERIFY_V2;
WINTRUST_DATA WinTrustData;
memset(&WinTrustData, 0, sizeof(WinTrustData));
WinTrustData.cbStruct = sizeof(WinTrustData);
WinTrustData.pPolicyCallbackData = NULL;
WinTrustData.pSIPClientData = NULL;
WinTrustData.dwUIChoice = WTD_UI_NONE;
WinTrustData.fdwRevocationChecks = WTD_REVOKE_NONE;
WinTrustData.dwUnionChoice = WTD_CHOICE_FILE;
WinTrustData.dwStateAction = WTD_STATEACTION_VERIFY;
WinTrustData.hWVTStateData = NULL;
WinTrustData.pwszURLReference = NULL;
WinTrustData.dwUIContext = 0;
WinTrustData.pFile = &FileData;
lStatus = WinVerifyTrust(NULL, &WVTPolicyGUID, &WinTrustData);
BOOL isSigned;
switch (lStatus) {
case ERROR_SUCCESS:
isSigned = TRUE;
break;
case TRUST_E_SUBJECT_FORM_UNKNOWN:
case TRUST_E_PROVIDER_UNKNOWN:
case TRUST_E_EXPLICIT_DISTRUST:
case CRYPT_E_SECURITY_SETTINGS:
case TRUST_E_SUBJECT_NOT_TRUSTED:
isSigned = TRUE;
break;
case TRUST_E_NOSIGNATURE:
isSigned = FALSE;
break;
default:
isSigned = FALSE;
break;
}
WinTrustData.dwStateAction = WTD_STATEACTION_CLOSE;
WinVerifyTrust(NULL, &WVTPolicyGUID, &WinTrustData);
return isSigned;
}
BOOL ListImportedFunctions(const wchar_t* binaryPath) {
BOOL isOpenProcessPresent = FALSE;
BOOL isVirtualAllocExPresent = FALSE;
BOOL isWriteProcessMemoryPresent = FALSE;
BOOL isCreateRemoteThreadPresent = FALSE;
HMODULE hModule = LoadLibraryEx(binaryPath, NULL, DONT_RESOLVE_DLL_REFERENCES);
if (hModule != NULL) {
IMAGE_NT_HEADERS* ntHeaders = ImageNtHeader(hModule);
if (ntHeaders != NULL) {
IMAGE_IMPORT_DESCRIPTOR* importDesc = (IMAGE_IMPORT_DESCRIPTOR*)((BYTE*)hModule + ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
while (importDesc->Name != 0) {
const char* moduleName = (const char*)((BYTE*)hModule + importDesc->Name);
IMAGE_THUNK_DATA* thunk = (IMAGE_THUNK_DATA*)((BYTE*)hModule + importDesc->OriginalFirstThunk);
while (thunk->u1.AddressOfData != 0) {
if (!(thunk->u1.Ordinal & IMAGE_ORDINAL_FLAG)) {
IMAGE_IMPORT_BY_NAME* importByName = (IMAGE_IMPORT_BY_NAME*)((BYTE*)hModule + thunk->u1.AddressOfData);
if (strcmp("OpenProcess", importByName->Name) == 0)
isOpenProcessPresent = TRUE;
if (strcmp("VirtualAllocEx", importByName->Name) == 0)
isVirtualAllocExPresent = TRUE;
if (strcmp("WriteProcessMemory", importByName->Name) == 0)
isWriteProcessMemoryPresent = TRUE;
if (strcmp("CreateRemoteThread", importByName->Name) == 0)
isCreateRemoteThreadPresent = TRUE;
}
thunk++;
}
importDesc++;
}
}
FreeLibrary(hModule);
}
return (isOpenProcessPresent && isVirtualAllocExPresent && isWriteProcessMemoryPresent && isCreateRemoteThreadPresent);
}
BOOL lookForSeDebugPrivilegeString(const wchar_t* filename) {
FILE* file;
_wfopen_s(&file, filename, L"rb");
if (file != NULL) {
fseek(file, 0, SEEK_END);
long file_size = ftell(file);
rewind(file);
char* buffer = (char*)malloc(file_size);
if (buffer != NULL) {
if (fread(buffer, 1, file_size, file) == file_size) {
const char* search_string = "SeDebugPrivilege";
size_t search_length = strlen(search_string);
for (int i = 0; i <= file_size - search_length; i++) {
int j = 0;
for (; j < search_length; j++) {
if (buffer[i + j] != search_string[j]) break;
}
if (j == search_length) {
free(buffer);
fclose(file);
return TRUE;
}
}
}
free(buffer);
}
fclose(file);
}
return FALSE;
}
int main() {
LPCWSTR pipeName = L"\\\\.\\pipe\\rottentomato-analyzer";
DWORD bytesRead = 0;
wchar_t target_binary_file[MESSAGE_SIZE] = { 0 };
printf("Launching analyzer named pipe server\n");
HANDLE hServerPipe = CreateNamedPipe(
pipeName,
PIPE_ACCESS_DUPLEX,
PIPE_TYPE_MESSAGE,
PIPE_UNLIMITED_INSTANCES,
MESSAGE_SIZE,
MESSAGE_SIZE,
0,
NULL
);
while (TRUE) {
BOOL isPipeConnected = ConnectNamedPipe(hServerPipe, NULL);
if (isPipeConnected) {
ReadFile(
hServerPipe,
&target_binary_file,
MESSAGE_SIZE,
&bytesRead,
NULL
);
printf("~> Received binary file %ws\n", target_binary_file);
BOOL isSeDebugPrivilegeStringPresent = lookForSeDebugPrivilegeString(target_binary_file);
if (isSeDebugPrivilegeStringPresent)
printf("\t\033[31mFound SeDebugPrivilege string.\033[0m\n");
else
printf("\t\033[32mSeDebugPrivilege string not found.\033[0m\n");
BOOL isDangerousFunctionsFound = ListImportedFunctions(target_binary_file);
if (isDangerousFunctionsFound)
printf("\t\033[31mDangerous functions found.\033[0m\n");
else
printf("\t\033[32mNo dangerous functions found.\033[0m\n");
BOOL isSigned = VerifyEmbeddedSignature(target_binary_file);
if (isSigned)
printf("\t\033[32mBinary is signed.\033[0m\n");
else
printf("\t\033[31mBinary is not signed.\033[0m\n");
wchar_t response[MESSAGE_SIZE] = { 0 };
if (isSigned) {
swprintf_s(response, MESSAGE_SIZE, L"OK\0");
printf("\t\033[32mStaticAnalyzer allows\033[0m\n");
}
else {
if (isDangerousFunctionsFound || isSeDebugPrivilegeStringPresent) {
swprintf_s(response, MESSAGE_SIZE, L"KO\0");
printf("\n\t\033[31mStaticAnalyzer denies\033[0m\n");
}
else {
swprintf_s(response, MESSAGE_SIZE, L"OK\0");
printf("\n\t\033[32mStaticAnalyzer allows\033[0m\n");
}
}
DWORD bytesWritten = 0;
WriteFile(hServerPipe, response, MESSAGE_SIZE, &bytesWritten, NULL);
}
DisconnectNamedPipe(hServerPipe);
printf("\n\n");
}
return 0;
}
This is where things get really cool. Instead of just blocking a process, what if we could monitor it from the inside? This agent’s job is to inject a DLL (a small library of our own code) into every new process.
This injected DLL will perform user-space hooking. Since we can’t touch the kernel’s SSDT, we’ll modify the process’s own copy of ntdll.dll
. We'll use a brilliant library called MinHook to make this easy.
Our goal is to hook the NtAllocateVirtualMemory
function. This function is used to allocate memory. The hook works like this:
NtAllocateVirtualMemory
function in the process's memory.JMP
(jump) instruction that points to our own custom function.NtAllocateVirtualMemory
, our custom function runs first.Our custom function will check one thing: is the program trying to allocate memory that is Read, Write, and Execute (RWX)? This is a massive red flag. Legitimate programs rarely need memory that is simultaneously writable and executable. It’s the hallmark of shellcode injection. If we detect an RWX allocation, our hook will immediately terminate the process.
rottenTomatoDLL.dll
#include "pch.h"
#include <MinHook.h>// Defines the prototype of the NtAllocateVirtualMemoryFunction
typedef DWORD(NTAPI* pNtAllocateVirtualMemory)(
HANDLE ProcessHandle,
PVOID* BaseAddress,
ULONG_PTR ZeroBits,
PSIZE_T RegionSize,
ULONG AllocationType,
ULONG Protect
);
// Pointer to the trampoline function used to call the original NtAllocateVirtualMemory
pNtAllocateVirtualMemory pOriginalNtAllocateVirtualMemory = NULL;
// This is the function that will be called whenever the injected process calls
// NtAllocateVirtualMemory. This function takes the arguments Protect and checks
// if the requested protection is RWX (which shouldn't happen).
DWORD NTAPI NtAllocateVirtualMemory(
HANDLE ProcessHandle,
PVOID* BaseAddress,
ULONG_PTR ZeroBits,
PSIZE_T RegionSize,
ULONG AllocationType,
ULONG Protect
) {
// Checks if the program is trying to allocate some memory and protect it with RWX
if (Protect == PAGE_EXECUTE_READWRITE) {
// If yes, we notify the user and terminate the process
MessageBox(NULL, L"Dude, are you trying to RWX me ?", L"Found u bro", MB_OK);
TerminateProcess(GetCurrentProcess(), 0xdeadb33f);
}
//If no, we jump on the originate NtAllocateVirtualMemory
return pOriginalNtAllocateVirtualMemory(ProcessHandle, BaseAddress, ZeroBits, RegionSize, AllocationType, Protect);
}
// This function initializes the hooks via the MinHook library
DWORD WINAPI InitHooksThread(LPVOID param) {
if (MH_Initialize() != MH_OK) {
return -1;
}
// Here we specify which function from wich DLL we want to hook
MH_CreateHookApi(
L"ntdll", // Name of the DLL containing the function to hook
"NtAllocateVirtualMemory", // Name of the function to hook
NtAllocateVirtualMemory, // Address of the function on which to jump when hooking
(LPVOID*)(&pOriginalNtAllocateVirtualMemory) // Address of the original NtAllocateVirtualMemory function
);
// Enable the hook on NtAllocateVirtualMemory
MH_STATUS status = MH_EnableHook(MH_ALL_HOOKS);
return status;
}
// Here is the DllMain of our DLL
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
switch (ul_reason_for_call) {
case DLL_PROCESS_ATTACH: {
// This DLL will not be loaded by any thread so we simply disable DLL_TRHEAD_ATTACH and DLL_THREAD_DETACH
DisableThreadLibraryCalls(hModule);
// Calling WinAPI32 functions from the DllMain is a very bad practice
// since it can basically lock the program loading the DLL
// Microsoft recommends not using any functions here except a few one like
// CreateThread IF AND ONLY IF there is no need for synchronization
// So basically we are creating a thread that will execute the InitHooksThread function
// thus allowing us hooking the NtAllocateVirtualMemory function
HANDLE hThread = CreateThread(NULL, 0, InitHooksThread, NULL, 0, NULL);
if (hThread != NULL) {
CloseHandle(hThread);
}
break;
}
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
RemoteInjector.cpp
#include <stdio.h>
#include <windows.h>#define MESSAGE_SIZE 2048
#define MAX_PATH 260
int main() {
LPCWSTR pipeName = L"\\\\.\\pipe\\rottentomato-analyzer";
DWORD bytesRead = 0;
wchar_t target_binary_file[MESSAGE_SIZE] = { 0 };
// Full path to your DLL
const char dll_full_path[] = "C:\\Users\\vboxuser\\source\\repos\\rottenTomatoDLL\\x64\\Debug\\rottenTomatoDLL.dll";
printf("Launching injector named pipe server, injecting %s\n", dll_full_path);
// Creates a named pipe
HANDLE hServerPipe = CreateNamedPipe(
pipeName,
PIPE_ACCESS_DUPLEX,
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
PIPE_UNLIMITED_INSTANCES,
MESSAGE_SIZE,
MESSAGE_SIZE,
0,
NULL
);
if (hServerPipe == INVALID_HANDLE_VALUE) {
printf("Failed to create named pipe, error: %lu\n", GetLastError());
return 1;
}
while (TRUE) {
BOOL isPipeConnected = ConnectNamedPipe(hServerPipe, NULL);
if (!isPipeConnected) {
printf("Failed to connect to named pipe, error: %lu\n", GetLastError());
continue;
}
wchar_t message[MESSAGE_SIZE] = { 0 };
if (!ReadFile(hServerPipe, &message, MESSAGE_SIZE, &bytesRead, NULL)) {
printf("Failed to read from pipe, error: %lu\n", GetLastError());
DisconnectNamedPipe(hServerPipe);
continue;
}
DWORD target_pid = _wtoi(message);
printf("~> Received process id %d\n", target_pid);
HANDLE hProcess = OpenProcess(
PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION |
PROCESS_VM_WRITE | PROCESS_VM_READ,
FALSE,
target_pid
);
if (hProcess == NULL) {
printf("Can't open handle, error: %lu\n", GetLastError());
DisconnectNamedPipe(hServerPipe);
continue;
}
printf("\tOpen handle on PID: %d\n", target_pid);
FARPROC loadLibAddress = GetProcAddress(GetModuleHandle(L"kernel32.dll"), "LoadLibraryA");
if (loadLibAddress == NULL) {
printf("Could not find LoadLibraryA, error: %lu\n", GetLastError());
CloseHandle(hProcess);
DisconnectNamedPipe(hServerPipe);
continue;
}
printf("\tFound LoadLibraryA function\n");
LPVOID vae_buffer = VirtualAllocEx(hProcess, NULL, MAX_PATH, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (vae_buffer == NULL) {
printf("Can't allocate memory, error: %lu\n", GetLastError());
CloseHandle(hProcess);
DisconnectNamedPipe(hServerPipe);
continue;
}
printf("\tAllocated: %d bytes\n", MAX_PATH);
SIZE_T bytesWritten;
if (!WriteProcessMemory(hProcess, vae_buffer, dll_full_path, strlen(dll_full_path) + 1, &bytesWritten)) {
printf("Can't write into memory, error: %lu\n", GetLastError());
VirtualFreeEx(hProcess, vae_buffer, 0, MEM_RELEASE);
CloseHandle(hProcess);
DisconnectNamedPipe(hServerPipe);
continue;
}
printf("\tWrote %zu bytes into PID %d memory\n", bytesWritten, target_pid);
HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE)loadLibAddress,
vae_buffer, 0, NULL);
if (hThread == NULL) {
printf("Can't launch remote thread, error: %lu\n", GetLastError());
VirtualFreeEx(hProcess, vae_buffer, 0, MEM_RELEASE);
CloseHandle(hProcess);
DisconnectNamedPipe(hServerPipe);
continue;
}
printf("\tLaunched remote thread\n");
// Wait for thread to complete (optional)
WaitForSingleObject(hThread, INFINITE);
VirtualFreeEx(hProcess, vae_buffer, 0, MEM_RELEASE);
CloseHandle(hThread);
CloseHandle(hProcess);
printf("\tClosed handle\n");
wchar_t response[MESSAGE_SIZE] = { 0 };
swprintf_s(response, MESSAGE_SIZE, L"OK");
DWORD pipeBytesWritten = 0;
WriteFile(hServerPipe, response, sizeof(response), &pipeBytesWritten, NULL);
DisconnectNamedPipe(hServerPipe);
printf("\n\n");
}
return 0;
}
Final Driver Code (RottenTomato.c)
#include <ntddk.h>// Global variables
UNICODE_STRING DEVICE_NAME = RTL_CONSTANT_STRING(L"\\Device\\rottenTomatoEDR");
UNICODE_STRING SYM_LINK = RTL_CONSTANT_STRING(L"\\??\\rottenTomatoEDR");
// Forward declaration of the unload routine and the callback
void UnloadRottenTomatoEDR(_In_ PDRIVER_OBJECT DriverObject);
void CreateProcessNotifyRoutine(PEPROCESS process, HANDLE pid, PPS_CREATE_NOTIFY_INFO createInfo);
// The main entry point for the driver
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
UNREFERENCED_PARAMETER(RegistryPath);
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "rottenTomatoEDR: Initializing the driver\n");
NTSTATUS status;
PDEVICE_OBJECT DeviceObject = NULL;
// Set the function to be called when the driver is unloaded
DriverObject->DriverUnload = UnloadRottenTomatoEDR;
// Create a device object for our driver
status = IoCreateDevice(DriverObject, 0, &DEVICE_NAME, FILE_DEVICE_UNKNOWN, 0, FALSE, &DeviceObject);
if (!NT_SUCCESS(status)) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "rottenTomatoEDR: Device creation failed: 0x%X\n", status);
return status;
}
// Create a symbolic link so user-mode applications can communicate with the driver
status = IoCreateSymbolicLink(&SYM_LINK, &DEVICE_NAME);
if (!NT_SUCCESS(status)) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "rottenTomatoEDR: Symlink creation failed: 0x%X\n", status);
IoDeleteDevice(DeviceObject);
return status;
}
// Register our process creation callback routine
status = PsSetCreateProcessNotifyRoutineEx(CreateProcessNotifyRoutine, FALSE);
if (!NT_SUCCESS(status)) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "rottenTomatoEDR: Failed to set callback routine: 0x%X\n", status);
IoDeleteSymbolicLink(&SYM_LINK);
IoDeleteDevice(DeviceObject);
return status;
}
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "rottenTomatoEDR: Driver loaded successfully.\n");
return STATUS_SUCCESS;
}
// The routine that is called when a process is created or terminated
void CreateProcessNotifyRoutine(PEPROCESS process, HANDLE pid, PPS_CREATE_NOTIFY_INFO createInfo) {
UNREFERENCED_PARAMETER(process);
if (createInfo) {
createInfo->CreationStatus = STATUS_SUCCESS;
HANDLE hPipeAnalyzer, hPipeInjector;
OBJECT_ATTRIBUTES objAttr;
IO_STATUS_BLOCK ioStatusBlock;
NTSTATUS status;
UNICODE_STRING pipeNameAnalyzer = RTL_CONSTANT_STRING(L"\\??\\pipe\\rottenTomato-analyzer");
UNICODE_STRING pipeNameInjector = RTL_CONSTANT_STRING(L"\\??\\pipe\\rottenTomato-injector");
// Connect to Static Analyzer Agent
InitializeObjectAttributes(&objAttr, &pipeNameAnalyzer, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, NULL, NULL);
status = ZwCreateFile(&hPipeAnalyzer, GENERIC_WRITE | GENERIC_READ | SYNCHRONIZE, &objAttr, &ioStatusBlock, NULL,
FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ | FILE_SHARE_WRITE, FILE_OPEN,
FILE_SYNCHRONOUS_IO_NONALERT, NULL, 0);
if (!NT_SUCCESS(status)) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "rottenTomatoEDR: Failed to open analyzer pipe: 0x%X\n", status);
return;
}
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "rottenTomatoEDR: Sending '%wZ' to Static Analyzer\n", createInfo->ImageFileName);
status = ZwWriteFile(hPipeAnalyzer, NULL, NULL, NULL, &ioStatusBlock,
createInfo->ImageFileName->Buffer, createInfo->ImageFileName->Length, NULL, NULL);
if (!NT_SUCCESS(status)) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "rottenTomatoEDR: Failed to write to analyzer pipe: 0x%X\n", status);
ZwClose(hPipeAnalyzer);
return;
}
wchar_t response[10] = { 0 };
status = ZwReadFile(hPipeAnalyzer, NULL, NULL, NULL, &ioStatusBlock,
response, sizeof(response) - sizeof(WCHAR), NULL, NULL);
if (!NT_SUCCESS(status)) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "rottenTomatoEDR: Failed to read from analyzer pipe: 0x%X\n", status);
ZwClose(hPipeAnalyzer);
return;
}
ZwClose(hPipeAnalyzer);
if (wcscmp(response, L"KO") == 0) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "rottenTomatoEDR: Static Analyzer denied process. Blocking.\n");
createInfo->CreationStatus = STATUS_ACCESS_DENIED;
return;
}
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "rottenTomatoEDR: Static Analyzer allowed process. Forwarding to Injector.\n");
// Connect to Remote Injector Agent
InitializeObjectAttributes(&objAttr, &pipeNameInjector, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, NULL, NULL);
status = ZwCreateFile(&hPipeInjector, GENERIC_WRITE | SYNCHRONIZE, &objAttr, &ioStatusBlock, NULL,
FILE_ATTRIBUTE_NORMAL, 0, FILE_OPEN, FILE_SYNCHRONOUS_IO_NONALERT, NULL, 0);
if (!NT_SUCCESS(status)) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "rottenTomatoEDR: Failed to open injector pipe: 0x%X\n", status);
return;
}
WCHAR pidBuffer[20];
UNICODE_STRING pidUnicodeString;
pidUnicodeString.Buffer = pidBuffer;
pidUnicodeString.Length = 0;
pidUnicodeString.MaximumLength = sizeof(pidBuffer);
status = RtlIntegerToUnicodeString((ULONG)(ULONG_PTR)pid, 10, &pidUnicodeString);
if (!NT_SUCCESS(status)) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "rottenTomatoEDR: Failed to convert PID to string: 0x%X\n", status);
ZwClose(hPipeInjector);
return;
}
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "rottenTomatoEDR: Sending PID %wZ to Remote Injector\n", &pidUnicodeString);
status = ZwWriteFile(hPipeInjector, NULL, NULL, NULL, &ioStatusBlock,
pidUnicodeString.Buffer, pidUnicodeString.Length, NULL, NULL);
if (!NT_SUCCESS(status)) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "rottenTomatoEDR: Failed to write to injector pipe: 0x%X\n", status);
}
ZwClose(hPipeInjector);
}
}
// The routine that is called when the driver is unloaded
void UnloadRottenTomatoEDR(_In_ PDRIVER_OBJECT DriverObject) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "rottenTomatoEDR: Unloading routine called\n");
PsSetCreateProcessNotifyRoutineEx(CreateProcessNotifyRoutine, TRUE);
IoDeleteSymbolicLink(&SYM_LINK);
IoDeleteDevice(DriverObject->DeviceObject);
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "rottenTomatoEDR: Driver unloaded successfully.\n");
}
Press enter or click to view image in full size
Building this project taught me three things. First, I now have a much deeper respect for how EDRs are architectured. Second, I hope this article demystifies the process for others and maybe even gives you a few ideas on how to bypass them. And third, I realized just how incredibly difficult it is to build a production-ready security tool.
As penetration testers, it’s easy to brag, “Haha, I bypassed that EDR.” But remember the teams of developers and blue teamers who work tirelessly to build and manage these complex systems. It’s a constant cat-and-mouse game, and they deserve a huge thumbs-up.
Happy hacking!