Skip to content

Instantly share code, notes, and snippets.

@kyle0r
Last active August 26, 2025 00:47
Show Gist options
  • Save kyle0r/b3b7df3576953f898d8495854dea13d0 to your computer and use it in GitHub Desktop.
Save kyle0r/b3b7df3576953f898d8495854dea13d0 to your computer and use it in GitHub Desktop.
Forensic inspection tool for ext2/3/4 partition images

This script is a forensic inspection tool for ext2/3/4 partition images that have been rescued with ddrescue or similar methods.
It allows the user to map and inspect specific regions of an image by either disk sector (LBA) or ext filesystem block number.

Download the latest version with curl:

curl -sSL 'https://gist.githubusercontent.com/kyle0r/b3b7df3576953f898d8495854dea13d0/raw/ext_block_inspector.sh' > ~/ext_block_inspector.sh

With wget:

wget -qO- 'https://gist.githubusercontent.com/kyle0r/b3b7df3576953f898d8495854dea13d0/raw/ext_block_inspector.sh' > ~/ext_block_inspector.sh

chmod

chmod a+rx ~/ext_block_inspector.sh

You can now run the script via ~/ext_block_inspector.sh which will print usage info.

Usage

sudo -u nobody ./ext_block_inspector.sh
Usage:
  ext_block_inspector.sh -i <image> --fs-block=<N> [--fs-block-size=<bytes>] [--sector-bytes=<bytes>]
  ext_block_inspector.sh -i <image> --lba=<DISK_LBA> --part-start=<PART_START_LBA> [--sector-bytes=<bytes>]

Options:
  -i, --image=<PATH>          Path to partition image (required)
      --fs-block=<N>          Filesystem block index (0-based; 1:1 with image; ddrescuelog -b <FS_BS>)
      --lba=<N>               Disk LBA (512-byte sectors by default)
      --part-start=<N>        Partition start LBA on original disk (required with --lba)
      --sector-bytes=<BYTES>  LBA sector size. Default: 512
      --fs-block-size=<BYTES> filesystem block size override. Default reads from image.
  -h, --help                  Show this help

Note: long options must be used with '=' (e.g. --fs-block=123), not space separation.
Note: current version supports ext2/3/4 filesystems but could be expanded to support others.

Sample output for --fs-block=<N> mode:

sudo -u nobody ./ext_block_inspector.sh --image=sdn6.img --fs-block=22453175
IMG=sdn6.img
IMG_SIZE=895455592448  SECTOR_BYTES=512  FS_BS=4096  PER_BLK=8
First block=0  Block count=218617088
FS_BLOCK=22453175  offset=91968204800
[cmd] hexdump -C
[read] 4096-byte filesystem block FS_BLOCK=22453175 (offset=91968204800)
[zeros] all-zero (4096 bytes)
00000000  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00001000

Sample output for --lba=<DISK_LBA> --part-start=<PART_START_LBA> mode:

sudo -u nobody ./ext_block_inspector.sh --image=sdn6.img --lba=384208312 --part-start=204582912
IMG=sdn6.img
IMG_SIZE=895455592448  SECTOR_BYTES=512  FS_BS=4096  PER_BLK=8
First block=0  Block count=218617088
DISK_LBA=384208312  PART_START_LBA=204582912  sect_in_part=179625400
FS_BLOCK=22453175  sector_in_4k=0  byte_off=91968204800
[cmd] hexdump -C
[read] 512 bytes at sector_in_partition=179625400 (byte_off=91968204800)
[zeros] all-zero (512 bytes)
00000000  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000200
[read] 4096-byte filesystem block FS_BLOCK=22453175 (offset=91968204800)
[zeros] all-zero (4096 bytes)
00000000  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00001000
[read] 512 bytes within FS_BLOCK=22453175 at sector_in_4k=0
[zeros] all-zero (512 bytes)
00000000  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000200
#!/usr/bin/env bash
# shellcheck shell=bash
set -Eeuo pipefail
: <<'DOC'
===============================================================================
This script is a forensic inspection tool for ext2/3/4 partition images that
have been rescued with ddrescue or similar methods. It allows the user to map
and inspect specific regions of an image by either disk sector (LBA) or ext
filesystem block number.
===============================================================================
This script is not POSIX compliant and uses bash-specific features
===============================================================================
I coached ChatGPT to generate this script based on my requirements and style
guidance. The development was iterative, with one unit of functionality added at
a time, followed by the addition of guardrails, getopts, usage, shellcheck and
polish.
===============================================================================
Notes on POSIX vs. Bash-Specific Features
-------------------------------------------------------------------------------
This script currently requires Bash and GNU userland tools for full
functionality. The following elements are not POSIX, grouped by type:
Bash language features:
- set -o pipefail and set -E (not POSIX, Bash/Ksh extensions)
- Arrays and array expansion: VIEW=(hexdump -C), "${VIEW[@]}"
- [[ ... ]] test syntax and =~ regex
- Arithmetic evaluation: (( ... ))
- Process substitution: tee >( zeros_or_not ... )
- printf '%q' (shell escaping, Bash extension)
External utilities and flags (GNU/BSD specific):
- stat (GNU: stat -Lc %s, BSD: stat -f%z); not POSIX
- dd flags: iflag=skip_bytes,count_bytes, status=none (GNU only)
- cmp -n N (limit compare to N bytes, GNU extension)
- hexdump -C (canonical output, not POSIX mandated)
- od -Ax (BSD/GNU form; POSIX form is od -A x -t x1 -v)
- dumpe2fs (and future debugfs) are ext2/3/4 tools, not POSIX
Likely POSIX-OK:
- set -e and set -u are POSIX compliant
- awk, sed, grep, printf with used options are POSIX
- $(( ... )) arithmetic expansion is POSIX
- here-docs (including quoted delimiters) are POSIX
- command -v is POSIX
In short:
- The script is Bash-only today.
- Porting to strict /bin/sh would require replacing arrays,
[[ ... ]], (( ... )), process substitution, and GNU-specific dd/stat.
===============================================================================
DOC
# ===== usage & small helpers =====
usage() {
cat >&2 <<'EOF'
Usage:
ext_block_inspector.sh -i <image> --fs-block=<N> [--fs-block-size=<bytes>] [--sector-bytes=<bytes>]
ext_block_inspector.sh -i <image> --lba=<DISK_LBA> --part-start=<PART_START_LBA> [--sector-bytes=<bytes>]
Options:
-i, --image=<PATH> Path to partition image (required)
--fs-block=<N> Filesystem block index (0-based; 1:1 with image; ddrescuelog -b <FS_BS>)
--lba=<N> Disk LBA (512-byte sectors by default)
--part-start=<N> Partition start LBA on original disk (required with --lba)
--sector-bytes=<BYTES> LBA sector size. Default: 512
--fs-block-size=<BYTES> filesystem block size override. Default reads from image.
-h, --help Show this help
Note: long options must be used with '=' (e.g. --fs-block=123), not space separation.
Note: current version supports ext2/3/4 filesystems but could be expanded to support others.
EOF
exit 2
}
die() { printf '%s\n' "$*" >&2; exit 1; }
warn() { printf 'Warning: %s\n' "$*" >&2; }
note() { printf '%s\n' "$*" >&2; }
is_uint() { [[ "${1-}" =~ ^[0-9]+$ ]]; }
# ===== deps & viewer =====
ensure_deps() {
local need=(dd awk grep printf dumpe2fs cmp tee stat stdbuf)
local ok=1
for bin in "${need[@]}"; do
command -v "$bin" >/dev/null 2>&1 || { printf 'Missing dependency: %s\n' "$bin" >&2; ok=0; }
done
if ! command -v hexdump >/dev/null 2>&1; then
command -v od >/dev/null 2>&1 || { printf 'Missing dependency: hexdump or od\n' >&2; ok=0; }
fi
(( ok == 1 )) || exit 127
}
# Note: uses a small array; keep Bash shebang.
set_viewer() {
if command -v hexdump >/dev/null 2>&1; then
VIEW=(hexdump -C)
else
VIEW=(od -Ax -tx1 -v)
fi
}
print_view_cmd() {
# Show exactly what will execute (shell-safe quoting)
printf '[cmd] %s\n' "$(printf '%q ' "${VIEW[@]}")" >&2
}
# ===== image introspection (globals) =====
IMG=""
IMG_SIZE=0
SECTOR_BYTES="512"
FS_BS="" # optional override; auto-detected otherwise
FS_BS_ACTUAL=""
FIRST_BLOCK="0"
BLKCNT=0
PER_BLK=0 # sectors per filesystem block
init_image() {
local img=${1:?image path required}
[[ -r "$img" ]] || die "Image not readable: $img"
# Ensure stable parsing from dumpe2fs output
export LC_ALL=C
# image size (GNU/BSD stat)
if IMG_SIZE=$(stat -Lc %s "$img" 2>/dev/null); then :; else IMG_SIZE=$(stat -f%z "$img"); fi
FS_BS_ACTUAL=$(dumpe2fs -h "$img" 2>/dev/null | awk -F': *' '/Block size:/ {gsub(/[^0-9]/,"",$2); print $2}')
FIRST_BLOCK=$(dumpe2fs -h "$img" 2>/dev/null | awk -F': *' '/First block:/ {gsub(/[^0-9]/,"",$2); print $2}')
BLKCNT=$(dumpe2fs -h "$img" 2>/dev/null | awk -F': *' '/Block count:/ {gsub(/[^0-9]/,"",$2); print $2}')
[[ -n "$FS_BS_ACTUAL" && -n "$BLKCNT" ]] || die 'Could not read filesystem parameters from image'
if [[ -n "$FS_BS" && "$FS_BS" != "$FS_BS_ACTUAL" ]]; then
warn "FS_BS override ($FS_BS) differs from image block size ($FS_BS_ACTUAL). Using image value."
fi
FS_BS="$FS_BS_ACTUAL"
(( FS_BS % SECTOR_BYTES == 0 )) || die "FS_BS ($FS_BS) is not a multiple of sector bytes ($SECTOR_BYTES)"
PER_BLK=$(( FS_BS / SECTOR_BYTES ))
}
# ===== parked debugfs (no-op for now) =====
debugfs_probe() { :; }
# ===== zero-detect & viewer wrapper =====
zeros_or_not() {
# stdin is the byte stream; $1=label text, $2=byte count
local label=${1:?label required}
local nbytes=${2:?byte-count required}
# Bound the comparison to avoid infinite /dev/zero length mismatch
if cmp -s -n "$nbytes" - /dev/zero; then
printf '[zeros] all-zero (%s)\n' "$label"
else
printf '[zeros] non-zero (%s)\n' "$label"
fi
}
preview_and_zero() {
# stdin is the byte stream; $1=byte-count, $2=label
local nbytes=${1:?byte-count required}
local label=${2:?label required}
# One tee fork to zero-check; main stream to hex viewer, then stdbuf to mitigate stdout and stderr interleaving
tee >( zeros_or_not "$label" "$nbytes" 1>&2 ) | stdbuf -o "$nbytes" "${VIEW[@]}"
}
# ===== read by filesystem block (1:1 with image) =====
read_from_fsblock() {
local img=${1:?} fs_block=${2:?}
is_uint "$fs_block" || die 'FS block must be a non-negative integer'
init_image "$img"
set_viewer
(( fs_block >= 0 && fs_block < BLKCNT )) || die "FS_BLOCK $fs_block out of range [0..$((BLKCNT-1))]"
local block_off=$(( fs_block * FS_BS ))
(( block_off + FS_BS <= IMG_SIZE )) || die "Requested range [$block_off..$((block_off+FS_BS))) exceeds image size $IMG_SIZE"
note "IMG=$img"
note "IMG_SIZE=$IMG_SIZE SECTOR_BYTES=$SECTOR_BYTES FS_BS=$FS_BS PER_BLK=$PER_BLK"
note "First block=$FIRST_BLOCK Block count=$BLKCNT"
note "FS_BLOCK=$fs_block offset=$block_off"
print_view_cmd
note "[read] ${FS_BS}-byte filesystem block FS_BLOCK=$fs_block (offset=$block_off)"
dd if="$img" bs="$FS_BS" skip="$fs_block" count=1 status=none | preview_and_zero "$FS_BS" "${FS_BS} bytes"
}
# ===== read by disk LBA + partition start =====
read_from_lba() {
local img=${1:?} lba=${2:?} part_start=${3:?}
is_uint "$lba" || die 'LBA must be a decimal integer'
is_uint "$part_start" || die 'PART_START_LBA must be a decimal integer'
init_image "$img"
set_viewer
local sect_in_part=$(( lba - part_start ))
(( sect_in_part >= 0 )) || die "LBA $lba is before partition start $part_start"
local byte_off=$(( sect_in_part * SECTOR_BYTES ))
local fs_block=$(( sect_in_part / PER_BLK ))
local intra_sec=$(( sect_in_part % PER_BLK ))
(( byte_off + SECTOR_BYTES <= IMG_SIZE )) || die "Requested byte range [$byte_off..$((byte_off+SECTOR_BYTES))) exceeds image size $IMG_SIZE"
(( fs_block >= 0 && fs_block < BLKCNT )) || die "FS_BLOCK $fs_block out of range [0..$((BLKCNT-1))]"
note "IMG=$img"
note "IMG_SIZE=$IMG_SIZE SECTOR_BYTES=$SECTOR_BYTES FS_BS=$FS_BS PER_BLK=$PER_BLK"
note "First block=$FIRST_BLOCK Block count=$BLKCNT"
note "DISK_LBA=$lba PART_START_LBA=$part_start sect_in_part=$sect_in_part"
note "FS_BLOCK=$fs_block sector_in_4k=$intra_sec byte_off=$byte_off"
print_view_cmd
# 512-byte sector
note "[read] 512 bytes at sector_in_partition=$sect_in_part (byte_off=$byte_off)"
dd if="$img" iflag=skip_bytes,count_bytes skip="$byte_off" count="$SECTOR_BYTES" status=none | preview_and_zero "$SECTOR_BYTES" "512 bytes"
# Whole FS block
local block_off=$(( fs_block * FS_BS ))
note "[read] ${FS_BS}-byte filesystem block FS_BLOCK=$fs_block (offset=$block_off)"
dd if="$img" bs="$FS_BS" skip="$fs_block" count=1 status=none | preview_and_zero "$FS_BS" "${FS_BS} bytes"
# The 512 bytes within that block for this LBA
note "[read] 512 bytes within FS_BLOCK=$fs_block at sector_in_4k=$intra_sec"
dd if="$img" bs="$FS_BS" skip="$fs_block" count=1 status=none | dd bs="$SECTOR_BYTES" skip="$intra_sec" count=1 status=none | preview_and_zero "$SECTOR_BYTES" "512 bytes"
}
# ===== option parsing (getopts with long options) =====
# Should be POSIX compatible
# Thank you: https://stackoverflow.com/users/519360/adam-katz
# https://stackoverflow.com/a/28466267/490487
IMG=""
LBA=""
PART_START=""
FS_BLOCK=""
SECTOR_BYTES="512"
FS_BS=""
while getopts hi:l:p:B:s:k:-: opt; do
if [[ "$opt" == "-" ]]; then
opt=${OPTARG%%=*}
OPTARG=${OPTARG#"$opt"}
OPTARG=${OPTARG#=}
fi
case "$opt" in
h|help) usage ;;
i|image) IMG=${OPTARG:-} ;;
l|lba) LBA=${OPTARG:-} ;;
p|part-start) PART_START=${OPTARG:-} ;;
B|fs-block) FS_BLOCK=${OPTARG:-} ;;
s|sector-bytes) SECTOR_BYTES=${OPTARG:-} ;;
k|fs-block-size) FS_BS=${OPTARG:-} ;; # optional override; auto-detected by default
\?) usage ;;
*) printf 'Illegal option --%s\n' "$opt" >&2; usage ;;
esac
done
shift $((OPTIND-1))
ensure_deps
set_viewer
# ===== dispatch =====
if [[ -n "$FS_BLOCK" ]]; then
[[ -n "$IMG" ]] || usage
read_from_fsblock "$IMG" "$FS_BLOCK"
exit 0
fi
if [[ -n "$LBA" || -n "$PART_START" ]]; then
[[ -n "$IMG" && -n "$LBA" && -n "$PART_START" ]] || usage
read_from_lba "$IMG" "$LBA" "$PART_START"
exit 0
fi
usage
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment