peb-walking.md (14402B)
1 --- 2 title: "Walking the PEB and Comparing Image to Disk" 3 description: "PEB field offsets, walking InMemoryOrderModuleList in WinDBG, and the PE header arithmetic that lets you diff a loaded module against the file it claims to come from." 4 category: dfir 5 subcategory: "Windows Internals" 6 tags: [windows, memory-forensics, pe-format, detection-engineering, reverse-engineering] 7 tools: [windbg, cff-explorer, pe-bear, process-hacker] 8 difficulty: advanced 9 updated: 2026-10-04 10 upstreamName: "ired.team" 11 upstreamUrl: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/exploring-process-environment-block" 12 upstreamAuthor: "Mantvydas Baranauskas" 13 upstreamLicense: none 14 upstreamRelation: derived 15 references: 16 - name: "Process Environment Block" 17 url: "https://ired.team/miscellaneous-reversing-forensics/process-environment-block" 18 author: "Mantvydas Baranauskas" 19 license: none 20 relation: derived 21 note: "Second copy of the PEB lab; contributed the ProcessParameters command-line offsets and the dl/!list walking commands." 22 - name: "Parsing PE File Headers with C++" 23 url: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/pe-file-header-parser-in-c++" 24 author: "Mantvydas Baranauskas" 25 license: none 26 relation: derived 27 note: "Contributed the RVA-to-file-offset arithmetic and the import-descriptor walk in the second half." 28 - name: "PEB structure — Microsoft Learn" 29 url: "https://learn.microsoft.com/en-us/windows/win32/api/winternl/ns-winternl-peb" 30 relation: inspired 31 note: "The documented subset of the PEB; everything beyond ImageBaseAddress and Ldr is version-dependent." 32 - name: "PE Format — Microsoft Learn" 33 url: "https://learn.microsoft.com/en-us/windows/win32/debug/pe-format" 34 relation: inspired 35 note: "Normative header and data-directory layout used in the offset arithmetic." 36 --- 37 38 ## What this is 39 40 Two structures and the arithmetic that joins them. The Process Environment Block is the 41 user-mode record of what a process is: where its image is based, what command line it was 42 given, which modules it has loaded. The PE headers are the same information as it exists on 43 disk. Almost every memory-forensics judgement about a Windows process is a comparison 44 between those two views, and almost every masquerading technique works by making the 45 in-memory view lie while the on-disk file stays innocent. 46 47 You need both halves to make the comparison. This sheet does the PEB walk first, then the 48 file-offset arithmetic you need to read the disk side. 49 50 ## Prerequisites 51 52 - WinDBG with public symbols set (`.symfix` then `.reload`). Without `ntdll` symbols, 53 `dt _peb` prints nothing useful. 54 - A target process attached, user-mode. `cmd.exe` is a good first subject because its 55 command line and module list are short. 56 - CFF Explorer, PE-bear or any header viewer for the on-disk side. 57 - PEB field offsets are **version- and architecture-dependent**. The x64 offsets below hold 58 for current builds; re-read them with `dt` rather than hard-coding them in anything you 59 intend to keep. 60 61 ## Walkthrough 62 63 ### The structure 64 65 ```erlang 66 dt _peb 67 ``` 68 69 The fields that carry forensic weight on x64: 70 71 | Offset | Field | Why it matters | 72 |---|---|---| 73 | `0x002` | `BeingDebugged` | The byte every anti-debug check reads. | 74 | `0x010` | `ImageBaseAddress` | Where the primary image is mapped. The anchor for every image/memory comparison. | 75 | `0x018` | `Ldr` | Pointer to `_PEB_LDR_DATA` — the loaded-module lists. | 76 | `0x020` | `ProcessParameters` | `_RTL_USER_PROCESS_PARAMETERS`: image path, command line, current directory, environment. | 77 78 <figure class="shot"> 79 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/peb-structure%20%281%29.png" 80 alt="WinDBG dt _peb output listing the PEB field names with their hexadecimal offsets and types" 81 loading="lazy" referrerpolicy="no-referrer"> 82 <figcaption><code>dt _peb</code> — the field names and offsets, with no process attached to them yet. 83 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 84 </figcaption> 85 </figure> 86 87 ### Overlay it on a live process 88 89 The PEB address is in a pseudo-register, so you do not have to find it: 90 91 ```erlang 92 r $peb 93 dt _peb @$peb 94 ``` 95 96 That second command is the whole trick for any structure in WinDBG: `dt <type> <address>` 97 interprets the memory at an address as that type and prints real values. 98 99 <figure class="shot"> 100 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/peb-overlay%20%281%29.png" 101 alt="WinDBG output of dt _peb applied to the live PEB address, each field now populated with the target process's actual values" 102 loading="lazy" referrerpolicy="no-referrer"> 103 <figcaption>The same structure with the live process's values in it. 104 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 105 </figcaption> 106 </figure> 107 108 Follow `ImageBaseAddress` and you are looking at the mapped PE: 109 110 ```erlang 111 dd @$peb+0x10 L2 112 db 0000000049d40000 L100 113 ``` 114 115 The `MZ` and the `This program cannot be run in DOS mode` stub confirm you are at a real 116 image base. Remember the dword pair is little-endian, so read it back as one qword before 117 using it as an address. 118 119 For everything the debugger can summarise for you, `!peb` prints the same key fields 120 pre-formatted. Use it for speed; use the manual walk when you need to know exactly which 121 bytes you are trusting. 122 123 ### The command line, and why it is not evidence 124 125 `ProcessParameters` holds a `_RTL_USER_PROCESS_PARAMETERS`, and the command line lives at 126 `+0x70` inside it as a `_UNICODE_STRING`: 127 128 ```erlang 129 dt _peb @$peb ProcessParameters 130 dt _RTL_USER_PROCESS_PARAMETERS 0x00000000002a1f40 131 dt _UNICODE_STRING 0x00000000002a1f40+70 132 du 00000000002a283c 133 ``` 134 135 <figure class="shot"> 136 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/peb-cmdline2%20%281%29.png" 137 alt="WinDBG resolving the command line through ProcessParameters to a UNICODE_STRING buffer and printing the cmd.exe command line" 138 loading="lazy" referrerpolicy="no-referrer"> 139 <figcaption>Resolving the command line down to the <code>_UNICODE_STRING</code> buffer that holds it. 140 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 141 </figcaption> 142 </figure> 143 144 That buffer is ordinary writable process memory. A process can overwrite its own command 145 line after start-up with a single write — `eu <address> "something else"` in the debugger 146 demonstrates it in one command. Any tool that reads the command line from the PEB therefore 147 reports whatever the process last wrote there, not what it was launched with. 148 149 ### Walking the module list 150 151 `Ldr` points to `_PEB_LDR_DATA`, which holds three doubly-linked lists of the same modules in 152 different orders. `InMemoryOrderModuleList` at `+0x20` is the usual entry point: 153 154 ```erlang 155 dt _peb @$peb ldr->InMemoryOrderModuleList* 156 dl 0x00000000002a2980 6 157 !list -x "dt _LDR_DATA_TABLE_ENTRY" 0x00000000002a2980 158 ``` 159 160 `dl <first-entry> <count>` prints the raw link pointers, which is how you confirm the list is 161 intact. `!list` does the traversal and overlays `_LDR_DATA_TABLE_ENTRY` on each node, giving 162 you `DllBase`, `SizeOfImage`, `FullDllName` and `BaseDllName` per module. 163 164 <figure class="shot"> 165 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/peb-modulelist.png" 166 alt="WinDBG LDR_DATA_TABLE_ENTRY dumps for consecutive loaded modules showing DllBase, SizeOfImage and FullDllName for cmd, ntdll and kernel32" 167 loading="lazy" referrerpolicy="no-referrer"> 168 <figcaption>Consecutive <code>_LDR_DATA_TABLE_ENTRY</code> nodes: base, size and full path per module. 169 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 170 </figcaption> 171 </figure> 172 173 Two things to carry away from the walk. First, the list is a doubly-linked list in writable 174 user memory, so a node can be unlinked from inside the process — which means "not in the 175 module list" is not the same as "not mapped", and you should enumerate committed regions as 176 well. Second, `DllBase` and `FullDllName` together are exactly the pair you need for the 177 disk comparison, which is the rest of this sheet. 178 179 ### The disk side: turning an RVA into a file offset 180 181 To compare a loaded module against its file you have to read the file's headers, and every 182 address in a PE's data directories is a Relative Virtual Address — an offset from the image 183 base *as mapped*. On disk, sections sit at their `PointerToRawData`, which is not the same 184 as their `VirtualAddress`. So an RVA has to be translated. 185 186 Find which section contains the RVA, then convert: 187 188 ```text 189 section contains rva when rva >= section.VirtualAddress 190 and rva < section.VirtualAddress + section.VirtualSize 191 192 fileOffset = section.PointerToRawData + (rva - section.VirtualAddress) 193 ``` 194 195 Worked through with the import directory of a 32-bit `notepad.exe`: the data directory gives 196 an import-table RVA of `0xA0A0`; `.text` has `VirtualAddress 0x1000`, `VirtualSize 0xA6FC` 197 and raw offset `0x400`. `0xA0A0` falls inside `.text`, so the file offset is 198 `0x400 + (0xA0A0 - 0x1000) = 0x94A0`. 199 200 <figure class="shot"> 201 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/Screenshot%20from%202018-11-06%2020-51-04.png" 202 alt="CFF Explorer data directories view for notepad.exe showing the import table RVA and its size" 203 loading="lazy" referrerpolicy="no-referrer"> 204 <figcaption>The import directory's RVA, read out of the optional header's data directories. 205 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 206 </figcaption> 207 </figure> 208 209 <figure class="shot"> 210 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/Screenshot%20from%202018-11-06%2020-51-27.png" 211 alt="CFF Explorer section headers table for notepad.exe listing virtual size, virtual address and raw offset per section" 212 loading="lazy" referrerpolicy="no-referrer"> 213 <figcaption>Section headers: <code>VirtualAddress</code>, <code>VirtualSize</code> and the raw offset the conversion needs. 214 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 215 </figcaption> 216 </figure> 217 218 At that offset sits an array of `IMAGE_IMPORT_DESCRIPTOR`, terminated by an all-zero entry. 219 Each descriptor's `Name` field is itself an RVA to an ASCII DLL name, so it needs the same 220 conversion again; `OriginalFirstThunk` points at the `IMAGE_THUNK_DATA` array, whose 221 non-ordinal entries are RVAs to `IMAGE_IMPORT_BY_NAME`. The double indirection is the part 222 that catches people: nothing inside a data directory is a file offset, it is RVAs all the 223 way down. 224 225 The headers to read for a comparison, in order: `e_lfanew` from the DOS header gets you to 226 the NT headers; `FileHeader.Machine` and `NumberOfSections`; `OptionalHeader.ImageBase`, 227 `SizeOfImage`, `AddressOfEntryPoint` and the data directories; then the section table. 228 229 ## What it tells you 230 231 **Image/memory mismatch is the conclusive artefact, and this is how you compute it.** For 232 the primary image: read `ImageBaseAddress` from the PEB, read the PE headers at that address 233 in memory, read the file at the path from `ProcessParameters`, and compare `SizeOfImage`, 234 `NumberOfSections`, the section names and `AddressOfEntryPoint`. A disagreement is not 235 explainable by anything legitimate. This is the decisive check for 236 [process hollowing](/sheets/exploitation/process-hollowing), where the mapped image is gone 237 or replaced while the path still names the host binary. 238 239 **Per module, the same comparison catches overwritten modules.** Walk 240 `InMemoryOrderModuleList`, and for each node compare the bytes at `DllBase` against the file 241 at `FullDllName`. Expect benign differences — the loader applies relocations, patches the 242 IAT, and writable sections diverge immediately — so compare the executable sections only, 243 after accounting for the relocation delta. A module whose `.text` does not match its file is 244 a module someone wrote into, which is what makes 245 [classic DLL injection](/sheets/exploitation/dll-injection) variants that overwrite a loaded 246 module visible when the thread-start check misses them. 247 248 **Treat the PEB as a claim, not a fact.** Everything in it is writable by the process. The 249 command line, the image path string, the module list links, `BeingDebugged` — all of it can 250 be rewritten after start-up, and a process can unlink a module node so the module stops 251 appearing in the list it is still mapped in. So: 252 253 - Prefer the kernel's view where you can get it. The image path recorded at process creation 254 comes from the kernel, and an [ETW](/sheets/dfir/etw-telemetry) process-start event carries 255 the command line as it was at creation. A PEB read gives you the current value. 256 - Where the two disagree, the disagreement is the finding. A command line in the PEB that 257 does not match the one in the creation event means something rewrote it. 258 - Never take the module list as the complete set of mapped code. Enumerate committed regions 259 and look for executable memory that no list entry covers. 260 261 **Offsets drift; structures do not.** Hard-coded PEB offsets are the most common reason a 262 forensic script silently returns garbage on a new build. Resolve them from symbols, or at 263 minimum sanity-check `ImageBaseAddress` against an `MZ` before trusting anything else you 264 read through it. 265 266 ## References 267 268 - [PEB structure — Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/api/winternl/ns-winternl-peb) 269 - [PE Format — Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format) 270 - [PEB_LDR_DATA structure](https://learn.microsoft.com/en-us/windows/win32/api/winternl/ns-winternl-peb_ldr_data) 271 - [Process hollowing](/sheets/exploitation/process-hollowing) — the technique this comparison 272 is built to catch 273 - [Hunting injected threads](/sheets/dfir/injected-thread-hunting) — the complementary check 274 on thread start addresses