FUN WITH VIRTUALQUERY: READING THE PEB WITHOUT SEGMENT REGISTERS
For those who are unaware, the Process Environment Block (PEB) is a struct used by Windows to store useful information about a process within a single accessible region of memory. The PEB contains information such as the Process PID, Process Name, Command Line Arguments, and loaded modules. For further information about the PEB and its uses, please refer to the references→ below.
The PEB can be located through its sister, the Thread Environment Block (TEB), which is a struct containing information about the current thread. The TEB can be accessed through the GS Segment Register on the x64 architecture, via the TEB->NtTib.Self member (offset 0x30). By reading this offset from the GS segment register, we can obtain a linear address to the full TEB. From there, we can access the current process PEB through the TEB->ProcessEnvironmentBlock member (offset 0x60). We can also just query this member directly through the GS segment register using the TEB->ProcessEnvironmentBlock member offset.
C1// Reading the current TEB from GS:[0x30] 2undocumented_ntapi::PTEB GetCurrentTeb() 3{ 4 return (undocumented_ntapi::PTEB)__readgsqword(offsetof(NT_TIB, Self)); 5} 6 7// Reading the current PEB from GS:[0x60] 8undocumented_ntapi::PPEB GetCurrentPeb() 9{ 10 return (undocumented_ntapi::PPEB)__readgsqword(offsetof(undocumented_ntapi::TEB, ProcessEnvironmentBlock)); 11}
This isn't a kernel development tutorial so I won't get into the nitty gritty, the following information is x64 specific. The FS and GS Segment Registers are CPU registers used to point to memory as specified in the FSBase/GSBase Model Specific Registers (MSRs). These MSRs are set at the kernel level using the RDMSR/WRMSR privileged instructions. When a transition from user-mode to kernel-mode occurs, the kernel uses the SWAPGS instruction to exchange the GSBase and the KernelGSBase addresses. The same instruction is used again when the kernel transitions back to user-mode.
Note: Newer CPUs implement the RDGSBASE/WRGSBASE instructions, which allow both kernel-mode and user-mode to adjust the segment base.
In essence, the FS and GS Segment Registers simply contain an address specified by the kernel. In user mode, Windows sets GSBase to point to the current thread's TEB.
To locate the TEB in memory, we need to systematically examine the process's memory space. We can accomplish this using VirtualQuery (or VirtualQueryEx for other processes), a Windows API function that retrieves information about a region of memory. VirtualQuery acts like a memory inspector. You give it a memory address, and it returns details about that region: its size, protection level (read/write/execute permissions), state (committed, reserved, or free), and type (private, mapped, or image). Think of it as asking Windows, "What's at this address and what are its properties?"
To find the TEB, we can walk through memory one page allocation at a time, starting from address zero and moving forward. The TEB page allocation has a specific signature: it exists in private memory (not shared with other processes), is committed (actually allocated, not just reserved), and has read-write permissions. By filtering for pages matching these criteria, we narrow down the search to TEB candidates.

Not every page matching these properties is a TEB, so a sanity check is needed. We can read the TEB->NtTib.Self member to see if it matches the page allocation base. By verifying the NtTib.Self matches the current page's address, we can confirm we've found an actual TEB structure rather than just similar-permissioned memory.
Once we have confirmed we have hit a genuine TEB page allocation, we can simply read and return the TEB->ProcessEnvironmentBlock member.
Remote Processes
The same technique works for locating the PEB in a remote process. Normally, you would utilize the NtQueryInformationProcess function to get a PROCESS_BASIC_INFORMATION struct, which contains the PEB base address of the remote process. You can then read the PEB from the process's virtual memory space using the ReadProcessMemory Windows API function.
With our memory scan technique, we can avoid the NtQueryInformationProcess call, and all required permissions stay the same (process handle requires PROCESS_QUERY_INFORMATION | PROCESS_VM_READ access).
CPP1#include <windows.h> 2 3#include "undocumented_ntapi.h" 4 5#define CURRENT_PROCESS_HANDLE (HANDLE)-1 6 7undocumented_ntapi::PPEB GetCurrentPebWithMemoryScan(HANDLE hProcess) 8{ 9 MEMORY_BASIC_INFORMATION mbi; 10 11 if (hProcess == NULL) 12 return nullptr; 13 14 // Start at Page 0x0 15 uint8_t* nextPageRange = (uint8_t*)0x0; 16 17 // Scan all our memory pages until we meet a page that fits our criteria 18 do 19 { 20 // Query current page 21 if (VirtualQueryEx(hProcess, nextPageRange, &mbi, sizeof(mbi)) == 0) 22 { 23 printf("%d\n", GetLastError()); 24 return nullptr; 25 } 26 27 // TEB blocks are RW Committed Private memory. 28 // For a reason I haven't deciphered yet, TEB pages can be variable length 29 // (PEB/TEB allocation occurs in ntoskrnl.exe!MiCreatePebOrTeb) 30 // So we look for pages that meet the criteria and run a sanity check to pull the TEB 31 if ((mbi.AllocationProtect & PAGE_READWRITE) != 0 32 && mbi.State == MEM_COMMIT 33 && mbi.Type == MEM_PRIVATE) 34 { 35 auto teb = (undocumented_ntapi::TEB*)mbi.BaseAddress; 36 37 // Read TEB from our own process 38 if (hProcess == CURRENT_PROCESS_HANDLE) 39 { 40 // Sanity Check, the NtTib.Self member points to the TEB 41 if ((ULONG_PTR)teb->NtTib.Self == (ULONG_PTR)teb) 42 return teb->ProcessEnvironmentBlock; 43 } 44 // Read TEB from external process memory 45 else 46 { 47 NT_TIB* processTibSelf = nullptr; 48 SIZE_T cbBytesRead = 0; 49 50 // Sanity Check, the NtTib.Self member points to the TEB 51 if (!ReadProcessMemory( 52 hProcess, 53 (uint8_t*)teb + offsetof(NT_TIB, Self), 54 &processTibSelf, 55 sizeof(processTibSelf), 56 &cbBytesRead)) 57 { 58 printf("%d\n", GetLastError()); 59 return nullptr; 60 } 61 62 if ((ULONG_PTR)processTibSelf == (ULONG_PTR)teb) 63 { 64 undocumented_ntapi::PPEB lpPeb = nullptr; 65 66 // Copy the PEB address from process TEB 67 if (!ReadProcessMemory( 68 hProcess, 69 (uint8_t*)teb + offsetof(undocumented_ntapi::TEB, ProcessEnvironmentBlock), 70 &lpPeb, 71 sizeof(lpPeb), 72 &cbBytesRead)) 73 { 74 printf("%d\n", GetLastError()); 75 return nullptr; 76 } 77 78 return lpPeb; 79 } 80 } 81 82 } 83 84 // Page isn't a TEB, move on to the next 85 nextPageRange += mbi.RegionSize; 86 } while (1); 87}
- https://www.geoffchappell.com/studies/windows/km/ntoskrnl/inc/api/pebteb/peb/index.htm
- https://www.geoffchappell.com/studies/windows/km/ntoskrnl/inc/api/pebteb/teb/index.htm
- https://mohamed-fakroud.gitbook.io/red-teamings-dojo/windows-internals/peb
- https://wiki.osdev.org/SWAPGS
- https://www.felixcloutier.com/x86/swapgs
- https://www.intel.com/content/www/us/en/developer/articles/technical/software-security-guidance/best-practices/guidance-enabling-fsgsbase.html
- https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-virtualqueryex
