Worked Example: Recover a Deleted File from a ReFS Image
Goal: run every available ReFS deletion-recovery method against one real image and read the results honestly — which method finds what, and why the others come up empty.
Setup
Image: a native ReFS 3.14 test volume
(referred to below as $IMG). This is the last frame of the step5/testatomic action chain: a
native ReFS 3.14 volume that was BitLocker-encrypted, had files created and modified, then had a
file deleted with Explorer and was cleanly unmounted. A clean unmount is the worst case for
the easy recovery paths (Trash Table is drained, both checkpoints converge), which makes it a good
stress test for honest annotation.
IMG=deleted_file_test.raw # a v3.14 ReFS test volume; file deleted via Explorer, then cleanly unmounted
Steps
Step 1 — The fast metadata methods: Trash Table + checkpoint diff (deleted --no-slack)
python3 forefst.py "$IMG" deleted --no-slack
ReFS version: 3.14
Cluster size: 0x1000
Checkpoints: 2 (VC: 46, 45)
Objects: 19
── Trash Table (OID 0xD) ──
Empty (all deletions fully processed)
── Checkpoint Comparison ──
Current checkpoint: VC=46
Previous checkpoint: VC=45
Same top-level files in both (change may be in subdirectories or metadata)
── Recovery methods ──
Mode: recovery. Ran: Trash table (0xD), checkpoint diff.
Slack scan SKIPPED (--no-slack). Run `deleted` (the default) to recover deleted rows.
--no-slack runs only the two fast metadata methods, and on this clean image both come up empty —
honestly so:
- Trash Table (OID 0x0D):
Empty. The deferred-deletion queue was already drained byTrashCleanerWorkItemMethodbefore unmount, so nothing is parked there. See Trash Table. - Checkpoint differential: the two checkpoints decode to VC 46 vs 45 but resolve to the same top-level pointer list. This reproduces exactly: on a cleanly-unmounted volume both checkpoints converge to the same 13-root pointer list, so the differential yields nothing. A genuine mid-transaction crash capture is required for this method to fire.
So the metadata-only pass reports nothing here. That is the correct answer for these two methods — it
is not the end of the story: the default deleted run adds the B+-tree node-slack scan (Step 2).
Step 2 — The strong method: B+-tree node slack (forefst deleted, Method 5)
python3 forefst.py "$IMG" deleted --full # --full adds the whole-volume orphan-page scan
── B+-tree Node Slack Scan (Method 5) ──
Recovering deleted directory entries from metadata-page free space
(ReFS deletion removes only the row's index slot; the row body persists).
Orphan-page scan: scanned 1,044,480 of 1,044,480 clusters (100% of the volume)
Scanned 55 live pages + 11 orphan pages with recoverable slack rows
DELETED (no live file with this name + creation time remains on the volume): 25 [22 with valid timestamps, 3 partial remnants]
DIR $RECYCLE.BIN (non-resident, orphan-slack @ cluster 4616 off 0x360)
Deleted from: / (table 0x600)
Created: 2026-05-23 08:36:40 UTC
Modified: 2026-05-23 08:36:40 UTC
Recoverable: metadata only (non-resident — file data is NOT in this remnant)
FILE FVE2.{09cf57b8-9e6c-43d4-ae1f-0408882a397d}.1 (non-resident, live-slack @ cluster 3072 off 0x2e50)
Deleted from: /System Volume Information (table 0x701)
Created: 2026-05-23 08:46:26 UTC
Modified: 2026-05-23 08:46:26 UTC
... (25 deleted entries total) ...
+ 3 partial remnants (name fragment only, no valid timestamps — corroborate before use):
'FVE2.{b' (live-slack c3072 o0x260)
'FVE2.{c9ca54a3-6983-46b7-8684-a7e5e23499e3}.3' (live-slack c3072 o0x1c0)
'FVE2.{e40ad34d-dae9-4bc7-95bd-b16218c10f72}.1' (live-slack c3072 o0x120)
STILL PRESENT — a live file with this name + creation time exists (CoW remnants, and former locations of moved/renamed files): 5 [5 with valid timestamps]
$RECYCLE.BIN (orphan-slack @ cluster 52 off 0x360) — metadata only (non-resident — file data is NOT in this remnant)
IndexerVolumeGuid (orphan-slack @ cluster 1540 off 0x80) — FULL FILE recoverable (resident — 76 B stored inline in the record)
System Volume Information (orphan-slack @ cluster 52 off 0x2c0) — metadata only (non-resident — file data is NOT in this remnant)
WPSettings.dat (live-slack @ cluster 3072 off 0x378) — content zero or absent (indistinguishable in the remnant) — the $DATA descriptor survives and declares 12 inline bytes, and that region reads as zeros: the file held zeros, or the deletion took the bytes
desktop.ini (live-slack @ cluster 14852 off 0x378) — FULL FILE recoverable (resident — 129 B stored inline in the record)
DELETED files: 2 of 25 are RESIDENT with full content recoverable; 18 are non-resident (carve-able with --carve).
PRIOR versions of live files: 2 of 5 decode to a file.
The plain deleted run (live pages only, seconds) recovers 16 of these 25. The other 9 sit in orphan
pages — pages the live tree no longer references — which only the --full scan reaches. That scan reads
every cluster of the volume, so on this 4 GB image it costs a couple of seconds; on a real volume budget about
a minute per 60–100 GB.
This is the recovery payoff. ReFS deletion (CmsBPlusTable::DeleteFromIndex) removes only the
deleted row’s slot in the page’s offset array and queues delayed compaction — the row body is
not scrubbed. So the deleted directory entry’s name + inline $SI survives in the page’s
node slack (bytes not referenced by the live offset array) until a later copy-on-write rewrite
reuses the space.
- Each recovered row is a live type-0x30 filename entry decoded out of slack: a filename plus inline
$STANDARD_INFORMATIONcarrying the MACB timestamps. Thecluster N off 0xNNNNlocator is exactly where the orphaned row body sits. - DELETED vs STILL-PRESENT is the load-bearing distinction, decided by file identity — name plus
creation-time: a row is deleted only when no live file with that same name and creation-time exists
anywhere on the volume; otherwise it is still present — a CoW prior version (e.g.
WPSettings.dat,desktop.inihere are live), or, if the live file now sits in a different directory, a neutral former location of a moved/renamed file. Creation-time is immutable across a move or rename, so it separates the same file relocated from a different file that merely reuses the name — two unrelatedShield.pngin different folders are judged independently, and a common filename deleted from one folder is never masked by an unrelated live copy elsewhere. - Non-resident deleted files carve too. The roll-up notes
5 extent-backed (carve-able with --carve): for these the file’s data is in separate extents, and the extent map survived in a type-0x40 backing record recovered from the same slack —export deleted --carvereconstructs them best-effort (the clusters may have been reused since deletion). Deleted directories, when present, are grouped under$DELETED/DIR_OID_0x<oid>/(none on this clean single-delete image). - Rows are confidence-graded: 18 here have two plausible FILETIMEs (high confidence); 3 are
partial remnants — name fragments from a row whose body was partly overwritten (note
'FVE2.{b', a truncated name). Per the doc records 0 false positives on the clean baseline. - Honest read of the names: most recovered entries are
FVE2.{...}BitLocker metadata files churned by the encryption step, not the user file the analyst clicked “delete” on in Explorer. The Explorer deletion moved that file through$RECYCLE.BIN(renamed to$R...), so it does not resurface here under its original name — a faithful illustration of what slack actually preserves versus what an analyst hopes to see. The method demonstrably works; the specific yield is image-dependent.
This is the only method that recovers these rows — confirm that with Step 3.
Step 3 — The broad method: orphan MSB+ page scan (deleted --scan-pages)
python3 forefst.py "$IMG" deleted --scan-pages
── Orphaned Page Scan ──
Current tree references 128 unique physical clusters
Orphan-page scan: scanned 1,044,480 of 1,044,480 clusters (100% of the volume)
Found 99 orphaned MSB+ leaf pages
No deleted file entries found in scanned area
The orphan page scan carves MSB+ leaf pages off the disk and finds 99 orphaned pages — but
No deleted file entries. This is the crucial contrast with Step 2: the orphan scan follows each
page’s live offset array, so it never sees rows that live only in slack. The same 11 orphan
pages that Step 2 mined for slack rows yield nothing here. This is exactly why the docs state the
slack rows are “recoverable by no other method.”
Both scans cover the whole volume: the coverage line above states it explicitly, and a run bounded
with --max-scan says so instead — a page-level search that stopped part-way would report “not found”
for a file still sitting on the disk.
Step 4 — Method 4 (stream snapshots) does not apply to this image
The strongest deterministic content path is $SNAPSHOT stream-snapshot
recovery. Step 1 already told us this image has 0 snapshots, and the *_snapshot_* token in the
filename was a VM/disk snapshot, not a ReFS file-level stream snapshot. So Method 4 is
not applicable here — for a real worked snapshot extraction use a v3.14 image bearing ReFS stream snapshots with
forefst.py <image> snapshots --extract DIR (MD5-verified up to 21 extents / 13.4 MB).
What this tells you
- On a cleanly-unmounted ReFS volume the two fast metadata methods (
deleted --no-slack: Trash Table
- checkpoint diff) correctly return nothing — a drained trash queue and converged checkpoints are expected, not a parsing failure.
- B+-tree node slack (Method 5) is the method that actually recovers deleted directory entries +
their
$SItimestamps on this image — 25 deleted rows under--full(16 from live pages alone), confidence-graded, with 3 partials flagged. - The orphan page scan (
--scan-pages) finds the pages but not the rows, proving the slack scan reaches data no other method does. - Recovery yield is image-dependent and honest: most recovered names are BitLocker
FVE2churn, and an Explorer deletion routes the user file through$RECYCLE.BIN, so the original filename need not reappear. Always corroborate a single slack row (timestamps, neighbours, DELETED-vs-PRIOR flag) before relying on it. - OID gaps remain permanent deletion evidence independent of all the above: the volume reports 19 objects, and any missing OID between present ones is proof of a create-then-delete of a directory or system object that survives even full page reallocation. (Files carry no OID, so a deleted file leaves no gap — that is what the slack-row recovery above is for.)
See also
- Deletion Recovery — the five methods in full (Methods 1–5 mapped above)
- Forensic Analysis Workflow — where Step 5 (recover) sits in the 7-stage runbook
- Copy-on-Write — why deleted/old rows survive in slack and orphan pages
- Trash Table — Method 1 deferred-deletion queue (OID 0x0D)
- Object Table — Method 3 orphan-OID detection
- $STANDARD_INFORMATION — the inline MACB timestamps decoded from each recovered row
- $SNAPSHOT — Method 4 deterministic prior-content recovery (not present on this image)