Stream Snapshots and File Versioning
A stream that has not diverged from its snapshot owns no allocation of its own —
DataResidencyreports it assnapshot-shared. See Record placement and data residency.
A ReFS stream snapshot freezes a file’s current content under a new stream identity, so that later writes leave the snapshotted bytes intact and exactly recoverable from a single disk image. For a forensic analyst this is the strongest recovery path on a ReFS volume: unlike carving free space or diffing checkpoints, the prior version of a versioned file is reconstructed deterministically, from explicitly-referenced extents that the file system has committed to keep alive. This page explains how a snapshot is wired into the on-disk structures, why copy-on-write makes the old bytes survive, and the exact chain an analyst follows to recover them.
A snapshot is not a separate file
The first thing to understand is where a snapshot lives. It is not a second file object with its own
OID, and it is not stored elsewhere on the volume. A stream snapshot is an embedded sub-record
($SNAPSHOT, type 0xB0 / schema 0x1B0) inside the file’s resident type-0x30
directory-entry row, and it points at a sibling $DATA sub-record
in that same row which holds the frozen extents. Everything — the live version, every snapshot, and the
metadata linking them — sits inside the one parent directory row.
That has a direct consequence for how you reach a snapshotted file: because the whole chain is resident
inside the parent directory’s type-0x30 value, the file is addressed by (parent_oid, filename) — a
path — and not by an Object-Table OID, since files have no own OID (resident or not). A tooling lookup keyed
on --oid will miss them; use a path-based lookup.
The link between a $SNAPSHOT record and the extents it froze is a stream sub-id namespace carried
in the $DATA key:
data_sub_id | Meaning |
|---|---|
0x1000 | Current (live) file version |
0x1001, 0x1002, … | Snapshot versions, allocated oldest-first |
0x8 | Metadata: the next-sub-id counter |
Each $SNAPSHOT value records the data_sub_id of the version it captured at val[0x44] (u32), the
stream size at snapshot time at val[0x20] (u64), and the snapshot creation FILETIME at the unaligned
offset val[0x4c]. That data_sub_id is matched against $DATA key[16:20] to find the frozen extent
set — this is the join that drives the whole recovery.
resident type-0x30 row (the file)
├─ $SNAPSHOT sub-record (0xB0, marker 0x80000002, val[0x10]=2)
│ val[0x44] = data_sub_id ──────────────┐ (0x1001, 0x1002, …)
│ val[0x20] = stream size at snapshot │
│ val[0x4c] = snapshot FILETIME │
└─ $DATA sub-record (0x80, descriptor 0x10028)│
key[16:20] == data_sub_id ◄────────────┘
val[ihdr+0x14] = extent_count
val[ihdr+0x28] = 24-byte extents {VLCN@+0x00, file_vcn@+0x0C, run_length@+0x14}
Why the prior bytes survive: copy-on-write and refcount sharing
ReFS never overwrites data in place. A write to a snapshotted file allocates new clusters and rewrites
the affected B+-tree pages, so the snapshot’s $DATA extents keep pointing at the original,
un-rewritten clusters. This is the same update model the Copy-on-Write page describes
in full — a stream snapshot is its directly-recoverable application.
What keeps those original clusters from being recycled is block reference-count sharing. A cluster
range referenced by both the live version and a snapshot carries a refcount >= 2 in the
Block Refcount table (root #6, schema 0xe0b0), and a refcount above 1
tells the allocator the range is still in use, so it is never reclaimed. In the deletion-recovery model
a refcount >= 2 is the guaranteed-survival category: the cluster is alive precisely because more
than one object — here, a version — points at it. This is why a snapshot offers a stronger guarantee
than recovering a deleted file: the snapshot’s bytes are not merely not yet overwritten, they are
pinned.
The refcount also doubles as a triage signal before extraction. A cluster range with refcount >= 2
(bit 15 clear) is a normal multi-reference — a snapshot, a hard link, or a block clone — and is
guaranteed intact; clusters with refcount 0 survive only until the allocator reuses them. So a refcount
check distinguishes “this version will reconstruct byte-for-byte” from “this is opportunistic salvage.”
The consequence: no single stream is a complete block map
Because only the changed blocks are rewritten, the live version’s extent list ends up covering only what has been written since the last snapshot. Everything untouched is still described by the snapshot’s list, and the cluster is shared rather than duplicated. Neither list, on its own, describes the whole file.
The stream record says so directly, in two fields that are easy to confuse:
| field | means |
|---|---|
| allocated size for this stream | what this version owns — the bytes its own extent list covers |
| total allocated size | the file’s whole allocation, including the blocks it shares |
A snapshotted, then modified, file shows the second much larger than the first. One lab file reports a 22,688-byte stream with a 24,576-byte total allocation but only 8,192 bytes of its own — its extent list holds two clusters, numbers 4 and 5. Clusters 0–2 belong to the first snapshot and cluster 3 to the second.
So reading any version means resolving each block position across the versions, which is what the driver does on an ordinary read:
- for the live version, a block it does not own comes from the most recent snapshot that holds it — a block only drops out of the live list while it has not changed since;
- for a prior version, only snapshots older than it may be used; a newer one can hold bytes written after the version you are recovering;
- a position no version holds is a genuine sparse hole, and reads as zeros.
Nothing here is reconstructed or inferred: every block comes from a real cluster that is pinned by its refcount. Reading only one version’s own extents produces a file of the right length whose shared regions are zeros — bytes that are not on the volume at all.
The recovery chain
From the resident type-0x30 row, recovery proceeds in four steps:
- Read each
$SNAPSHOTsub-record: thedata_sub_id(val[0x44]), the stream size at snapshot time (val[0x20]), and the snapshot FILETIME (val[0x4c]). - Find the
$DATAsub-record whosekey+0x10 == data_sub_id. Its inner-header offset is atval[0x00](typically 0x88); the on-disk allocation atval[0x48]decides residency —0means the content is inline in the 0x30 body (typical of the current version),> 0means the content is non-resident and follows extents. The extent count is atval[ihdr+0x14]and the 24-byte extents begin atval[ihdr+0x28]. - Sort the extents by
file_vcn, translate each VLCN → PLCN through the Container Table (mandatory — the VLCN in an extent is a virtual address, see Virtual Addressing), readrun_lengthclusters per extent, concatenate, and trim to the recorded stream size. - The current version is
data_sub_id = 0x1000; if its allocation is0the live content is inline, otherwise it follows the same extent chain.
The snapshot extents use the identical 24-byte format as ordinary non-resident type-0x40 data runs:
VLCN at +0x00, flags at +0x08, file_vcn at +0x0C, run_length at +0x14. That is not a
coincidence — the driver routes snapshot/CoW $DATA reads through the same allocation lookup as ordinary
file reads, so standard Container-Table resolution recovers the content unchanged. The full byte-level
walk of the embedded headers is on the $SNAPSHOT attribute page.
Forensic implications
Exact prior-content recovery from a single image. This is deletion-recovery Method 4 (see Deletion Recovery) — the most deterministic single-image path. Unlike the orphan-page scan or the checkpoint differential, it does not depend on superseded pages surviving: the snapshot extents are explicitly referenced and refcount-pinned. Recovery has been verified byte-for-byte (MD5-identical to an independent export) on multi-extent, multi-megabyte chains across several volumes.
A complete, timestamped edit history. Multiple snapshots on one file reconstruct an ordered version history, each version stamped by its own
val[0x4c]FILETIME — distinct from the file’s$SItimestamps. A short worked example: a file with four versions might recover as v1 = 42 bytes ofa, v2 = 65 bytes (a+b), v3 = 92 bytes (a+b+c), and the live version =abc\r\n, all from one image with each version’s creation time read straight from its snapshot record. This is the closest ReFS comes to handing the analyst a file’s editing timeline.Do not confuse snapshots with Alternate Data Streams. Type 0xB0 serves both stream snapshots and ADS. The reliable discriminator is
data_sub_id: a value in0x1000–0xFFFFis a true snapshot, an ASCII/named stream is an ADS. This is corroborated byval[0x10](the StreamSummary flag:2= snapshot,0= ADS) and by theval[0x02]attribute flags (a snapshot sets0x1C00, with bit0x0400= HasSnapshot). The u32 atval[0x38]is not a discriminator: it is the stream’s integrity/checksum-type selector —0x02on None/CRC64 volumes and0x04on SHA-256 volumes — so it follows the volume’s checksum configuration rather than the snapshot/ADS distinction and is useless for this purpose. Mislabeling an ADS row as a snapshot, or the reverse, corrupts both the version count and the recovery target.Snapshot count is bounded only by the file. A file with N snapshots carries at least N+2 embedded sub-records (1 live
$DATA, one$SNAPSHOTper version, plus a metadata row); the embedded-row count atvalue+0x20is an unbounded count, not a fixed enum. A row count of 4–6 is a common “has snapshots” signal but never a ceiling — treat it as a hint to inspect, not a count.
Version and state differences
$SNAPSHOT / schema 0x1B0 is gated v3.7+ (the version-gating matrix lists 0x1B0 as present from v3.7
through Insider). v3.4 volumes have no $SNAPSHOT attribute at all. The $SNAPSHOT value formats are
otherwise identical across v3.7 to Insider; only the embedded sub-record key layout differs by
version — v3.4-era keys carry no instance markers, while v3.7+ keys place markers at key[8:12].
The Block Refcount table (root #6, schema 0xe0b0) and its empty B+-tree exist since v3.4, but the
table is only populated on v3.14 volumes with sharing activity. Stream-snapshot recovery does not
require a populated refcount table — the $SNAPSHOT → $DATA chain is self-contained and resolves on
its own — but where refcount data is present, a refcount >= 2 confirms the “guaranteed survival”
classification.
Tooling
forefst.py <image> snapshots --show --file '<path>' previews each recovered version of a file;
--extract DIR writes every version to disk. The implementation re-derives the chain above and was
validated MD5-identical to an independent export.
Cross-references
Driver Transitions — taking a stream snapshot converts an inline stream to extents
Copy-on-Write — the update model that keeps snapshot extents intact; the canonical recovery-chain walk-through and the refcount-survival categories
Deletion Recovery — stream snapshots are Method 4, the strongest single-image exact-content path
$SNAPSHOT attribute — the 0xB0 / schema 0x1B0 value layout and the byte-level snapshot-vs-ADS discrimination
Block Refcount Table — the refcount
>= 2sharing that pins snapshot clusters against reuse (root #6, schema 0xe0b0)Container Table — the VLCN → PLCN map required to read snapshot extents off disk
Virtual Addressing — why the VLCN in a snapshot extent is virtual and must be translated before any disk read
Directory Entries — the resident type-0x30 row that holds the whole snapshot chain
Evidence
The snapshot is created by RefsCreateStreamSnapshot, which builds the StreamSummary, sets the snapshot
flag, and stamps the stream-set id and FILETIME; GetResidentStreamSummaryFromDisk and
SetResidentStreamSummary read back and persist the on-disk layout the recovery chain walks. The CoW write path that frees the newly-allocated clusters while leaving the snapshot
extents in place is MsUpdateDataWithRoot — leaf allocate, parent copy, root propagation, checkpoint. Snapshot/CoW $DATA reads share the ordinary file-read allocation routine
CmsStream::LookupAllocation, which is why the 24-byte extent format is identical to type-0x40 runs.
Block-refcount maintenance is IncrementRefcount / DecrementRefcount against the table built by
CmsBlockRefcount::Initialize; DecrementRefcount is the v3.4 symbol, while v3.14 builds expose only
IncrementRefcount and handle refcount-down via a signed delta (with
MsKmeBlockRefCountUnderflowEventNotification). The value-layout offsets, the data_sub_id namespace,
the snapshot-vs-ADS discriminators, and byte-for-byte content recovery are raw-disk verified.
Also registered for statements on this page:.
Also registered for statements on this page:.