GS:[0x60]
PEB.Ldr + 0x10
LIST_ENTRY.Flink
LDR_DATA_TABLE_ENTRY
BaseDllName.Buffer
g3tsyst3m // module

Walking the PEB

A visual deep-dive into the Process Environment Block — from the GS segment register all the way to a kernel32 base address, one pointer at a time.

Architecturex64 Windows
Prerequisitex64 Registers, Pointers
Sections6 Interactive
TypeVisual + Code
01
Section One

The GS Segment Register — Your Entry Point

// Every PEB walk starts here

On 64-bit Windows, the CPU's GS segment register is wired to point at the current thread's Thread Environment Block (TEB). This is not a coincidence — Microsoft hardwired this mapping so the OS can efficiently access per-thread data without an expensive system call. Every thread on every Windows process has its own TEB, and GS always points to the running thread's.

Two offsets into the GS segment are particularly important for shellcode development. GS:[0x30] holds the TEB's own base address (a self-referential pointer), and GS:[0x60] holds a pointer directly to the PEB. Both routes lead to the same destination — the choice between them affects resilience against certain EDR hooks as covered in Module 9.

◆ GS Segment — Key Offsets
Virtual Address
Offset
Value
Field / Description
GS:[0x00]
+0x000
0x0000000000000000
ExceptionList (NT_TIB)
GS:[0x30]
+0x030
→ TEB base address (self-ref)
Self (TEB self-pointer)
GS:[0x38]
+0x038
0xFFFFFF...
EnvironmentPointer
GS:[0x40]
+0x040
...
ClientId (PID / TID)
GS:[0x60]
+0x060
→ PEB base address
PebBaseAddress ◀ we want this
The TEB self-pointer at GS:[0x30] is the more resilient path — reading [GS:[0x30] + 0x60] requires two dereferences but avoids certain EDR products that monitor direct GS:[0x60] access. Both paths arrive at the same PEB address.

02
Section Two

Thread Environment Block — TEB Structure

// The per-thread data store that bridges to the PEB

The TEB (_TEB in Windows internals notation) is a per-thread data structure that Windows maintains in user-mode memory. Every thread has its own TEB. It begins with an NT_TIB block (the oldest part, going back to Win9x) and extends with dozens of fields — most of which we don't care about for shellcode purposes.

The one field that matters most is PebBaseAddress at offset +0x060. This is a pointer to the process-wide PEB. Every thread in a process has the same PebBaseAddress value pointing to the shared PEB.

◆ _TEB Structure (x64) — Fields relevant to PEB walk
_TEB (Thread Environment Block)
+0x000
0x070
NtTib
_NT_TIB block — exception list, stack base/limit
+0x060
0x008
PebBaseAddress ◀
Pointer to PEB — THIS is what we read
+0x068
0x010
EnvironmentPointer
Not relevant
+0x078
0x010
ClientId
UniqueProcess (PID) / UniqueThread (TID)
+0x1480
varies
... (many more fields) ...
Stack cookie, fiber data, locale, etc.
NASM x64 teb_to_peb.asm
BITS 64 ; Method A — Direct (one dereference) mov rax, [gs:0x60] ; RAX = PEB base address ; Method B — Via TEB self-pointer (two dereferences, more resilient) mov rax, [gs:0x30] ; RAX = TEB base address (Self pointer) mov rax, [rax+0x60] ; RAX = TEB.PebBaseAddress = PEB base ; Both land here: RAX = PEB base address ; Continue to next section to navigate the PEB itself

03
Section Three

Process Environment Block — PEB Layout

// The process-wide data store — one per process, shared by all threads

The PEB (_PEB) is a process-wide structure — unlike the TEB, there is only one per process, shared by all threads. It holds everything about the process: the image base address, heap handles, environment variables, command-line arguments, and crucially for us: a pointer to the loader data that tracks every loaded DLL.

We need Ldr at offset +0x018. This is a pointer to a PEB_LDR_DATA structure that the Windows loader populates when the process starts and updates every time a DLL loads or unloads. It contains three doubly-linked lists of loaded modules. We will use one of them to find kernel32.

◆ _PEB Structure (x64) — Fields used in PEB walk
_PEB (Process Environment Block)
+0x000
0x001
InheritedAddressSpace
BOOLEAN
+0x008
0x008
ImageBaseAddress
Base address of the main executable
+0x018
0x008
Ldr ◀
Pointer to _PEB_LDR_DATA — next stop
+0x020
0x008
ProcessParameters
_RTL_USER_PROCESS_PARAMETERS*
+0x058
0x008
NtGlobalFlag
Used for anti-debug detection
+0x068
0x010
NtHeap / HeapBase
Process default heap handle
+0x0C0
...
... many more ...
OSMajorVersion, OSMinorVersion, etc.
NASM x64 peb_to_ldr.asm
; Continuing from Section 2 — RAX = PEB base address mov rax, [rax+0x18] ; RAX = PEB.Ldr (_PEB_LDR_DATA*) ; RAX now points to the loader data structure. ; Next: read the InMemoryOrderModuleList from Ldr.
PEB.Ldr is a pointer to PEB_LDR_DATA, not the structure itself. Always an extra dereference. Think of it as: PEB tells you where the loader info lives, not the info itself.

04
Section Four

PEB_LDR_DATA — Three Lists of Loaded Modules

// The loader's registry of every DLL in the process

The _PEB_LDR_DATA structure contains three doubly-linked lists — each containing every loaded module, but ordered differently. All three lists hold the same modules; they differ only in ordering. We use InMemoryOrderModuleList at offset +0x010 — this is the one most commonly referenced in shellcode and the one used in both the classic walk and the KERN-search approach from Module 9.

Each list is a LIST_ENTRY struct containing two pointers: Flink (forward link, points to next) and Blink (backward link, points to previous). They form a circular doubly-linked list — the last entry's Flink points back to the LIST_ENTRY head inside PEB_LDR_DATA itself.

◆ _PEB_LDR_DATA Structure
_PEB_LDR_DATA
+0x000
0x004
Length
Size of this structure (0x58 on x64)
+0x004
0x001
Initialized
TRUE when loader data is valid
+0x008
0x008
SsHandle
Not used
+0x008
0x010
InLoadOrderModuleList
Flink/Blink — load order (first loaded → last)
+0x010
0x010
InMemoryOrderModuleList ◀
Flink/Blink — memory address order — WE USE THIS
+0x020
0x010
InInitializationOrderModuleList
Flink/Blink — init order
+0x030
0x008
EntryInProgress
Currently loading module

When we read [Ldr + 0x10] we get the Flink of InMemoryOrderModuleList — a pointer to the first _LDR_DATA_TABLE_ENTRY in the list. The _LDR_DATA_TABLE_ENTRY is the per-module structure that holds the DLL base address, the full path, and the base name — everything we need to identify a module.

◆ _LDR_DATA_TABLE_ENTRY — Fields used in the walk
_LDR_DATA_TABLE_ENTRY (one entry per loaded module)
+0x000
0x010
InMemoryOrderLinks ◀ LIST_ENTRY (Flink/Blink)
Flink → next module entry. This IS offset +0x000 from the Flink pointer.
+0x010
0x010
InInitializationOrderLinks
Links for the init-order list
+0x030
0x008
DllBase ◀
Base address of the DLL in memory — what we want!
+0x038
0x008
EntryPoint
DLL entry point (DllMain)
+0x040
0x008
SizeOfImage
Size of mapped DLL in bytes
+0x048
0x010
FullDllName
_UNICODE_STRING — full path
+0x058
0x010
BaseDllName ◀
_UNICODE_STRING — just filename. [+0x060] = Buffer pointer to the chars
! Critical offset arithmetic: When you follow a Flink from InMemoryOrderModuleList, the pointer lands at offset +0x000 of the entry's InMemoryOrderLinks field — NOT the start of the _LDR_DATA_TABLE_ENTRY. They happen to be the same address here, but the named field is InMemoryOrderLinks. DllBase is at [Flink + 0x30] and BaseDllName.Buffer is at [Flink + 0x60].

05
Section Five

The Full Walk — Step by Step

// Watch each pointer dereference happen in sequence

The complete PEB walk chains six pointer dereferences together. Each step reads a value from the previous step's address. Missing any one step — or getting an offset wrong — produces a garbage pointer. The interactive stepper below walks through every dereference, showing what register changes and what it now points to.

◆ Pointer Chain — GS to kernel32 base
CPU / GS register
GS → TEB base
hidden base segment descriptor
GS:[0x60]
_PEB
+0x008 ImageBase
+0x018 Ldr →
+0x020 ProcessParams
[PEB+0x18]
_PEB_LDR_DATA
+0x008 InLoadOrder
+0x010 InMemOrder →
+0x020 InInitOrder
[Ldr+0x10]
_LDR_DATA_TABLE_ENTRY
+0x000 Flink →
+0x030 DllBase ★
+0x060 Name.Buffer →
◆ Interactive Walk — step through each dereference
1
GS → TEB
2
TEB → PEB
3
PEB → Ldr
4
Ldr → ModuleList
5
Walk entries
6
DllBase ★
Step 1 of 6
Read TEB base from GS segment
The GS segment register's hidden base always points to the current thread's TEB. Reading GS:[0x30] gives the TEB's own base address (a self-referential pointer — the TEB points to itself). Reading GS:[0x60] directly gives the PEB. We use the two-step path for EDR resilience.
mov rax, [gs:0x30] ; RAX = TEB base address
◆ InMemoryOrderModuleList — walking entries to find KERNEL32
HEAD
InMemoryOrderModuleList (Ldr+0x10)
list head — not a real module
Start here
0
[main executable]
DllBase: 0x00007FF600000000
check KERN → no
1
ntdll.dll
DllBase: 0x00007FFB4A000000
check KERN → no (ntdl...)
2
KERNEL32.DLL ✓
DllBase: 0x00007FFB49A00000 ← our target
KERN match! →

06
Section Six

Full Assembly — Annotated Walk from GS to DllBase

// Every instruction, every offset, every register explained

The complete PEB walk in NASM assembly, combining every step from sections 1–5. This is the code from the Module 9 calc.asm — isolated here so you can study just the walk without the export table search or WinExec call mixed in. Every single instruction is annotated with the register state it produces.

NASM x64 peb_walk_annotated.asm — complete walk, GS → kernel32 DllBase
BITS 64 SECTION .text global main main: sub rsp, 0x28 ; align stack and rsp, 0xFFFFFFFFFFFFFFF0 ; 16-byte alignment (x64 ABI requirement) xor rcx, rcx ; RCX = 0 (null-free zero) ; ════════════════════════════════════════════════════════════ ; STEP 1 — GS → TEB ; GS:[0x30] = self-referential TEB pointer ; ════════════════════════════════════════════════════════════ mov rax, [gs:0x30] ; RAX = TEB base address ; ════════════════════════════════════════════════════════════ ; STEP 2 — TEB → PEB ; TEB.PebBaseAddress is at TEB+0x60 ; ════════════════════════════════════════════════════════════ mov rax, [rax+0x60] ; RAX = PEB base address ; ════════════════════════════════════════════════════════════ ; STEP 3 — PEB → Ldr ; PEB.Ldr is at PEB+0x18 — pointer to _PEB_LDR_DATA ; ════════════════════════════════════════════════════════════ mov rax, [rax+0x18] ; RAX = _PEB_LDR_DATA* ; ════════════════════════════════════════════════════════════ ; STEP 4 — Ldr → InMemoryOrderModuleList.Flink ; PEB_LDR_DATA.InMemoryOrderModuleList at +0x10 ; Reading the Flink gives first _LDR_DATA_TABLE_ENTRY ; ════════════════════════════════════════════════════════════ mov rsi, [rax+0x10] ; RSI = first entry's InMemoryOrderLinks address ; ════════════════════════════════════════════════════════════ ; STEP 5 — Walk the list, check each module's Unicode name ; RSI currently = InMemoryOrderLinks of the first entry ; Each [RSI] = Flink → next entry (advance the walk) ; [RSI+0x60] = BaseDllName.Buffer (pointer to UTF-16LE chars) ; [RSI+0x30] = DllBase (what we want when we find KERNEL32) ; ════════════════════════════════════════════════════════════ checkit: mov rsi, [rsi] ; RSI = Flink → advance to next list entry mov rcx, [rsi+0x60] ; RCX = BaseDllName.Buffer pointer mov rbx, [rcx] ; RBX = first 8 bytes of module name (UTF-16LE) mov rdx, 0x004E00520045004B ; RDX = "KERN" in UTF-16LE ; K=004B E=0045 R=0052 N=004E cmp rbx, rdx ; does this module start with "KERN"? jz foundit ; yes → KERNEL32.DLL found jnz checkit ; no → advance Flink and check next foundit: ; ════════════════════════════════════════════════════════════ ; STEP 6 — Read DllBase from the found entry ; DllBase is at [entry + 0x30] ; The Flink pointer (RSI) = address of InMemoryOrderLinks (+0x00 of entry) ; So DllBase = [RSI + 0x30] ; ════════════════════════════════════════════════════════════ mov rbx, [rsi+0x30] ; RBX = KERNEL32.DLL base address ★ mov r8, rbx ; R8 = kernel32 base (save in non-volatile reg) ; ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ; Walk complete. R8 = KERNEL32.DLL base address. ; Continue to export table parsing to resolve WinExec or ; any other API you need. (Covered in the shellcode course.) ; ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
After this walk, R8 holds the base address of KERNEL32.DLL in the current process. The next step — parsing the PE Export Address Table to resolve a function like WinExec — is covered in the shellcode course (Module 2). The walk output is the input to the EAT parser.

The register state after each step, summarized:

◆ Register State After Each Step
After Instruction
Register
Contains
Points To
mov rax,[gs:0x30]
RAX
TEB base address
_TEB structure
mov rax,[rax+0x60]
RAX
PEB base address
_PEB structure
mov rax,[rax+0x18]
RAX
Ldr pointer
_PEB_LDR_DATA
mov rsi,[rax+0x10]
RSI
First Flink
_LDR_DATA_TABLE_ENTRY InMemOrderLinks
mov rsi,[rsi] (loop)
RSI
Next entry Flink
Walking the linked list
mov rbx,[rsi+0x30]
RBX / R8
KERNEL32.DLL base ★
PE header of kernel32