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

312 lines
13 KiB
Go

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
}