A novel Double Hijacking technique

Introduction

Double Hijacking combines a user-mode call redirect with a hijack of a kernel service table. The first hijack changes how an application reaches a native call. The second changes the table entry selected when the thread executes a fixed syscall stub.

The idea came from my previous work hijacking the IDT, GDT, SSDT, and Shadow SSDT tables in VTL0. It applies precise control of the service tables to an evasion problem: can the syscall number executed by a thread differ from the kernel service that runs, while the application still receives the result it requested? 

Techniques such as SysWhispers, Hell’s Gate, RecycledGate, and direct syscalls operate on the user-mode side of the transition. They change how a service number is obtained or how execution reaches a syscall instruction. Double Hijacking also changes the kernel table entry selected by that number. In the NtReadFile example, the thread executes the NtSetQuotaInformationFile service number while the modified SSDT entry dispatches to nt!NtReadFile. But let's not get ahead of ourselves, lets begin from the syscalls themselves.

How a Windows system call reaches the kernel

A Windows application normally starts with a user-mode API. An API such as ReadFile may eventually call a native function such as NtReadFile in ntdll.dll. NtReadFile is a the entry point for requesting a kernel service.

On x64 Windows, a typical native stub moves the first argument from RCX to R10, puts a service number in EAX, and executes the syscall instruction. The first four pointer or integer arguments arrive in registers; later arguments are on the caller’s stack. The stub performs the transition into kernel mode. It does not contain the file-reading implementation.

Once the processor enters the kernel’s syscall path, Windows uses the service number to select an entry in a system service table. That entry identifies the kernel routine to run and contains information the dispatch path needs for the call. The result eventually returns to the instruction after syscall, then back to the application.

The SSDT (System Service Descriptor Table) contains entries for native services associated with the NT kernel. A separate table commonly called the Shadow SSDT is used for GUI-related services reached through win32u.dll. These tables matter here because changing a service-table entry changes the routine selected for a particular syscall number. The NtReadFile example below uses the native SSDT; the Shadow path has additional session-specific handling.

First hijack: Intercepting the user-mode call

Before the application’s main thread resumes, the injected user-mode component replaces selected Import Address Table (IAT) entries with addresses of IAT hook stubs. The Windows loader normally fills each IAT entry with the address of an imported function. Changing that entry redirects calls without modifying the application’s call instructions.

For a ReadFile call, the patched IAT entry leads to an IAT hook stub and then to a copied, relocated ReadFile wrapper. The wrapper prepares the NtReadFile arguments and handles the Win32 result as the original wrapper would. Its native NtReadFile call leads to an NtReadFile native thunk.

The native thunk preserves the NtReadFile arguments while the NtSetQuotaInformationFile SSDT slot is prepared. It then calls the unmodified ntdll!NtSetQuotaInformationFile stub with the NtReadFile argument layout. The stub issues SYSCALL using the NtSetQuotaInformationFile service number. The temporary SSDT entry causes the kernel to execute nt!NtReadFile.

The user-mode component also handles supported native imports, dynamically resolved functions, and recognized direct syscall sites through their respective interception points. The shared channel that coordinates the temporary SSDT change is described next.

Shared memory between the intercepted thread and the controller

The injected DLL and the controller process map the same shared memory section. The section holds one request and response record, and a request event wakes the controller, then a response event wakes the calling thread. The calling thread holds a mutex from acquisition through release so another thread cannot change the carrier slot during its syscall.

For the NtReadFile example, the native thunk writes an acquisition request containing the caller’s process and thread IDs, the native SSDT selection, and the NtReadFile service number. The NtReadFile arguments remain in the calling thread’s registers and stack, the controller saves the original NtSetQuotaInformationFile SSDT entry, copies the complete encoded NtReadFile entry into that slot, and replies with a generation value identifying this call.

The calling thread waits for that reply before invoking the NtSetQuotaInformationFile stub. After the syscall returns, it sends a release request with the same generation value. The controller checks the caller and generation value, restores the saved SSDT entry, verifies the restoration, and replies. The calling thread then releases the mutex.

The shared memory section carries these requests between the two user-mode processes. The kernel dispatches the syscall using the SSDT entry present when the target thread executes SYSCALL.

Second hijack: Intercepting the service-table entry

The controller has read and write access to VTL0 kernel memory. During initialization, it records the original SSDT entry for NtSetQuotaInformationFile.

For an NtReadFile call, the controller reads the complete encoded entry from SSDT[NtReadFile] and writes it to SSDT[NtSetQuotaInformationFile]. The carrier stub still supplies the NtSetQuotaInformationFile service number. The kernel dispatcher uses that number to select the modified slot and executes nt!NtReadFile. The complete entry is copied because its low bits contain dispatch information as well as the encoded function offset.

When the call returns, the controller restores the recorded NtSetQuotaInformationFile entry and verifies the restored value.

Shadow SSDT calls with stack arguments

The Shadow SSDT carrier is win32u!NtUserDrawAnimatedRects. On x64, the first four arguments are passed in registers; further arguments are passed on the stack. A Shadow SSDT entry contains a count for those additional stack arguments.

A target win32k service that needs no additional stack arguments can be selected directly through the carrier entry. For a target that does need them, the controller uses nt!VslpDispatchIumSyscall. This kernel routine accepts a target address and an argument block, then invokes the target with its stack arguments in place.

For that call, the controller:

  1. Copies the target’s arguments into session memory.
  2. Changes the Shadow carrier entry to select nt!VslpDispatchIumSyscall.
  3. Supplies the carrier call with the target address, the session-memory address, and the number of additional stack arguments.

VslpDispatchIumSyscall reads the argument block and calls the requested win32k service. After the call returns, the controller restores the original Shadow carrier entry.

Following an NtReadFile call

The copied ReadFile wrapper calls the NtReadFile native thunk with nine arguments: four in registers and five on the stack. NtReadFile’s signature differs from the declared signature of the selected carrier, NtSetQuotaInformationFile.

The thunk preserves the NtReadFile register arguments and stack layout while it requests the SSDT change. The controller copies the complete encoded NtReadFile entry into the SSDT slot selected by the NtSetQuotaInformationFile service number.

After the controller confirms the write, the thunk calls the unmodified ntdll!NtSetQuotaInformationFile stub with the NtReadFile argument layout. The stub moves RCX to R10, loads its own service number into EAX, and executes SYSCALL. It does not convert the arguments to the NtSetQuotaInformationFile signature.

The kernel dispatcher indexes the native SSDT using the carrier service number. That slot now contains the encoded NtReadFile entry, so dispatch reaches nt!NtReadFile with the preserved argument layout. The carrier’s kernel implementation is not executed.

The native result returns through the carrier stub to the thunk. The thunk requests restoration of the carrier entry, then returns the result to the copied ReadFile wrapper. The wrapper performs its Win32 result handling and returns to the application. ReadFile returns a BOOL, with error details available through GetLastError.

What the double hijack can achieve:

The first hijack changes the application’s ReadFile IAT entry. The call enters the IAT hook stub, runs the copied ReadFile wrapper, and reaches the NtReadFile native thunk. The second hijack changes the SSDT entry selected by the carrier’s service number for the duration of that call.

Observation point Value during the call
API called by the application ReadFile
Native service called by the copied wrapper NtReadFile
Syscall stub executed by the thread ntdll!NtSetQuotaInformationFile
Service number loaded into EAX NtSetQuotaInformationFile number
Entry in the selected SSDT slot Encoded NtReadFile entry
Kernel routine reached nt!NtReadFile

A trace of the service number records NtSetQuotaInformationFile. Dispatch through the temporarily changed SSDT slot reaches nt!NtReadFile. The two hijacks coordinate those two observations around the same call.

A live demostration of the Double Hijacking technique:

ConsoleApplication.exe is a small aplication to test the API behavior and the Double Hijacking, its complete source is:

 #include <windows.h>

#include <iostream>

#define PAGELIMIT 4 // Number of pages to reserve

int main() {
    SYSTEM_INFO sysInfo;
    GetSystemInfo(&sysInfo);
    DWORD pageSize = sysInfo.dwPageSize;

    // Reserve a block of pages (no physical memory yet)
    LPVOID baseAddr = VirtualAlloc(
        NULL,
        PAGELIMIT * pageSize,
        MEM_RESERVE,
        PAGE_NOACCESS
    );

    if (!baseAddr) {
        std::cerr << "Reserve failed. Error: "
                  << GetLastError() << "\n";
        return 1;
    }

    std::cout << "Reserved "
              << PAGELIMIT
              << " pages.\n";

    // Commit the first page
    LPVOID committed = VirtualAlloc(
        baseAddr,
        pageSize,
        MEM_COMMIT,
        PAGE_READWRITE
    );

    if (!committed) {
        std::cerr << "Commit failed. Error: "
                  << GetLastError() << "\n";

        VirtualFree(baseAddr, 0, MEM_RELEASE);
        return 1;
    }

    std::cout << "Committed first page at address: "
              << committed << "\n";

    // Use the committed memory
    char* data = static_cast<char*>(committed);

    strcpy_s(
        data,
        pageSize,
        "Hello from VirtualAlloc!"
    );

    std::cout << data << "\n";

    // Release all reserved and committed pages
    VirtualFree(baseAddr, 0, MEM_RELEASE);
}


The program performs two distinct calls to VirtualAlloc:
1. It reserves four pages with MEM_RESERVE | PAGE_NOACCESS.
2. It commits the first page with MEM_COMMIT | PAGE_READWRITE.

It then writes a string into the committed page, prints that string and releases the allocation with VirtualFree.

We will trace the execution with the application APISpy:The first screenshot shows the regular behavior via VirtualAlloc routing


In this screenshot, APISpy launches ConsoleApplication1.exe directly:
APISpy.exe --quiet --filter VirtualAlloc --no-duplicates -- ConsoleApplication1.exe

And then we use APISpy to confirms that the executable declares the expected import:
DECLARED IMPORTS (PE metadata):
    kernel32.dll!VirtualAlloc
And last, during runtime APISpy show use the live destination:
LIVE_CALL kernel32.dll!VirtualAlloc -> kernel32.dll!VirtualAlloc
The left side identifies the API imported by the application. The right side identifies the address that receives control at runtime. In this first execution, both sides resolve to kernel32.dll!VirtualAlloc and until here the application follows the normal module Windows API route. 


The DOG syscall host, the one that will do the kernel-side hijack,  is visible in the other terminal, but it reports:
[Waiting] Ready for userland application.. as it has received no input from user-land.

The following screenshot, shows the actual double hijacking:

For the second screenshot, we will use again APISpy to trace the API behavior, as follows:
APISpy.exe --trace-child --quiet  --filter VirtualAlloc  --redirected-only  --no-duplicates  -- DogHookLauncher.exe ConsoleApplication1.exe, and as you could imagine without APISpy in the middle we would run it like this:
DogHookLauncher.exe ConsoleApplication1.exe or DogHookLauncher.exe Anyapp.exe

What we can now see from APISpy after we execute the DoubleHijack: The two VirtualAlloc call sites were instrumented before either was executed.
The critical part as marked in red is:
LIVE_CALL kernel32.dll!VirtualAlloc  -> private executable memory (0x...)
VirtualAlloc still appears on the left because that is the API declared by ConsoleApplication1.exe and the semantic identity of the instrumented call site, the application has not been modified, but it will not reach the final destination, as the first hijack (in userland) takes over.

User-land hijack:
The destination on the right is what changed, instead of resolving inside kernel32.dll, it enters a private executable allocation containing the injected dispatcher, coming from an inject .DLL (DogHook.DLL) This is the first hijack: a normal imported API call has been redirected away from its original module-backed implementation.

Kernel-side hijack:
At the same time, the DOG host records native lease operations such as:
operation=AcquireNativeLease
followed by entries resembling:
lease acquired
table=ssdt
native=NtProtectVirtualMemory
target_syscall=0x50
carrier_syscall=0x1b9
args=preserved
Other visible target services include:
NtMapViewOfSection
NtUnmapViewOfSection
NtSetEvent

The repeated field: carrier_syscall=0x1b9, marked in red, shows that different native operations are transported through the same carrier syscall, for reference, we hijack all syscall to the carrier.

Carrier:

0x1b9 = NtSetQuotaInformationFile

And the most imporant syscalls on this example are:
VirtualAlloc:
0x18  = NtAllocateVirtualMemory

VirtualFree:
0x1e  = NtFreeVirtualMemory



During each lease, the carrier’s dispatch target is temporarily associated with the requested native service while preserving the original arguments. The corresponding release records show:
operation=ReleaseNativeLease restoration=verified
The host also detects terminated owners and verifies restoration:
dead owner ...
restore=verified

This is done to avoid corrupting the table data. And these messages show that the dispatch modification is leased, meaning temporal, rather than permanent. DOG restores the carrier after every operation and also performs recovery if a client thread exits while owning a lease, application crash, user forces kill.

Conclusion

Double Hijacking combines a user-mode call redirect with temporary service-table entry substitution. In the ReadFile example, the intercepted call reaches an NtReadFile native thunk, which invokes the NtSetQuotaInformationFile syscall stub after the controller has changed its service-table entry.

This architecture presents a cross-layer attribution challenge for antivirus and EDR products. The API observed in user mode, the syscall number executed by the processor and the service selected by the kernel can describe three different operations. In the VirtualAlloc demonstration, APISpy identifies the original kernel32.dll!VirtualAlloc call site, its live destination points to private executable memory, and the resulting NtAllocateVirtualMemory operation is transported through syscall 0x1b9, normally assigned to NtSetQuotaInformationFile.

Monitoring based on conventional API hooks observes the first layer. Syscall telemetry records the carrier identity. Kernel dispatch determines the final native operation. Accurate attribution therefore requires correlation across all three layers rather than assigning permanent semantics to an API name or syscall number.
This also defines concrete detection opportunities for EDR systems: imported APIs resolving outside their expected modules, execution from private memory, low-frequency syscalls carrying unexpected argument layouts, rapid service-table substitutions, kernel writes associated with lease acquisition, and repeated restoration of the same carrier entry. Antivirus products can additionally identify the binaries, drivers and static artifacts supporting the mechanism.

Double Hijacking demonstrates how temporary data-driven routing can separate an application’s apparent behavior from the native service ultimately executed by the kernel. For endpoint security, the central implication is clear: syscall identity alone is no longer sufficient telemetry. Detection must follow the complete path from the original API call site to the live user-mode destination, carrier syscall and final kernel dispatch target.


Back to blog