Home lab & data vault
Share
Explore
Data recovery stories

icon picker
Intenso USB drive recovery

Date 2025-08-24

Readers summary

This write-up walks through a practical methodology for handling data recovery on a damaged filesystem image and demonstrates how to build small, focused tools to support the process. Instead of focusing on the specifics of one failing device, it shows how to image a disk safely with ddrescue, interpret the resulting log of bad sectors, and then map those sectors back into filesystem context using ext2/3/4 utilities. Along the way the write-up highlights how to distinguish between real data loss and blocks that fall in unused space, and how to inspect suspect areas directly. The article also captures the development of a bash inspection script that automates many of these steps, combining hex dumps and zero-checks, and metadata reporting into a reusable utility.
A reader can take away both a reliable workflow for validating the extent of corruption after a bad disk rescue and practical scripting techniques for turning ad-hoc recovery commands into repeatable forensic tools.

Uh oh... bad blocks

An elderly standalone portable 1TB USB drive attached to a hypervisor started to report Current_Pending_Sector SMART errors. The hypervisors smartd daemon sent alerts about this issues, as its configured to perform basic SMART checks on a regular basis. The drive had been used as direct attached storage for backups. Use cases included a local zfs send target and storage for misc. backups.
The main use case of the drive is that, in the event of a disaster, the hypervisor can be booted from portable media that supports ZFS. Allowing the restoration of zpools and datasets from verified backups stored on the direct attached USB drive - eliminating the need to access a NAS or an off-site backup.
So, it’s time to check that the data on the drive has been backed up elsewhere, then retire the drive... The main objective is to have a deterministic method of revealing which files have been impacted by the bad blocks. Any corrupt files can then be investigated further or skipped during data transfer operations.
A record of the drive details, with some key points highlighted, follows:
##########
# smartctl excerpt
=== START OF INFORMATION SECTION ===
Model Family: Seagate Samsung SpinPoint M8 (AF)
Device Model: ST1000LM024 HN-M101MBB
Serial Number: <SNIP>
LU WWN Device Id: 5 0004cf 207fb5afa
Firmware Version: 2AR10002
User Capacity: 1,000,204,886,016 bytes [1.00 TB]
Sector Sizes: 512 bytes logical, 4096 bytes physical
Rotation Rate: 5400 rpm
Form Factor: 2.5 inches
Device is: In smartctl database 7.3/5319
ATA Version is: ATA8-ACS T13/1699-D revision 6
SATA Version is: SATA 3.0, 3.0 Gb/s (current: 3.0 Gb/s)
Local Time is: Sat Aug 23 14:31:21 2025 UTC
SMART support is: Available - device has SMART capability.
SMART support is: Enabled

##########
# lsblk excerpt

NAME VENDOR MODEL FSTYPE TYPE LABEL SIZE
sdn Intenso External USB 3.0 disk 931.5G
├─sdn1 vfat part EFI 200M
├─sdn2 hfsplus part OS X Mountain Lion Install Disk - 10.8 7.5G
├─sdn3 hfsplus part Mountain Lion Portable 29.3G
├─sdn4 hfsplus part Recovery HD 619.9M
├─sdn5 ntfs part NTFS2 59.9G
└─sdn6 ext4 part space 834G

##########
# smartctl excerpt

SMART Attributes Data Structure revision number: 16
Vendor Specific SMART Attributes with Thresholds:
ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE
1 Raw_Read_Error_Rate 0x002f 100 100 051 Pre-fail Always - 1880
2 Throughput_Performance 0x0026 252 252 000 Old_age Always - 0
3 Spin_Up_Time 0x0023 086 086 025 Pre-fail Always - 4461
4 Start_Stop_Count 0x0032 088 088 000 Old_age Always - 12566
5 Reallocated_Sector_Ct 0x0033 252 252 010 Pre-fail Always - 0
7 Seek_Error_Rate 0x002e 252 252 051 Old_age Always - 0
8 Seek_Time_Performance 0x0024 252 252 015 Old_age Offline - 0
9 Power_On_Hours 0x0032 100 100 000 Old_age Always - 885
10 Spin_Retry_Count 0x0032 252 252 051 Old_age Always - 0
11 Calibration_Retry_Count 0x0032 100 100 000 Old_age Always - 28
12 Power_Cycle_Count 0x0032 100 100 000 Old_age Always - 504
191 G-Sense_Error_Rate 0x0022 100 100 000 Old_age Always - 5
192 Power-Off_Retract_Count 0x0022 252 252 000 Old_age Always - 0
194 Temperature_Celsius 0x0002 064 054 000 Old_age Always - 32 (Min/Max 18/46)
195 Hardware_ECC_Recovered 0x003a 100 100 000 Old_age Always - 0
196 Reallocated_Event_Count 0x0032 252 252 000 Old_age Always - 0
197 Current_Pending_Sector 0x0032 100 100 000 Old_age Always - 111
198 Offline_Uncorrectable 0x0030 252 252 000 Old_age Offline - 0
199 UDMA_CRC_Error_Count 0x0036 200 200 000 Old_age Always - 0
200 Multi_Zone_Error_Rate 0x002a 100 100 000 Old_age Always - 626
223 Load_Retry_Count 0x0032 100 100 000 Old_age Always - 28
225 Load_Cycle_Count 0x0032 097 097 000 Old_age Always - 33289

SMART Error Log Version: 1
No Errors Logged

SMART Self-test log structure revision number 1
Num Test_Description Status Remaining LifeTime(hours) LBA_of_first_error
# 1 Short offline Completed: read failure 90% 880 384208312
# 2 Short offline Completed: read failure 90% 877 384208312
# 3 Short offline Completed: read failure 90% 875 384208312
# 4 Short offline Completed: read failure 90% 872 384208312

##########

gdisk -l /dev/sdn
GPT fdisk (gdisk) version 1.0.9

Partition table scan:
MBR: protective
BSD: not present
APM: not present
GPT: present

Found valid GPT with protective MBR; using GPT.
Disk /dev/sdn: 1953523712 sectors, 931.5 GiB
Model: External USB 3.0
Sector size (logical/physical): 512/512 bytes
Disk identifier (GUID): 6BE45827-XXXX-XXXX-8872-XXXXXXXX22F3
Partition table holds up to 128 entries
Main partition table begins at sector 2 and ends at sector 33
First usable sector is 34, last usable sector is 1953523678
Partitions will be aligned on 8-sector boundaries
Total free space is 266333 sectors (130.0 MiB)

Number Start (sector) End (sector) Size Code Name
1 40 409639 200.0 MiB EF00 EFI System Partition
2 409640 16042727 7.5 GiB 0700 Untitled 1
3 16304872 77797471 29.3 GiB 0700 Untitled 2
4 77797472 79067015 619.9 MiB AF00 Recovery HD
5 79067136 204582911 59.9 GiB AF00 Basic data partition
6 204582912 1953519615 834.0 GiB 0700 Basic data partition

Data recovery

# create a dir with space to perform the partition dump
mkdir /sas/data-nocrypt/usb-recovery
cd $_

# 💡 note the ddresuce commands are additive and use the ddrescue log as a checkpoint and reference

# quick first pass:
ddrescue --idirect --no-scrape --no-trim /dev/sdn6 sdn6.img sdn6.log

# read-only verification of the image (the partition was ext4)
time e2fsck -n sdn6.img
Pass 1: Checking inodes, blocks, and sizes
Pass 2: Checking directory structure
Pass 3: Checking directory connectivity
Pass 4: Checking reference counts
Pass 5: Checking group summary information
space: 155741/218628096 files (9.7% non-contiguous), 197067537/218617088 blocks
# 💡 in my case the image check ran without issues - metadata and structure *should* be OK

# 👇 ABORTED - taking a long time with little success
# retry pass:
time ddrescue --idirect --retry-passes=3 /dev/sdn6 sdn6.img sdn6.log

# 👇 SKIPPED - the underlying ZFS storage I was using allows ddrescue to write files with holes/zero blocks
# I cover this in more detail later on
# 💡 you might want to perform this step if your underlying storage doesn't support this
# scrape/fill pass — scrape small blocks and fill remaining unreadable sectors with zeros:
sudo ddrescue --idirect --scrape --fill-mode=zero /dev/sdn6 sdn6.img sdn6.log
As ddrescue was run against the partition where smartctl listed the first LBA read error rather than the whole device, the sectors in the ddrescue Mapfile can be mapped 1:1 to the blocks in the newly created partition image: sdn6.img. The block size and the location of the first block in the image can be determined as follows:
dumpe2fs -h sdn6.img |egrep -i 'Block size|First block'
dumpe2fs 1.47.0 (5-Feb-2023)
First block: 0
Block size: 4096
💡 If the First block value is not 0 then something is off and a calculation will be required to figure out the sector/block offset which is outside the scope of this doc. Your preferred GPT prompt should be able to give you some guidance on this.
The bad blocks can be dumped as follows: ddrescuelog -l '-*/' -b 4096 sdn6.log > bad-blocks.txt where 4096 is replaced with your images Block size.
In my case this generated a single column file listing the bad blocks integers - blocks that ddrescue could not read and/or had skipped.
⚠ If there were contiguous bad blocks the listing would of included ranges - you’ll want to watch out for this in your own scenario and handle/parse accordingly.
In my case there were 111 unique bad blocks in my bad-blocks.txt which after running ddrescue matched the number of Current_Pending_Sector from the SMART metrics.
The following shell snippet was used to enumerate the bad blocks in the image and check whether the blocks were in-use, and try to determine the inode and file path.
⌚ The debugfs commands were slow, even when operating on the image file. 💡 Investigate the debugfs -f option which supports command batches - this might speed things up if you have a lot of bad blocks (DYOR - YMMV).
# If you didn't already - output the list of bad blocks to file
ddrescuelog -l '-*/' -b 4096 sdn6.log > bad-blocks.txt

while read -r block; do
echo "Checking block $block"
inode=$(debugfs -R "icheck $block" sdn6.img 2>/dev/null | awk -F'\t' '/^Block/ {next}; ($2+0 == int($2)) {print $2}')
if [ -n "$inode" ]; then
echo "Block $block is in-use, inode: $inode"
filename=$(sudo debugfs -R "ncheck $inode" sdn6.img 2>/dev/null | awk -F'\t' '/^Inode/ {next}; {print $2}')
echo "Filename: $filename"
else echo "Block $block is free"; fi
done <bad-blocks.txt | tee block-check.log
From here you can inspect the block-check.log to see details on the bad blocks. I used the following follow up commands:
# any unused blocks?
grep -c '^Block.*is free' block-check.log
0

# unique list of file paths
grep '^Filename' block-check.log | sort -u
...
I had two unique file paths, and I checked my file vault and found that those files were already stored in a verified backup.
As e2fsck didn’t detect any issues with the dumped image, suggesting that the image metadata is OK, the focus is on which data blocks were skipped in the recovery. As per the steps above, we need to figure out if the blocks and related inodes were allocated to files or freespace.
💡 In my scenario, based on the available info, aside from the bad blocks ddrescue skipped and the impacted files, it should be safe to read all other data from the image and expect it to be integral. It obviously helps if you have some form of verification for the data like checksums or archives that can verify their content.

Checking the content of bad blocks in the dumped image

ddrescure supports writing sparse files, so for the sectors that it cannot read, it effecively writes a HOLE in the file and because the filesystem where I saved the ddrescue dump is ZFS which supports sparse allocation... I was interested to see what was the content of the unreadable sectors/blocks. In theory ZFS should return null/zero bytes for the blocks.
The bad-blocks.txt block addresses from ddrescuelog is zero based so they can be used direct to read blocks from the image. For illustration, here is the maths to calculate the block addresses from the original partition:
Sector (LBA) with a problem: 384,208,312 - per smartctl SMART test results
problem_sector=384208312
Start sector of the partition on the drive: 204,582,912
part_start_sector=204582912

Relative sector inside partition - gives us the zero based sector address:
problem_sector − part_start_sector = 179,625,400
zb_problem_sector=179,625,400

Sectors per 4 KiB block:
4096 ÷ 512 = 8
sectors_per_fs_block=8

Filesystem block index (zero-based):
zb_problem_sector ÷ sectors_per_fs_block = 22,453,175 # remainder 0
zb_bad_block_address=22453175
The zero based bad block address 22453175 is present in the bad-blocks.txt along with the other bad block addresses. We can sanity check the contents of these blocks inside the sdn6.img as follows:
fs_block_size=$(dumpe2fs -h sdn6.img 2>/dev/null |awk -F: 'tolower($0) ~ /block size/ {gsub(/[^0-9]/,"",$2); print $2}')

dd if=sdn6.img bs=$fs_block_size skip=22453175 count=1 status=none | od
0000000 000000 000000 000000 000000 000000 000000 000000 000000
*
0010000
Here, od shows that all bytes of the $fs_block_size block at address 22453175 are zero — contiguous NUL (0x00) bytes. The * indicates the previous address repeats until the last address.
This verifies that ddrescue wrote a file with holes where it detected bad blocks on the source drive, and my underlying ZFS reports those blocks as contiguous zero bytes (NUL / 0x00).
Want to print your doc?
This is not the way.
Try clicking the ⋯ next to your doc name or using a keyboard shortcut (
CtrlP
) instead.