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

156 lines
5.6 KiB
Go

package winpwn
import "testing"
// These tests pin heap_lfh.go's decoding against field *values* captured
// from live runs of examples/heap_lfh.exe (Windows build 10.0.26100) rather
// than raw byte dumps, the same style heap_test.go's
// TestReadHeapDecodesRealCapturedFields uses -- the offsets themselves were
// confirmed via `dt ntdll!_LFH_HEAP`/`dt ntdll!_HEAP_SUBSEGMENT` and the
// values via cdb reads cross-checked against winpwn's own output, not
// assumed.
func TestReadLFHBuckets(t *testing.T) {
buf := make([]byte, lfhOffBuckets+lfhBucketCount*lfhBucketStride)
// Bucket 2: 48-byte blocks (3 granularity units), the bucket that
// actually served examples/heap_lfh's 32-byte Note/buffer allocations
// once LFH took over that size class -- see FindLFHBucket's doc comment
// for why 32-byte requests land in the 48-byte bucket, not the 32-byte
// one.
off := lfhOffBuckets + 2*lfhBucketStride
buf[off] = 3
buf[off+1] = 0
buf[off+2] = 2 // SizeIndex
buckets, err := ReadLFHBuckets(newByteReaderAt(buf), 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if buckets[2].BlockUnits != 3 || buckets[2].BlockSize() != 48 {
t.Errorf("bucket[2] = %+v, want BlockUnits=3 BlockSize=48", buckets[2])
}
if buckets[0].BlockUnits != 0 {
t.Errorf("bucket[0] should be unused, got %+v", buckets[0])
}
}
func TestFindLFHBucketAccountsForBlockHeader(t *testing.T) {
var buckets [lfhBucketCount]HeapBucket
buckets[1] = HeapBucket{Index: 1, BlockUnits: 2} // 32 bytes total, 16 usable -- too small
buckets[2] = HeapBucket{Index: 2, BlockUnits: 3} // 48 bytes total, 32 usable -- the real fit
got, err := FindLFHBucket(buckets, 32)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Index != 2 {
t.Errorf("FindLFHBucket(32) chose bucket %d, want 2 (the naive BlockSize()>=wantSize check would wrongly pick bucket 1)", got.Index)
}
}
func TestFindLFHBucketNoneActive(t *testing.T) {
var buckets [lfhBucketCount]HeapBucket
if _, err := FindLFHBucket(buckets, 32); err == nil {
t.Fatal("expected an error when no bucket has BlockUnits set")
}
}
func TestActiveSubsegment(t *testing.T) {
const lfhHeapAddr = 0x1000
buf := make([]byte, 0x2000)
const segInfoAddr = 0x1500
const subsegAddr = 0x1700
binaryLEPutUint64(buf[lfhHeapAddr+lfhOffSegmentInfoArrays+2*8:], segInfoAddr)
binaryLEPutUint64(buf[segInfoAddr+hlsiOffActiveSubsegment:], subsegAddr)
got, err := ActiveSubsegment(newByteReaderAt(buf), lfhHeapAddr, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != subsegAddr {
t.Errorf("ActiveSubsegment = 0x%x, want 0x%x", got, subsegAddr)
}
if _, err := ActiveSubsegment(newByteReaderAt(buf), lfhHeapAddr, 5); err == nil {
t.Fatal("expected an error for a bucket with no segment info")
}
}
// TestReadLFHSubsegmentBusyBitmap pins ReadLFHSubsegment against the exact
// shape captured from a live run: bucket 2 (48-byte blocks), a 19-block
// subsegment, with one bit clear (an early, pre-LFH-activation allocation
// that never got a tracked slot -- see heap_lfh.go's package comment) and
// the rest busy.
func TestReadLFHSubsegmentBusyBitmap(t *testing.T) {
const subsegAddr = 0x713480
const userBlocksAddr = 0x713050
buf := make([]byte, 0x800000)
binaryLEPutUint64(buf[subsegAddr+subsegOffUserBlocks:], userBlocksAddr)
buf[subsegAddr+subsegOffBlockSize] = 3 // granularity units -> 48 bytes
buf[subsegAddr+subsegOffBlockCount] = 19
const bitmapBufferAddr = 0x713200
binaryLEPutUint64(buf[userBlocksAddr+userDataOffBitmapSize:], 19)
binaryLEPutUint64(buf[userBlocksAddr+userDataOffBitmapBuffer:], bitmapBufferAddr)
// bits: every slot busy except slot 5 (0-indexed) -- matches a real
// captured run's BusyBitmap word.
var word uint64 = 0
for i := 0; i < 19; i++ {
if i != 5 {
word |= 1 << uint(i)
}
}
binaryLEPutUint64(buf[bitmapBufferAddr:], word)
subseg, err := ReadLFHSubsegment(newByteReaderAt(buf), subsegAddr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if subseg.BlockSize != 48 || subseg.BlockCount != 19 {
t.Errorf("BlockSize/BlockCount = %d/%d, want 48/19", subseg.BlockSize, subseg.BlockCount)
}
if subseg.UserBlocksAddr != userBlocksAddr {
t.Errorf("UserBlocksAddr = 0x%x, want 0x%x", subseg.UserBlocksAddr, userBlocksAddr)
}
for i, busy := range subseg.Busy {
want := i != 5
if busy != want {
t.Errorf("Busy[%d] = %v, want %v", i, busy, want)
}
}
}
// TestCalibrateLFHFirstBlockOffsetMatchesLiveAddress pins
// CalibrateLFHFirstBlockOffset/BlockAddress/SlotOf against a real
// before/after-free pair captured live: freeing the note at 0x7132b0
// flipped exactly Busy[11] from true to false, and slot 11's address by
// BlockAddress must equal 0x7132b0 -- not 0x713280 (one block short), which
// is what the naive "(addr-UserBlocksAddr) % BlockSize" formula gives
// before the +BlockSize correction (see CalibrateLFHFirstBlockOffset's doc
// comment for why that's wrong: it lands on _HEAP_USERDATA_HEADER's own
// reserved region, one block before any real, bit-tracked slot).
func TestCalibrateLFHFirstBlockOffsetMatchesLiveAddress(t *testing.T) {
subseg := &LFHSubsegment{
UserBlocksAddr: 0x713050,
BlockSize: 48,
BlockCount: 19,
}
const victimAddr = 0x7132b0
off := CalibrateLFHFirstBlockOffset(subseg, victimAddr)
if off != 80 {
t.Fatalf("CalibrateLFHFirstBlockOffset = %d, want 80", off)
}
if got := subseg.BlockAddress(off, 11); got != victimAddr {
t.Errorf("BlockAddress(80, 11) = 0x%x, want 0x%x", got, victimAddr)
}
idx, ok := subseg.SlotOf(off, victimAddr)
if !ok || idx != 11 {
t.Errorf("SlotOf = (%d, %v), want (11, true)", idx, ok)
}
if _, ok := subseg.SlotOf(off, 0xdeadbeef); ok {
t.Error("SlotOf should reject an address outside the subsegment")
}
}