Sitemap
Nicolas Guerinet

Nicolas Guerinet

BitLocker Recovery Procedure — Wiped Volume Header

11 min readMay 23, 2026

--

Scenario this solves: A BitLocker-encrypted drive (especially whole-disk encryption with no partition wrapper) where:

  • The first ~1 MB of the disk has been zeroed (partition table + BitLocker volume header destroyed)
  • The last ~1 MB of the disk has been zeroed (GPT backup destroyed)
  • The middle of the disk is intact (encrypted data + BitLocker metadata copies)
  • You know the BitLocker password (or recovery key)

This is the damage pattern produced by diskpart clean or accidental disk initialization (By SATA controller?) on a BitLocker drive. The case documented here used a Samsung Portable SSD T5, 2 TB, whole-disk BitLocker encryption with AES-XTS-128, password-protected.

The standard recovery tool (repair-bde on Windows) doesn't help because it requires an addressable volume with an intact volume header. Dislocker (Linux) is more flexible but needs a synthetic volume header constructed from the surviving metadata.

Hardware/software requirements

  • A Windows machine with sufficient disk space for an image of the source drive (or comparable Linux setup)
  • The source drive (e.g., the damaged T5) connected via USB
  • A destination drive with capacity ≥ source drive (the “image target” — referred to as D: below)
  • A second destination drive with capacity ≥ used data on source (the “recovery target” — referred to as E: below)
  • WSL2 with Ubuntu installed on the Windows machine
  • About 1 TB free in WSL2’s ext4 home directory for sparse test files
  • The BitLocker password
  • Several hours of uninterrupted time (laptop on AC power, sleep disabled)

Phase 1: Diagnostic and metadata extraction

Step 1.1 — Confirm the source drive’s state

In PowerShell (admin):

Get-Disk | Format-Table Number, FriendlyName, OperationalStatus, HealthStatus, BusType, Size

Identify the source drive’s disk number (called N below).

Get-Disk -Number N | Select-Object Number, PartitionStyle, OperationalStatus, HealthStatus

If PartitionStyle is RAW, the partition table is wiped. This procedure applies.

Step 1.2 — Verify the damage pattern (read-only checks)

In PowerShell (admin), check sector 0:

$disk = [System.IO.File]::OpenRead("\\.\PhysicalDriveN")
$disk.Position = 0
$buffer = New-Object byte[] 512
$disk.Read($buffer, 0, 512) | Out-Null
$disk.Close()
$nonzero = ($buffer | Where-Object { $_ -ne 0 }).Count
"Sector 0: $nonzero non-zero bytes out of 512"
"Last 2 bytes (MBR signature should be 55 AA): {0:X2} {1:X2}" -f $buffer[510], $buffer[511]

Expected: nearly all zeros, no 55 AA signature. Confirms partition table is wiped.

Check last 1 MB (substitute the correct $diskSize):

$disk = [System.IO.File]::OpenRead("\\.\PhysicalDriveN")
$diskSize = 2000398934016 # Actual size of your drive in bytes
$disk.Position = $diskSize - 1048576
$buffer = New-Object byte[] 1048576
$disk.Read($buffer, 0, 1048576) | Out-Null
$disk.Close()
$nonzero = ($buffer | Where-Object { $_ -ne 0 }).Count
"Last 1 MB: $nonzero non-zero bytes out of 1048576"

Expected: 0 non-zero. Confirms the trailing wipe.

Check a mid-disk sample for encrypted data:

$disk = [System.IO.File]::OpenRead("\\.\PhysicalDriveN")
$disk.Position = 500000000000 # ~500 GB in
$buffer = New-Object byte[] 32
$disk.Read($buffer, 0, 32) | Out-Null
$disk.Close()
"HEX: " + (($buffer | ForEach-Object { $_.ToString('X2') }) -join ' ')

Expected: random-looking bytes (high entropy). Confirms encrypted data is intact.

Step 1.3 — Find the BitLocker metadata locations

Install PowerShell 7 for better performance (optional but recommended):

winget install --id Microsoft.PowerShell --source winget

Launch pwsh as admin. Set the disk to stay awake:

powercfg /change standby-timeout-ac 0
powercfg /change monitor-timeout-ac 0
powercfg /change hibernate-timeout-ac 0

Scan the full disk for the -FVE-FS- signature. This finds the BitLocker metadata block locations. Substitute your $diskSize:

$path = "\\.\PhysicalDriveN"
$fs = New-Object System.IO.FileStream($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite, 1048576, [System.IO.FileOptions]::None)
$diskSize = [long]2000398934016  # Substitute your disk size
$chunkSize = [int]4MB
$sigBytes = [System.Text.Encoding]::ASCII.GetBytes("-FVE-FS-")
$enc = [System.Text.Encoding]::Latin1
$sigStr = $enc.GetString($sigBytes)
$sigLen = $sigBytes.Length
$buffer = New-Object byte[] $chunkSize
$tail = New-Object byte[] ($sigLen - 1)
$tailValid = $false
$position = [long]0
$found = New-Object System.Collections.ArrayList
$startTime = Get-Date
try {
while ($position -lt $diskSize) {
$fs.Position = $position
$remaining = $diskSize - $position
$toRead = if ($remaining -lt $chunkSize) { [int]$remaining } else { $chunkSize }
$toRead = $toRead - ($toRead % 512)
if ($toRead -le 0) { break }
$read = $fs.Read($buffer, 0, $toRead)
if ($read -le 0) { break }
$elapsed = (Get-Date) - $startTime
$mbPerSec = if ($elapsed.TotalSeconds -gt 0) { ($position / 1MB) / $elapsed.TotalSeconds } else { 0 }
$etaSec = if ($mbPerSec -gt 0) { (($diskSize - $position) / 1MB) / $mbPerSec } else { 0 }
if ($etaSec -gt 86400) { $etaSec = 86400 }
$eta = [TimeSpan]::FromSeconds($etaSec)
Write-Progress -Activity "Scanning for -FVE-FS-" `
-Status ("{0:N0} / {1:N0} MB | {2:N1} MB/s | Found: {3} | ETA {4:hh\:mm\:ss}" -f ($position/1MB), ($diskSize/1MB), $mbPerSec, $found.Count, $eta) `
-PercentComplete ([int](($position / $diskSize) * 100))
if ($tailValid) {
$boundary = New-Object byte[] (($sigLen - 1) * 2)
[Array]::Copy($tail, 0, $boundary, 0, $sigLen - 1)
[Array]::Copy($buffer, 0, $boundary, $sigLen - 1, $sigLen - 1)
$boundaryText = $enc.GetString($boundary)
$bIdx = $boundaryText.IndexOf($sigStr)
if ($bIdx -ge 0) {
$foundAt = $position - ($sigLen - 1) + $bIdx
[void]$found.Add($foundAt)
"FOUND -FVE-FS- at 0x{0:X}" -f $foundAt
}
}
$text = $enc.GetString($buffer, 0, $read)
$idx = 0
while (($idx = $text.IndexOf($sigStr, $idx)) -ge 0) {
$foundAt = $position + $idx
[void]$found.Add($foundAt)
"FOUND -FVE-FS- at 0x{0:X}" -f $foundAt
$idx++
}
if ($read -ge ($sigLen - 1)) {
[Array]::Copy($buffer, $read - ($sigLen - 1), $tail, 0, $sigLen - 1)
$tailValid = $true
}
$position += $read
}
}
finally {
$fs.Close()
$fs.Dispose()
Write-Progress -Activity "Scanning" -Completed
}
"Done. Matches: {0}" -f $found.Count
$found | ForEach-Object { " 0x{0:X} ({1:N2} GB)" -f $_, ($_/1GB) }

Expected: 3 matches, all clustered within ~10 MB of each other, somewhere in the middle of the disk. Note these offsets — they’re the signature offsets (let’s call them SIG1, SIG2, SIG3).

Step 1.4 — Extract the metadata region to a file

Take the first signature offset (SIG1). Subtract about 8 MB to get a region start, and dump 64 MB:

$src = "\\.\PhysicalDriveN"
$dst = "$env:USERPROFILE\T5_metadata_full.bin"
$offset = [long]SIG1_MINUS_8MB # e.g. if SIG1=0x8E688A0000, use 0x8E68000000
$size = [int]64MB
$fs = New-Object System.IO.FileStream($src, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite, 1048576, [System.IO.FileOptions]::None)
$out = [System.IO.File]::Create($dst)
$fs.Position = $offset
$buf = New-Object byte[] 4MB
$total = 0
while ($total -lt $size) {
$r = $fs.Read($buf, 0, 4MB)
if ($r -le 0) { break }
$out.Write($buf, 0, $r)
$total += $r
}
$fs.Close(); $out.Close()
"Extracted $total bytes to $dst"

Phase 2: Verify metadata is valid (in WSL2)

Step 2.1 — Set up WSL2 and dislocker

In WSL2 Ubuntu:

sudo apt update
sudo apt install dislocker -y
which dislocker-fuse dislocker-metadata

Step 2.2 — Copy metadata file into WSL2

cp /mnt/c/Users/YOUR_WINDOWS_USERNAME/T5_metadata_full.bin ~/T5_metadata_full.bin
ls -la ~/T5_metadata_full.bin

Step 2.3 — Extract the metadata block

Compute the offset within the dump where SIG1 falls. Then extract starting from there (or from the actual MDB region start, which is typically 1 MiB before the SIG offset — see explanatory note in Step 3):

# Example: dump starts at 0x8E68000000, SIG1=0x8E688A0000, dump offset = 0x8A0000
# 0x8A0000 / 4096 = 2208 (must be exact integer)
dd if=~/T5_metadata_full.bin of=~/test_header.bin bs=4K skip=2208 count=256 status=none
ls -la ~/test_header.bin
xxd ~/test_header.bin | head -3

Expected first bytes: 2d 46 56 45 2d 46 53 2d (the -FVE-FS- signature).

Phase 3: Construct the synthetic volume header

This is the critical step. The wiped volume header must be reconstructed so dislocker can find the metadata. The reconstruction is based on:

  • The standard BitLocker volume header format (from dislocker’s metadata.priv.h)
  • Fields specific to your volume (size, MDB offsets)
  • A known-good template (such as a reference dump from a working BitLocker drive)

Step 3.1 — Identify what your metadata says

Parse the metadata block to extract the claimed MDB offsets. These are the offsets dislocker will use (NOT the signature offsets — there’s a 1 MiB difference because the metadata block spans more than just the signature region).

Get Nicolas Guerinet’s stories in your inbox

Join Medium for free to get updates from this writer.

A Python helper:

import struct
with open('/home/jojo/test_header.bin', 'rb') as f:
mdb = f.read(512)
assert mdb[0:8] == b'-FVE-FS-'
print(f"Volume size (bytes): {struct.unpack_from('<Q', mdb, 0x10)[0]:,}")
print(f"MDB1 offset: 0x{struct.unpack_from('<Q', mdb, 0x20)[0]:X}")
print(f"MDB2 offset: 0x{struct.unpack_from('<Q', mdb, 0x28)[0]:X}")
print(f"MDB3 offset: 0x{struct.unpack_from('<Q', mdb, 0x30)[0]:X}")
print(f"Boot sector backup: 0x{struct.unpack_from('<Q', mdb, 0x38)[0]:X}")

Save the MDB offsets — they go into the synthetic header.

Step 3.2 — Build the synthetic volume header

The synthetic header is 512 bytes with these specific fields at these exact offsets (from dislocker’s volume_header_t struct):

Offset Size Field Value 0x00 3 JMP EB 58 90 0x03 8 Signature -FVE-FS- 0x0B 2 sector_size 512 0x0D 1 sectors_per_cluster 8 0x15 1 media_descriptor 0xF8 0x18 2 sectors_per_track 0x3F 0x1A 2 nb_of_heads 0xFF 0x24 4 unknown2 (NTFS marker) 0x00800080 0x28 8 nb_sectors_64b (volume_size / 512) 0x30 8 mft_start_cluster 786432 (arbitrary safe value) 0x38 8 metadata_lcn 0 0xA0 16 guid (format magic) 3B D6 67 49 29 2E D8 4A 83 99 F6 A3 39 E3 D0 01 0xB0 8 info_off[0] (MDB1 claimed offset) 0xB8 8 info_off[1] (MDB2 claimed offset) 0xC0 8 info_off[2] (MDB3 claimed offset) 0x1FE 2 boot signature 55 AA

Critical: The GUID at 0xA0 is NOT your volume’s GUID. It is a fixed format-identifier (INFORMATION_OFFSET_GUID) from dislocker/src/metadata/guid.c. Use those exact 16 bytes.

Python builder:

import struct
VOLUME_SIZE = 2000396321280  # FROM YOUR METADATA
MDB1 = 0x8E687A0000 # FROM YOUR METADATA
MDB2 = 0x8E690D4000 # FROM YOUR METADATA
MDB3 = 0x8E690E4000 # FROM YOUR METADATA
header = bytearray(512)
header[0:3] = b'\xEB\x58\x90'
header[3:11] = b'-FVE-FS-'
struct.pack_into('<H', header, 0x0B, 512)
header[0x0D] = 8
header[0x15] = 0xF8
struct.pack_into('<H', header, 0x18, 0x3F)
struct.pack_into('<H', header, 0x1A, 0xFF)
struct.pack_into('<I', header, 0x24, 0x00800080)
struct.pack_into('<Q', header, 0x28, VOLUME_SIZE // 512)
struct.pack_into('<Q', header, 0x30, 786432)
struct.pack_into('<Q', header, 0x38, 0)
header[0xA0:0xB0] = bytes([0x3b, 0xd6, 0x67, 0x49, 0x29, 0x2e, 0xd8, 0x4a,
0x83, 0x99, 0xf6, 0xa3, 0x39, 0xe3, 0xd0, 0x01])
struct.pack_into('<Q', header, 0xB0, MDB1)
struct.pack_into('<Q', header, 0xB8, MDB2)
struct.pack_into('<Q', header, 0xC0, MDB3)
header[0x1FE] = 0x55
header[0x1FF] = 0xAA
with open('/home/jojo/synth_header.bin', 'wb') as f:
f.write(header)
print("Done.")

Phase 4: Validate the recipe on a sparse test image

Before committing to imaging the entire source drive, validate the recipe works on a sparse test file.

Step 4.1 — Create a sparse image in WSL2’s ext4

NTFS doesn’t support real sparse files, so create this in your WSL2 home directory:

truncate -s 2000398934016 ~/test_sparse.img  # Substitute your disk size
ls -la ~/test_sparse.img
du -h ~/test_sparse.img # Should be 0 or tiny

Step 4.2 — Patch the synthetic header at offset 0x100000

The volume header lives at file offset 0x100000 (1 MiB), matching the standard BitLocker partition layout. Dislocker is told this via the -O / --offset flag.

dd if=~/synth_header.bin of=~/test_sparse.img bs=4K seek=256 conv=notrunc status=none

Step 4.3 — Write the metadata block at the signature offsets

# For each signature offset (SIG1, SIG2, SIG3), write the metadata block
# Use byte-offset arithmetic to ensure alignment
for OFFSET in SIG1 SIG2 SIG3; do
dd if=~/test_header.bin of=~/test_sparse.img bs=4K seek=$((OFFSET / 4096)) conv=notrunc status=none
done

Step 4.4 — Test dislocker can parse the metadata

dislocker-metadata -o 1048576 -V ~/test_sparse.img

Expected: “Volume GUID (INFORMATION OFFSET) supported”, then full metadata printout including encryption type, volume description, key protector list.

Step 4.5 — Test password unlock

Enable FUSE allow_other:

sudo sed -i 's/^#user_allow_other/user_allow_other/' /etc/fuse.conf
mkdir -p /tmp/dl_test

Then unlock (use leading space + set HISTCONTROL to keep password out of bash history):

export HISTCONTROL=ignorespace
dislocker-fuse --offset 1048576 -V ~/test_sparse.img -u'YOUR_REAL_PASSWORD' -r -- -s -o allow_other /tmp/dl_test
ls -la /tmp/dl_test/

Expected: silent return, then dislocker-file of the correct size. Confirms password and metadata are functional.

file /tmp/dl_test/dislocker-file

Expected: “DOS/MBR boot sector … NTFS … $MFT start cluster …” — this is the decrypted NTFS boot sector visible through dislocker’s virtualization. Decryption is provably working.

Unmount:

fusermount -u /tmp/dl_test

If this step succeeds, the recipe is proven. Proceed to imaging.

Phase 5: Image the source drive

Image the source to D:. The image needs to capture all sectors containing real data and all metadata locations. If the trailing portion of the source disk is wiped/zeroed, you can image only the populated region to save space.

Step 5.1 — Image with PowerShell (or HDD Raw Copy Tool)

$src = "\\.\PhysicalDriveN"
$dst = "D:\T5.img"
$total = [long]2000398934016 # Or smaller if you know the tail is zeros
$fs = New-Object System.IO.FileStream($src, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite, 16MB, [System.IO.FileOptions]::None)
$out = [System.IO.File]::Create($dst)
$buf = New-Object byte[] 16MB
$copied = [long]0
$start = Get-Date
while ($copied -lt $total) {
$r = $fs.Read($buf, 0, 16MB)
if ($r -le 0) { break }
$out.Write($buf, 0, $r)
$copied += $r
if ($copied % 1GB -lt 16MB) {
$elapsed = (Get-Date) - $start
$mbps = ($copied / 1MB) / $elapsed.TotalSeconds
$eta = [TimeSpan]::FromSeconds((($total - $copied) / 1MB) / $mbps)
Write-Host ("{0:N0} / {1:N0} MB | {2:N1} MB/s | ETA {3:hh\:mm\:ss}" -f ($copied/1MB), ($total/1MB), $mbps, $eta)
}
}
$fs.Close(); $out.Close()
"Done. Copied $copied bytes."

Takes hours. Let it complete fully.

Step 5.2 — Patch synthetic header into the real image

dd if=~/synth_header.bin of=/mnt/d/T5.img bs=4K seek=256 conv=notrunc status=none

(The image’s offset 0 should already be zeros since the T5’s first MB was wiped. We’re putting the synthetic header at 0x100000 where the volume header should live.)

Phase 6: Unlock and recover files

Step 6.1 — Unlock with dislocker

Important: use the -s flag (single-threaded FUSE) to avoid race conditions that corrupt ntfs-3g's reads:

mkdir -p /tmp/t5_unlocked
dislocker-fuse --offset 1048576 -V /mnt/d/T5.img -u'YOUR_REAL_PASSWORD' -r -- -s -o allow_other /tmp/t5_unlocked
ls -la /tmp/t5_unlocked/

Step 6.2 — Use The Sleuth Kit to list and recover files

ntfs-3g may fail to mount even decrypted-correctly NTFS due to FUSE-on-FUSE issues. The Sleuth Kit reads NTFS structures directly and bypasses this entirely:

sudo apt install sleuthkit -y
# Verify TSK can read the volume's root directory
fls -o 0 /tmp/t5_unlocked/dislocker-file | head -30

Expected: list of top-level files/folders with their actual names.

Step 6.3 — Recover all allocated files to E:

mkdir -p /mnt/e/Recovered_T5
sudo tsk_recover -a /tmp/t5_unlocked/dislocker-file /mnt/e/Recovered_T5/

-a recovers all current (non-deleted) files. Use -e for everything including deleted files.

Monitor progress in another terminal:

watch -n 60 'du -sh /mnt/e/Recovered_T5'

Takes hours. When done, spot-check files for readability.

Step 6.4 — Clean up

# Unmount everything
sudo umount /tmp/t5_files 2>/dev/null
fusermount -u /tmp/t5_unlocked
# Clear bash history of password
nano ~/.bash_history # Manually delete lines containing the password
# Or:
history -c && history -w

Key reference values that worked in the documented case

For the specific Samsung T5 case used to develop this procedure:

  • Source: Samsung Portable SSD T5, 2 TB (size = 2,000,398,934,016 bytes)
  • Volume size from metadata: 2,000,396,321,280 bytes
  • Encryption: AES-XTS-128
  • MDB1: 0x8E687A0000 (claimed) / 0x8E688A0000 (signature)
  • MDB2: 0x8E690D4000 (claimed) / 0x8E691D4000 (signature)
  • MDB3: 0x8E690E4000 (claimed) / 0x8E691E4000 (signature)
  • Volume header location after patch: file offset 0x100000
  • Dislocker flag: --offset 1048576 and -- -s -o allow_other

What was learned (lessons for the future)

  1. A mid-transfer USB disconnect does not by itself wipe the partition table. The damage pattern in this case (first MB + last MB zeroed) is the signature of diskpart clean or accepting a Windows "initialize disk" dialog. If a BitLocker drive shows as RAW, do NOT accept any format/initialize prompts. Image it first.
  2. BitLocker amplifies recovery difficulty even if you hold the user password or the Key. A wiped partition table on plain NTFS is recoverable in minutes with TestDisk. The same wipe on a BitLocker drive requires the recipe above.
  3. The 48-digit recovery key is more robust than the password for some types of damage, but in this case the password worked because the password key-protector was intact in the surviving metadata.
  4. Single-threaded FUSE is needed when dislocker-fuse output is being read by another FUSE filesystem (ntfs-3g). Without -s, race conditions corrupt reads at the second filesystem layer.
  5. Sample reference: when developing a recipe like this, an intact reference drive (same OS version, same BitLocker variant, same password type) is invaluable for verifying the synthetic header structure matches.
  6. Future encryption choice: VeraCrypt containers are more recoverable than BitLocker on portable drives because the encrypted container is just a regular file. Structural damage to the drive’s partition table doesn’t affect the container file’s accessibility.
  7. Always have a backup of important data. Don’t rely on a single storage.

--

--