Global Descriptor Table Hijacking in Windows 11
Share
Interested in Windows Kernel? Check out our Technical Training Courses
We have demonstrated how DOG, our Data Only Gadgets tooling inside EP3, can use existing kernel read/write primitives to redirect kernel execution through tables such as the SSDT, Shadow SSDT, and IDT.
The next logical target was: the Global Descriptor Table, or GDT.
This post walks through the GDT call-gate payload path implemented in DOG. The important detail is that this is not a classic "patch kernel code and jump" approach. The implementation is built around transient data-only state: clone the active GDT page, install a user-callable IA-32e call-gate descriptor in that clone, expose the clone to a prepared WOW64 helper process, route execution through an existing WOW64 transition path, and restore the temporary mapping state immediately after the call.
Reference and interesting articles used to learn and research over GDT under VBS/HVCI:
GDT and LDT in Windows kernel vulnerability exploitation
A Way Around UMIP and Descriptor-Table Exiting via TSX-based Side-Channel
Ready? Ok, Let's go :-)
What is the GDT?
The Global Descriptor Table (GDT) is a core part of Intel's x86 architecture that helps manage how memory is accessed and protected. Introduced with the Intel 80286 processor, it plays a key role in defining memory segments and their attributes: the base address, the size, and access privileges like executability and writability. In long mode, segmentation is mostly flattened, so it is easy to forget that the GDT is still there. But the CPU still consults it for important descriptor types:
- code descriptors
- data descriptors
- TSS descriptors
- LDT descriptors
- call-gate descriptors
The GDT helps isolate memory between application software and the operating system.
In protected mode (used in most modern devices of course), the GDT defines, as listed above:
Code segments: regions of memory that contain executable instructions.
Data segments: areas used to store program data.
System segments, like the Task State Segment (TSS), which is used for multitasking support.
Each entry in the GDT is 8 or 16 bytes long and holds a segment descriptor that defines the properties of one segment. Each descriptor includes access rights, ensuring programs cannot modify protected memory.
![]()
Format of a segment descriptor
To use a segment, a program refers to it using a segment selector, a special value that tells the processor which GDT entry to use. The processor then loads this descriptor into a segment register, which holds both visible and hidden metadata about the segment.
The GDT is referenced by GDTR, a CPU register containing a base address and a limit. On Windows, each processor has its own relevant descriptor-table state, so the GDT path must be CPU-aware. In DOG, the code pins the current thread to the selected CPU before inspecting the live GDTR state. That avoids preparing a table on one processor and triggering on another one.
The GDT structures are represented explicitly:
struct Gdtr64
{
uint16_t limit;
uint64_t base;
};
struct SegmentDescriptor8
{
uint16_t limit_low;
uint16_t base_low;
uint8_t base_mid;
uint8_t access;
uint8_t flags_limit_high;
uint8_t base_high;
};
struct Ia32eCallGateDescriptor16
{
uint16_t offset_low;
uint16_t selector;
uint8_t ist;
uint8_t access;
uint16_t offset_mid;
uint32_t offset_high;
uint32_t reserved;
};
The call-gate descriptor is 16 bytes in IA-32e mode, which means DOG needs a two-slot window in the GDT. The current staging path uses selector 0x0058, and the clone limit is expanded to 0x007f so the call-gate selector is covered by GDTR.
Why GDT hijacking?
VBS/HVCI/kCET changed the way Windows kernel exploitation has to be approached.
HVCI raises the cost of arbitrary kernel code execution by enforcing code integrity through the hypervisor-backed security model. kCET makes classic return-slot games much less attractive because shadow stacks break common ROP-style assumptions. Hypervisor-protected text and related memory protections also make "just patch this code page" a bad plan on modern builds.
The question becomes: what can still be done when you already have a reliable kernel read/write primitive, but you do not want to inject unsigned kernel code, patch executable text, or rely on a return-address rewrite?
That is where data-only table attacks are interesting. The SSDT path redirects an existing syscall table entry. The Shadow SSDT path does the same in the GUI syscall world. The IDT path works through interrupt dispatch. The GDT call-gate path explores another architectural dispatch surface: a descriptor that the CPU itself understands.
The goal is to make the processor perform the transition using valid descriptor machinery, while DOG only mutates transient data and restores it when finished.
Under VBS/HVCI the mechanism differs:
Live GDT = the GDT currently loaded in the processor's GDTR for execution
Inventory GDT = the GDT state VBS/HVCI knows about, tracks, or validates as part of the virtualized CPU/security state
The CPU still performs selector lookup against the live GDTR. If a far call, interrupt return, task-state lookup, or call-gate transfer happens, the processor uses the GDTR base/limit currently active for that logical processor.
But with VBS/HVCI enabled, the machine is not bare metal Windows kernel state anymore. The hypervisor/secure-kernel world keeps its own view of sensitive processor state. That includes descriptor-table state.
So from an exploitation perspective, changing what normal VTL0 code sees is not always enough. You can create a GDT-looking page, or even make one mapping point somewhere else, but VBS/HVCI may still have an inventory/saved view of what the GDT should be.
And this matters because a GDT hijack has to avoid creating an obvious disagreement between the two. If the live path points to a clone, but the VBS/HVCI inventory still describes the original table, some transitions may fail, fault, or get rejected depending on what state is being checked.
The high-level workflow of the hijack:
From a high level perspective this is the current workflow for this technique
- Pin to the target CPU, read the live/saved GDTR state, and inventory the table layout.
- Resolve a call-gate (redacted as we can't disclose this)
- Use the FWA (Free Writable Area) clone backing as the cloned GDT page, place the call-gate descriptor in that clone, and materialize the needed helper DTB mappings.
- Resolve a native service slot carrier and keep its original entry for restoration.
- For each
ABI Call, we route the requested kernel target through the "redacted" call-gate carrier, capture RAX/exception state, and restore the transient call-gate state. - Execute
DbgPrintthroughour ABI Call gatwayfirst, then execute the token payload (PsGetCurrentProcess,PsReferencePrimaryToken,MemCpy)
The details about this technique are what make it fun, let's continue.
Reading and validating the live GDT
The first phase is inventory. DOG pins execution to a single CPU with SetThreadAffinityMask, reads GDTR with SGDT, and checks that the base is a canonical kernel pointer. It also verifies that the GDTR limit describes a sane 8-byte slot layout and that the table does not span an unexpected number of pages.
You can see it in the log (full screenshot below the article):
[gdt:table] target cpu=...
[gdt:table] live gdtr cpu=... base=... limit=... pa=... source=... pages=... authoritative=1
[gdt:inventory] effective inventory table cpu=... gdtr_base=... limit=... pa=... source=...
The code then decodes visible selectors and table descriptors. It looks for a free two-slot window suitable for a 16-byte call-gate descriptor and for a present DPL0 code selector. If the live table is not readable from the current tool context, the remap stage can still continue by materializing the required view in the trigger process DTB.
The important part is that DOG treats the live GDT as authoritative. The processor state is checked again before staging so the code does not build a payload from stale descriptor-table information.
WOW64 and the trigger.
On Microsoft platforms, WoW64 (Windows 32-bit on Windows 64-bit) is a subsystem of the Windows operating system capable of running 32-bit applications on 64-bit Windows
The call-gate trigger path uses a WOW64 side-app. This app runs as x86/WOW64. It captures segment state, probes GDTR with SGDT, validates selectors with LAR and LSL, reads descriptor bytes, decodes the call-gate descriptor, decodes the target code descriptor, and then invokes a selected native syscall carrier stub.
This application (it's a separate app) produces the most needed diagnostics as explained before:
[gdt32] helper: gdtr probe ok=... base=... limit=...
[gdt32] helper: selector probe role=gate-call selector=...
[gdt32] helper: descriptor read selector=... bytes=...
[gdt32] helper: callgate decode selector=... target_selector=... target=... type=... dpl=... present=...
[gdt32] helper: wow64 context ntdll=... wow64=... wow64cpu=... wow64base=... Wow64Transition=...
Building the cloned GDT
DOG does not permanently overwrite the live GDT, as this would potentially trigger a bugcheck. Instead, this GDT hijack uses an external FWA (Free Writable Area) physical page as clone backing. The code reads the original GDT page, seeds the clone page, and then uses the call-gate descriptor into the clone.
The current stagings are:
constexpr uint16_t kCloneLimit = XXXX (redacted);
constexpr uint16_t kCloneGateSelector = XXXX (redacted);
constexpr uint8_t kGateDpl = XXXX (redacted);
The DPL3 part matters. A user-mode WOW64 caller must be allowed to reference the gate, so the call gate is intentionally user-callable. The gate target is resolved relative to the helper process.
Remapping
The clone has to be visible from the context that will trigger the call gate. As mentioned, DOG prepares a WOW64 process and resolves its DTB/user-DTB. Then it materializes the needed mappings into that trigger process:
- the cloned GDT page
- a compatibility alias when the 32-bit helper observes a truncated GDTR base
- the call-gate target page if it is not already mapped
- the TSS page required for privilege stack-switch validation
- optional payload aliases for ABI calls that need helper-visible buffers
The PTE manipulation has to be transient. DOG saves the original mapping entry, writes a patched PTE pointing at the cloned GDT page, verifies the readback, uses it for the trigger, and restores the original PTE afterward.
This is the core difference between a GDT hijack and a persistent hook. The cloned GDT is used only long enough to service the intended call. Then the mapping state is restored and verified.
The native carrier
The GDT path still needs a user-mode way to reach the transition machinery. For that, DOG resolves a native syscall carrier. The helper defaults to NtSetQuotaInformationFile, but the carrier resolution is able to evaluate other candidates and save the original service entry for restoration.
The transient call state is then patched so that when the helper invokes the carrier stub, the path routes through the WOW64 call-gate and reaches the requested kernel ABI target.
The helper prints the carrier bytes, the WOW64 modules, and the Wow64Transition slot: That gives us a clean debugging trail from user mode into the descriptor-driven transition path.
Executing ABI calls through the gate
This is the trampoline to arbitrary function execution:
- the target kernel routine name
- the target VA
- a vector of ABI arguments
- an optional tail buffer
- an output RAX value
Basically here, we run the whole workflow, with the function we want to use. Starting from the pinned GDT attempt, the prepared clone/remap pages, execution and the trigger of the thunk, capture of the result, and finally clean up.
The first payload is simple: DbgPrint. That gives an easy proof that the call path works before moving to anything more sensitive: After that, DOG runs the token-swap payload.
Last, cleanup and restoration
The restoration logic is as important as the trigger. After the ABI call, DOG restores:
- the GDTR limit if it had to be patched
- the cloned GDT PTE mapping
- the compatibility alias PTE
- TSS and target-code materialized pages
- payload alias mappings
- the helper process state
- the external clone page, if configured for clearing
The final stage result makes the success criteria explicit:
Key properties of this GDT Hijack technique:
Data-only staging: this hijack works by mutating descriptor-table and mapping data. It does not write or execute unsigned kernel code.
Transient state: the cloned GDT is used only for the trigger and then restored.
CPU: the code pins to the target CPU and checks GDTR stability.
WOW64-driven trigger: Used to reach the call-gate path through existing WOW64 transitions.
Restoration-: original mappings and carrier state are saved, restored, and verified.
The proof is in the pudding:


Takeaway
The GDT path is a good reminder that modern kernel exploitation is no longer only about code injection. VBS/HVCI/kCET raise the bar exactly where they should: executable memory, unsigned code, return-slot rewriting, and careless control-flow corruption.
But operating systems still have architectural data structures that decide where execution is allowed to go. If an attacker already owns a powerful read/write primitive, descriptor tables become interesting again.
In this implementation, DOG uses that idea in a controlled way: clone the GDT, use a call gate, use the clone page, route one ABI call, and restore the machine state.
What this means for VBS/HVCI? VBS and HVCI are very strong against kernel code modification, but critical kernel data structure integrity still matters. Descriptor tables are data, and data-only attacks remain a serious part of the Windows kernel exploitation landscape.