ssdt-and-idt.md (13475B)
1 --- 2 title: "SSDT and IDT" 3 description: "KiServiceTable offset arithmetic, resolving a syscall number to its kernel routine, the _KIDTENTRY64 layout, and why table hooking stopped being how rootkits work on x64." 4 category: dfir 5 subcategory: "Windows Internals" 6 tags: [windows, kernel, rootkit, detection-engineering, reverse-engineering] 7 tools: [windbg, kdnet, volatility] 8 difficulty: advanced 9 updated: 2026-10-04 10 upstreamName: "ired.team" 11 upstreamUrl: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/glimpse-into-ssdt-in-windows-x64-kernel" 12 upstreamAuthor: "Mantvydas Baranauskas" 13 upstreamLicense: none 14 upstreamRelation: derived 15 references: 16 - name: "Interrupt Descriptor Table - IDT" 17 url: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/interrupt-descriptor-table-idt" 18 author: "Mantvydas Baranauskas" 19 license: none 20 relation: derived 21 note: "Contributed the whole IDT half: the idtr read, the _KIDTENTRY64 offset layout, and the _KPCR/_KPRCB route to _KINTERRUPT." 22 - name: "The Quest for the SSDTs" 23 url: "https://www.codeproject.com/Articles/1191465/The-Quest-for-the-SSDTs" 24 relation: inspired 25 note: "The KeServiceDescriptorTable layout and the 4-bit shift in the x64 offset formula." 26 - name: "Interrupt dispatching — CodeMachine" 27 url: "https://www.codemachine.com/article_interruptdispatching.html" 28 relation: inspired 29 note: "How an interrupt reaches a driver's ISR, which the IDT walkthrough traces by hand." 30 --- 31 32 ## What this is 33 34 Two dispatch tables that decide where the CPU goes next. The System Service Descriptor Table 35 maps a syscall number to a kernel routine; the Interrupt Descriptor Table maps an interrupt 36 vector to a service routine. For a decade both were the standard place to install a rootkit, 37 because overwriting one entry redirects every caller. On x64 that era is over — but the 38 tables are still how you read a stack trace that crosses the user/kernel boundary, still how 39 you work out which driver handles a given interrupt, and still worth checking. 40 41 ## Prerequisites 42 43 - A live kernel debugging session. See 44 [the kernel debugging lab](/sheets/dfir/kernel-debugging-lab); none of this works from a 45 user-mode debugger. 46 - Symbols for `nt` and, for the syscall exercise, `ntdll`. 47 - The IDT values below are from a single-processor x64 VM. **Each processor has its own IDT** 48 and its own `IDTR`, so on a multiprocessor host you must check every one — a hook on 49 processor 3 only is a real technique. 50 - Addresses differ across boots. Re-read them rather than reusing the ones printed here. 51 52 ## Walkthrough 53 54 ### The service descriptor table 55 56 `KeServiceDescriptorTable` is a small structure whose first member points at the dispatch 57 table itself: 58 59 ```cpp 60 typedef struct tagSERVICE_DESCRIPTOR_TABLE { 61 SYSTEM_SERVICE_TABLE nt; // -> KiServiceTable, the SSDT proper 62 SYSTEM_SERVICE_TABLE win32k; 63 SYSTEM_SERVICE_TABLE sst3; // -> count of routines in the table 64 SYSTEM_SERVICE_TABLE sst4; 65 } SERVICE_DESCRIPTOR_TABLE; 66 ``` 67 68 Read it with symbols resolved so the pointers are named: 69 70 ```erlang 71 dps nt!KeServiceDescriptorTable L4 72 ``` 73 74 The first pointer is `nt!KiServiceTable`; the fourth is `nt!KiArgumentTable`. The third slot 75 holds the routine count, which you need to bound a loop over the table. 76 77 ### x64 entries are offsets, not pointers 78 79 On x86 the SSDT held absolute addresses. On x64 it holds 32-bit values that encode an offset 80 from the table's own base, with the low four bits used for argument-stack information. So: 81 82 ```text 83 routineAddress = KiServiceTable + (entry >>> 4) 84 ``` 85 86 `>>>` is WinDBG's arithmetic right shift. Read the first entry and resolve it: 87 88 ```erlang 89 dd /c1 nt!KiServiceTable L2 90 u nt!KiServiceTable + (0xfd9007c4 >>> 4) L1 91 ``` 92 93 That resolves to `nt!NtAccessCheck`, which you can confirm independently: 94 95 ```erlang 96 u nt!NtAccessCheck L1 97 ``` 98 99 Same address, same first instruction. If those two disagree, either your shift is wrong or 100 something has rewritten the table. 101 102 <figure class="shot"> 103 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28266%29.png" 104 alt="Diagram showing a syscall index selecting an entry in KiServiceTable and that entry's offset being converted into the absolute address of a kernel routine" 105 loading="lazy" referrerpolicy="no-referrer"> 106 <figcaption>Syscall index to table entry to absolute routine address. 107 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 108 </figcaption> 109 </figure> 110 111 <figure class="shot"> 112 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28258%29.png" 113 alt="WinDBG output resolving an SSDT offset to nt!NtAccessCheck and the same routine disassembled directly, with matching addresses and first instructions" 114 loading="lazy" referrerpolicy="no-referrer"> 115 <figcaption>The resolved offset and the symbol agree: same address, same prologue. 116 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 117 </figcaption> 118 </figure> 119 120 ### From a user-mode call to its kernel routine 121 122 The useful exercise is going end to end. A user-mode `CreateFile` reaches 123 `ntdll!NtCreateFile`, whose body loads a syscall number into `eax` and issues `syscall`. 124 Read the number out of the stub: 125 126 ```erlang 127 .reload /f ntdll.dll 128 u ntdll!NtCreateFile L2 129 ``` 130 131 The `mov eax, <n>` is the index. Entries are four bytes, so index into the table and resolve: 132 133 ```erlang 134 dd /c1 nt!KiServiceTable + 4*0x55 L1 135 u nt!KiServiceTable + (0x01fa3007 >>> 4) L1 136 ``` 137 138 Which lands on `nt!NtCreateFile`. Doing this once by hand is what makes a syscall number in a 139 sandbox report or a disassembly legible. 140 141 <figure class="shot"> 142 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28265%29.png" 143 alt="Diagram annotated with concrete values showing one specific syscall index resolving through KiServiceTable to its kernel routine address" 144 loading="lazy" referrerpolicy="no-referrer"> 145 <figcaption>The same path with real values substituted in. 146 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 147 </figcaption> 148 </figure> 149 150 ### Dump the whole table with names 151 152 Loop over every entry, resolving each to a symbol. The routine count comes from the third 153 slot of the descriptor table, at `+0x10`: 154 155 ```erlang 156 .foreach /ps 1 /pS 1 ( offset {dd /c 1 nt!KiServiceTable L poi(nt!KeServiceDescriptorTable+10)}) { r $t0 = ( offset >>> 4) + nt!KiServiceTable; .printf "%p - %y\n", $t0, $t0 } 157 ``` 158 159 Any entry that resolves to an address outside `nt` is the thing you are looking for. On a 160 clean system every one of them resolves inside the kernel image. 161 162 ### The interrupt descriptor table 163 164 `IDTR` holds the IDT base for the current processor: 165 166 ```erlang 167 r idtr 168 !idt 169 ``` 170 171 `!idt` dumps the table with symbols, and its header repeats the base address so you can 172 confirm it matches the register. 173 174 <figure class="shot"> 175 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28295%29.png" 176 alt="WinDBG reading the idtr register and printing the interrupt descriptor table base address for the current processor" 177 loading="lazy" referrerpolicy="no-referrer"> 178 <figcaption><code>IDTR</code> — the IDT base for this processor. 179 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 180 </figcaption> 181 </figure> 182 183 Low vectors are CPU-defined and resolve to kernel trap handlers: `0x00` divide error, `0x03` 184 breakpoint, `0x0e` page fault. Higher vectors are device interrupts and resolve into drivers — 185 `0xa0` to the keyboard driver's interrupt service on the reference machine, with a 186 `KINTERRUPT` address printed beside it. 187 188 ### An IDT entry's layout 189 190 Each x64 descriptor is 16 bytes, so entry `n` is at `IDT base + n * 0x10`: 191 192 ```erlang 193 dt nt!_KIDTENTRY64 194 dq @idtr + (0xa0*0x10) L2 195 dt nt!_KIDTENTRY64 (@idtr + (0xa0*0x10)) 196 ``` 197 198 ```text 199 +0x000 OffsetLow Uint2B 200 +0x002 Selector Uint2B 201 +0x004 IstIndex 3 bits 202 +0x004 Type 5 bits 0xe = 64-bit interrupt gate 203 +0x004 Dpl 2 bits 204 +0x004 Present 1 bit 205 +0x006 OffsetMiddle Uint2B 206 +0x008 OffsetHigh Uint4B 207 ``` 208 209 `OffsetLow`, `OffsetMiddle` and `OffsetHigh` concatenate into the 64-bit address of the 210 interrupt service routine's entry point. That is the one field a hook has to change, and 211 reassembling it by hand is the only way to be sure the value `!idt` printed is the value in 212 memory. 213 214 <figure class="shot"> 215 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28314%29.png" 216 alt="Diagram tracing a keyboard interrupt from the CPU through the IDT entry to the ISR entry point and on into the keyboard driver's service routine" 217 loading="lazy" referrerpolicy="no-referrer"> 218 <figcaption>Interrupt to IDT entry to ISR entry point to the driver's service routine. 219 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 220 </figcaption> 221 </figure> 222 223 ### Which driver actually handles it 224 225 The IDT entry points at a kernel stub, not at the driver. The driver's routine is in a 226 `_KINTERRUPT`, whose `ServiceRoutine` sits at `+0x18`: 227 228 ```erlang 229 dt nt!_KINTERRUPT ffffd4816353ea00 230 ``` 231 232 To find that object without taking the address from `!idt`, go through the processor control 233 region. `_KPCR` describes a processor; `_KPRCB` at `_KPCR+0x180` describes its state; and 234 `InterruptObject` inside the PRCB is an array of 256 `_KINTERRUPT` pointers indexed by 235 vector: 236 237 ```erlang 238 ? @$pcr 239 dt @$pcr nt!_KPCR Prcb.InterruptObject[a0] 240 ``` 241 242 The result matches what `!idt` printed, which is the point of doing it the long way. 243 244 <figure class="shot"> 245 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28293%29.png" 246 alt="WinDBG _KINTERRUPT structure dump with the ServiceRoutine member resolving to the keyboard driver's interrupt service routine symbol" 247 loading="lazy" referrerpolicy="no-referrer"> 248 <figcaption><code>_KINTERRUPT.ServiceRoutine</code> names the driver routine that handles the interrupt. 249 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 250 </figcaption> 251 </figure> 252 253 ## What it tells you 254 255 **The check is one line: does every entry resolve inside a module you expect.** For the SSDT, 256 every routine address must fall inside the `nt` image range. For the IDT, low vectors must 257 resolve to `nt!Ki*` trap handlers and device vectors to a loaded driver. An entry pointing 258 into a non-image region, or into a driver that has no business handling that vector, is a 259 hook. There is no legitimate reason for either table to point outside a loaded image. 260 261 **On x64 this is a historical check, and you should know why.** PatchGuard verifies the SSDT, 262 the IDT, the GDT and a list of other structures periodically, and bugchecks the machine on a 263 mismatch. A table hook on a modern x64 kernel is not a stealthy rootkit; it is a crash with a 264 delay. So the realistic finding is not an active hook — it is a bugcheck with a 265 `CRITICAL_STRUCTURE_CORRUPTION` stop code, which is PatchGuard telling you something modified 266 a protected structure. Treat that stop code as a security event, not a driver bug. 267 268 **Where the hooking went instead.** Since the tables are defended, interception moved to the 269 sanctioned extension points — the registered callbacks covered in 270 [kernel notification callbacks](/sheets/dfir/kernel-notification-callbacks) — and to user 271 mode, where hooking `ntdll` stubs is unprotected and correspondingly easy to bypass by 272 issuing the syscall directly. That is the same limitation discussed in 273 [API tracing with Frida](/sheets/dfir/frida-api-tracing), and it is why direct-syscall 274 malware defeats user-mode hooking without touching the kernel at all. 275 276 **Syscall numbers are the practical payoff.** They change between builds, so a sample that 277 hard-codes them is pinned to a build range — which is both a reliability weakness for the 278 attacker and a dating signal for you. Being able to resolve a hard-coded index to a routine 279 name on the matching build is how you read what such a sample does when it never calls an 280 export you can hook. 281 282 **Per-processor, always.** `IDTR` is per-logical-processor. Any IDT verification that reads 283 one register has checked one processor. The same applies to anything you read through 284 `@$pcr`, which refers to the processor you are currently broken in on — switch with `~<n>s` 285 and re-read. 286 287 ## References 288 289 - [The Quest for the SSDTs](https://www.codeproject.com/Articles/1191465/The-Quest-for-the-SSDTs) 290 - [Interrupt dispatching — CodeMachine](https://www.codemachine.com/article_interruptdispatching.html) 291 - [Interrupt Descriptor Table — OSDev](https://wiki.osdev.org/Interrupt_Descriptor_Table) 292 - [Kernel Patch Protection — Microsoft Learn](https://learn.microsoft.com/en-us/windows-hardware/drivers/develop/64-bit-driver-installation-issues#kernel-patch-protection) 293 - [The kernel debugging lab](/sheets/dfir/kernel-debugging-lab) — the session every command 294 here needs