IDT Table Hijacking under VBS/HVCI/kCET in Windows 11
Share
So far we have demostrated how DOG tool (Data Only Gadgets) capabilities from our EP3 platform can be used to hijack Kernel tables like SSDT and Shadow SSDT. Our next target was the IDT Table: The Interrupt Descriptor Table (IDT) is a processor data structure that tells the CPU what code to execute when various types of interrupts occur. It's the central dispatch mechanism for all interrupt handling in x86/x64 systems, serving as the bridge between hardware events and the software that handles them.
The IDT at it's core is an array of descriptors, each descriptor being 8/16 bytes in size (32 or 64). This entries have the following form:
Offset: Size: Field:
0x00 2 bytes Offset bits 0-15 (low word of handler address)
0x02 2 bytes Segment Selector (code segment in GDT/LDT)
0x04 1 byte Interrupt Stack Table (3 bits used, 5 reserved)
0x05 1 byte Gate Type & Attributes (present, DPL, gate type)
0x06 2 bytes Offset bits 16-31 (middle word)
0x08 4 bytes Offset bits 32-63 (high dword)
0x0C 4 bytes Reserved (must be zero)
The IDT entries are called gates. It can contain Interrupt Gates, Task Gates and Trap Gates. Each IDT entry defines the target handler for a specific vector, together with the gate metadata required to transfer execution into kernel mode.
Because the IDT is local to each processor, the first issue was to bind execution to the target CPU and resolve the IDT mapping for that processor, including the IDT base address, the physical page backing the table, and the page-table entry controlling the mapping.
Multiple processors were handled by treating the IDT as a per-CPU structure rather than a single global table. There are several solutions to this issue, but my current implementation adjusts the executing thread to a selected processor before resolving or modifying any IDT-related state. Once setup for that processor, it resolves the effective IDT mapping for, including the processor’s IDTR-derived base, the physical IDT page, and the page-table entry that controls the mapping.
For the token-swap payload, the active proc is bound to CPU 0. The thread is adjusted to CPU 0, the IDT mapping for CPU 0 is cloned and also remapped, the int3 redirection to INT2E path is triggered, and the token payload runs only once.
This avoids duplicate token swaps and duplicate shell launches while still respecting the fact that the IDT is processor-local.
Limitation inherent to hooking an interrupt like 0x2e is that almost nobody is using it anymore, current hardware uses SYSENTER in conjuntion with MSRs to jump through the system call gate, in the old days it was fairly simple to see if anmyone has hooked the IDT, the IDT descriptor for the 0x2E KiSystemService() that resides of course in ntoskrnl.exe. If the offset address is in the descriptorfor and INT 0x2E is a value that resides outside of the range of ntoskrnl.exe then it would be obvious that something has been modified.
What is new in this research besides using data only modification and remapping you might ask? To alter the IDT. This technique operates by cloning the live IDT page backed by an FWA (Free Writable Area) page. In this context, FWA refers to a physical page that is outside the normal operating system managed RAM ranges, but is still readable and writable through the physical memory primitive.
FWA pages are physical pages found outside the normal managed RAM ranges reported by the operating system, but which are still accessible through the physical memory primitive. The FWA pages are the gaps between allocations leave unused by the operating system. In this project they are used as external backing storage for DOG operations: scanning these gaps around managed physical memory, filters out unsafe regions such as MMIO, then verify candidate zero pages by writing a temporary pattern, just to test them, reading it back, and restoring the original contents.
Once verified, an FWA page can be used as a controlled physical workspace. For the IDT technique, the original IDT page is copied into a verified FWA page, the clone is modified there, and the processor’s IDT mapping is transiently redirected to that FWA-backed clone during the trigger window.
Using an FWA page as the clone backing keeps the technique aligned with a data-only model. The original IDT page is not modified. Instead, the original IDT contents are copied into the verified FWA page, the selected gate is modified inside that clone, and the processor’s IDT page-table entry is transiently changed to resolve the IDT mapping to the FWA-backed clone.
From the CPU’s point of view, the IDTR base remains the same virtual address; only the physical page reached by the page-table translation changes during the trigger window.
This gives the IDT manipulation three useful properties:
First, the live IDT page remains intact.
Second, the modified table exists in a physical page selected and controlled by the data-only primitive rather than in ordinary process allocation.
Third, restoration is much simpler: the IDT page-table entry is restored to the original physical page, and the FWA clone page can be cleared after use.
The resulting flow becomes:
FWA page -> cloned IDT -> #BP -> INT2E dispatch bridge -> native service slot -> x64 ABI target -> token payload -> restore
Token Payload calls: PsGetCurrentProcess, PsReferencePrimaryToken, memcpy, ObDereferenceObject, DbgPrint, and DbgPrintEx
What is INT2E? is the legacy way of performing user to kernel mode transitions and is supported by all x86 CPUs existing today. The call to INT2E results in the interrupt service routine registered in the interrupt descriptor table (IDT) for vector 0x2e nt!KiSystemService being invoked. This is similar to SSDT concepts.
On x64 Windows there are two contracts involved, and they should not be mixed. The first one is the interrupt/syscall dispatch contract, which identifies a native service by service number and transfers execution into the kernel service dispatcher. The second one is the normal Windows x64 function ABI used once a kernel routine is actually invoked.
The Windows x64 ABI passes the first four integer or pointer arguments in RCX, RDX, R8, and R9. The caller also reserves 32 bytes of shadow space on the stack, and additional arguments are passed on the stack. This is the ABI used by normal kernel routines.
Historically, 32-bit Windows used INT 0x2E as a native system-call entry mechanism, with EAX holding the service number and EDX pointing to the caller’s argument stack. That is the x86 contract. On x64 Windows, the normal native system-call path uses SYSCALL rather than INT 0x2E, but the IDT still provides an interrupt-dispatch surface. In this technique, INT2E is not used as a 32-bit ABI. It is used as an existing kernel dispatch bridge reached through the cloned IDT path.
In this technique a preselected native service is used and we temporarily redirect the corresponding service-table entry to the intended kernel routine. The NT function used is NtSetQuotaInformationFile. The service itself is not important; it provides a stable service-table slot that can be redirected during the trigger window. The actual target routine is then invoked according to the Windows x64 calling convention, and the service-table entry is restored immediately after the call.
The token-swap payload uses this mechanism to call the kernel routines: PsGetCurrentProcess, PsReferencePrimaryToken, memcpy, and ObDereferenceObject
Also as a proof of concept DbgPrint and DbgPrintEx are called, the print can be seen in KD, in the following screenshot we are attached to the target kernel using EP3 Tool:

This technique shows that the IDT can be used as a data-only redirection surface. The IDT gate is modified in a cloned table, the live mapping is switched only for the trigger window, and the actual call is completed through existing kernel dispatch machinery.
After execution, both the native service entry and the original IDT mapping are restored.
And last, but not least the mandatory screenshot with the token swap as a proof of kernel code execution under VBS/HVCI/kCET enabled environment with Windows 11 (latest build):