Files
go_pwner/procmem_windows.go
T
2026-07-18 21:37:15 +03:00

285 lines
10 KiB
Go

//go:build windows
package winpwn
import (
"debug/pe"
"errors"
"fmt"
"io"
"os/exec"
"path/filepath"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
// ProcessMemory is an io.ReaderAt/io.WriterAt over a remote process's
// address space, anchored at a base address -- the live-memory analogue of
// reading bytes off disk. Offset 0 in ReadAt/WriteAt means "Base itself",
// matching how a memory-backed PEFile's RVAToFileOffset treats RVAs as
// identical to read offsets: that's exactly what an RVA means once an
// image is actually loaded.
type ProcessMemory struct {
Handle windows.Handle
Base uintptr
}
// OpenProcessMemory opens pid for VM read/write, anchored at base -- the
// building block behind OpenPEFromProcess, but also useful standalone for
// any arbitrary-read/write-shaped primitive once you have a target address
// (Phase 8's ARW interface is satisfied directly by *ProcessMemory).
func OpenProcessMemory(pid uint32, base uintptr) (*ProcessMemory, error) {
h, err := windows.OpenProcess(
windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ|windows.PROCESS_VM_WRITE|windows.PROCESS_VM_OPERATION,
false, pid)
if err != nil {
return nil, err
}
return &ProcessMemory{Handle: h, Base: base}, nil
}
func (m *ProcessMemory) Close() error {
return windows.CloseHandle(m.Handle)
}
func (m *ProcessMemory) ReadAt(p []byte, off int64) (int, error) {
if off < 0 {
return 0, errors.New("ProcessMemory.ReadAt: negative offset")
}
if len(p) == 0 {
return 0, nil
}
var n uintptr
err := windows.ReadProcessMemory(m.Handle, m.Base+uintptr(off), &p[0], uintptr(len(p)), &n)
if err != nil {
return int(n), err
}
if int(n) < len(p) {
return int(n), io.ErrUnexpectedEOF
}
return int(n), nil
}
func (m *ProcessMemory) WriteAt(p []byte, off int64) (int, error) {
if off < 0 {
return 0, errors.New("ProcessMemory.WriteAt: negative offset")
}
if len(p) == 0 {
return 0, nil
}
var n uintptr
err := windows.WriteProcessMemory(m.Handle, m.Base+uintptr(off), &p[0], uintptr(len(p)), &n)
return int(n), err
}
// OpenPEFromProcess opens the PE module loaded at base inside pid's address
// space, the live-process analogue of OpenPE. Every PEFile accessor
// (Checksec, ListExports/ListImports, the ROP gadget scanner, PatchBytes)
// works against it exactly as it does against a disk-backed PEFile -- the
// whole point of routing everything through PEFile.r/.w/.RVAToFileOffset.
// Find base with ResolveModuleBase, or read it straight from a leaked
// pointer once you have one.
func OpenPEFromProcess(pid uint32, base uintptr) (*PEFile, error) {
mem, err := OpenProcessMemory(pid, base)
if err != nil {
return nil, err
}
f, err := pe.NewFile(mem)
if err != nil {
mem.Close()
return nil, err
}
pf := &PEFile{File: f, r: mem, w: mem, closer: mem, live: true}
if h, herr := pf.header(); herr == nil {
pf.size = int64(h.sizeOfImage)
}
return pf, nil
}
// ResolveModuleBase walks pid's PEB -> Ldr -> InMemoryOrderModuleList to
// find moduleName's load address (e.g. "kernel32.dll", "ntdll.dll", or the
// process's own main-module file name), matched against the loader's
// FullDllName case-insensitively by base name.
//
// This is the live, cross-process twin of
// shellcode/asm/resolver.inc's get_kernel32_base: that NASM walks the exact
// same PEB/Ldr chain from *inside* the target process at IP-control time
// with no Windows API calls available; this does it from *outside*, with a
// debugger/exploit-tooling process's full WinAPI access, which is why it
// can resolve any module by name instead of relying on a fixed loader
// order. It's the real Windows analogue of how pwntools' DynELF defeats
// ASLR by walking ELF structures through a leak oracle -- here the "oracle"
// is ReadProcessMemory itself.
func ResolveModuleBase(pid uint32, moduleName string) (uintptr, error) {
h, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, pid)
if err != nil {
return 0, err
}
defer windows.CloseHandle(h)
var pbi windows.PROCESS_BASIC_INFORMATION
var retLen uint32
if err := windows.NtQueryInformationProcess(h, windows.ProcessBasicInformation,
unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), &retLen); err != nil {
return 0, fmt.Errorf("NtQueryInformationProcess(ProcessBasicInformation): %w", err)
}
pebAddr := uintptr(unsafe.Pointer(pbi.PebBaseAddress))
if pebAddr == 0 {
return 0, errors.New("PEB address is null")
}
peb, err := readRemoteStruct[windows.PEB](h, pebAddr)
if err != nil {
return 0, fmt.Errorf("read PEB: %w", err)
}
ldrAddr := uintptr(unsafe.Pointer(peb.Ldr))
if ldrAddr == 0 {
return 0, errors.New("PEB.Ldr is null -- ntdll's loader (LdrInitializeThunk) hasn't run in this process yet; " +
"this is normal for a CREATE_SUSPENDED process before it's resumed, retry shortly after ResumeMainThread")
}
ldr, err := readRemoteStruct[windows.PEB_LDR_DATA](h, ldrAddr)
if err != nil {
return 0, fmt.Errorf("read PEB_LDR_DATA: %w", err)
}
// Flink/Blink point at the InMemoryOrderLinks *field* of each entry, not
// at the start of its LDR_DATA_TABLE_ENTRY -- the field sits at a
// nonzero offset (0x10 on x64: it's the second of three LIST_ENTRYs at
// the head of the real struct), so every address read off the list has
// to be corrected by that offset before it's used as an entry address.
entryLinksOffset := unsafe.Offsetof(windows.LDR_DATA_TABLE_ENTRY{}.InMemoryOrderLinks)
headAddr := ldrAddr + unsafe.Offsetof(windows.PEB_LDR_DATA{}.InMemoryOrderModuleList)
cur := uintptr(unsafe.Pointer(ldr.InMemoryOrderModuleList.Flink))
for cur != 0 && cur != headAddr {
entryAddr := cur - entryLinksOffset
entry, err := readRemoteStruct[windows.LDR_DATA_TABLE_ENTRY](h, entryAddr)
if err != nil {
return 0, fmt.Errorf("read LDR_DATA_TABLE_ENTRY: %w", err)
}
if name, err := readRemoteUTF16(h, entry.FullDllName); err == nil {
if strings.EqualFold(moduleBaseName(name), moduleName) {
return entry.DllBase, nil
}
}
cur = uintptr(unsafe.Pointer(entry.InMemoryOrderLinks.Flink))
}
return 0, fmt.Errorf("module %q not found in process %d's loaded module list", moduleName, pid)
}
// readRemoteStruct copies sizeof(T) bytes from h's address space at addr
// into a T, by raw memcpy through ReadProcessMemory -- safe specifically
// because T's fields here are always fixed-width (uintptr/pointer-shaped)
// values being read as bit patterns, never dereferenced as if they were
// local pointers.
func readRemoteStruct[T any](h windows.Handle, addr uintptr) (T, error) {
var v T
size := int(unsafe.Sizeof(v))
buf := make([]byte, size)
var n uintptr
if err := windows.ReadProcessMemory(h, addr, &buf[0], uintptr(size), &n); err != nil {
return v, err
}
if int(n) < size {
return v, io.ErrUnexpectedEOF
}
return *(*T)(unsafe.Pointer(&buf[0])), nil
}
func readRemoteUTF16(h windows.Handle, s windows.NTUnicodeString) (string, error) {
if s.Buffer == nil || s.Length == 0 {
return "", errors.New("empty NTUnicodeString")
}
buf := make([]uint16, s.Length/2)
var n uintptr
if err := windows.ReadProcessMemory(h, uintptr(unsafe.Pointer(s.Buffer)),
(*byte)(unsafe.Pointer(&buf[0])), uintptr(s.Length), &n); err != nil {
return "", err
}
return windows.UTF16ToString(buf), nil
}
func moduleBaseName(path string) string {
if idx := strings.LastIndexAny(path, `/\`); idx >= 0 {
path = path[idx+1:]
}
return path
}
// SpawnSuspended launches target with CREATE_SUSPENDED, returning the Tube
// (stdin/stdout wired exactly like Spawn) and the new process's PID, with
// the main thread parked before it executes a single instruction.
//
// Verified by testing, worth recording because the obvious assumption is
// wrong: you can NOT resolve module bases via ResolveModuleBase while the
// thread is still suspended. CREATE_SUSPENDED only pins the thread before
// its start routine runs, and that start routine *is*
// ntdll!LdrInitializeThunk -- the loader code that populates
// PEB->Ldr->InMemoryOrderModuleList in the first place. Until it runs,
// PEB.Ldr reads back as null (confirmed empirically: see the smoke test in
// this package's history). What this primitive is actually for is pausing
// *before the loader and entry point run*, e.g. to let Phase 4's debugger
// attach and plant a breakpoint before any application/TLS-callback code
// executes. Call ResumeMainThread(pid), then poll ResolveModuleBase for a
// few milliseconds (the loader runs fast, but it isn't instant) once you
// actually need module bases.
func SpawnSuspended(target string) (tube *Tube, pid uint32, err error) {
if abs, aerr := filepath.Abs(target); aerr == nil {
target = abs
}
cmd := exec.Command(target)
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_SUSPENDED}
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, 0, fmt.Errorf("StdinPipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, 0, fmt.Errorf("StdoutPipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, 0, fmt.Errorf("Start: %w", err)
}
return newTube(cmd, nil, stdin, stdout), uint32(cmd.Process.Pid), nil
}
// ResumeMainThread resumes a process started with SpawnSuspended. A
// CREATE_SUSPENDED process has exactly one thread (its initial thread)
// until something resumes it, so finding "the" thread to resume is just
// finding the one thread Toolhelp reports for pid -- there's no race with
// the target spawning more threads first, because it hasn't run yet.
func ResumeMainThread(pid uint32) error {
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
if err != nil {
return fmt.Errorf("CreateToolhelp32Snapshot: %w", err)
}
defer windows.CloseHandle(snap)
var entry windows.ThreadEntry32
entry.Size = uint32(unsafe.Sizeof(entry))
for err = windows.Thread32First(snap, &entry); err == nil; err = windows.Thread32Next(snap, &entry) {
if entry.OwnerProcessID != pid {
continue
}
th, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID)
if err != nil {
return fmt.Errorf("OpenThread(%d): %w", entry.ThreadID, err)
}
_, err = windows.ResumeThread(th)
windows.CloseHandle(th)
return err
}
return fmt.Errorf("no thread found for process %d", pid)
}