ReFS Forensics Reference

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 by TrashCleanerWorkItemMethod before 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_INFORMATION carrying the MACB timestamps. The cluster N off 0xNNNN locator 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.ini here 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 unrelated Shield.png in 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 --carve reconstructs 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 $SI timestamps 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 FVE2 churn, 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