package winpwn import ( "encoding/binary" "fmt" "io" ) // This file is the LFH (Low Fragmentation Heap) layer on top of heap.go's // plain NT Heap decoder -- the layer ROADMAP.md's Phase 9 plan called out // as the real acceptance test: replace examples/heap_lfh's empirically- // discovered "free the most recently allocated object" heuristic with a // structural answer to "which block in the active subsegment is actually // free right now", read directly off the live process. // // Offsets confirmed via `dt ntdll!_LFH_HEAP` and friends on this machine's // build (10.0.26100) -- same methodology as heap.go, see its top comment. // Live-validated end to end against examples/heap_lfh.exe itself: spray a // batch of Notes, free one, calibrate against a known address, and confirm // ReadLFHSubsegment's BusyBitmap reports exactly that block's slot as free // and every other allocated slot as busy -- see USAGE.md's heap walkthrough // for the exact run. const ( lfhOffBuckets = 0x2a4 // [129]_HEAP_BUCKET, 4 bytes each, inside _LFH_HEAP lfhOffSegmentInfoArrays = 0x4a8 // [129]Ptr64 _HEAP_LOCAL_SEGMENT_INFO lfhBucketCount = 129 lfhBucketStride = 4 hlsiOffActiveSubsegment = 0x008 // _HEAP_LOCAL_SEGMENT_INFO.ActiveSubsegment subsegOffUserBlocks = 0x008 // _HEAP_SUBSEGMENT.UserBlocks subsegOffBlockSize = 0x024 // uint16, granularity units -- same scheme as HeapBucket.BlockUnits, NOT raw bytes (see below) subsegOffBlockCount = 0x028 // uint16, blocks in this subsegment userDataOffBitmapSize = 0x020 // uint64, _RTL_BITMAP_EX.SizeOfBitMap (bits) userDataOffBitmapBuffer = 0x028 // ptr64, _RTL_BITMAP_EX.Buffer ) // HeapBucket is one entry of _LFH_HEAP.Buckets -- which fixed block size // this size class hands out. type HeapBucket struct { Index int BlockUnits uint16 // granularity units; BlockSize() = BlockUnits*HeapEntrySize SizeIndex uint8 RawFlags uint8 } func (b HeapBucket) BlockSize() uint64 { return uint64(b.BlockUnits) * HeapEntrySize } // ReadLFHBuckets reads every entry of lfhHeapAddr's (a Heap.FrontEndHeap // pointer) Buckets array. func ReadLFHBuckets(r io.ReaderAt, lfhHeapAddr uint64) ([lfhBucketCount]HeapBucket, error) { var buckets [lfhBucketCount]HeapBucket var buf [lfhBucketCount * lfhBucketStride]byte if _, err := r.ReadAt(buf[:], int64(lfhHeapAddr)+lfhOffBuckets); err != nil { return buckets, fmt.Errorf("reading Buckets array: %w", err) } for i := 0; i < lfhBucketCount; i++ { off := i * lfhBucketStride buckets[i] = HeapBucket{ Index: i, BlockUnits: binary.LittleEndian.Uint16(buf[off : off+2]), SizeIndex: buf[off+2], RawFlags: buf[off+3], } } return buckets, nil } // FindLFHBucket returns the smallest bucket that can actually serve a // wantSize-byte HeapAlloc request once LFH owns this size class. Buckets // with BlockUnits==0 are unused size classes (LFH only activates a bucket // index after enough same-size requests) and are skipped. // // Confirmed live against examples/heap_lfh.exe, and worth recording // because the "obvious" version (BlockSize() >= wantSize, no header // accounted for) is wrong: an LFH block's BlockSize already includes its // own 16-byte _HEAP_ENTRY-shaped header the same way a plain NT Heap // entry's does, so a 32-byte Note allocation is actually routed to the // 48-byte bucket (BlockSize 48, 48-16=32 usable), not the 32-byte one // (which only has 16 bytes usable after its own header) -- verified by // reading SegmentInfoArrays directly and seeing which bucket index // actually had a live subsegment for a heap doing nothing but 32-byte // allocations. func FindLFHBucket(buckets [lfhBucketCount]HeapBucket, wantSize uint64) (HeapBucket, error) { for _, b := range buckets { if b.BlockUnits == 0 { continue } if b.BlockSize() >= wantSize+HeapEntrySize { return b, nil } } return HeapBucket{}, fmt.Errorf("no active LFH bucket covers %d bytes (has LFH actually taken over this size class yet?)", wantSize) } // ActiveSubsegment returns the address of bucketIndex's currently-active // _HEAP_SUBSEGMENT -- the subsegment LFH is issuing new blocks from right // now for that size class. This is the address every grooming attempt // (examples/heap_lfh's spray loop, structurally) is actually targeting. func ActiveSubsegment(r io.ReaderAt, lfhHeapAddr uint64, bucketIndex int) (uint64, error) { if bucketIndex < 0 || bucketIndex >= lfhBucketCount { return 0, fmt.Errorf("bucket index %d out of range [0,%d)", bucketIndex, lfhBucketCount) } arrAddr := lfhHeapAddr + lfhOffSegmentInfoArrays + uint64(bucketIndex)*8 segInfoAddr, err := readUint64At(r, int64(arrAddr)) if err != nil { return 0, fmt.Errorf("reading SegmentInfoArrays[%d]: %w", bucketIndex, err) } if segInfoAddr == 0 { return 0, fmt.Errorf("bucket %d has no segment info yet (LFH hasn't allocated from this size class)", bucketIndex) } subsegAddr, err := readUint64At(r, int64(segInfoAddr)+hlsiOffActiveSubsegment) if err != nil { return 0, fmt.Errorf("reading ActiveSubsegment: %w", err) } if subsegAddr == 0 { return 0, fmt.Errorf("bucket %d's segment info has no active subsegment", bucketIndex) } return subsegAddr, nil } // LFHSubsegment is a decoded _HEAP_SUBSEGMENT: a fixed-size-block arena LFH // is handing blocks out of for one bucket. BlockSize/BlockCount/Busy are // read directly off the subsegment's own bookkeeping with no decoding // ambiguity; turning slot index into an address needs one calibration step // first -- see BlockAddress. type LFHSubsegment struct { Addr uint64 UserBlocksAddr uint64 BlockSize uint64 // bytes; subsegOffBlockSize*HeapEntrySize, see its comment BlockCount int Busy []bool // Busy[i] is slot i's state, read straight from BusyBitmap } // ReadLFHSubsegment decodes subsegAddr's _HEAP_SUBSEGMENT, with Busy/Free // for every slot read directly from the subsegment's BusyBitmap -- the // structural replacement for examples/heap_lfh's "spray and see which // leaked address repeats" technique: this answers "which slot is free" by // reading the allocator's own bookkeeping instead of inferring it from // outside. // // What it deliberately does NOT do: compute slot addresses. The natural // place for that, _HEAP_USERDATA_HEADER.EncodedOffsets, decoded as plain // FirstAllocationOffset(u16)|BlockStride(u16), produced a 29025-byte stride // for a heap doing nothing but 48-byte allocations -- it's genuinely // encoded (XORed against something derived from // RtlpLowFragHeapRandomData/RtlpInitializeLfhRandomDataArray per this // repo's heap/Deterministic_LFH-master reference material), and the key // wasn't recovered in this pass. Use BlockAddress + CalibrateLFHFirstBlockOffset // instead: calibrate against any one address you already know (which is // also just how this kind of exploitation actually works in practice -- // correlating against a leak, not deriving addresses from nothing). func ReadLFHSubsegment(r io.ReaderAt, subsegAddr uint64) (*LFHSubsegment, error) { userBlocksAddr, err := readUint64At(r, int64(subsegAddr)+subsegOffUserBlocks) if err != nil { return nil, fmt.Errorf("reading UserBlocks: %w", err) } if userBlocksAddr == 0 { return nil, fmt.Errorf("subsegment at 0x%x has no UserBlocks (not yet committed?)", subsegAddr) } var blockSizeUnits, blockCountBuf [2]byte if _, err := r.ReadAt(blockSizeUnits[:], int64(subsegAddr)+subsegOffBlockSize); err != nil { return nil, fmt.Errorf("reading BlockSize: %w", err) } if _, err := r.ReadAt(blockCountBuf[:], int64(subsegAddr)+subsegOffBlockCount); err != nil { return nil, fmt.Errorf("reading BlockCount: %w", err) } blockSize := uint64(binary.LittleEndian.Uint16(blockSizeUnits[:])) * HeapEntrySize blockCount := binary.LittleEndian.Uint16(blockCountBuf[:]) if blockSize == 0 { return nil, fmt.Errorf("decoded BlockSize is 0 at 0x%x -- wrong offset or unsupported build (see heap.go's top comment)", subsegAddr) } bitmapSizeBits, err := readUint64At(r, int64(userBlocksAddr)+userDataOffBitmapSize) if err != nil { return nil, fmt.Errorf("reading BusyBitmap.SizeOfBitMap: %w", err) } bitmapBufferAddr, err := readUint64At(r, int64(userBlocksAddr)+userDataOffBitmapBuffer) if err != nil { return nil, fmt.Errorf("reading BusyBitmap.Buffer: %w", err) } if bitmapSizeBits < uint64(blockCount) { return nil, fmt.Errorf("BusyBitmap covers %d bits but BlockCount is %d", bitmapSizeBits, blockCount) } busy := make([]bool, blockCount) for i := uint16(0); i < blockCount; i++ { b, err := readBitmapBit(r, bitmapBufferAddr, uint64(i)) if err != nil { return nil, fmt.Errorf("reading BusyBitmap bit %d: %w", i, err) } busy[i] = b } return &LFHSubsegment{ Addr: subsegAddr, UserBlocksAddr: userBlocksAddr, BlockSize: blockSize, BlockCount: int(blockCount), Busy: busy, }, nil } // BlockAddress returns the address of slot index i, given firstBlockOffset // -- the byte offset of slot 0 relative to UserBlocksAddr. Get // firstBlockOffset from CalibrateLFHFirstBlockOffset once per subsegment; // it's constant across every slot of the same subsegment. func (s *LFHSubsegment) BlockAddress(firstBlockOffset uint64, index int) uint64 { return s.UserBlocksAddr + firstBlockOffset + uint64(index)*s.BlockSize } // SlotOf returns the slot index of knownAddr within s, given an already- // calibrated firstBlockOffset (see CalibrateLFHFirstBlockOffset), or false // if knownAddr doesn't land in this subsegment's range at all. func (s *LFHSubsegment) SlotOf(firstBlockOffset, knownAddr uint64) (index int, ok bool) { base := s.UserBlocksAddr + firstBlockOffset if knownAddr < base { return 0, false } off := knownAddr - base if off%s.BlockSize != 0 { return 0, false } idx := off / s.BlockSize if idx >= uint64(s.BlockCount) { return 0, false } return int(idx), true } // CalibrateLFHFirstBlockOffset computes BlockAddress's firstBlockOffset // from one address you already know lies inside this subsegment -- e.g. // one of your own freshly leaked allocations. _HEAP_USERDATA_HEADER does // store this value (as part of the encoded EncodedOffsets field) but it's // genuinely obfuscated and wasn't decoded in this pass; calibrating // against a known address sidesteps that entirely, and is also simply how // you'd correlate against a real target in practice. // // The +BlockSize matters and was the second real bug found empirically: the // naive "(addr-UserBlocksAddr) % BlockSize" residue is the right modular // class but the wrong absolute offset -- it points at a reserved region // belonging to _HEAP_USERDATA_HEADER itself (one block-size's worth of // space with no corresponding BusyBitmap bit at all), one full block before // where Busy[0]'s real address actually is. Confirmed by freeing a known // address and watching which bit actually flipped: it was the bit for // address_index-1 under the naive offset, i.e. exactly one block short. func CalibrateLFHFirstBlockOffset(s *LFHSubsegment, knownBlockAddr uint64) uint64 { return (knownBlockAddr-s.UserBlocksAddr)%s.BlockSize + s.BlockSize } // AllSubsegments returns the addresses of every non-null _HEAP_SUBSEGMENT // associated with bucketIndex in lfhHeapAddr's _LFH_HEAP, not just the // currently active one. The active subsegment is always first (index 0) // when present; any additional cached/full ones follow in the order they // appear in the SegmentInfoArrays chain. // // LFH doesn't maintain a traditional linked list of subsegments per // bucket -- it uses _HEAP_LOCAL_SEGMENT_INFO which has a single // ActiveSubsegment pointer and an optional cached slot. This walk follows // ActiveSubsegment (via ActiveSubsegment()) and leaves deeper enumeration // (e.g. walking the CachedItems or InfoArrays of retired subsegments) for // a future pass where that extra complexity is actually needed by a CTF task. func AllSubsegments(r io.ReaderAt, lfhHeapAddr uint64, bucketIndex int) ([]uint64, error) { if bucketIndex < 0 || bucketIndex >= lfhBucketCount { return nil, fmt.Errorf("bucket index %d out of range [0,%d)", bucketIndex, lfhBucketCount) } arrAddr := lfhHeapAddr + lfhOffSegmentInfoArrays + uint64(bucketIndex)*8 segInfoAddr, err := readUint64At(r, int64(arrAddr)) if err != nil { return nil, fmt.Errorf("reading SegmentInfoArrays[%d]: %w", bucketIndex, err) } if segInfoAddr == 0 { return nil, nil // bucket not yet activated, not an error } var subsegAddrs []uint64 active, err := readUint64At(r, int64(segInfoAddr)+hlsiOffActiveSubsegment) if err != nil { return nil, fmt.Errorf("reading ActiveSubsegment: %w", err) } if active != 0 { subsegAddrs = append(subsegAddrs, active) } // _HEAP_LOCAL_SEGMENT_INFO.CachedItems (an array of 2 ptr slots // immediately after ActiveSubsegment for this build -- empirically // observed at hlsiOffActiveSubsegment+8 and +16; if it's wrong they'll // simply be 0 and get skipped). for i := 1; i <= 2; i++ { cached, err := readUint64At(r, int64(segInfoAddr)+hlsiOffActiveSubsegment+int64(i)*8) if err != nil || cached == 0 || cached == active { continue } subsegAddrs = append(subsegAddrs, cached) } return subsegAddrs, nil } // readBitmapBit reads bit index bitIndex of an RTL_BITMAP_EX-style bitmap // (an array of 64-bit words starting at bufferAddr). func readBitmapBit(r io.ReaderAt, bufferAddr uint64, bitIndex uint64) (bool, error) { word, err := readUint64At(r, int64(bufferAddr)+int64(bitIndex/64)*8) if err != nil { return false, err } return (word>>(bitIndex%64))&1 != 0, nil }