ReFS Forensics Reference

Extent Descriptors

Extent descriptors (type 0x40) map a file’s logical cluster offsets (VCNs) to virtual LCNs (VLCNs). A VLCN is not a physical address: it must be translated through the Container Table to obtain the physical cluster where the data actually lives. Every extent — even a single contiguous run — is a fixed 24-byte entry.

Do not sanity-check a VLCN against the volume’s cluster count. The virtual address space is wider than the physical one — it starts above the first container and runs roughly twice as far — so a perfectly valid extent can name a cluster number larger than the volume has. The only sound test is whether the Container Table can translate it. Getting this wrong is quietly destructive: a decoder that walks extents in order and stops at the first “implausible” one throws the whole map away, and the file then looks as though it had no extents at all. On one 4 GB volume that mistake hid the contents of 583 of its 629 files, every one of which read back byte-perfect once the check was corrected.

Extent Entry — 24 bytes

OffsetSizeFieldDescription
0x008Virtual LCN (VLCN) (u64)Requires Container Table for physical translation
0x082Flags (u16)See Extent Flags below
0x0A2Record size (u16)24 for a plain run; 24 + run_length*4 when the run carries per-cluster checksums, the trailing 4-byte values being them
0x0C4File VCN (u32)Cluster index within file
0x104Padding (u32)Always zero
0x144Run length (u32)Number of contiguous clusters

Extent Flags

The flags occupy two bytes at extent+0x08; the two bytes above them are the record size. Read as one 32-bit value the pair reads (record_size << 16) | flags, which is why the constants seen in the wild all begin 0x18 — that leading 0x18 is 24, the size of a plain record, not a flag bit.

FlagsRecord sizeMeaning
0x004024Standard data-run extent (variable run_length)
0x005024Data-run with bit 0x10 set
0x0060 / 0x006424Sparse hole (bit 0x20 set): the entry has VLCN == 0 and its run is a zero-filled hole — never read from disk (see below)
0x00d024 + run*4Per-cluster checksums present (bit 0x80 set, over the 0x0050 run flags): each cluster of the run is followed by its CRC32-C (Castagnoli poly 0x82f63b78)

Measured across 48,474 extent records on 40 volumes: the record size is 24 + (bit 0x80 ? run*4 : 0) in every case, with no exception. Observed flag values are 0x0040, 0x0050, 0x0060 (plain) and 0x00d0 (checksummed).

Run cardinality is carried explicitly by the Run length field at extent+0x14, not by the flag bits: both 0x0040 and 0x0050 appear with single-cluster and multi-cluster runs. The meaning of the 0x10 bit that distinguishes 0x0050 from 0x0040 remains unresolved; it correlates with the file_attrs 0x8000 flag.

Records are 8-byte aligned, so the step from one record to the next is record_size rounded up to a multiple of 8. A checksummed single-cluster record is 28 bytes (24 + one 4-byte CRC32-C) and is therefore followed 32 bytes later — the 4 bytes in between are padding, not a field. A plain record is already a multiple of 8 and needs none.

Checksummed and plain records share the same header format and both point at real file data; they differ only in the trailing checksums and the size that implies.

No single-extent “shortcut” form

There is no compact 16-byte single-extent form. A contiguous file uses the standard 24-byte extent entry (one entry, run_length = the whole contiguous run). Non-resident files resolve via the 24-byte stride only; sampled single-extent files content-match (24-byte VLCN@0x00 → PLCN holds the file’s bytes).

This 16-byte region is the embedded $DATA sub-record header: the bytes 02000080 80000e00 … decode as the multi-instance marker 0x80000002 followed by the $DATA descriptor 0x000E0080 — i.e. the “VLCN” is the descriptor, not a cluster. See the Embedded $DATA section below.

VCN ordering

Extents may be stored out of order on disk. A parser must sort by file_VCN before reassembling file content — roughly half of multi-extent entries observed are unsorted.

VLCN to PLCN translation

A VLCN must be resolved through the Container Table to reach the physical disk address:

physical_LCN = container_phys_start + (vlcn & (CPC - 1))

Where CPC (clusters per container) is read from the Container Table at value + 0x18, and the container index is computed as vlcn >> CPC.bit_length() (shift = 15 for 4 KiB clusters, 11 for 64 KiB).

See Container Table for the full address-translation formula and the failover/checksum details.

Embedded extent sub-record header — 40 bytes

When $DATA (type 0x80) appears as an embedded multi-instance sub-record in a file value, the extent list is preceded by two headers. This is the extent-bearing $DATA stream summary (marker 0x80000002, descriptor 0x000E0080) — distinct from the smaller resident-SI $DATA record.

$DATA sub-record header (offsets are the v3.7+ layout)

Offset (v3.7+)SizeFieldDescription
0x004Inner header size (u32)Typically 0x88 (all versions)
0x0C4Summary size (u32)0x200 on v3.14+, 0x1A0 on v3.4–v3.10 (the resident-SI $DATA record uses 0x30)
0x2C4Data offset (u32)0x28
0x308Total allocated size (u64)
0x388Stream size (u64)Logical file size
0x408Valid data length (u64)
0x488Disk allocated size (u64)0 = inline; >0 = non-resident
0x508Version count (low 31) + sparse flag (bit 31) (u64)low31 = stream version count (=1 for a single-version file, N for an N-version snapshotted file); bit 31 = sparse flag

On ReFS v3.4 the four size fields shift +4 (total allocated 0x34, stream size 0x3C, valid data length 0x44); the disk-allocated slot (0x4C) is 0 and the allocation is carried by the total-allocated field. The inner header size (0x88) and the extent sub-record at +0x88 are unchanged across versions.

Extent sub-record header (at inner_header_size offset)

OffsetSizeFieldDescription
0x004Sub-record size (u32)Always 0x28
0x044Extent area end (u32)0x28 + extent_count x 24
0x0C4Flags (u32)0xe00 = non-resident extents; 0x600 = resident/empty
0x144Extent count (u32)Number of 24-byte extent entries following

Extent entries following this header use the same 24-byte format as the Extent Entry table above. Snapshot and copy-on-write $DATA reuse this identical extent format (the driver routes them through the same allocation-lookup routine as ordinary file reads).

Where the extent records live — a B+-tree node

A file’s extent records are not a bare list. They are the rows of a small B+-tree node held inside the file’s own $DATA record, at offset 0x88 of that record. A file with a single run looks like a plain array because the node holds one row; a fragmented or large file needs more, and then the node framing has to be read.

The node begins with a 40-byte header:

OffsetSizeFieldDescription
+0x004Header size0x28 — identifies the node
+0x0C1Level0 = leaf: the rows are extent records. Non-zero = the rows point at a child page
+0x104Key-index array startOffset, relative to the node, of the row-pointer array
+0x144Row countRows on this node
+0x204Key-index array endOne past the last entry
+0x2440 on every node measured

This is the same node header the B+-tree page uses, and the same invariant applies: (u32@+0x20 − u32@+0x10) / 4 == u32@+0x14. Read the array’s start from +0x10; deriving it by subtracting 4 × row_count from a value at +0x20 gives the same answer on a well-formed node — which is why that reading went unnoticed — but it cannot tell a damaged header from a good one, and it treats +0x20 as an 8-byte field when +0x24 is a separate word. Verified on 480 extent nodes with 0 violations.

Rows are addressed by that trailing index of 4-byte slots — a 2-byte row offset plus a 2-byte hint holding the row’s file VCN. Use the file VCN inside the row itself, not the hint: the hint is only 16 bits and saturates on a file larger than 65,535 clusters.

When the map outgrows one node, the level byte becomes non-zero and each row’s value is a 48-byte node reference holding four cluster numbers. Those are virtual cluster numbers — translate them through the Container Table — and together they address one 16 KiB page. That page carries the same node structure again, at offset 0x50 + <the 32-bit value at page+0x50>, so the walk repeats unchanged.

Picking the right record. A file that has stream snapshots carries one $DATA record per version, all under the same key and all the same size. They are told apart by a sub-stream id in the key at key+0x10: 0x1000 is the live stream, and 0x1001, 0x1002 and so on are the snapshots, oldest first. A reader that picks by size or takes the first match will read a snapshot’s extents and hand back an older version of the file as though it were the current one.

Forensic notes

  • Two-level translation (VCN → VLCN → PLCN) is fundamental to ReFS. A parser that treats VLCN values as direct physical addresses — a common mistake for NTFS-trained tools — will read wrong data.
  • Sorting by file_VCN is mandatory before content reassembly.
  • Every extent (including a single contiguous run) is a 24-byte entry; there is no 16-byte single-extent form. A 0x80000002 marker in this region is the embedded $DATA sub-record header, not an extent.

Cross-references

Sparse files: the ratio is the signal

A sparse region is unallocated, so the gap between a file’s logical size and its allocated size is what identifies one. A 64 KiB file with a punched middle was measured at 65,536 logical against 8,192 allocated — two clusters backing a sixteen-cluster file — carrying Archive|SparseFile. A fully sparse file goes further: its allocated size is 0. Both measured on format 3.14.

Extraction fills the hole with zeros, which is what the volume holds there. On a sparsely-stored raw image those zeros are indistinguishable from a region the acquisition never captured — see the note on image holes in what survives.

Evidence

The 24-byte extent entry layout (VLCN@0x00, flags@0x08, file_VCN@0x0C, padding@0x10, run_length@0x14) is raw-disk decoded and corroborated in the driver: CmsStream::LookupAllocation (with AddAllocation / DeleteAllocation) reads run+0x00 VLCN, +0x0C file_vcn, +0x14 run_length. The VLCN→PLCN translation formula is confirmed in the CmsVolumeContainer container subsystem and verified on disk across the corpus.

The extent-flag meanings are raw-disk verified: flags 0x0040 is the standard data run, 0x00d0 carries per-cluster checksums, and the 0x0050 vs 0x0040 distinction (bit 0x10) is not run cardinality — both occur with single- and multi-cluster runs. A plain record is 24 bytes; the 16-byte region at the descriptor is the embedded $DATA sub-record header. Snapshot/CoW DATA uses the identical 24-byte extent format (same CmsStream::LookupAllocation routine).

The checksum appended to an integrity record is CRC32-C (Castagnoli, poly 0x82f63b78) of the 4 KiB cluster: confirmed in the driver — the crc32c_4096 kernels via ComputeOneChecksum (which uses the 4096-byte path only when the span is one cluster) — and on disk, 886 checksummed clusters recomputed across three integrity volumes with 0 mismatches (a cross-algorithm control ruled out plain CRC-32 and CRC64). The sparse-hole entry (VLCN == 0, flags bit 0x20) is raw-disk verified: VLCN 0 → PLCN 0 is the boot region on every volume, so a zero VLCN is a hole, zero-filled and never read (213 records across 18 images, v3.9–v3.14, 4K and 64K).

The remaining field-level statements on this page are registered as — each with its own evidence tier and witness in the claim register. The logical-versus-allocated signal is confirmed, and the alloc_size = 0 case for a fully sparse file is confirmed. The extent-record key shape that identifies a non-inline stream’s own extent sub-record — key offset 0x10, key size 0x50 — is confirmed.