daemon-sec-cheatsheet

The cheatsheet vault for operators: AD, enumeration, exploitation, priv-esc, web, DFIR
git clone https://git.daemon-sec.xyz/daemon-sec-cheatsheet.git
Log | Files | Refs | README | LICENSE

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