Shadow SSDT Hijacking: Achieving Kernel Code Execution via Read-Write Primitives

On this blog I'll walk you through Shadow SSDT hijacking to achieve kernel code execution under VBS/HVCI/kCET enabled environments in Windows 11 (latest build). I'll first cover the fundamentals of userland access and debug our way into kernel mode using WinDBG and Ret-Sync. Understanding the fundamentals will help you follow the logic of the Shadow SSDT hijack during the deep dive into Kernel Code Execution via the Shadow SSDT. Let's begin :-)

What is the shadow SSDT?

The shadow SSDT (System Service Descriptor Table) is the GUI syscall dispatch table used by the Win32 kernel subsystem. User mode reaches it through win32u.dll. On modern Windows, many GUI-related APIs are split across user-mode DLLs and kernel-mode win32k components:

user32.dll and gdi32.dll expose familiar high-level APIs. But the low-level syscall stubs for many GUI services live in win32u.dll. These stubs look similar to ntdll syscall stubs: they load a syscall/service number, move the first argument as required by the syscall ABI, and execute the syscall.

A syscall ABI (Application Binary Interface) defines the strict low-level contract for communication between user-space applications and the kernel, specifically how to pass arguments and system call numbers via registers.

Conceptually, it looks like this:


The regular SSDT and shadow SSDT are similar but serve different families of syscalls:

  • ntdll.dll  -> native SSDT        -> ntoskrnl.exe services
  • win32u.dll -> shadow SSDT / GUI  -> win32k* services

The native SSDT is mostly for core NT services: process, thread, file, registry, memory manager, object manager, etc.

The shadow SSDT is for GUI and graphics subsystem services: window management, desktop objects, user input state, GDI, composition-related paths, and other Win32 subsystem operations.

One important detail: The KeServiceDescriptorTableShadow is undocumented.

Going back to win32u.dll (user-land), it's important to understand that it does not implement the kernel behaviour itself. It is a user-mode syscall gateway. Its exported NtUser* / NtGdi* style functions provide the service numbers that the kernel dispatcher will use to index GUI service tables.

The shadow SSDT in a nutshell:
The Shadow SSDT is an internal Windows kernel dispatch structure that maps GUI-related system-call numbers to their corresponding kernel-mode implementations in the Win32 subsystem. It complements the ordinary SSDT, which services core NT system calls, by providing dispatch entries for windowing, graphics, input, and related user-interface operations. In practical terms, when a user-mode API such as a user32.dll or gdi32.dll function transitions through win32u.dll into kernel mode, the system-call dispatcher uses the appropriate service number to locate the corresponding entry in the Shadow SSDT and transfer control to the relevant win32k* implementation.

You might want to know at this point why it is called “Shadow”?

Historically, Windows had the main service descriptor table for native NT calls and an additional “shadow” descriptor table for Win32 GUI services. The name stuck because it is a parallel service-dispatch mechanism, not because it's a hidden table.

The GUI Calling Context:
Shadow SSDT calls are not normal syscalls with a different table. They enter the Win32 kernel subsystem, and that subsystem keeps state that is tied to the caller’s session, window station, desktop, process, and thread. Although they use the same architectural system-call transition mechanism, they enter the kernel-mode Win32 subsystem, where the requested operation is interpreted in the context of session-specific and thread-specific graphical state. This state includes the caller’s session, window station, desktop, process, thread, and associated GUI objects.

A useful, maybe oversimplified, analogy is against a game engine, or a game loop. Think of the Win32 subsystem as the stateful engine of the Windows graphical environment: GUI operations are meaningful only relative to the current graphical context. However, unlike a game engine with a continuously executing render loop, Windows GUI processing is primarily event-driven and syscall-driven. The Shadow SSDT functions as the dispatch structure that routes GUI service numbers to the appropriate win32k* implementation.

A native syscall, such as an Nt* file or process operation, can usually be understood mostly in terms of handles, objects, and access checks. A shadow syscall often has an extra dependency: win32k must be able to interpret the calling thread as a GUI-capable thread.

For example, an NtUser* service may need to resolve the caller’s desktop, current thread GUI information, input queue, or session-specific win32k state. If the call is made from the wrong context, the syscall number can be correct, and the table entry can be correct, but the service may still fail, return meaningless results, or take a different path than expected.

Let's see it in action, let's dive in:

Once upon a time.. In a galaxy far, far away, someone opened a notepad process.

We are doing this debugging session using full kernel debugging, meaning having two separate Virtual Machines connected to each other's networking (KDNET), and from one VM, the Debugger, we are debugging the Kernel of the Target (the debugee).

Here to start we are, from the debugger machine already connected to that remote kernel,  using the command !process 0 0 notepad.exe to list all the processes that match that filter, there is just one.

Then we are going to set the debugger to that process context using the command: .process /r /p memory_address

Then we are going to dump the routines from user32 and win32u from symbols that match the filter for SetWindowPos, using: x user32!SetWindowPos and x win32u!SetWindowPos notice the * in between, these wildcards can be used to help filter function names/structures/etc.

What does this function do? From MSDN: SetWindowPos() changes the size, position, and Z order of a child, pop-up, or top-level window.

Then we are going to use the x command again (display symbols), to see how the actual tables look like from the debugger perspective: x nt!KeServiceDescriptorTable and x nt!KeServiceDescriptorTableShadow

Then we follow with the command dq to display (display quadwords) to show the content of the tables we are interested in, the shadow SSDT, and the SSDT for comparison: dq nt!KeServiceDescriptorTable L10 and dq nt!KeServiceDescriptorTableShadow L10

To start the journey from user-mode, we are going to set a breakpoint in win32u in the Notepad process. We found the address of that process before, and set the value of the breakpoint to the function we are interested in: NtUserSetWindowPos, using this command: bu /p process win32u!NtUserSetWindowPos bu is more useful than just bp to an address, as it creates a symbolic, deferred breakpoint.

Then we need to continue the execution of the target kernel (debugee) using the command g, go back to that VM and move the notepad window to trigger the breakpoint.

Moving the notepad window will dispatch from user mode the function win32u!NtUserSetWindowPos to set the new location of that window on the screen. That is the action that triggered our breakpoint. In the breakpoint, we have set a context to that process so it's not for any window, but this specific notepad process.

Using ret-sync (https://github.com/bootleg/ret-sync), a powerful tool that let us synchronize WinDBG and Ghidra, we can see the decompilation of NtUserSetWindowPos() function from win32u.dll being hit at the trigger point.

And from the disassembly perspective, this is how it looks from WinDBG side of the story.

Now we get into Syscall ABI territory, as we are at the point of the breakpoint we can see the content of rip continue the execution until we move forward the syscall number and display the content of eax. Why EAX? It holds the win32k service number. But we already knew that, by just reading the disassembly.

notepad.exe
 -> user32!SetWindowPos
 -> win32u!NtUserSetWindowPos
 -> syscall, EAX = win32k service number <- We are here.
 -> nt!KiSystemCall64 / KiSystemService*
 -> shadow SSDT / win32k service table
 -> win32k!NtUserSetWindowPos

Next we will evaluate the syscall values using ? @eax & 0xfff and ? (@eax >> 0xc) & 0xf

In a win32u syscall stub you usually see something like:

mov     r10, rcx
mov     eax, 10xxh
syscall
ret

This means that EAX does not only contain a simple function number. For GUI/syscall-table dispatching, it is commonly treated as:

bits  0–11   = service index inside the selected table
bits 12+ = service table selector
syscall value 0x1021 and Shadow SSDT index 0x21
And our commands give us just that:
service table selector= 1      -> Shadow SSDT / Win32k service table
service index  = 0x21   -> NtUserSetWindowPos entry inside that table

Knowing the descriptor we can display the Shadow SSDT, the command dq nt!KeServiceDescriptorTableShadow+0x20 L4 dumps the second service descriptor inside KeServiceDescriptorTableShadow. On x64, each descriptor is 0x20 bytes, so the +0x20 offset selects descriptor index 1. This descriptor corresponds to the Win32k. The first quadword in the descriptor is the base address of the actual Shadow SSDT entries. 

The Shadow SSDT descriptor is the service-table descriptor for the Win32k system-call table. It is an internal kernel structure that identifies the base address, size, and associated metadata of the Shadow SSDT. The descriptor itself is not the dispatch table; rather, it points to the dispatch table used for GUI-related system calls. When a syscall number selects table index 1, the kernel dispatcher uses the second descriptor in KeServiceDescriptorTableShadow to locate the Win32k service table and then indexes into that table using the syscall’s lower service-number bits.

Long story short..
Descriptor 0 for SSDT and Descriptor 1 for Shadow SSDT.

KeServiceDescriptorTableShadow

├── descriptor 0  -> NT SSDT
│                   core kernel services
│                   ntoskrnl.exe

└── descriptor 1  -> Shadow SSDT
                    GUI / Win32k services
                    win32kbase.sys / win32kfull.sys / win32k.sys

For our syscall 0x1021 from NtUserSetWindowPos

selector = 1
sizeof(descriptor) = 0x20

descriptor_offset = 1 * 0x20
descriptor_offset = 0x20

Give us: dq nt!KeServiceDescriptorTableShadow+0x20 L4

And now let's start the math and display the entry we are looking for, each Shadow SSDT entry is a 32-bit encoded value, so each entry is 4 bytes.

fffff805`728ce000 + 0x21 * 4
= fffff805`728ce084

The command dd poi(nt!KeServiceDescriptorTableShadow+20)+(21h*4) L1 reads the encoded 32-bit Shadow SSDT entry for service index 0x21. The table base is obtained from the Shadow SSDT descriptor, and the index is multiplied by four because each entry is a DWORD. The value f166c783 is not a direct pointer, it's encoded. To decode it, we need to sign-extend it, shifted right by four, and added to the Shadow SSDT base to recover the actual win32k* routine address. 

There is no MSDN that documents this SSDT-entry decoding formula. The SSDT, KeServiceDescriptorTableShadow, and the x64 SSDT entry encoding are internal Windows implementation details.

Credits of this formula (SSDT) to ired.team:
The formula described on their blog for SSDT
TargetFunction = ShadowTableBase + ((LONG)EncodedEntry >> 4)

Link to their blog: https://www.ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/glimpse-into-ssdt-in-windows-x64-kernel

That for us will be: ShadowRoutineAddress = ShadowTableBase + ((LONG)EncodedEntry >> 4)

ShadowTableBase = poi(nt!KeServiceDescriptorTableShadow+20)
                = fffff805`728ce000

Service index   = 0x21

Entry address   = fffff805`728ce000 + 0x21 * 4
                = fffff805`728ce084

Encoded entry   = ff66c783

We have the encoded table entry, now we need to decode it by sign-extending the 32-bit entry, shifting away the low four metadata bits, and adding the resulting signed offset to the Shadow SSDT table base. Since the encoded entry begins with 0xff, unsigned shifting would produce the wrong address using this command: ? poi(nt!KeServiceDescriptorTableShadow+0x20)+@@c++(((long)@@masm(dwo(poi(nt!KeServiceDescriptorTableShadow+0x20)+(0x21*4))))>>4)

The decoding formula looks like this:
ShadowDescriptor = nt!KeServiceDescriptorTableShadow + 0x20
ShadowTableBase = poi(ShadowDescriptor)
EncodedEntry = dwo(ShadowTableBase + ServiceIndex * 4)
TargetFunction = ShadowTableBase + ((LONG)EncodedEntry >> 4)

And the syscall:
EAX = 0x1021
Table selector = 1
Service index  = 0x21

And all together replacing the values:
ShadowDescriptor = nt!KeServiceDescriptorTableShadow + 1 * 0x20
                 = nt!KeServiceDescriptorTableShadow + 0x20
ShadowTableBase = poi(nt!KeServiceDescriptorTableShadow+0x20)
EncodedEntry = dwo(ShadowTableBase + 0x21 * 4)

Now we are going to confirm if all this math was worth doing. The decoded Shadow SSDT entry for service index 0x21 resolved. Using the command lm a <address> we can list the module at an specific address and confirm that the decoded target address is inside the  win32k

As a quick recap:

win32u!NtUserSetWindowPos
    mov eax, 1021h

EAX = 0x1021

selector = 1
index    = 0x21

KeServiceDescriptorTableShadow + 0x20
    -> Shadow SSDT descriptor

poi(nt!KeServiceDescriptorTableShadow+0x20)
    -> Shadow SSDT table base

ShadowTableBase + 0x21 * 4
    -> encoded entry

ShadowTableBase + ((LONG)EncodedEntry >> 4)
    -> fffff805`72834c78

lm a fffff805`72834c78 <- here is where we are now
    -> win32k.sys

As we have the address, and we confirmed it belongs to Win32k, lets also ask WinDBG to show us the nearest symbol, it should show win32k!NtUserSetWindowPos using the command: ln <address> ln means List Nearest Symbols.

Bingo! We can also confirm it by disassembling the address, it should show us the function, using the command u <address>

u  = unassemble / disassemble instructions
address = fffff805`72834c78 <- The address we got by decoding
L30 = How much? Show 0x30 instructions/units from that address


Now, that we have the correct entry, after decoding it we got the address of the function from win32k, let's prepare the debugger to set a breakpoint there, first lets clear all of them.

Now, we set a breakpoint for our notepad process context and for the address that points to the function win32k!NtUserSetWindowPos, go back to the Target VM (debugee) and move the notepad window position to trigger it.

notepad.exe
 -> user32!SetWindowPos
 -> win32u!NtUserSetWindowPos
 -> syscall, EAX = win32k service number 
 -> nt!KiSystemCall64 / KiSystemService*
 -> shadow SSDT / win32k service table
 -> win32k!NtUserSetWindowPos <- We are setting a breakpoint here now

And it works! The breakpoint has been hit and it breaks on the function win32k!NtUserSetWindowPos

And this is how it looks after syncing WinDBG with Ghidra using ret-sync, we can see the decompiled code of Win32k right at the NtUserSetWindowPos function.

Taking advantage of the fact that execution is now stopped at the kernel-side
implementation, we can inspect the arguments passed to NtUserSetWindowPos.

At the entry of win32k!NtUserSetWindowPos, the arguments follow the x64 Windows
calling convention: the first four arguments are passed in registers, and the
remaining arguments are passed on the stack.

NtUserSetWindowPos(
    HWND hWnd,              // rcx
    HWND hWndInsertAfter,   // rdx
    int X,                  // r8
    int Y,                  // r9
    int cx,                 // stack
    int cy,                 // stack
    UINT flags         // stack
)



And last, let's look at the stack trace to wrap it up to confirm the full path. 

Notepad user-mode code
win32u!NtUserSetWindowPos syscall stub
syscall transition
nt!KiSystemServiceCopyEnd
win32k!NtUserSetWindowPos <- This is where we are at the breakpoint.

 

The long awaited shadow SSDT Hijack

Now that we cover the fundamentals we can move on to the shadow SSDT hijack, this can be understood as a temporary redirection of the Win32 kernel syscall dispatch path. Rather than modifying arbitrary kernel code directly, the technique uses one selected GUI syscall entry as a controlled transition point into a chosen kernel routine.

A minimal setup to reproduce this technique:
physical memory read/write <- You need a RW primitive, for this example I'm using physical 
kernel virtual-to-physical translation <- And if you use a physical RW primitive you will need to do address translation, not covered here.
kernel symbol and module resolution <- I'm using my own lib for dynamic resolution (https://github.com/jsacco/NTKernelWalkerLib) but if you hate yourself use hardcoded addresses/offsets

What is the hijack flow?
1. prepare GUI-capable caller <- As we need to reach the win32k
2. resolve shadow service table <- As described in the fundamentals
3. select win32u syscall candidate <- The function we will hijack but from user-land side
4. locate corresponding shadow table entry <- Similar approach as in the fundamentals
5. locate usable win32k dispatch indirection <- The hijack
6. save original dispatch state <- Save it to be restored later
7. patch selected route <- Patch it
8. invoke syscall <- The hijack in action
9. restore original state <- Restore it or bugcheck, your choice.

The chosen win32u syscall acts as the user-mode trigger from userland. Its syscall number identifies an entry in the shadow SSDT. That entry normally routes execution to a legitimate win32k service. During the hijack, the entry is temporarily changed so execution reaches a dispatch sequence whose indirect target can be controlled.

As we learn from the fundamentals this is the normal behavior:
win32u syscall stub
  -> shadow SSDT entry
  -> original win32k service

Have you read the fundamentals yet? Go back to the top if you haven't!

And the hijack behavior:
win32u syscall stub
  -> patched shadow SSDT entry
  -> win32k dispatch indirection
  -> chosen kernel routine

A key implementation detail is that the table entry is not treated as a simple raw function pointer. Shadow SSDT entries are encoded dispatch entries, the formula from ired.. so the redirection must respect the table’s encoding and the expected dispatch mechanics. The implementation therefore uses a compatible win32k dispatch path and changes an associated indirect slot to point to the desired kernel routine.

This creates a reusable kernel-call primitive:
patch indirect slot - target routine
patch shadow entry - dispatch path
call selected win32u syscall
restore both values

The hijack is deliberately scoped to a single call, with a reason. The original shadow table entry and original indirect target are saved before patching, and restoration is performed after invocation. This avoids treating the shadow SSDT as a persistent hook mechanism and instead uses it as a transient call gate.

The GUI execution context is also part of the design. Since the shadow SSDT services belong to the Win32 kernel subsystem, the triggering syscall is issued from a GUI-capable thread. This gives win32k the caller state it expects while the modified dispatch path is exercised.

Payloads should be viewed separately from the hijack mechanism. A debug-print call, a memory copy, a process lookup, or another kernel routine can be invoked through the same temporary route. The academic primitive is not the payload; it is the controlled redirection of one shadow syscall entry into a selected kernel target, followed by restoration.

And all together in an execution timeline it looks like this:

Initial state:
  selected shadow syscall -> original win32k service

Preparation:
  resolve selected win32u syscall number
  locate corresponding shadow SSDT entry
  locate compatible win32k dispatch path
  locate its indirect call slot
  save original entry and slot

Patch:
  indirect slot = target kernel routine
  shadow entry  = encoded dispatch path

Trigger:
  call selected win32u syscall from GUI-capable thread

Kernel execution:
  syscall dispatcher indexes shadow table
  patched entry routes to dispatch path
  dispatch path follows patched slot
  target kernel routine executes

Restore:
  shadow entry  = original entry
  indirect slot = original value

Final state:
  selected shadow syscall -> original win32k service

This is a transient dispatch redirection primitive, meaning it is temporary. It uses the shadow SSDT only long enough to convert a legitimate GUI syscall transition into a controlled call into kernel code, then removes the redirection.

The hijack implementation itself and the trickery:

The structural design here is: One should not write an arbitrary kernel pointer directly into the shadow SSDT entry, instead, search win32kbase for a dispatch pattern and get a result pair:

gadget_va
iat_slot_va

The shadow SSDT entry is then patched with these to route execution to the gadget, and the gadget’s indirect slot is patched to point to the desired kernel routine.

shadow entry -> win32kbase gadget
IAT slot     -> target kernel routine

The selected win32u syscall is invoked from the prepared GUI thread. Because the table entry and indirect slot are patched, the syscall dispatch path reaches the chosen kernel routine instead of the normal syscall to win32k.

For routines with simple argument needs, the direct syscall argument path is enough. For more complex calls, we need to do a bit of digging.

The shadow SSDT does not store raw function pointers. It stores encoded dispatch entries. Because of that, the hijack cannot simply write the final target address into the table. The table entry must decode to something structurally valid. This implementation solves that by encoding a reachable win32kbase dispatch gadget into the shadow SSDT entry, then using that gadget’s indirect slot as the place where the arbitrary kernel target is written.

write #1 via RW primitive: shadow table entry = encoded dispatch gadget
write #2 via RW primitive: indirect slot = actual routine we want to hijack to

But can you just write into the table?

No you can't! The memory page containing the table entry is not  writable. Virtual write to shadow SSDT is blocked by page protections. This is one of the reason we are using a physical memory as R/W primitive.

The CPU’s normal page protections apply when writing through a virtual address. If kernel virtual memory marks the shadow SSDT page read-only, a normal kernel virtual write will fault, get blocked or even bugcheck if faulty. But through physical memory writes we can bypass the ordinary virtual page permission check for that write operation, because the RW primitive is writing to physical memory rather than asking the CPU to store through the protected virtual mapping. 

How this page protection bypass works?
First we need the KVA of shadow entry, once we have that we can translate.
-> VA-to-PA translation
-> physical write callback <- And this is what bypasses the page protection.

Historically, using only a VA R/W primitive it was possible to modify protected dispatch tables by changing the page-table permissions first. But on modern Windows systems, especially with VBS/HVCI/kCET, this approach is much less reliable or just blocked.

Page table permissions and executable/kernel integrity assumptions are no longer just passive data. They are enforced, mirrored, validated thus protected by the hypervisor. 

PDE RW technique, when only VA RW are available:
As I shown in the SSDT hijack article, it's possible from VA: There I temporarily set the RW bit on the PDE that maps KiServiceTable. This does not swap the PFN or redirect the mapping to a different page. It leaves the mapping pointing to the same physical page, but changes the page permission so the service table page is writable while the hijack is active.&nbsp;

Once the PDE RW bit is set, the CPU treats that virtual mapping as writable. At that point, a kernel-mode VA write to the SSDT entry would be allowed. But if you have a physical RW then this is not needed.

Would Page-Table Remapping still work?
On modern Windows with VBS/HVCI and newer page-table protections, this class of attack is no longer available. Microsoft has specifically hardened against page-table remapping and aliasing attacks, especially for protected kernel memory.

From microsoft: https://techcommunity.microsoft.com/blog/windowsosplatform/protecting-linear-address-translations-with-hypervisor-enforced-paging-translati/4399739

A page-table remapping attack, is a page-table manipulation technique where the attacker does not directly overwrite the protected object. Instead, modify the paging entry that maps a virtual address so that the same virtual address temporarily resolves to a different physical page frame.

PFN means Page Frame Number. In x64 paging, a PTE/PDE contains access bits plus a physical page-frame field. That PFN field determines which physical page backs a virtual address.

Normal mapping:
SensitiveTable VA
  -> PTE/PDE
  -> PFN X
  -> physical page X
  -> original protected object
Attacker prepares a controlled page:

ControlledCopy VA
  -> PTE/PDE
  -> PFN Y
  -> physical page Y
  -> attacker-controlled copy
Original sensitive PTE:

PFN = X
RW  = 0
Then the attacker modifies the sensitive mapping:

SensitiveTable VA
  -> modified PTE/PDE
  -> PFN Y
  -> physical page Y
  -> attacker-controlled or writable replacement page
Modified sensitive PTE:

PFN = Y
RW  = chosen permissions
So the virtual address remains the same, but the physical page behind it changes.

PFN remapping was clever because it attacked the translation, not the object. The protected virtual address still looked like the right address, but it no longer pointed to the original protected physical page. Modern Windows mitigations such as VBS/HVCI and HVPT are designed to make this kind of CR3-rooted page-table tampering ineffective for protected kernel mappings.

The proof is in the pudding! And to wrap it up, the Shadow SSDT Hijack doing a token swap in Windows 11 (26200) with VBS/HVCI/kCET enabled.


 

Back to blog