Hello! Welcome to my new article on developing a driver for protecting your process. Before that, I had never really mentioned kernel in my articles mode, because I didn’t really like to go there, but then I came up with the idea of implementing a pair of ideas that I will talk about in the article. Actually, in this article, I wrote a driver from which The work of the process is highly dependent.
Let’s start with the simplest, parsing offsets of fields (mainly from the structure of EPROCESS). Yes, parsing offsets is not the best idea, especially if you understand which one user will have a build that is not supported by the driver.
In total, there will be three projects in the solution:
We have this enum where supported versions of Windows are stored:
enum WindowsBuildNumber
{
Win11 = 22000,
Win10_21H2 = 19044,
Win10_21H1 = 19043,
Win10_20H2 = 19042,
Win10_20H1 = 19041,
Win10_19H2 = 18363,
Win10_19H1 = 18362,
Win10_Redstone5 = 17763,
Win10_Redstone4 = 17134,
Win10_Redstone3 = 16299,
Win10_Redstone2 = 15063,
Win10_Redstone1 = 14393,
Win10_Threshold2 = 10586,
Win10_Threshold1 = 10240,
Win8_1 = 9600,
Win8 = 9200,
Win7_SP1 = 7601,
Win7 = 7600
};
Using the RtlGetVersion function, we can get the current BuildNumber from the RTL_OSVERSIONINFOW:
NTSTATUS Initialize()
{
RTL_OSVERSIONINFOW VersionInfo = { sizeof(RTL_OSVERSIONINFOW) };
NTSTATUS Status = RtlGetVersion(&VersionInfo);
if (!NT_SUCCESS(Status))
{
return Status;
}
CurrentOffsets = GetOffsets(VersionInfo.dwBuildNumber);
if (CurrentOffsets.DebugPort == 0)
{
return STATUS_NOT_SUPPORTED;
}
InitialSystemProcess = (PEPROCESS)(PsInitialSystemProcess);
return STATUS_SUCCESS;
}
With the GetOffsets function, I’ve made it so that the function for each build populates here such structure:
struct ProcessOffsets
{
UINT64 UniqueProcessId;
UINT64 ActiveProcessLinks;
UINT64 ImageFileName;
UINT64 ActiveThreads;
UINT64 HideFromDebugger;
UINT64 DebugPort;
UINT64 NoDebugInherit;
UINT64 InstrumentationCallback;
UINT64 InheritedFromUniqueProcessId;
UINT64 Process;
UINT64 Protection;
};
ProcessOffsets CurrentOffsets = { 0 };
ProcessOffsets GetOffsets(UINT64 BuildNumber)
{
switch (BuildNumber)
{
case Win11:
return { 0x440, 0x448, 0x5a8, 0x5f0, 0x560, 0x578, 0x464, 0x3d8, 0x540, 0x220, 0x87a };
case Win10_21H2:
case Win10_21H1:
case Win10_20H2:
case Win10_20H1:
return { 0x440, 0x448, 0x5a8, 0x5f0, 0x510, 0x578, 0x464, 0x3d8, 0x540, 0x220, 0x87a };
case Win10_19H2:
case Win10_19H1:
return { 0x2e8, 0x2f0, 0x450, 0x498, 0x6e0, 0x420, 0x30c, 0x2d0, 0x3e8, 0x220, 0x6fa };
case Win10_Redstone5:
case Win10_Redstone4:
case Win10_Redstone3:
return { 0x2e0, 0x2e8, 0x450, 0x498, 0x6d0, 0x420, 0x304, 0x2c8, 0x3e0, 0x220, 0x6ca };
case Win10_Redstone2:
return { 0x2e0, 0x2e8, 0x450, 0x498, 0x6c8, 0x420, 0x304, 0x2c8, 0x3e0, 0x220, 0x6ca };
case Win10_Redstone1:
return { 0x2e8, 0x2f0, 0x448, 0x490, 0x6c0, 0x420, 0x304, 0x2c8, 0x3e0, 0x220, 0x6c2 };
case Win10_Threshold2:
return { 0x2e8, 0x2f0, 0x448, 0x490, 0x6bc, 0x420, 0x304, 0x2c8, 0x3e0, 0x220, 0x6b2 };
case Win10_Threshold1:
return { 0x2e8, 0x2f0, 0x448, 0x490, 0x6bc, 0x420, 0x304, 0x2c8, 0x3e0, 0x220, 0x6aa };
case Win8_1:
return { 0x2e0, 0x2e8, 0x438, 0x480, 0x6b4, 0x410, 0x2fc, 0x2c0, 0x3d0, 0x220, 0x67a };
case Win8:
return { 0x2e0, 0x2e8, 0x438, 0x440, 0x42c, 0x410, 0x2fc, 0x2c0, 0x3d0, 0x220, 0x648 };
case Win7_SP1:
case Win7:
return { 0x180, 0x188, 0x2e0, 0x328, 0x448, 0x1f0, 0x440, 0x100, 0x290, 0x210, 0x43c };
default:
return { 0 };
}
}
Following Vanguard, I decided to implement something like this:

And as the label location I chose the TimeStamp field in PE, now the driver is looking for a process with timestamp with a value of 0x1337:
NTSTATUS FindProtectedProcess(ProtectedProcessInfo* ProtectedInfo)
{
PEPROCESS CurrentProcess = InitialSystemProcess;
do
{
KAPC_STATE ApcState;
KeStackAttachProcess((PRKPROCESS)CurrentProcess, &ApcState);
__try
{
PVOID BaseAddress = PsGetProcessSectionBaseAddress(CurrentProcess);
if (BaseAddress)
{
PIMAGE_DOS_HEADER DosHeader = (PIMAGE_DOS_HEADER)BaseAddress;
if (DosHeader->e_magic == IMAGE_DOS_SIGNATURE)
{
PIMAGE_NT_HEADERS NtHeaders =
(PIMAGE_NT_HEADERS)((ULONG_PTR)BaseAddress + DosHeader->e_lfanew);
if (NtHeaders->Signature == IMAGE_NT_SIGNATURE &&
NtHeaders->FileHeader.TimeDateStamp == 0x1337)
{
RtlCopyMemory(
ProtectedInfo->ImageName,
(CHAR*)((UINT64)CurrentProcess + GetImageFileName()),
sizeof(ProtectedInfo->ImageName)
);
ProtectedInfo->ProcessId = *(HANDLE*)((UINT64)CurrentProcess + GetUniqueProcessId());
ProtectedInfo->Process = CurrentProcess;
ObReferenceObject(CurrentProcess);
KeUnstackDetachProcess(&ApcState);
return STATUS_SUCCESS;
}
}
}
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
}
KeUnstackDetachProcess(&ApcState);
PLIST_ENTRY List = (PLIST_ENTRY)((ULONG_PTR)CurrentProcess + GetActiveProcessLinks());
CurrentProcess = (PEPROCESS)((ULONG_PTR)List->Flink - GetActiveProcessLinks());
} while (CurrentProcess != InitialSystemProcess);
return STATUS_NOT_FOUND;
}
In, setting a value for a field after each compilation is very tedious, so for this I had to Made eyedress-tools, which itself patches to the desired value:
void patch_timestamp()
{
auto dos_header = reinterpret_cast<PIMAGE_DOS_HEADER>(binary.data());
auto nt_headers = reinterpret_cast<PIMAGE_NT_HEADERS>(binary.data() + dos_header->e_lfanew);
nt_headers->FileHeader.TimeDateStamp = 0x1337;
printf("Patched PE Timestamp to 0x%X\n", nt_headers->FileHeader.TimeDateStamp);
}
Let’s move on to the protection parts, the first of which is Shadow-thread.
To interact with them, we also need up-to-date offsets, which we as hardcode for different builds, and create the following functions to access them:
UINT64 GetDebugPort()
{
return CurrentOffsets.DebugPort;
}
UINT64 GetHideFromDebugger()
{
return CurrentOffsets.HideFromDebugger;
}
Shadow-thread finds the main thread of the program:
KeStackAttachProcess((PKPROCESS)Process, &ApcState);
ULONG BufferSize = 0;
PVOID Buffer = NULL;
Status = ZwQuerySystemInformation(5, NULL, 0, &BufferSize);
if (Status != STATUS_INFO_LENGTH_MISMATCH)
{
LogToFile("Failed to get buffer size: 0x%X\n", Status);
__leave;
}
Buffer = ExAllocatePoolWithTag(NonPagedPool, BufferSize, 'THRD');
if (Buffer == NULL)
{
LogToFile("Failed to allocate buffer\n");
Status = STATUS_INSUFFICIENT_RESOURCES;
__leave;
}
Status = ZwQuerySystemInformation(5, Buffer, BufferSize, NULL);
if (!NT_SUCCESS(Status))
{
LogToFile("Failed to query system information: 0x%X\n", Status);
__leave;
}
PSYSTEM_PROCESS_INFORMATION ProcessInfo = (PSYSTEM_PROCESS_INFORMATION)Buffer;
PETHREAD TargetThread = NULL;
while (TRUE)
{
if (ProcessInfo->UniqueProcessId == ProcessId)
{
if (ProcessInfo->NumberOfThreads > 0)
{
HANDLE ThreadId = ProcessInfo->Threads[0].ClientId.UniqueThread;
Status = PsLookupThreadByThreadId(ThreadId, &TargetThread);
if (NT_SUCCESS(Status))
{
LogToFile("Found target thread: 0x%p\n", TargetThread);
break;
}
}
}
if (ProcessInfo->NextEntryOffset == 0)
break;
ProcessInfo = (PSYSTEM_PROCESS_INFORMATION)((PUCHAR)ProcessInfo + ProcessInfo->NextEntryOffset);
}
if (TargetThread == NULL)
{
LogToFile("Failed to find target thread\n");
Status = STATUS_NOT_FOUND;
__leave;
}
And after finding the main thread, it changes two fields, the HideFromDebugger from PETHREAD and the DebugPort from PEPROCESS:
ULONG* HideFromDebugger = (ULONG*)((ULONG_PTR)TargetThread +
ProcessManagement::GetHideFromDebugger());
if (MmIsAddressValid(HideFromDebugger))
{
*HideFromDebugger |= THREAD_HIDE_FROM_DEBUGGER;
LogToFile("Set THREAD_HIDE_FROM_DEBUGGER flag\n");
}
else
{
LogToFile("Invalid HideFromDebugger address\n");
}
PVOID* DebugPort = (PVOID*)((UINT64)Process +
ProcessManagement::GetDebugPort());
if (MmIsAddressValid(DebugPort))
{
*DebugPort = NULL;
LogToFile("Cleared DebugPort\n");
}
else
{
LogToFile("Invalid DebugPort address\n");
}
If during these patches the user debugged the application, then it will be dropped from the debugger :)
But this is all banality, let’s move on to how to make a usermod application work dependent on the driver.
What was my idea? Eyedress-tools encrypts the .data section of the application’s usermode:
void encrypt_data_section()
{
for (int i = 0; i < main_pe_info.number_of_sections; i++)
{
auto section = &main_pe_info.section_headers[i];
if (strcmp((char*)section->Name, ".data") == 0)
{
DWORD file_offset = section->PointerToRawData;
DWORD size = section->SizeOfRawData;
uint8_t* data_start = exe_data.data() + file_offset;
xor_encrypt_decrypt(data_start, size, current_xor_key);
return;
}
}
}
And it embedded the XOR key into the driver, the driver already had a blank in the form of a volatile variable, so that the compiler would not remove it:
volatile ULONG XorKey = 0xDEADC0DE;
The tool found this key in the binary buffer and patched it to the generated key:
std::vector<DWORD> find_deadc0de_markers(const std::vector<uint8_t>& data) const
{
std::vector<DWORD> markers;
const uint32_t deadc0de = 0xDEADC0DE;
auto info = get_pe_info(data);
for (int i = 0; i < info.number_of_sections; i++)
{
auto section = &info.section_headers[i];
if (section->Characteristics & IMAGE_SCN_MEM_READ)
{
DWORD start = section->PointerToRawData;
DWORD end = start + section->SizeOfRawData - sizeof(uint32_t);
for (DWORD offset = start; offset <= end; offset += sizeof(uint32_t))
{
if (*reinterpret_cast<const uint32_t*>(&data[offset]) == deadc0de)
{
markers.push_back(section->VirtualAddress + (offset - start));
}
}
}
}
return markers;
}
void replace_markers_with_xor_key(std::vector<uint8_t>& data)
{
auto info = get_pe_info(data);
auto markers = find_deadc0de_markers(data);
for (const auto& marker : markers)
{
DWORD file_offset = rva_to_file_offset(info, marker);
*reinterpret_cast<uint32_t*>(&data[file_offset]) = current_xor_key;
}
}
And since my driver was not intended for mmap via the conditional kdmapper, then after the patch the driver certificate successfully fell off, so I had to re-sign the driver after the patch :(
Let’s return to our section cryptor, eyedress-tools has embedded the decryption key into the driver, the driver in turn must understand when it needs to decrypt the values.
And since it is impossible to handle usermode exceptions in the driver without a PatchGuard trigger, I decided to hook KiUserExceptionDispatcher from the protected process instead of installing VEH. Why? Eyedress-usermode will set PAGE_GUARD for the entire .data section, in the hook we will send the necessary context to the driver via IOCTL.
If you hook the KiUserExceptionDispatcher export directly, you will need a Proxy function, but I was too lazy to add a MASM file, and I found a loophole through which you could hook it without any hassle:

Here it was enough to replace the instruction “mov rax, ptr” with your own instruction, where there will be imm64 with the address of our hook:
bool patch_instruction(void* target_address, void* new_handler_address)
{
if (!protect_memory_section(target_address, 12, PAGE_EXECUTE_READWRITE))
{
return false;
}
std::vector<std::uint8_t> target = {
0x48, 0xB8, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x90, 0x90
};
*reinterpret_cast<std::uint64_t*>(target.data() + 2) =
reinterpret_cast<std::uint64_t>(new_handler_address);
std::copy(target.begin(), target.end(), reinterpret_cast<std::uint8_t*>(target_address));
return protect_memory_section(target_address, 12, PAGE_EXECUTE_READ);
}
Result of hook installation:

Before that, in our TLS callback we set PAGE_GUARD | PAGE_READWRITE rights:
auto [section_address, section_size] = memory_utils::find_section(module, ".data");
if (!section_address || section_size == 0)
{
MessageBoxA(nullptr, "Failed to find .data section", "Error", MB_OK | MB_ICONERROR);
return false;
}
if (!memory_utils::protect_memory_section(section_address, section_size, PAGE_READWRITE | PAGE_GUARD))
{
MessageBoxA(nullptr, "Failed to protect .data section", "Error", MB_OK | MB_ICONERROR);
return false;
}
Now in the exception handler we will pass our context to the driver:
if (!m_device)
{
return std::make_error_code(std::errc::bad_file_descriptor);
}
exception_input input{ *exception_record, *context_record };
exception_output output{};
DWORD bytes_returned = 0;
if (!DeviceIoControl(m_device.value(), ioctl_handle_exception, &input,
sizeof(input), &output, sizeof(output), &bytes_returned, nullptr))
{
return std::error_code(GetLastError(), std::system_category());
}
if (output.m_status == 0)
{
*context_record = output.m_updated_context;
}
Here I decided to cheat, when the driver receives the context, it decrypts the value by the pointer, but does not write this decrypted value to the section, but creates a special shellcode in the allocated memory, where the same instruction is executed, but with a decrypted value, thereby leaving the .data section always encrypted.
Let’s start by getting the IOCTL:
NTSTATUS DeviceControl(_In_ PDEVICE_OBJECT DeviceObject, _In_ PIRP Irp)
{
UNREFERENCED_PARAMETER(DeviceObject);
PIO_STACK_LOCATION IrpSp = IoGetCurrentIrpStackLocation(Irp);
NTSTATUS Status = STATUS_SUCCESS;
ULONG_PTR Information = 0;
switch (IrpSp->Parameters.DeviceIoControl.IoControlCode)
{
case IOCTL_HANDLE_EXCEPTION:
{
if (IrpSp->Parameters.DeviceIoControl.InputBufferLength < sizeof(HandleExceptionInput) ||
IrpSp->Parameters.DeviceIoControl.OutputBufferLength < sizeof(HandleExceptionOutput))
{
Status = STATUS_BUFFER_TOO_SMALL;
break;
}
HandleExceptionInput* Input = (HandleExceptionInput*) Irp->AssociatedIrp.SystemBuffer;
HandleExceptionOutput* Output = (HandleExceptionOutput*) Irp->AssociatedIrp.SystemBuffer;
Status = RuntimeManagement::Decryptor::HandleExceptionRequest(Input, Output);
Information = sizeof(HandleExceptionOutput);
break;
}
default:
Status = STATUS_INVALID_DEVICE_REQUEST;
break;
}
Irp->IoStatus.Status = Status;
Irp->IoStatus.Information = Information;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return Status;
}
Here, basically, there is just a check on the size of the incoming and outgoing structures; we are interested in the implementation of the HandleExceptionRequest function:
NTSTATUS RuntimeManagement::Decryptor::HandleExceptionRequest(
HandleExceptionInput* Input,
HandleExceptionOutput* Output)
{
hde64s Hs;
PVOID InstructionAddress = (PVOID)Input->Context.Rip;
UINT32 InstructionSize = hde64_disasm(InstructionAddress, &Hs);
LogToFile("Exception triggered: 0x%p - %d\n", InstructionAddress, InstructionSize);
PVOID ShellcodeAddress;
NTSTATUS Status = DecryptAndCreateShellcode(
PsGetCurrentProcess(),
InstructionAddress,
InstructionSize,
&ShellcodeAddress
);
if (NT_SUCCESS(Status))
{
Output->UpdatedContext = Input->Context;
Output->UpdatedContext.Rip = (DWORD64)ShellcodeAddress;
LogToFile("Shellcode created: 0x%p\n", ShellcodeAddress);
}
else
{
Status = STATUS_UNSUCCESSFUL;
}
Output->Status = Status;
return Status;
}
In this function I get the current Rip address where the exception occurred and get the instruction size via the lightweight Hde64 disassembler, then with all the necessary information the driver goes to generate the shellcode for this instruction.
I must warn you right away that the driver can only handle the following instructions:
mov rax, qword ptr ds:[data_ptr]
mov qword ptr ds:[data_ptr], rax
Two instructions, because I didn’t want to add checks for all sorts of instructions, but how to make this a universal thing for instructions like,

or,
repmovsb
I couldn’t come up with a good idea. So this idea remained an incomplete PoC.
For the instructions above I had these checks:
if (Size >= 7 && *(PUSHORT)Address == 0x8B48 && *(PUCHAR)((ULONG_PTR)Address + 2) == 0x05)
{
// mov rax, qword ptr [...]
INT32 Offset = *(PINT32)((ULONG_PTR)Address + 3);
MemoryAddress = (UINT64)Address + Size + Offset;
IsWrite = FALSE;
LogToFile("Read instruction detected. Address: 0x%p\n", (PVOID)MemoryAddress);
}
else if (Size >= 7 && *(PUSHORT)Address == 0x8948 && *(PUCHAR)((ULONG_PTR)Address + 2) == 0x05)
{
// mov qword ptr [...], rax
INT32 Offset = *(PINT32)((ULONG_PTR)Address + 3);
MemoryAddress = (UINT64)Address + Size + Offset;
IsWrite = TRUE;
LogToFile("Write instruction detected. Address: 0x%p\n", (PVOID)MemoryAddress);
}
else
{
LogToFile("Unexpected instruction format\n");
Status = STATUS_INVALID_PARAMETER;
__leave;
}
Then, after fully parsing the information about the instruction, the driver began generating a shellcode of this type:
SIZE_T ShellcodeSize = 32;
PVOID Shellcode = NULL;
Status = ZwAllocateVirtualMemory(
ZwCurrentProcess(), &Shellcode, 0, &ShellcodeSize,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE
);
if (!NT_SUCCESS(Status))
{
LogToFile("Failed to allocate memory for shellcode: 0x%X\n", Status);
__leave;
}
PUCHAR ShellcodePtr = (PUCHAR)Shellcode;
if (!IsWrite)
{
*(PUSHORT)ShellcodePtr = 0xB848;
ShellcodePtr += 2;
*(PUINT64)ShellcodePtr = *(PUINT64)DataBuffer;
ShellcodePtr += 8;
}
*ShellcodePtr++ = 0x50; // push rax
*(PUSHORT)ShellcodePtr = 0xB848; // mov rax, [next addr]
ShellcodePtr += 2;
*(PUINT64)ShellcodePtr = (UINT64)Address + Size;
ShellcodePtr += 8;
*(PUINT32)ShellcodePtr = 0x24048748; // xchg [rsp], rax
ShellcodePtr += 4;
*ShellcodePtr = 0xC3; // ret
ULONG OldProtect;
Status = ZwProtectVirtualMemory(
ZwCurrentProcess(), &Shellcode, &ShellcodeSize,
PAGE_EXECUTE_READ, &OldProtect
);
if (NT_SUCCESS(Status))
{
*ShellcodeAddress = Shellcode;
LogToFile("Shellcode created at 0x%p, Next instruction address: 0x%p\n",
Shellcode, (PVOID)((UINT64)Address + Size));
}
else
{
LogToFile("Failed to change memory protection: 0x%X\n", Status);
ZwFreeVirtualMemory(ZwCurrentProcess(), &Shellcode, &ShellcodeSize, MEM_RELEASE);
}
If you are interested in the result of such a meme, here it is:

However, the .data section is still encrypted and will always remain like this:

void hooked_ki_user_exception_dispatcher(
PEXCEPTION_RECORD exception_record,
PCONTEXT context_record)
{
if (exception_record->ExceptionCode == STATUS_GUARD_PAGE_VIOLATION)
{
auto status = decryptor::handle_exception(exception_record, context_record);
if (status.value() == 0)
{
auto module = GetModuleHandleA(nullptr);
auto [section_address, section_size] = memory_utils::find_section(module, ".data");
if (!section_address || section_size == 0)
{
MessageBoxA(nullptr, "Failed to find .data section", "Error",
MB_OK | MB_ICONERROR);
return;
}
if (!memory_utils::protect_memory_section(section_address, section_size,
PAGE_READWRITE | PAGE_GUARD))
{
MessageBoxA(nullptr, "Failed to protect .data section", "Error",
MB_OK | MB_ICONERROR);
return;
}
RtlRestoreContext(context_record, nullptr);
}
else
{
MessageBoxA(nullptr, "Failed to handle exception", "Error", MB_OK | MB_ICONERROR);
}
}
}
When handle_exception is called, we will already have a ready shellcode, which will be executed via RtlRestoreContext with the updated context from the driver.


In our case, the driver decrypted the value inside __security_init_cookie and embedded the value into the instruction so that it would be correctly passed to the register. Then the next instruction after the one where the exception occurred is placed in rax, and we get back to in the .text section:


Before the idea of implementing the encrypt and decrypt the .data section via the driver, I also implemented the encrypt of the .sekai section, which contained the test code. The driver decrypted the instruction, then encrypted it back after execution.
As with the .data section, encryption occurred, but in the case of the “.sekai” section, I made it so that it encrypted only the functions that were found in the .PDB file.
cryptor("eyedress-usermode.exe", "eyedress-usermode.pdb");
cryptor.patch_timestamp();
cryptor.encrypt_function("test::call");
cryptor.save_encrypted_exe("eyedress-usermode.encrypted.exe");
void encrypt_function(const std::string& function_name)
{
try
{
std::uint64_t function_rva = pdb_parser.get_symbol_address(function_name)
- main_pe_info.image_base;
std::uint32_t file_offset = rva_to_file_offset(main_pe_info,
static_cast<DWORD>(function_rva));
uint8_t* function_start = exe_data.data() + file_offset;
size_t max_size = main_pe_info.nt_headers->OptionalHeader.SizeOfImage
- file_offset;
size_t function_size = get_function_size(function_start, max_size);
crypt(function_start, function_size, current_xor_key);
}
catch (const std::exception& e)
{
std::cerr << "Error encrypting function " << function_name
<< ": " << e.what() << std::endl;
}
}
Initially, the exception handler for decrypting/encrypting instructions looked like this:
if (ExceptionRecord->ExceptionCode != STATUS_SINGLE_STEP)
{
void* exception_address = (void*) ContextRecord->Rip;
DWORD old_protect;
VirtualProtect(exception_address, 15, PAGE_EXECUTE_READWRITE, &old_protect);
auto status = decryptor::decrypt_instruction(exception_address);
if (status != 0)
{
return;
}
last_address = exception_address;
ContextRecord->EFlags |= 0x100;
RtlRestoreContext(ContextRecord, ExceptionRecord);
}
else
{
if (last_address != nullptr)
{
NTSTATUS status = decryptor::decrypt_instruction(last_address);
if (status != 0)
{
return;
}
DWORD old_protect;
VirtualProtect(last_address, 15, PAGE_EXECUTE_READ | PAGE_GUARD, &old_protect);
}
ContextRecord->EFlags &= ~0x100;
RtlRestoreContext(ContextRecord, ExceptionRecord);
}
And in the decrypt_instruction function there remained an IOCTL call:
NTSTATUS decrypt_instruction(void* address)
{
DECRYPT_INSTRUCTION_INPUT input = {address};
DECRYPT_INSTRUCTION_OUTPUT output = {0};
DWORD bytesReturned = 0;
const auto success = DeviceIoControl(
m_device.value(),
IOCTL_DECRYPT_INSTRUCTION,
&input, sizeof(input),
&output, sizeof(output),
&bytesReturned, NULL
);
if (!success)
{
return GetLastError();
}
return output.Status;
}
Here I did not take the size of the current instructions, but decrypted and encrypted 15 bytes (maximum instruction size).
In the driver, it was easier for me to implement with encrypt/decrypt instructions than to mess around with shellcodes for each instruction, because here the driver’s role was only to decrypt and encrypt:
NTSTATUS RuntimeManagement::Decryptor::DecryptInstruction(PVOID address)
{
__try
{
Crypt(reinterpret_cast<PUCHAR>(address), MAX_INSTRUCTION_SIZE);
return STATUS_SUCCESS;
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
LogToFile("DecryptInstruction failed\n");
return GetExceptionCode();
}
}
NTSTATUS RuntimeManagement::Decryptor::HandleDecryptRequest(
PVOID InputBuffer,
ULONG InputBufferLength,
PVOID OutputBuffer,
ULONG OutputBufferLength,
PULONG BytesReturned)
{
if (InputBufferLength < sizeof(PVOID) || !InputBuffer)
{
return STATUS_INVALID_PARAMETER;
}
PVOID AddressToDecrypt = *reinterpret_cast<PVOID*>(InputBuffer);
NTSTATUS Status = DecryptInstruction(AddressToDecrypt);
if (OutputBufferLength >= sizeof(NTSTATUS))
{
*reinterpret_cast<NTSTATUS*>(OutputBuffer) = Status;
*BytesReturned = sizeof(NTSTATUS);
}
return Status;
}
After the full implementation of such a cryptor, there was a flaw in the form of slow operation. If you put a cycle under this cryptor, it would take a very long time to execute.
For the test, we will give the cryptor the following code:
namespace test
{
void call()
{
Sleep(100);
MessageBoxA(nullptr, "Test", "Test", MB_OK);
}
}

Here you can see that the previous instructions are in the encrypt, but they were already decrypted and executed by the program, then encrypted again.
After the code is fully executed, it returns back to its fully encrypted form:

Well, to consolidate at the end of the article, I decided to create a separate thread that would check the hash of the .text section at a random time interval:
NTSTATUS RuntimeManagement::Initialize(ProcessManagement::ProtectedProcessInfo* ProtectedInfo)
{
RuntimeContext* Ctx = (RuntimeContext*) ExAllocatePool2(POOL_FLAG_NON_PAGED,
sizeof(RuntimeContext), 'RNTM');
if (!Ctx)
return STATUS_INSUFFICIENT_RESOURCES;
RtlZeroMemory(Ctx, sizeof(RuntimeContext));
Ctx->ProtectedProcess = ProtectedInfo->Process;
Ctx->StopThread = FALSE;
NTSTATUS Status = Internal::FindSection(Ctx->ProtectedProcess, ".text",
&Ctx->TextSectionBase, &Ctx->TextSectionSize);
if (!NT_SUCCESS(Status))
{
ExFreePool(Ctx);
return Status;
}
Internal::CalculateTextSectionHash(Ctx->ProtectedProcess, Ctx->TextSectionBase,
Ctx->TextSectionSize, &Ctx->InitialHash);
Status = PsCreateSystemThread(&Ctx->ThreadHandle, THREAD_ALL_ACCESS, NULL, NULL, NULL,
Internal::CheckerThreadRoutine, Ctx);
if (!NT_SUCCESS(Status))
{
ExFreePool(Ctx);
return Status;
}
return STATUS_SUCCESS;
}
The flow itself:
VOID RuntimeManagement::Internal::CheckerThreadRoutine(PVOID Context)
{
RuntimeContext* Ctx = (RuntimeContext*) Context;
while (!Ctx->StopThread)
{
if (PsGetProcessExitStatus(Ctx->ProtectedProcess) != STATUS_PENDING)
{
Ctx->StopThread = TRUE;
break;
}
}
LARGE_INTEGER Interval;
Interval.QuadPart = -(LONGLONG) GetRandomInterval() * 10000LL;
KeDelayExecutionThread(KernelMode, FALSE, &Interval);
PsTerminateSystemThread(STATUS_SUCCESS);
}
In addition to this, in the driver I used a spoof of the protected process ID and PPL.
When you spoof the ID process in this way:
VOID ChangeProcessId(PEPROCESS Process)
{
HANDLE* UniqueProcessId = (HANDLE*)((UINT64)Process +
ProcessManagement::GetUniqueProcessId());
*UniqueProcessId = (HANDLE)1337;
}
You have a corrupted CLIENT_ID structure, and because of this, usermod tools such as System Informer cannot provide information about your process, the result will be: 1.



But I wouldn’t recommend playing with it, especially if you’re going to spoof an existing process ID (for example, SVHOST), otherwise you’ll get a BSOD at a random moment.
With PPL the situation is more stable, but if you suddenly don’t know what it is, then I’ll explain briefly - this technology is used to protect system processes, such as lsass.exe, svchost.exe, etc., depending on the level of protection it does not allow usermod processes to interact with the virtual memory of the process in any way.
union PsProtection
{
UCHAR Level;
struct
{
int Type : 3;
int Audit : 1;
int Signer : 4;
} Flags;
};
enum PsProtectedSigner
{
PsProtectedSignerNone = 0,
PsProtectedSignerAuthenticode,
PsProtectedSignerCodeGen,
PsProtectedSignerAntimalware,
PsProtectedSignerLsa,
PsProtectedSignerWindows,
PsProtectedSignerWinTcb,
PsProtectedSignerWinSystem,
PsProtectedSignerApp,
PsProtectedSignerMax
};
enum PsProtectedType
{
PsProtectedTypeNone = 0,
PsProtectedTypeProtectedLight,
PsProtectedTypeProtected,
PsProtectedTypeMax
};
These structures are used to define and manage protection levels of processes. They allow the system to distinguish between processes with different levels of trust.
PsProtectedSigner Enumeration: This enumeration allows defining different types of signers for protected processes:
And the PsProtectedType enumeration defines the process protection levels:
To install PPL to your process from the driver, you need to set flags in Signer and Type, in my case I used PsProtectedSignerWinSystem and PsProtectedTypeProtectedLight, and then patched Protection in EPROCESS.
VOID ProtectProcess(PEPROCESS Process)
{
UINT8* CurrentProtection = (UINT8*)((UINT64)Process +
ProcessManagement::GetProtection());
PS_PROTECTION Protection;
Protection.Flags.Signer = PsProtectedSignerWinSystem;
Protection.Flags.Type = PsProtectedTypeProtectedLight;
*CurrentProtection = Protection.Level;
}
Now our process has a similar level of protection as some Windows services or certain system processes that perform important but non-critical functions.
That’s all for me, unfortunately, I don’t have any time left for this project, but I was able to implement everything I wanted, trying to strongly tie the usermod to the driver for full-fledged work.
Health and happiness to all :)