Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,34 @@ All notable changes to this project are documented here. The format follows

### Fixed

- **A VS user block that would begin in the last sixteen bytes of a page is read where the
allocator put it: at the next page start.** Under
`_HEAP_VS_CONTEXT.Config.Flags.PageAlignLargeAllocs`, `nt!RtlpHpVsContextAllocate` hands out
`chunk + 0x20` rather than `chunk + 0x10` when the former is page-aligned, and
`RtlpHpVsContextFree` takes a page-aligned user block's chunk from `block - 0x20` -- the
kernel's own way of keeping a `_POOL_HEADER` out of a page's last unit, since `ExFreeHeapPool`
tells a chunk from a page allocation by `P & 0xfff` (x64 26100.33438, read at
`RtlpHpVsContextAllocate+0xf6..+0x12a`, `RtlpHpVsContextAllocateFinalize+0x3d` and
`RtlpHpVsContextFree+0xf2`). The walk placed every such header one unit early, decoding two
stale free-tree pointers as a `_POOL_HEADER`: the tag came out as the top half of a kernel
address, the span a unit early and a unit long, and the real header at the page start went
unclaimed. Measured on a live 26100.33438 kernel 2026-10-14: 1,275 paged chunks under the "tag"
`0x84e7ffff`, every one with its candidate header at page offset 0xff0;
`0xffffe78459203fe0` is a 0x80-byte chunk holding `ffffe78459207e18 ffffe78459213bc8` at
`+0x10` and the `MmDT` header `!pool` names at `0xffffe78459204000`. The flag is read from each
heap's context at discovery -- `_HEAP_VS_CONTEXT.Config` is a new optional field, so **every
layout fingerprint moves** -- carried on the region, and applied only where set: a target
without it is walked as before, and a PDB without the field says so in a diagnostic.
`adjust_page_end_header`, which moved a candidate *back* to the end of the previous page and
could never fire on a sixteen-byte-aligned one, is gone. windbg-mcp `FOLLOWUPS.md` item 101.
`test_a_vs_user_block_that_would_end_a_page_begins_the_next_one` pins the placement,
`test_without_the_flag_a_page_end_user_block_is_read_at_its_chunk_header` the gate,
`test_a_vs_user_block_on_a_page_boundary_is_left_where_it_is` the boundary and
`test_discovery_reads_whether_a_vs_user_block_skips_a_page_end_from_the_context` the read.
Beside it, `tree_nodes` now polls the budget before its first read: the one extra read per
heap moved the sampled deadlines onto a five-read stretch between polls that
`test_the_walk_stops_reading_promptly_once_its_budget_is_gone` had never landed on.

- **A break left pending no longer empties the next reload.** `Execute` discards a pending
`SetInterrupt` as a command begins; `Reload` does not. A host interrupt that landed between
operations (`BreakRequest::NothingRunning`) made the next `reload_symbols("")` on a live kernel
Expand Down
26 changes: 6 additions & 20 deletions src/pool/decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ use super::{PoolBackend, PoolState};

pub(crate) const PAGE_SIZE: u64 = 0x1000;
pub(crate) const VS_SIGNATURE: u16 = 0x2bed;
/// The granularity of a VS chunk: `_HEAP_VS_CHUNK_HEADER.Sizes` counts in units of this many
/// bytes, and `nt!RtlpHpVsContextAllocate` moves a user block by one of them
/// (`snapshot::PoolRegion::vs_skips_page_end`).
pub(crate) const VS_UNIT: u64 = 16;
pub(crate) const PAGE_SEGMENT_SIGNATURE: u64 = 0xa2e6_4ead_a2e6_4ead;
pub(crate) const DESCRIPTOR_TREE_SIGNATURE: u32 = 0xccdd_ccdd;

Expand Down Expand Up @@ -243,8 +247,8 @@ pub(crate) fn decode_vs_chunk(
subsegment_end: u64,
) -> Result<VsChunk, VsRejection> {
let sizes = decode_vs_sizes(encoded, header_address, heap_key);
let size = usize::from(sizes.size).saturating_mul(16);
let previous_size = usize::from(sizes.previous_size).saturating_mul(16);
let size = usize::from(sizes.size).saturating_mul(VS_UNIT as usize);
let previous_size = usize::from(sizes.previous_size).saturating_mul(VS_UNIT as usize);
let reject = |reason| {
Err(VsRejection {
reason,
Expand Down Expand Up @@ -476,21 +480,6 @@ pub(crate) fn lfh_bitmap_state(bitmap: &[u8], slot: usize) -> Option<PoolState>
})
}

/// A header that would straddle the page boundary is stored at the end of the
/// preceding page. Return the physical header location, checking all arithmetic.
pub(crate) fn adjust_page_end_header(candidate: u64, header_size: u64) -> Option<u64> {
if header_size == 0 || header_size > PAGE_SIZE {
return None;
}
let page_offset = candidate & (PAGE_SIZE - 1);
let last_header_start = PAGE_SIZE - header_size;
if page_offset > last_header_start {
candidate.checked_sub(page_offset - last_header_start)
} else {
Some(candidate)
}
}

pub(crate) fn decode_pool_header(
bytes: &[u8],
offset: usize,
Expand Down Expand Up @@ -1342,9 +1331,6 @@ mod tests {
// A page number that does fit in 32 bits is unaffected, which is why every user-mode
// fixture kept passing.
assert_eq!(big_page_hash(0x9000, 0x100), Some(0x57));
assert_eq!(adjust_page_end_header(0x1ff0, 0x10), Some(0x1ff0));
assert_eq!(adjust_page_end_header(0x1ff8, 0x10), Some(0x1ff0));
assert_eq!(adjust_page_end_header(0x2000, 0x10), Some(0x2000));
let tree = 0xffff_8000_0001_0000;
let root = 0xffff_8000_0002_0000;
assert_eq!(decode_rb_root(root, tree, false), Some(root));
Expand Down
16 changes: 14 additions & 2 deletions src/pool/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,11 @@ const OPTIONAL_FIELDS: &[(&str, &str, &[&str])] = &[
// scaled by 64 bytes; the slot map holds AffinityMask + 1 entries.
("_HEAP_VS_CONTEXT", "SlotMapRef", &["SlotMapRef"]),
("_HEAP_VS_CONTEXT", "AffinityMask", &["AffinityMask"]),
// `_RTL_HP_VS_CONFIG`, whose `Flags.PageAlignLargeAllocs` bit decides where a VS user block
// begins when it would otherwise begin in the last sixteen bytes of a page — see
// `snapshot::PoolRegion::vs_skips_page_end`. Optional so a PDB without it still walks: read
// as unset then, with a diagnostic saying so.
("_HEAP_VS_CONTEXT", "Config", &["Config"]),
// How an affinity slot names the context it belongs to. Two canonical names rather than two
// aliases of one, because they are not two spellings of the same fact: `VsContext` holds the
// context's address and `VsContextOffset` holds `slot - context`. Aliased, the newer build
Expand Down Expand Up @@ -1653,11 +1658,18 @@ mod tests {
/// an inline layout built on it used to pick up a stray `_HEAP_VS_AFFINITY_SLOT.VsContext`
/// through the *required* table and no longer does. That moves the synthetic digest while no
/// real build's moves, which is an artefact of the double rather than a regression.
///
/// **Re-recorded 2026-10-14**, when `_HEAP_VS_CONTEXT.Config` joined the optional fields
/// (windbg-mcp `FOLLOWUPS.md` item 101). Every build carries that field, so every digest
/// moved — the resolver found one more fact about each, which is the resolver's change and
/// not the target's — and the two below were taken from the code with the field added. The
/// pin is therefore against any *later* change moving an older build's schema, and the move
/// itself is recorded in the changelog, where a caller comparing runs across it will look.
#[test]
fn test_an_older_builds_schema_is_unchanged_by_the_self_relative_shape() {
for (build, recorded) in [
(&INLINE_BUILD, "fnv1a64:235676a22d0ccd01"),
(&ADDRESS_SLOT_BUILD, "fnv1a64:0084246676dae038"),
(&INLINE_BUILD, "fnv1a64:ef5b5fcd191a6c34"),
(&ADDRESS_SLOT_BUILD, "fnv1a64:f94b1d5fd7db5427"),
] {
let layout = PoolLayout::resolve(&build_symbols(build), key()).unwrap();

Expand Down
Loading
Loading