package winpwn import ( "errors" "testing" ) var errOutOfRange = errors.New("byteReaderAt: out of range") // TestDecodeHeapEntryMatchesLiveGroundTruth pins DecodeHeapEntry against // three real _HEAP_ENTRY headers + their heap's real Encoding key, // captured byte-for-byte from a live process (a tiny HeapAlloc(256)/ // HeapAlloc(4)/HeapAlloc(16) probe on this machine, Windows build 10.0.26100) // and cross-checked against WinDbg's own `!heap -a` ground-truth listing // before being trusted -- see heap.go's top comment for the full story. // // This is the test that caught the real bug worth remembering: the first // version of UserSize subtracted HeapEntrySize *again* on top of // UnusedBytes (i.e. assumed UnusedBytes was padding *after* a separately- // accounted-for header), which silently produced a UserSize 16 bytes // smaller than reality for every entry. !heap -a's own "(requested size)" // column is what caught it -- UnusedBytes already bakes the header in. func TestDecodeHeapEntryMatchesLiveGroundTruth(t *testing.T) { // Heap.Encoding, captured at heap+0x80 on the live probe. encoding := [16]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1e, 0xb6, 0x56, 0xf8, 0xe5, 0xd3, 0x00, 0x00} cases := []struct { name string addr uint64 raw [16]byte wantSize uint16 // granularity units, cross-checked against `!heap -a`'s byte-size column / HeapEntrySize wantFlags uint8 wantPrevSize uint16 wantUnused uint8 wantUserSize uint64 // the requested size `!heap -a` printed in parens }{ { // !heap -a: 00000000000d0850: 00110 . 00110 [101] - busy (100) name: "256-byte allocation, no slack", addr: 0xd0850, raw: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0x0f, 0xb6, 0x57, 0xe8, 0xf4, 0xd3, 0x00, 0x10}, wantSize: 17, // 17*0x10 = 0x110 = 272 wantFlags: HeapEntryBusy, wantPrevSize: 17, // previous entry (0xd0740) was also 0x110 bytes per !heap -a wantUnused: 16, wantUserSize: 0x100, }, { // !heap -a: 00000000000d0d70: 00050 . 00020 [101] - busy (4) name: "4-byte allocation, lots of slack", addr: 0xd0d70, raw: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0x1c, 0xb6, 0x57, 0xfb, 0xe0, 0xd3, 0x00, 0x1c}, wantSize: 2, // 2*0x10 = 0x20 wantFlags: HeapEntryBusy, wantPrevSize: 5, // previous entry (0xd0d20) was 0x50 bytes per !heap -a wantUnused: 28, wantUserSize: 4, }, { // !heap -a: 00000000000d0d90: 00020 . 00020 [101] - busy (10) name: "16-byte allocation, header-only slack", addr: 0xd0d90, raw: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0x1c, 0xb6, 0x57, 0xfb, 0xe7, 0xd3, 0x00, 0x10}, wantSize: 2, wantFlags: HeapEntryBusy, wantPrevSize: 2, // previous entry (0xd0d70) was also 0x20 bytes wantUnused: 16, wantUserSize: 16, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { e := DecodeHeapEntry(c.addr, c.raw, &encoding) if e.Size != c.wantSize { t.Errorf("Size = %d, want %d", e.Size, c.wantSize) } if e.Flags != c.wantFlags { t.Errorf("Flags = 0x%x, want 0x%x", e.Flags, c.wantFlags) } if e.PreviousSize != c.wantPrevSize { t.Errorf("PreviousSize = %d, want %d", e.PreviousSize, c.wantPrevSize) } if e.UnusedBytes != c.wantUnused { t.Errorf("UnusedBytes = %d, want %d", e.UnusedBytes, c.wantUnused) } if got := e.UserSize(); got != c.wantUserSize { t.Errorf("UserSize() = 0x%x, want 0x%x", got, c.wantUserSize) } if !e.Busy() { t.Error("Busy() = false, want true (all three live samples were busy)") } if e.UserData() != c.addr+HeapEntrySize { t.Errorf("UserData() = 0x%x, want 0x%x", e.UserData(), c.addr+HeapEntrySize) } }) } } func TestDetectHeapKind(t *testing.T) { ntHeap := make([]byte, 0x20) binaryLEPutUint32(ntHeap[0x10:], heapSignatureNT) segHeap := make([]byte, 0x20) binaryLEPutUint32(segHeap[0x10:], heapSignatureSegment) garbage := make([]byte, 0x20) cases := []struct { name string buf []byte want HeapKind ok bool }{ {"nt heap", ntHeap, HeapKindNT, true}, {"segment heap", segHeap, HeapKindSegment, true}, {"garbage", garbage, HeapKindUnknown, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { kind, err := DetectHeapKind(newByteReaderAt(c.buf), 0) if c.ok && err != nil { t.Fatalf("unexpected error: %v", err) } if !c.ok && err == nil { t.Fatal("expected an error for an unrecognized signature") } if kind != c.want { t.Errorf("kind = %v, want %v", kind, c.want) } }) } } // TestReadHeapDecodesRealCapturedFields pins ReadHeap against the actual // field bytes captured from the same live probe heap.go's top comment and // heap_test.go's ground-truth test describe (Flags=0x2/HEAP_GROWABLE, // EncodeFlagMask=0x100000, Signature=0xeeffeeff -- deliberately distinct // from _HEAP_SEGMENT's 0xffeeffee, confirmed with `db` against the live // process rather than assumed equal). func TestReadHeapDecodesRealCapturedFields(t *testing.T) { buf := make([]byte, 0x300) binaryLEPutUint32(buf[0x10:], heapSignatureNT) // _HEAP_SEGMENT.SegmentSignature binaryLEPutUint32(buf[0x70:], 0x00000002) // Flags = HEAP_GROWABLE binaryLEPutUint32(buf[0x7c:], 0x00100000) // EncodeFlagMask copy(buf[0x80:0x90], []byte{0, 0, 0, 0, 0, 0, 0, 0, 0x1e, 0xb6, 0x56, 0xf8, 0xe5, 0xd3, 0x00, 0x00}) binaryLEPutUint32(buf[0x98:], 0xeeffeeff) // _HEAP.Signature buf[0x1a2] = FrontEndHeapNone binaryLEPutUint64(buf[0x30:], 0x6f0000) // BaseAddress binaryLEPutUint64(buf[0x40:], 0x6f0740) // FirstEntry binaryLEPutUint64(buf[0x48:], 0x7ef000) // LastValidEntry h, err := ReadHeap(newByteReaderAt(buf), 0) if err != nil { t.Fatalf("unexpected error: %v", err) } if h.Signature != 0xeeffeeff { t.Errorf("Signature = 0x%x, want 0xeeffeeff", h.Signature) } if h.Flags != 0x2 { t.Errorf("Flags = 0x%x, want 0x2", h.Flags) } if !h.EncodingActive() { t.Error("EncodingActive() = false, want true (EncodeFlagMask is nonzero)") } if h.FrontEndHeapType != FrontEndHeapNone { t.Errorf("FrontEndHeapType = %d, want %d", h.FrontEndHeapType, FrontEndHeapNone) } if h.BaseAddress != 0x6f0000 || h.FirstEntry != 0x6f0740 || h.LastValidEntry != 0x7ef000 { t.Errorf("BaseAddress/FirstEntry/LastValidEntry = 0x%x/0x%x/0x%x", h.BaseAddress, h.FirstEntry, h.LastValidEntry) } } func TestReadHeapRejectsSegmentHeap(t *testing.T) { buf := make([]byte, 0x20) binaryLEPutUint32(buf[0x10:], heapSignatureSegment) if _, err := ReadHeap(newByteReaderAt(buf), 0); err == nil { t.Fatal("expected ReadHeap to reject a Segment Heap signature") } } func TestHeapSegmentsSingleSegmentIsEmpty(t *testing.T) { // A heap with only the embedded Segment0: SegmentList's Flink/Blink // both point back at the list head itself (heapOffSegmentList), the // standard "empty list" LIST_ENTRY representation. buf := make([]byte, 0x200) headAddr := uint64(heapOffSegmentList) binaryLEPutUint64(buf[heapOffSegmentList:], headAddr) binaryLEPutUint64(buf[heapOffSegmentList+8:], headAddr) h := &Heap{Addr: 0} segs, err := h.Segments(newByteReaderAt(buf)) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(segs) != 0 { t.Errorf("got %d segments, want 0 for an empty SegmentList", len(segs)) } } func TestWalkSegmentEntriesStopsAtLastEntry(t *testing.T) { // Three unencoded entries (no Encoding key, EncodeFlagMask=0 case): // 32 bytes busy, 16 bytes free, 16 bytes busy+LastEntry. buf := make([]byte, 64) putEntry := func(off int, size uint16, flags uint8, prevSize uint16) { buf[off+8] = byte(size) buf[off+9] = byte(size >> 8) buf[off+10] = flags buf[off+12] = byte(prevSize) buf[off+13] = byte(prevSize >> 8) } putEntry(0, 2, HeapEntryBusy, 0) putEntry(32, 1, 0, 2) putEntry(48, 1, HeapEntryBusy|HeapEntryLastEntry, 1) entries, err := WalkSegmentEntries(newByteReaderAt(buf), 0, 64, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(entries) != 3 { t.Fatalf("got %d entries, want 3", len(entries)) } if entries[0].Addr != 0 || entries[0].BlockSize() != 32 || !entries[0].Busy() { t.Errorf("entry 0 = %+v", entries[0]) } if entries[1].Addr != 32 || entries[1].BlockSize() != 16 || entries[1].Busy() { t.Errorf("entry 1 = %+v", entries[1]) } if entries[2].Addr != 48 || !entries[2].LastEntry() { t.Errorf("entry 2 = %+v", entries[2]) } } func TestWalkSegmentEntriesZeroSizeIsAnError(t *testing.T) { buf := make([]byte, 32) // entry at 0 decodes to Size=0 -- can't make progress _, err := WalkSegmentEntries(newByteReaderAt(buf), 0, 32, nil) if err == nil { t.Fatal("expected an error for a zero-size entry") } } // byteReaderAt adapts a plain []byte to io.ReaderAt for synthetic tests, // the same role buildSyntheticMinidump's bytes.Reader plays in // minidump_test.go. type byteReaderAt struct{ buf []byte } func newByteReaderAt(buf []byte) *byteReaderAt { return &byteReaderAt{buf: buf} } func (b *byteReaderAt) ReadAt(p []byte, off int64) (int, error) { if off < 0 || int(off) > len(b.buf) { return 0, errOutOfRange } n := copy(p, b.buf[off:]) if n < len(p) { return n, errOutOfRange } return n, nil } func binaryLEPutUint32(b []byte, v uint32) { b[0] = byte(v) b[1] = byte(v >> 8) b[2] = byte(v >> 16) b[3] = byte(v >> 24) } func binaryLEPutUint64(b []byte, v uint64) { for i := 0; i < 8; i++ { b[i] = byte(v >> (8 * i)) } }