Traverse the Process Environment Block (PEB) in Rust
A few days ago, we demonstrated how to call calc.exe by walking the PEB manually. But why isn't assembly code more widely used in the wild, especially in offensive tooling? Assembly has several major drawbacks: Easy to write, hard to read Hard to maintain (an actual nightmare) Difficult to compose or reuse Painful to debug In short, it’s only “good” for writing once—and then you hope you never have to read it again. Why Rust? Rust offers powerful memory safety guarantees while still allowing low-level control. It’s a great fit for scenarios like shellcode loading, manual API resolution, and process injection—situations where C or C++ might traditionally be used. Sounds promising? Let’s give it a shot! ⚠️ Warning: This requires manually defining C-style structures such as PEB, PEB_LDR_DATA, LDR_DATA_TABLE_ENTRY, IMAGE_DOS_HEADER, and IMAGE_NT_HEADERS, and heavy use of unsafe blocks. Overview: Manual API Resolution via the PEB Process NtCurrentTeb() -> PEB → PEB_LDR_DATA → InMemoryOrderModuleList → Find kernel32.dll → Parse PE header → Export Directory → Find AddressOfNames, AddressOfNameOrdinals, AddressOfFunctions → Match function name, resolve VA from RVA Step-by-Step Walkthrough Walking the PEB fn get_peb() ->*mut PEB { let mut peb: *mut PEB; unsafe { asm!( "mov {peb}, gs:[0x60]", peb = out(reg) peb, ); } peb } Accessing PEB_LDR_DATA let peb = get_peb(); if peb.is_null() { return None; } let ldr = (*peb).loader_data; if ldr.is_null() { return None; } Walking InMemoryOrderModuleList to Find kernel32.dll let list = &(*ldr).in_memory_order_module_list; let list_head = list as *const _ as usize; let mut link = list.flink; while (link as usize) != list_head { let entry = (link as usize - offset_of!(LoaderDataTableEntry, in_memory_order_links)) as *mut LoaderDataTableEntry; if entry.is_null() { break; } let base = (*entry).dll_base; let name = &(*entry).base_dll_name; let name_slice = std::slice::from_raw_parts(name.buffer, (name.length / 2) as usize); let name_str = String::from_utf16_lossy(name_slice); if name_str.to_lowercase().contains("kernel32.dll") { println!("[+] kernel32.dll base: {:p}", base); return Some(base as *mut c_void); } link = (*link).flink; } Parsing the Export Table unsafe fn get_export_directory(kernel32_base: *mut u8) -> *const ImageExportDirectory { let dos_header = kernel32_base as *const ImageDosHeader; let nt_header = kernel32_base.add((*dos_header).e_lfanew as usize) as *const ImageNtHeaders; let export_directory_rva = (*nt_header).optional_header.data_directory[IMAGE_DIRECTORY_ENTRY_EXPORT as usize].virtual_address; kernel32_base.add(export_directory_rva as usize) as *const ImageExportDirectory } Finding WinExec unsafe fn find_function(kernel32_base: *mut u8, function_name: &str) -> *mut u8 { let export_directory = get_export_directory(kernel32_base); let name_rva = (*export_directory).address_of_names; let names = kernel32_base.offset(name_rva as isize) as *const u32; let ordinals_rva = (*export_directory).address_of_name_ordinals; let ordinals = kernel32_base.offset(ordinals_rva as isize) as *const u16; let function_rva = (*export_directory).address_of_functions; let functions = kernel32_base.offset(function_rva as isize) as *const u32; for i in 0..(*export_directory).number_of_names { let name_rva = *names.offset(i as isize); let name_ptr = kernel32_base.offset(name_rva as isize) as *const i8; let name = std::ffi::CStr::from_ptr(name_ptr).to_str().unwrap(); if name == function_name { let ordinal = *ordinals.offset(i as isize); let func_rva = *functions.offset(ordinal as isize); return kernel32_base.offset(func_rva as isize); } } null_mut() } let winexec_addr = find_function(kernel32_base, "WinExec"); Full Code Example #![feature(asm)] #[warn(unused_imports)] #[warn(unsafe_op_in_unsafe_fn)] use core::arch::asm; use std::{ffi::{c_void, CString}, mem::transmute, ptr::null_mut}; use memoffset::offset_of; mod utils; use utils::{PEB, LoaderDataTableEntry, ImageExportDirectory, ImageDosHeader, ImageNtHeaders}; const IMAGE_DIRECTORY_ENTRY_EXPORT: usize = 0; fn get_peb() ->*mut PEB { let mut peb: *mut PEB; unsafe { asm!( "mov {peb}, gs:[0x60]", peb = out(reg) peb, ); } peb } unsafe fn find_kernel32_base() -> Option{ let peb = get_peb(); if peb.is_null() { return None; } let ldr = (*peb).loader_data; if ldr.is_null() { return None; } let list = &(*ldr).in_memory_order_module_list; let list_head = list as *const _ as usize; let mut li

A few days ago, we demonstrated how to call calc.exe
by walking the PEB manually. But why isn't assembly code more widely used in the wild, especially in offensive tooling?
Assembly has several major drawbacks:
- Easy to write, hard to read
- Hard to maintain (an actual nightmare)
- Difficult to compose or reuse
- Painful to debug
In short, it’s only “good” for writing once—and then you hope you never have to read it again.
Why Rust?
Rust offers powerful memory safety guarantees while still allowing low-level control. It’s a great fit for scenarios like shellcode loading, manual API resolution, and process injection—situations where C or C++ might traditionally be used.
Sounds promising? Let’s give it a shot!
⚠️ Warning: This requires manually defining C-style structures such as
PEB
,PEB_LDR_DATA
,LDR_DATA_TABLE_ENTRY
,IMAGE_DOS_HEADER
, andIMAGE_NT_HEADERS
, and heavy use ofunsafe
blocks.
Overview: Manual API Resolution via the PEB
Process
NtCurrentTeb() -> PEB
→
PEB_LDR_DATA
→
InMemoryOrderModuleList
→
Find kernel32.dll
→
Parse PE header → Export Directory
→
Find AddressOfNames, AddressOfNameOrdinals, AddressOfFunctions
→
Match function name, resolve VA from RVA
Step-by-Step Walkthrough
Walking the PEB
fn get_peb() ->*mut PEB {
let mut peb: *mut PEB;
unsafe {
asm!(
"mov {peb}, gs:[0x60]",
peb = out(reg) peb,
);
}
peb
}
Accessing PEB_LDR_DATA
let peb = get_peb();
if peb.is_null() {
return None;
}
let ldr = (*peb).loader_data;
if ldr.is_null() {
return None;
}
Walking InMemoryOrderModuleList to Find kernel32.dll
let list = &(*ldr).in_memory_order_module_list;
let list_head = list as *const _ as usize;
let mut link = list.flink;
while (link as usize) != list_head {
let entry = (link as usize - offset_of!(LoaderDataTableEntry, in_memory_order_links))
as *mut LoaderDataTableEntry;
if entry.is_null() {
break;
}
let base = (*entry).dll_base;
let name = &(*entry).base_dll_name;
let name_slice = std::slice::from_raw_parts(name.buffer, (name.length / 2) as usize);
let name_str = String::from_utf16_lossy(name_slice);
if name_str.to_lowercase().contains("kernel32.dll") {
println!("[+] kernel32.dll base: {:p}", base);
return Some(base as *mut c_void);
}
link = (*link).flink;
}
Parsing the Export Table
unsafe fn get_export_directory(kernel32_base: *mut u8) -> *const ImageExportDirectory {
let dos_header = kernel32_base as *const ImageDosHeader;
let nt_header = kernel32_base.add((*dos_header).e_lfanew as usize) as *const ImageNtHeaders;
let export_directory_rva = (*nt_header).optional_header.data_directory[IMAGE_DIRECTORY_ENTRY_EXPORT as usize].virtual_address;
kernel32_base.add(export_directory_rva as usize) as *const ImageExportDirectory
}
Finding WinExec
unsafe fn find_function(kernel32_base: *mut u8, function_name: &str) -> *mut u8 {
let export_directory = get_export_directory(kernel32_base);
let name_rva = (*export_directory).address_of_names;
let names = kernel32_base.offset(name_rva as isize) as *const u32;
let ordinals_rva = (*export_directory).address_of_name_ordinals;
let ordinals = kernel32_base.offset(ordinals_rva as isize) as *const u16;
let function_rva = (*export_directory).address_of_functions;
let functions = kernel32_base.offset(function_rva as isize) as *const u32;
for i in 0..(*export_directory).number_of_names {
let name_rva = *names.offset(i as isize);
let name_ptr = kernel32_base.offset(name_rva as isize) as *const i8;
let name = std::ffi::CStr::from_ptr(name_ptr).to_str().unwrap();
if name == function_name {
let ordinal = *ordinals.offset(i as isize);
let func_rva = *functions.offset(ordinal as isize);
return kernel32_base.offset(func_rva as isize);
}
}
null_mut()
}
let winexec_addr = find_function(kernel32_base, "WinExec");
Full Code Example
#![feature(asm)]
#[warn(unused_imports)]
#[warn(unsafe_op_in_unsafe_fn)]
use core::arch::asm;
use std::{ffi::{c_void, CString}, mem::transmute, ptr::null_mut};
use memoffset::offset_of;
mod utils;
use utils::{PEB, LoaderDataTableEntry, ImageExportDirectory, ImageDosHeader, ImageNtHeaders};
const IMAGE_DIRECTORY_ENTRY_EXPORT: usize = 0;
fn get_peb() ->*mut PEB {
let mut peb: *mut PEB;
unsafe {
asm!(
"mov {peb}, gs:[0x60]",
peb = out(reg) peb,
);
}
peb
}
unsafe fn find_kernel32_base() -> Option<*mut c_void>{
let peb = get_peb();
if peb.is_null() {
return None;
}
let ldr = (*peb).loader_data;
if ldr.is_null() {
return None;
}
let list = &(*ldr).in_memory_order_module_list;
let list_head = list as *const _ as usize;
let mut link = list.flink;
while (link as usize) != list_head {
let entry = (link as usize - offset_of!(LoaderDataTableEntry, in_memory_order_links))
as *mut LoaderDataTableEntry;
if entry.is_null() {
break;
}
let base = (*entry).dll_base;
let name = &(*entry).base_dll_name;
let name_slice = std::slice::from_raw_parts(name.buffer, (name.length / 2) as usize);
let name_str = String::from_utf16_lossy(name_slice);
if name_str.to_lowercase().contains("kernel32.dll") {
println!("[+] kernel32.dll base: {:p}", base);
return Some(base as *mut c_void);
}
link = (*link).flink;
}
None
}
unsafe fn get_export_directory(kernel32_base: *mut u8) -> *const ImageExportDirectory {
let dos_header = kernel32_base as *const ImageDosHeader;
let nt_header = kernel32_base.add((*dos_header).e_lfanew as usize) as *const ImageNtHeaders;
let export_directory_rva = (*nt_header).optional_header.data_directory[IMAGE_DIRECTORY_ENTRY_EXPORT as usize].virtual_address;
kernel32_base.add(export_directory_rva as usize) as *const ImageExportDirectory
}
unsafe fn find_function(kernel32_base: *mut u8, function_name: &str) -> *mut u8 {
let export_directory = get_export_directory(kernel32_base);
let name_rva = (*export_directory).address_of_names;
let names = kernel32_base.offset(name_rva as isize) as *const u32;
let ordinals_rva = (*export_directory).address_of_name_ordinals;
let ordinals = kernel32_base.offset(ordinals_rva as isize) as *const u16;
let function_rva = (*export_directory).address_of_functions;
let functions = kernel32_base.offset(function_rva as isize) as *const u32;
for i in 0..(*export_directory).number_of_names {
let name_rva = *names.offset(i as isize);
let name_ptr = kernel32_base.offset(name_rva as isize) as *const i8;
let name = std::ffi::CStr::from_ptr(name_ptr).to_str().unwrap();
if name == function_name {
let ordinal = *ordinals.offset(i as isize);
let func_rva = *functions.offset(ordinal as isize);
return kernel32_base.offset(func_rva as isize);
}
}
null_mut()
}
fn main() {
unsafe {
let kernel32_base = match find_kernel32_base() {
Some(ptr) => {
println!("Found kernel32.dll base at {:p}", ptr);
ptr as *mut u8
}
None => {
println!("kernel32.dll not found");
return;
}
};
// Find WinExec from export table
let get_proc_address = find_function(kernel32_base, "GetProcAddress");
let load_library_a = find_function(kernel32_base, "LoadLibraryA");
let winexec_addr = find_function(kernel32_base, "WinExec");
let exitprocess_addr = find_function(kernel32_base, "ExitProcess");
println!("GetProcAddress address: {:p}", get_proc_address);
println!("LoadLibraryA address: {:p}", load_library_a);
println!("WinExec address: {:p}", winexec_addr);
let winexec: extern "system" fn(*const i8, u32) -> u32 = transmute(winexec_addr);
let exitprocess: extern "system" fn(u32) -> ! = transmute(exitprocess_addr);
let calc_string = CString::new(r"C:\Windows\System32\calc.exe").unwrap();
let call_calc = winexec(calc_string.as_ptr(), 1);
println!("WinExec returned: {:#x}", call_calc);
exitprocess(0);
};
}
Comparison: Rust vs Assembly
Compiled Rust Binary
$ cargo build --release
$ ls calc.exe
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2025/5/2 05:35 PM 165376 calc.exe
Assembly Binary
$ ls .\asm.exe
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2025/5/2 05:37 PM 127488 asm.exe
Without using the standard library (#![no_std]), the binary size is surprisingly close to its assembly counterpart.