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

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