Goal
Having learned how to obtain a handle to a target process in our previous lab, we’ll now interact with that process’s memory space. Based on the theory from our previous lesson, this involves using specific WinAPI functions designed for cross-process memory manipulation.
Specifically, in this lab we’ll build on our solution from Lab 10.1, so that by the end our application it will be capable of:
- Finding a target process by name and obtain a handle (
hProcess
) with necessary permissions (includingPROCESS_VM_READ
this time). (From Lab 10.1) - Allocate ReadWrite (RW) memory in the target process using
VirtualAllocEx
. - Write a sample string into the allocated remote memory using
WriteProcessMemory
. - Read the string back from the remote memory using
ReadProcessMemory
to verify the write. - Change the remote memory’s protection to ReadOnly (
PAGE_READONLY
) usingVirtualProtectEx
. - Attempt to write to the memory region again (now ReadOnly) using
WriteProcessMemory
, demonstrating the protection change (expecting failure). - Clean up by freeing the allocated remote memory using
VirtualFreeEx
.
Code
A Quick Note on Using 3rd Party Libraries
In developing the code for this lab, I encountered a couple of hurdles, which I thought would have some value in sharing here instead of just showing you the final product.
When I originally wrote the code I assumed that VirtualAllocEx
and VirtualFreeEx
, like almost all the other common win32 API functions, would be found in golang.org/x/sys/windows
. However it soon became clear from compilation errors, and the lack of effect that dependency tidying and cache invalidation had, that something was amiss. (NOTE: Since I work on Mac OS but do these applications with Windows build tags, my IDE suppresses any errors. Just mentioning that in case you are curious why I had to wait for the compiler to point the issue out to me. )
And so I went to the official package docs - https://pkg.go.dev/golang.org/x/sys/windows
- and searched for these 2 functions, only to come up empty handed. In other words, VirtualAllocEx
and VirtualFreeEx
are not in the golang.org/x/sys/windows
library. Now I have no idea whether they were there before but later removed, or if they were never there in the first place, I had just assumed, since most common win32 API functions reside there that they would too.
In any case, we do need them, so that leaves us with 2 options - use CGO (which would allow us to use any Windows function, but is considerably more complex), or find another library candidate. There are pros and cons to both approaches, but for now I would definitely prefer to find another library candidate before resorting to CGO.
To find which package has a specific function you are looking for you can use the search bar right at the top of pkg.go.dev
that says Search Packages or Symbols
, just search for VirtualAllocEx
. Note that you’ll immediately come up empty-handed since it defaults to looking for packages, and well there is no package with this name. So change the selected tab to Symbols
, and then you should see a number of results.
And I actually made another error of poor judgement (or just complacency) here, which I’ll also share. The results of the search are ranked in order of total imports - i.e. how popular a library is over it’s lifetime. So I opted for github.com/AllenDang/w3
, since that ranked at the top, with no further consideration. One can find the function signatures on the package page, so you can then go and adapt it in your code.
I thought this was all to it, and then when I compiled this error came up:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -buildvcs=false
# github.com/AllenDang/w32
/go/pkg/mod/github.com/!allen!dang/w32@v0.0.0-20180428130237-ad0a36d80adc/user32.go:1040:3: cannot use flag (variable of type uint32) as uintptr value in argument to procRedrawWindow.Call
Now this is a compilation error of a completely different nature since it’s not an issue with my code - there’s an issue with the package. And how do we know this? The error user32.go:1040:3: cannot use flag (variable of type uint32) as uintptr value...
is happening inside the source code of the github.com/AllenDang/w32
library itself, specifically in the user32.go
file.
This means that it’s the package code that is breaking. I then went back to the original search results and saw that the package has not been updated since April 18, 2018.
This is a big deal, since I am currently using the latest version of Go (1.23), and Go’s internal syscall
mechanisms and type requirements have evolved significantly since then (especially around Go 1.17 and later). Code written for Go versions pre-2018 might have relied on implicit conversions that are no longer allowed or where function signatures have changed slightly in the underlying Go runtime/syscall implementation.
All to say, this package code is likely no longer compatible with more modern versions of Go. So back to the search results, it’s clear we should not just rely on import total, but also when last it’s been updated. Looking at the next two results:
- The one from
TheTitanrain
has 45 imports, but has not been updated in 5 years, so it’s probably been abandoned. - The next one from
zzl
has slightly less imports (35), but has been updated last year, so is most likely still actively being maintained.
So, in this case I opted for it, and it worked.
Do note however that if we were creating code that’s critical and we wanted to be assured that we’re not relying on a package that the maintainer might lose interest in in a few years, we’d probably have to opt for CGO. Doing the hard work up front, but future-proofing our code. This is however not one of those cases, and so I’m happy to take a bit of a risk. We will however definitely cover CGO in the future since it not only ensures we don’t rely on library code, but sometimes the function we want to use might not exists in a library at all, in which case we have to use CGO.
OK, with all that - let’s get back to the actual code.
Updated Code
//go:build windows
// +build windows
package main
import (
"fmt"
"log"
"os"
"strings"
"unsafe"
"github.com/zzl/go-win32api/v2/win32"
"golang.org/x/sys/windows"
)
// This is our function from Lab 10.1
func findProcessPID(targetName string) (uint32, error) {
handle, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return 0, err
}
defer windows.CloseHandle(handle)
var entry windows.ProcessEntry32
entry.Size = uint32(unsafe.Sizeof(entry))
err = windows.Process32First(handle, &entry)
if err != nil {
return 0, err
}
for {
processName := windows.UTF16ToString(entry.ExeFile[:])
if strings.EqualFold(processName, targetName) {
fmt.Printf("[+] Found target process '%s' with PID: %d\n", targetName, entry.ProcessID)
return entry.ProcessID, nil
}
err = windows.Process32Next(handle, &entry)
if err != nil {
if err == windows.ERROR_NO_MORE_FILES {
break
}
return 0, err
}
}
return 0, fmt.Errorf("process '%s' not found", targetName)
}
func main() {
fmt.Println("[+] Remote Memory Operations Tool")
if len(os.Args) < 2 {
fmt.Printf("Usage: %s <target_process_name.exe>\n", os.Args[0])
return
}
targetProcessName := os.Args[1]
targetPID, err := findProcessPID(targetProcessName)
if err != nil {
log.Fatalf("[-] Failed to find PID: %v", err)
}
// --- Define Access Rights (using windows package constants) ---
desiredAccess := uint32(windows.PROCESS_CREATE_THREAD |
windows.PROCESS_QUERY_INFORMATION |
windows.PROCESS_VM_OPERATION |
windows.PROCESS_VM_WRITE |
windows.PROCESS_VM_READ)
fmt.Printf("[*] Attempting to open process PID %d with access rights: 0x%X\n", targetPID, desiredAccess)
// --- Open Target Process (using windows package) ---
hProcess, err := windows.OpenProcess(desiredAccess, false, targetPID)
if err != nil {
log.Fatalf("[-] OpenProcess failed: %v", err)
}
fmt.Printf("[+] Successfully obtained handle: 0x%X\n", hProcess)
// Ensure handle is closed eventually (using windows package)
defer windows.CloseHandle(hProcess)
// --- Allocate Memory Remotely (using zzl/go-win32api) ---
const memSize = 1024 // Allocate 1KB for example
fmt.Printf("[*] Allocating %d bytes in target process (RW)...\n", memSize)
// Use win32.VirtualAllocEx from zzl/go-win32api
remoteAddrPtr, errCode := win32.VirtualAllocEx(
win32.HANDLE(hProcess), // Cast windows.Handle to win32.HANDLE
nil, // Let system choose address (pass nil for unsafe.Pointer)
uintptr(memSize), // Size
win32.MEM_COMMIT|win32.MEM_RESERVE, // Allocation type from win32 pkg
win32.PAGE_READWRITE, // Protection flags from win32 pkg
)
// Error check: uses returned WIN32_ERROR code
if errCode != win32.NO_ERROR {
log.Fatalf("[-] win32.VirtualAllocEx failed (Error code: %d)", errCode)
}
if remoteAddrPtr == nil { // Safety check
log.Fatalf("[-] win32.VirtualAllocEx succeeded (NO_ERROR) but returned nil address.")
}
// Convert unsafe.Pointer to uintptr for general use (printing, passing to windows funcs)
remoteAddrUintptr := uintptr(remoteAddrPtr)
fmt.Printf("[+] Allocated %d bytes at remote address: 0x%X\n", remoteAddrUintptr, remoteAddrUintptr) // Use uintptr for printing
// Ensure memory is freed eventually (using zzl/go-win32api in defer)
defer func(addrToFreeUintptr uintptr) { // Pass uintptr address to defer
if addrToFreeUintptr == 0 {
return // Don't try to free if allocation failed
}
fmt.Println("[*] Freeing remote memory...")
// Use win32.VirtualFreeEx from zzl/go-win32api
// Convert uintptr address back to unsafe.Pointer for the call
_, errCode := win32.VirtualFreeEx(
win32.HANDLE(hProcess), // Cast handle
unsafe.Pointer(addrToFreeUintptr), // Address to free
0, // dwSize (must be 0 for MEM_RELEASE)
win32.MEM_RELEASE, // Free type from win32 pkg
)
// Error check: uses returned WIN32_ERROR code
if errCode != win32.NO_ERROR {
log.Printf("[!] Warning: win32.VirtualFreeEx failed (Error code: %d)", errCode)
} else {
fmt.Println("[+] Remote memory freed.")
}
}(remoteAddrUintptr) // Pass the uintptr address to the deferred function
// --- Write to Remote Memory (Keep using windows package) ---
dataToWrite := []byte("Hello from remote process! \\o/\000")
var bytesWritten uintptr
fmt.Printf("[*] Writing %d bytes to remote address 0x%X...\n", len(dataToWrite), remoteAddrUintptr) // Use uintptr address
err = windows.WriteProcessMemory(hProcess, remoteAddrUintptr, &dataToWrite[0], uintptr(len(dataToWrite)), &bytesWritten) // Use uintptr address
if err != nil {
log.Fatalf("[-] WriteProcessMemory failed: %v", err)
}
if bytesWritten != uintptr(len(dataToWrite)) {
log.Fatalf("[-] WriteProcessMemory: incomplete write (%d/%d bytes)", bytesWritten, len(dataToWrite))
}
fmt.Printf("[+] Successfully wrote %d bytes.\n", bytesWritten)
// --- Read Back from Remote Memory (Keep using windows package) ---
readBuffer := make([]byte, len(dataToWrite))
var bytesRead uintptr
fmt.Printf("[*] Reading %d bytes back from remote address 0x%X...\n", len(readBuffer), remoteAddrUintptr) // Use uintptr address
err = windows.ReadProcessMemory(hProcess, remoteAddrUintptr, &readBuffer[0], uintptr(len(readBuffer)), &bytesRead) // Use uintptr address
if err != nil {
log.Fatalf("[-] ReadProcessMemory failed: %v", err)
}
if bytesRead != uintptr(len(readBuffer)) {
log.Fatalf("[-] ReadProcessMemory: incomplete read (%d/%d bytes)", bytesRead, len(readBuffer))
}
fmt.Printf("[+] Successfully read %d bytes: \"%s\"\n", bytesRead, string(readBuffer))
// Verify content
if string(readBuffer) != string(dataToWrite) {
log.Println("[!] Warning: Read data does not match written data!")
} else {
fmt.Println("[+] Read data verification successful.")
}
// --- Change Remote Memory Protection using windows package ---
var oldProtect uint32
// Use constant from windows package as input to windows.VirtualProtectEx
newProtect := uint32(windows.PAGE_READONLY)
fmt.Printf("[*] Changing protection of remote address 0x%X to PAGE_READONLY (0x%X)...\n", remoteAddrUintptr, newProtect) // Use uintptr address
// Use windows package for VirtualProtectEx
err = windows.VirtualProtectEx(hProcess, remoteAddrUintptr, uintptr(memSize), newProtect, &oldProtect) // Use uintptr address
if err != nil {
log.Fatalf("[-] VirtualProtectEx failed: %v", err)
}
fmt.Printf("[+] Protection changed successfully. Old protection was: 0x%X\n", oldProtect)
// --- Attempt Second Write using windows package ---
secondData := []byte("Attempting second write...\000")
fmt.Printf("[*] Attempting to write again to remote address 0x%X (should fail)...\n", remoteAddrUintptr) // Use uintptr address
// Use windows package for WriteProcessMemory
err = windows.WriteProcessMemory(hProcess, remoteAddrUintptr, &secondData[0], uintptr(len(secondData)), &bytesWritten) // Use uintptr address
if err != nil {
fmt.Printf("[+] WriteProcessMemory failed as expected after changing protection: %v\n", err)
} else {
log.Printf("[!] Warning: WriteProcessMemory succeeded unexpectedly after setting PAGE_READONLY!")
}
// --- Final Cleanup (handled by defers) ---
fmt.Println("[+] Lab complete.")
}
Code Breakdown
findProcessPID
Function:
- This is our function from Lab 10.1, it uses functions from
golang.org/x/sys/windows
(CreateToolhelp32Snapshot
,Process32First
,Process32Next
,CloseHandle
) to find and return the target process PID.
Access Rights:
- Defines
desiredAccess
using constants fromgolang.org/x/sys/windows
(PROCESS_VM_READ
,VM_OPERATION
,VM_WRITE
, etc.).
OpenProcess
:
- Opens the target process using
windows.OpenProcess
with the specified rights, obtaininghProcess
(awindows.Handle
). Adefer windows.CloseHandle(hProcess)
ensures the handle is closed on exit.
VirtualAllocEx
(using zzl/go-win32api
):
- Calls
win32.VirtualAllocEx
from thegithub.com/zzl/go-win32api/v2/win32
package. - Passes the process handle cast to
win32.HANDLE(hProcess)
,nil
for the address (unsafe.Pointer
), thememSize
(uintptr
), allocation type constants from thewin32
package (win32.MEM_COMMIT | win32.MEM_RESERVE
), and initial protection flags from thewin32
package (win32.PAGE_READWRITE
). - Receives the remote base address as
remoteAddrPtr
(unsafe.Pointer
) and an error codeerrCode
(win32.WIN32_ERROR
). - Checks for allocation failure by comparing
errCode
withwin32.NO_ERROR
. - Converts the
unsafe.Pointer
address touintptr
(remoteAddrUintptr
) for easier use with other functions and printing. - Adds a
defer
statement containingwin32.VirtualFreeEx
(see next point) to ensure remote memory is freed.
VirtualFreeEx
(within defer
, using zzl/go-win32api
):
- The deferred function calls
win32.VirtualFreeEx
. - Passes
win32.HANDLE(hProcess)
, the address cast back tounsafe.Pointer(remoteAddrUintptr)
,0
for size, and the free type constantwin32.MEM_RELEASE
. - Checks the returned
errCode
againstwin32.NO_ERROR
to determine success or failure.
WriteProcessMemory
(using golang.org/x/sys/windows
):
- Defines sample
dataToWrite
. - Calls
windows.WriteProcessMemory
usinghProcess
, theremoteAddrUintptr
, a pointer to the data, data length, and a pointer tobytesWritten
. - Performs error checking based on the returned
error
and verifies the number of bytes written.
ReadProcessMemory
(using golang.org/x/sys/windows
):
- Creates local
readBuffer
. - Calls
windows.ReadProcessMemory
usinghProcess
,remoteAddrUintptr
, a pointer to the buffer, buffer length, and a pointer tobytesRead
. - Performs error checking and verifies bytes read, comparing read data to original data.
VirtualProtectEx
(using golang.org/x/sys/windows
):
- Calls
windows.VirtualProtectEx
usinghProcess
,remoteAddrUintptr
,memSize
(cast touintptr
), the new protection constantwindows.PAGE_READONLY
, and a pointer tooldProtect
. - Checks the returned
error
.
Second Write Attempt (using golang.org/x/sys/windows
):
- Calls
windows.WriteProcessMemory
again to the sameremoteAddrUintptr
. - Expects this call to fail due to the memory now being read-only. Error handling confirms that an error (
err != nil
) is expected.
Instructions
Remember to add the third library manually using go get
(see below), or go mod tidy
should handle it automatically.
go get github.com/zzl/go-win32api/v2/win32
Once again compile it for Windows, since I’m working on Mac OS I will use:
GOOS=windows GOARCH=amd64 go build -buildvcs=false
I’ll once again use notepad.exe, so I’ll open that first, and then run:
.\proc_mem_write.exe notepad.exe
Results
PS C:\Users\vuilhond\Desktop> .\proc_mem_write.exe notepad.exe
[+] Remote Memory Operations Tool
[+] Found target process 'notepad.exe' with PID: 9412
[*] Attempting to open process PID 9412 with access rights: 0x43A
[+] Successfully obtained handle: 0x16C
[*] Allocating 1024 bytes in target process (RW)...
[+] Allocated 3115692654592 bytes at remote address: 0x2D56DC10000
[*] Writing 31 bytes to remote address 0x2D56DC10000...
[+] Successfully wrote 31 bytes.
[*] Reading 31 bytes back from remote address 0x2D56DC10000...
[+] Successfully read 31 bytes: "Hello from remote process! \o/"
[+] Read data verification successful.
[*] Changing protection of remote address 0x2D56DC10000 to PAGE_READONLY (0x2)...
[+] Protection changed successfully. Old protection was: 0x4
[*] Attempting to write again to remote address 0x2D56DC10000 (should fail)...
[+] WriteProcessMemory failed as expected after changing protection: Invalid access to memory location.
[+] Lab complete.
[*] Freeing remote memory...
[+] Remote memory freed.
- We can see that is with the previous lab, a handle to
notepad.exe
is successfully obtained. - Memory is allocated in
notepad.exe
’s address space. - Our string is then successfully written and read back correctly.
VirtualProtectEx
successfully changes the protection toPAGE_READONLY
(0x2) from the originalPAGE_READWRITE
(0x4).- The second
WriteProcessMemory
call fails, likely with an “Access is denied” error, confirming the memory is no longer writable. - Cleanup occurs successfully.
Discussion
This lab successfully demonstrates the use of VirtualAllocEx
, WriteProcessMemory
, ReadProcessMemory
, and VirtualProtectEx
to manipulate the memory of another process. We allocated memory, wrote data to it, verified the write by reading it back, and successfully changed its permissions to prevent further writes.
This sequence forms the core preparation phase for many remote process injection techniques. Instead of writing a simple string, we would write our shellcode to the remoteAddr
. Instead of changing the protection to PAGE_READONLY
, we would change it to PAGE_EXECUTE_READ
(RX).
Conclusion
We have now mastered the WinAPI functions required to allocate, write, read, and modify the protection of memory in a remote process using Go. This foundational capability allows us to place our payload precisely where we need it in the target’s address space and set the stage for its execution.
The next logical step is now to actually trigger the execution of the code residing at remoteAddr
within the target process.