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.

Revisions

  1. kyle0r revised this gist Aug 26, 2025. 1 changed file with 90 additions and 0 deletions.
    90 changes: 90 additions & 0 deletions ext_block_inspector.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,90 @@
    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.github.com/kyle0r/b3b7df3576953f898d8495854dea13d0/raw/ext_block_inspector.sh' > ~/ext_block_inspector.sh
    ```

    With `wget`:

    ```
    wget -qO- 'https://gist.github.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
    ```
  2. kyle0r created this gist Aug 26, 2025.
    284 changes: 284 additions & 0 deletions ext_block_inspector.sh
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,284 @@
    #!/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