package winpwn import ( "encoding/binary" "fmt" "io" ) // This file is the foundation layer of winpwn's heap-structure parsing // (ROADMAP.md's Phase 9): decoding the NT Heap's on-disk^Won-memory layout // directly off a ReaderAt, the same "works identically against a file or a // live process" design PEFile already uses for PE images. Everything here // works against any io.ReaderAt -- a *ProcessMemory, a *Debugger (via a // thin adapter), or a synthetic in-memory buffer in a test -- so it has no // build tag despite being Windows-structure-specific; only *finding* a // heap address in a live process (heap_windows.go's ListProcessHeaps) needs // actual Windows syscalls. // // Every offset/field below was cross-checked against `dt ntdll!_HEAP` and // friends via cdb (public ntdll symbols carry full type info even without // source) on this machine's build (10.0.26100, Windows 11 24H2) -- not // copied from a blog post and not guessed from memory. The decode logic // specifically (DecodeHeapEntry's XOR-encoding handling and the // Size/UnusedBytes arithmetic) was verified against `!heap -a`'s own // ground-truth entry listing for three real allocations of different // sizes before being trusted -- see heap_test.go's // TestDecodeHeapEntryMatchesLiveGroundTruth for the exact captured bytes // and the discovery that cost the most back-and-forth: UserSize is // `Size*HeapEntrySize - UnusedBytes` with NO separate subtraction for the // header, not the more "obvious" `Size*HeapEntrySize - HeapEntrySize - // UnusedBytes` -- UnusedBytes already accounts for the header itself, a // detail no amount of reading the struct definition alone would have // caught without comparing against real numbers. // // Struct layouts are a moving target across Windows builds -- if you're // reading this on a different build and something doesn't line up, redo // the `dt ntdll!_HEAP` capture in USAGE.md's heap walkthrough rather than // assuming these offsets still hold. // HeapKind identifies which allocator backend a heap handle is using, // determined the same way HeapAlloc itself effectively does: by the magic // signature 0x10 bytes into the handle (verified earlier in this project // against examples/heap_lfh and examples/heap_segment: 0xffeeffee for the // classic NT Heap, 0xddeeddee for Segment Heap). type HeapKind int const ( HeapKindUnknown HeapKind = iota HeapKindNT HeapKindSegment ) func (k HeapKind) String() string { switch k { case HeapKindNT: return "NT Heap" case HeapKindSegment: return "Segment Heap" default: return "unknown" } } const ( heapSignatureNT = 0xffeeffee heapSignatureSegment = 0xddeeddee ) // DetectHeapKind reads the 4-byte signature at heapAddr+0x10 -- the very // first thing to do with any heap handle/address before parsing it any // further, since _HEAP and _SEGMENT_HEAP are structurally unrelated past // this point. func DetectHeapKind(r io.ReaderAt, heapAddr uint64) (HeapKind, error) { sig, err := readUint32AtValue(r, int64(heapAddr)+0x10) if err != nil { return HeapKindUnknown, fmt.Errorf("reading signature at 0x%x+0x10: %w", heapAddr, err) } switch sig { case heapSignatureNT: return HeapKindNT, nil case heapSignatureSegment: return HeapKindSegment, nil default: return HeapKindUnknown, fmt.Errorf("unrecognized heap signature 0x%08x at 0x%x+0x10 (expected 0x%08x NT Heap or 0x%08x Segment Heap)", sig, heapAddr, heapSignatureNT, heapSignatureSegment) } } // HeapEntrySize is HEAP_GRANULARITY on x64: every _HEAP_ENTRY header is // exactly this many bytes, and Size/PreviousSize are both counted in units // of it, not in plain bytes. Confirmed empirically (heap_test.go), not // just asserted from the struct definition -- see this file's top comment. const HeapEntrySize = 0x10 // _HEAP_ENTRY.Flags bits. Long-standing, widely published constants (every // heap-exploitation writeup and WinDbg's own !heap extension use these same // values), unlike the struct offsets above which are this-build-specific -- // these have been stable since before Windows 8's header encoding existed. const ( HeapEntryBusy = 0x01 HeapEntryExtraPresent = 0x02 HeapEntryFillPattern = 0x04 HeapEntryVirtualAlloc = 0x08 HeapEntryLastEntry = 0x10 ) // HeapEntry is the decoded form of a 16-byte _HEAP_ENTRY header -- the // thing immediately preceding every NT Heap allocation's user data, // equally present whether or not LFH/the front-end allocator owns the // block (see this file's WalkSegment vs heap_lfh.go's subsegment-aware // walk for why both views matter). type HeapEntry struct { Addr uint64 // address of the header itself, i.e. UserData()-HeapEntrySize Size uint16 // total block size (header+user data+padding), in HeapEntrySize units Flags uint8 SmallTagIndex uint8 PreviousSize uint16 // previous block's total size, same units -- lets you walk backward SegmentOffset uint8 // doubles as LFHFlags when this entry belongs to an LFH subsegment UnusedBytes uint8 } func (e HeapEntry) Busy() bool { return e.Flags&HeapEntryBusy != 0 } func (e HeapEntry) LastEntry() bool { return e.Flags&HeapEntryLastEntry != 0 } func (e HeapEntry) VirtualAlloc() bool { return e.Flags&HeapEntryVirtualAlloc != 0 } // BlockSize is the entry's total physical footprint (header + user data + // any padding), in bytes. func (e HeapEntry) BlockSize() uint64 { return uint64(e.Size) * HeapEntrySize } // PreviousBlockSize is the immediately preceding entry's BlockSize, in // bytes -- lets you find where the previous entry starts without having // walked there directly (e.Addr - e.PreviousBlockSize()). func (e HeapEntry) PreviousBlockSize() uint64 { return uint64(e.PreviousSize) * HeapEntrySize } // UserData is the address HeapAlloc actually returned to the caller. func (e HeapEntry) UserData() uint64 { return e.Addr + HeapEntrySize } // UserSize is what HeapSize() would report for this block -- the original // requested size (rounded up to grain, header already accounted for). // Empirically, this is BlockSize()-UnusedBytes with no separate header // subtraction; see this file's top comment for how that was confirmed. func (e HeapEntry) UserSize() uint64 { bs := e.BlockSize() if uint64(e.UnusedBytes) > bs { return 0 } return bs - uint64(e.UnusedBytes) } // NextEntry is the address of the entry immediately following this one. func (e HeapEntry) NextEntry() uint64 { return e.Addr + e.BlockSize() } // DecodeHeapEntry decodes a raw 16-byte _HEAP_ENTRY read from addr, // reversing the Windows 8+ header-encoding mitigation if encoding is // non-nil (pass Heap.Encoding; nil only if you've separately confirmed // EncodeFlagMask is 0 for this heap, which is rare in practice). Only // bytes 8-15 of the entry are ever encoded -- bytes 0-7 // (PreviousBlockPrivateData) are plain, usually-stale data, not part of // the XOR scheme at all. func DecodeHeapEntry(addr uint64, raw [16]byte, encoding *[16]byte) HeapEntry { var compact [8]byte copy(compact[:], raw[8:16]) if encoding != nil { for i := range compact { compact[i] ^= encoding[8+i] } } return HeapEntry{ Addr: addr, Size: binary.LittleEndian.Uint16(compact[0:2]), Flags: compact[2], SmallTagIndex: compact[3], PreviousSize: binary.LittleEndian.Uint16(compact[4:6]), SegmentOffset: compact[6], UnusedBytes: compact[7], } } // ReadHeapEntry reads and decodes the entry header at addr. func ReadHeapEntry(r io.ReaderAt, addr uint64, encoding *[16]byte) (HeapEntry, error) { var raw [16]byte if _, err := r.ReadAt(raw[:], int64(addr)); err != nil { return HeapEntry{}, fmt.Errorf("reading entry at 0x%x: %w", addr, err) } return DecodeHeapEntry(addr, raw, encoding), nil } // Offsets within ntdll's x64 _HEAP, confirmed via `dt ntdll!_HEAP` against // this machine's build (10.0.26100) -- see this file's top comment. const ( heapOffBaseAddress = 0x030 // _HEAP_SEGMENT.BaseAddress (the embedded Segment0) heapOffFirstEntry = 0x040 // _HEAP_SEGMENT.FirstEntry heapOffLastValidEntry = 0x048 // _HEAP_SEGMENT.LastValidEntry heapOffSegmentListEntry = 0x018 // _HEAP_SEGMENT.SegmentListEntry (LIST_ENTRY) heapOffFlags = 0x070 heapOffForceFlags = 0x074 heapOffEncodeFlagMask = 0x07c heapOffEncoding = 0x080 // _HEAP_ENTRY-shaped, 16 bytes heapOffSignature = 0x098 heapOffSegmentList = 0x120 // LIST_ENTRY, head of all _HEAP_SEGMENTs (including Segment0 itself) heapOffFrontEndHeap = 0x198 heapOffFrontEndHeapType = 0x1a2 ) // FrontEndHeapType values (_HEAP.FrontEndHeapType). const ( FrontEndHeapNone = 0 FrontEndHeapLookaside = 1 // legacy, rarely seen on modern Windows FrontEndHeapLFH = 2 ) // Heap is the decoded subset of ntdll's _HEAP that matters for // exploitation -- not a byte-for-byte mirror of the ~700-byte real struct // (most of it is debug/tuning bookkeeping no script needs), the same // "decode what's useful, expose RawStream for the rest" philosophy // minidump.go uses. type Heap struct { Addr uint64 Signature uint32 // 0xeeffeeff on a real heap -- confirmed live; deliberately byte-rotated from _HEAP_SEGMENT's 0xffeeffee, a different field, not a typo if you see both Flags uint32 ForceFlags uint32 EncodeFlagMask uint32 Encoding [16]byte // pass to DecodeHeapEntry/ReadHeapEntry FrontEndHeapType uint8 FrontEndHeap uint64 // *_LFH_HEAP when FrontEndHeapType == FrontEndHeapLFH BaseAddress uint64 // Segment0's base address FirstEntry uint64 // Segment0's first entry LastValidEntry uint64 // Segment0's end-of-committed-range marker } // readUint32AtValue mirrors minidump.go's readUint32At but returns the // value directly instead of writing through an out-pointer, matching this // file's other readXAt helpers below. func readUint32AtValue(r io.ReaderAt, offset int64) (uint32, error) { var buf [4]byte if _, err := r.ReadAt(buf[:], offset); err != nil { return 0, err } return binary.LittleEndian.Uint32(buf[:]), nil } func readUint64At(r io.ReaderAt, offset int64) (uint64, error) { var buf [8]byte if _, err := r.ReadAt(buf[:], offset); err != nil { return 0, err } return binary.LittleEndian.Uint64(buf[:]), nil } func readUint8At(r io.ReaderAt, offset int64) (uint8, error) { var buf [1]byte if _, err := r.ReadAt(buf[:], offset); err != nil { return 0, err } return buf[0], nil } // ReadHeap decodes addr's _HEAP header. Returns an error if addr's // signature isn't the NT Heap one (use DetectHeapKind first if you don't // already know, or ReadSegmentHeap for a 0xddeeddee handle). func ReadHeap(r io.ReaderAt, addr uint64) (*Heap, error) { kind, err := DetectHeapKind(r, addr) if err != nil { return nil, err } if kind != HeapKindNT { return nil, fmt.Errorf("0x%x is a %s, not an NT Heap", addr, kind) } h := &Heap{Addr: addr} if h.Signature, err = readUint32AtValue(r, int64(addr)+heapOffSignature); err != nil { return nil, fmt.Errorf("Signature: %w", err) } if h.Flags, err = readUint32AtValue(r, int64(addr)+heapOffFlags); err != nil { return nil, fmt.Errorf("Flags: %w", err) } if h.ForceFlags, err = readUint32AtValue(r, int64(addr)+heapOffForceFlags); err != nil { return nil, fmt.Errorf("ForceFlags: %w", err) } if h.EncodeFlagMask, err = readUint32AtValue(r, int64(addr)+heapOffEncodeFlagMask); err != nil { return nil, fmt.Errorf("EncodeFlagMask: %w", err) } if _, err = r.ReadAt(h.Encoding[:], int64(addr)+heapOffEncoding); err != nil { return nil, fmt.Errorf("Encoding: %w", err) } if h.FrontEndHeapType, err = readUint8At(r, int64(addr)+heapOffFrontEndHeapType); err != nil { return nil, fmt.Errorf("FrontEndHeapType: %w", err) } if h.FrontEndHeap, err = readUint64At(r, int64(addr)+heapOffFrontEndHeap); err != nil { return nil, fmt.Errorf("FrontEndHeap: %w", err) } if h.BaseAddress, err = readUint64At(r, int64(addr)+heapOffBaseAddress); err != nil { return nil, fmt.Errorf("BaseAddress: %w", err) } if h.FirstEntry, err = readUint64At(r, int64(addr)+heapOffFirstEntry); err != nil { return nil, fmt.Errorf("FirstEntry: %w", err) } if h.LastValidEntry, err = readUint64At(r, int64(addr)+heapOffLastValidEntry); err != nil { return nil, fmt.Errorf("LastValidEntry: %w", err) } return h, nil } // EncodingActive reports whether this heap actually applies the // header-encoding mitigation -- EncodeFlagMask is occasionally zero (e.g. // explicitly disabled via HeapSetInformation), in which case // DecodeHeapEntry should be called with encoding=nil instead of // h.Encoding (an all-zero or stale key would silently corrupt every // decode otherwise). func (h *Heap) EncodingActive() bool { return h.EncodeFlagMask != 0 } // encodingOrNil returns &h.Encoding if encoding is actually active, else // nil -- the one-line helper every entry-decoding call in this package // uses instead of repeating the EncodingActive() check. func (h *Heap) encodingOrNil() *[16]byte { if h.EncodingActive() { return &h.Encoding } return nil } // WalkSegmentEntries decodes every _HEAP_ENTRY from firstEntry up to (not // including) lastValidEntry -- the same chain `!heap -a` itself walks to // print its block-by-block summary, which is what this decoder was // cross-checked against (see heap_test.go). Stops early if an entry // reports LastEntry(), and returns an error rather than looping forever if // an entry's Size decodes to zero (a corrupted heap or a wrong/missing // encoding key can't make forward progress otherwise). // // lastValidEntry is misleadingly named for this purpose: it marks the end // of the segment's *reserved* address range, not its *committed* one, and // `!heap -a` itself shows real heaps routinely ending with an uncommitted // tail before that address is reached -- confirmed live against a real // process while building this (see USAGE.md's heap walkthrough). A read // failure partway through is therefore treated as "the committed entry // chain ended here", not a hard error -- everything decoded up to that // point is still returned. func WalkSegmentEntries(r io.ReaderAt, firstEntry, lastValidEntry uint64, encoding *[16]byte) ([]HeapEntry, error) { var entries []HeapEntry addr := firstEntry for addr < lastValidEntry { e, err := ReadHeapEntry(r, addr, encoding) if err != nil { return entries, nil } entries = append(entries, e) if e.Size == 0 { return entries, fmt.Errorf("zero-size entry at 0x%x -- stopping to avoid an infinite loop (corrupted heap, or wrong/missing encoding key?)", addr) } if e.LastEntry() { break } addr = e.NextEntry() } return entries, nil } // WalkSegment0 walks this heap's embedded first segment -- the common // case for any heap that hasn't grown past one segment. Use Segments + // ReadSegmentRange + WalkSegmentEntries directly for a heap with more // than one. func (h *Heap) WalkSegment0(r io.ReaderAt) ([]HeapEntry, error) { return WalkSegmentEntries(r, h.FirstEntry, h.LastValidEntry, h.encodingOrNil()) } // ReadSegmentRange reads a _HEAP_SEGMENT's FirstEntry/LastValidEntry/ // BaseAddress -- segAddr is anything Segments returns (Segment0's address // equals the owning Heap's own address, since _HEAP embeds it at offset 0). func ReadSegmentRange(r io.ReaderAt, segAddr uint64) (baseAddress, firstEntry, lastValidEntry uint64, err error) { if baseAddress, err = readUint64At(r, int64(segAddr)+heapOffBaseAddress); err != nil { return 0, 0, 0, fmt.Errorf("BaseAddress: %w", err) } if firstEntry, err = readUint64At(r, int64(segAddr)+heapOffFirstEntry); err != nil { return 0, 0, 0, fmt.Errorf("FirstEntry: %w", err) } if lastValidEntry, err = readUint64At(r, int64(segAddr)+heapOffLastValidEntry); err != nil { return 0, 0, 0, fmt.Errorf("LastValidEntry: %w", err) } return baseAddress, firstEntry, lastValidEntry, nil } // Segments returns the address of every _HEAP_SEGMENT belonging to this // heap (walking the SegmentList LIST_ENTRY), including Segment0 (whose // address is h.Addr itself, since _HEAP embeds its first segment at // offset 0 -- the same struct-embedding pattern _HEAP_SEGMENT.Entry/ // SegmentSignature being literally at offset 0/0x10 of _HEAP relies on). // Most heaps never grow past one segment; HeapCreate(0,0,0)-style growable // heaps under sustained allocation pressure can. func (h *Heap) Segments(r io.ReaderAt) ([]uint64, error) { headAddr := h.Addr + heapOffSegmentList cur, err := readUint64At(r, int64(headAddr)) if err != nil { return nil, fmt.Errorf("reading SegmentList head at 0x%x: %w", headAddr, err) } var segments []uint64 for cur != headAddr && cur != 0 { segments = append(segments, cur-heapOffSegmentListEntry) if len(segments) > 4096 { return segments, fmt.Errorf("SegmentList walk exceeded 4096 entries, stopping (corrupted list?)") } next, err := readUint64At(r, int64(cur)) // Flink is LIST_ENTRY's first field if err != nil { return segments, fmt.Errorf("walking SegmentList at 0x%x: %w", cur, err) } cur = next } return segments, nil } // WalkAllHeapEntries decodes every entry in every segment of h -- calls // WalkSegment0 for the embedded first segment, then enumerates any // additional registered segments from Segments() and walks each one. The // returned slice is in address order, one segment after another. Segment // walk errors are returned immediately (unlike the uncommitted-tail // read-failure inside WalkSegmentEntries itself, which is graceful). func (h *Heap) WalkAllHeapEntries(r io.ReaderAt) ([]HeapEntry, error) { entries, err := h.WalkSegment0(r) if err != nil { return nil, fmt.Errorf("segment0 walk: %w", err) } segs, err := h.Segments(r) if err != nil { return entries, fmt.Errorf("Segments(): %w", err) } for _, segAddr := range segs { if segAddr == h.Addr { continue // segment0 already walked above } _, first, last, err := ReadSegmentRange(r, segAddr) if err != nil { return entries, fmt.Errorf("ReadSegmentRange(0x%x): %w", segAddr, err) } more, err := WalkSegmentEntries(r, first, last, h.encodingOrNil()) if err != nil { return entries, fmt.Errorf("walking segment 0x%x: %w", segAddr, err) } entries = append(entries, more...) } return entries, nil } // HeapStats summarises an NT Heap's entry layout across a slice of decoded // entries (typically from WalkAllHeapEntries or a per-segment walk). type HeapStats struct { TotalEntries int BusyEntries int FreeEntries int BusyBytes uint64 // sum of UserSize() for busy entries FreeBytes uint64 // sum of BlockSize() for free entries } // SummariseEntries computes a HeapStats over any entry slice -- useful after // WalkAllHeapEntries, WalkSegment0, or any filtered subset. func SummariseEntries(entries []HeapEntry) HeapStats { var s HeapStats s.TotalEntries = len(entries) for _, e := range entries { if e.Busy() { s.BusyEntries++ s.BusyBytes += e.UserSize() } else { s.FreeEntries++ s.FreeBytes += e.BlockSize() } } return s } // EntriesInRange returns the subset of entries whose header address falls // within [lo, hi) -- useful for filtering down to a known allocation region // (e.g. one specific segment) without re-walking. func EntriesInRange(entries []HeapEntry, lo, hi uint64) []HeapEntry { var out []HeapEntry for _, e := range entries { if e.Addr >= lo && e.Addr < hi { out = append(out, e) } } return out } // EntriesWithUserData returns every entry whose UserData() (the address // returned by HeapAlloc) equals any address in the addrs set -- direct // reverse lookup from leaked heap pointer to its decoded entry. func EntriesWithUserData(entries []HeapEntry, addrs ...uint64) []HeapEntry { set := make(map[uint64]struct{}, len(addrs)) for _, a := range addrs { set[a] = struct{}{} } var out []HeapEntry for _, e := range entries { if _, ok := set[e.UserData()]; ok { out = append(out, e) } } return out } // AdjacentBusyPairs returns every pair of busy entries that are physically // consecutive with no intervening free chunk -- i.e. entries[i+1].Addr == // entries[i].NextEntry(), both busy. This is the structural replacement for // examples/heap_segment's "spray many, look for a 32-byte gap" technique: // instead of spraying and comparing leaked addresses, read the allocator's // own chain to find which two allocations are adjacent before overflowing. // // Note: the returned pairs are in chain order, not insertion order. On NT // Heap the chain order matches allocation order within a given segment; // whether that's also true for Segment Heap's small-block allocator is not // yet confirmed on this build -- see heap_segment.go. func AdjacentBusyPairs(entries []HeapEntry) [][2]HeapEntry { var pairs [][2]HeapEntry for i := 0; i+1 < len(entries); i++ { a, b := entries[i], entries[i+1] if a.Busy() && b.Busy() && b.Addr == a.NextEntry() { pairs = append(pairs, [2]HeapEntry{a, b}) } } return pairs }