I got to do some data recovery work with an old HDD that used to be in the office machine of a small nonprofit. The PC was decommissioned, but the files needed going through. Turned out the HDD had a few huge (30 GB+) VirtualBox .vdi disk images as its main data — an Inception-style data recovery indeed, disks within disks!

Unfortunately, macOS on Apple Silicon proved very resistant to any easy attempt to get the drives read. After some hours of installing VirtualBox and QEMU and trying to get the things running, I decided to abort that route. Maybe modern AI could write a standalone tool that reads the image file and the filesystem inside it, and provides simple file listing and extraction?

Turns out it very much can — I was able to basically single-prompt the vdiread tool into existence. It is a small read-only CLI that opens a .vdi file, finds the NTFS partition inside it and lets you list, stat and copy out files. No VirtualBox, no mounting, no root. And, compared to the earlier options, I did not have to use some VDI tool to extract a 30 GB compressed file into a 70 GB uncompressed one and then try to mount it. I could work straight within the original image.

Standing on the Shoulders of Dissect

The reason this is a small project rather than a large one is Dissect, the pure-Python forensics toolkit from Fox-IT. It already has the three pieces needed here, each as a separate package:

  • dissect.hypervisor — reads the VDI container, including sparse dynamic images
  • dissect.volume — finds the MBR/GPT partitions
  • dissect.ntfs — walks the NTFS filesystem

All three are pure Python, so uv sync is the whole installation story — no kernel extensions, no ntfs-3g, nothing to compile. vdiread stacks them into a CLI and adds the bits Dissect deliberately leaves to the caller: partition auto-selection, path handling and copy semantics.

Usage

$ vdiread --help
usage: vdiread [-h] {inspect,partitions,ls,stat,cp,walk} ...

Read and extract files from VirtualBox VDI images

positional arguments:
  {inspect,partitions,ls,stat,cp,walk}
    inspect             Inspect the VDI container and detected filesystems
    partitions          List partitions inside the image
    ls                  List files from the guest filesystem
    stat                Show metadata for one guest path
    cp                  Copy a file or directory out of the guest filesystem
    walk                Recursively list a guest path

inspect is the "what am I even looking at" command — it prints the VDI format and type, virtual size, block size, how many blocks are actually allocated, and the partition table with detected filesystems and volume names.

The rest work like their Unix namesakes, with the image as the first argument and guest paths after it:

vdiread ls XPmachine2.vdi '/Documents and Settings/User'
vdiread stat XPmachine2.vdi /boot.ini
vdiread cp XPmachine2.vdi /boot.ini ./boot.ini
vdiread cp XPmachine2.vdi '/Documents and Settings/User/My Documents' ./MyDocs -r

Guest paths accept either / or \, which saves a lot of shell quoting pain when you are copy-pasting Windows paths.

Taking an Inventory First

The command I ended up using most is walk, especially with --tsv:

vdiread walk XPmachine2.vdi '/Documents and Settings/User' --tsv user_contents.tsv

That writes a tab-separated file with path, resolved_path, type, size, resident, segment and name columns. Rummaging through a decades-old user profile is much nicer in a spreadsheet than through repeated ls calls — sort by size, filter by extension, then go back and cp only what you actually want.

Design Notes

A few decisions worth mentioning:

  • Read-only, always. The image is never opened for writing. The worst thing a bug can do is give you wrong data, not eat the only copy of the data you are trying to recover.
  • Fail loudly. Unsupported NTFS cases raise a specific error rather than guessing. There is a small hierarchy of error types — container, partition, filesystem, unsupported feature — so the CLI can say which layer gave up.
  • Auto-select the obvious. If there is exactly one NTFS partition it is picked automatically; with several you pass --partition <n>.

It was built and tested against a VDI v1.1 dynamic image with an MBR partition table and a single NTFS partition — the classic Windows XP-era layout. Other combinations may well work, since Dissect handles them, but that is the one I actually verified.

Requires Python 3.13+ and uv:

git clone https://github.com/jokkebk/vdiread.git
cd vdiread
uv sync
uv run vdiread inspect yourimage.vdi

Source: github.com/jokkebk/vdiread