Software Engineering9 min read

Patch a Linux Install From Windows Without Booting It

A dual-boot Linux NVMe my firmware refused to boot was months behind on patches. The fix: don't boot it. Mount its disk as a secondary disk in a throwaway Hyper-V VM, chroot in, and patch it offline — all from Windows.

If you dual-boot Windows and Linux, this can be painful: you live in one OS, and the other one starts collecting dust. My Linux install lived on a separate NVMe that the motherboard firmware had stopped offering as a boot option due to switching from an external case to the internal slot — so I couldn't boot it even when I wanted to, and its security patches had drifted months behind.

The obvious fixes all have problems. I couldn't boot it to patch it — that's the whole issue. I could boot the real install inside a VM via raw-disk passthrough, but the install was provisioned for real hardware (a specific GPU, specific NICs, UUID-keyed networking); under a hypervisor it sees synthetic devices and may not even reach a login prompt — initramfs missing the synthetic disk driver, netplan bound to the wrong interface name, a Secure Boot template mismatch. You can burn an evening just getting it to boot, work you then throw away. And reinstalling to run apt is absurd.

The approach that actually worked is interesting and I hope will be helpful: don't boot the Linux install at all. Mount its disk as a secondary disk inside a tiny throwaway Linux VM, chroot into the real root, and patch it offline — entirely from Windows. It's the same technique you'd use to rescue an unbootable system from a live USB, except the live USB is a 20-minute Hyper-V VM and the broken disk is just a disk you can't conveniently boot.

The mental model that makes it safe

The thing that makes it safe is that only one OS ever owns the block device at a time — and the OS enforces it. Windows releases the disk, the VM takes exclusive ownership, then Windows reclaims it. There is no moment where both sides have it mounted.

  Windows host                          Helper VM (Linux)
  +-------------------------+           +-----------------------+
  |  C:  (Windows system)   |           |  sda  (helper OS)     |
  |                         |           |                       |
  |  Disk N = Lexar NVMe    |  attach   |  sdb  = the REAL disk |
  |   |  ESP  (sdb1)        | =======>  |   |  sdb1 -> /boot/efi |
  |   |  Linux root (sdb2)  |  offline  |   |  sdb2 -> /mnt + chroot
  +-------------------------+           +-----------------------+

  Step 1: Set-Disk -IsOffline $true   (Windows DROPS the disk)
  Step 2: Add-VMHardDiskDrive          (VM takes EXCLUSIVE ownership)
  Step 3: mount + chroot, apt upgrade  (writes land on the real install)
  Step 4: Stop-VM ; Set-Disk -IsOffline $false  (handed back to Windows)

Why this can't corrupt the disk: if you forget step 1, Hyper-V simply refuses to attach the disk — 'in use by another process.' The guardrail against double-mounting is built into the OS; you can't accidentally have both sides mount it at once.

Why Hyper-V, not VirtualBox

If your Windows box already runs WSL2 or Docker Desktop, it is already running the Hyper-V hypervisor. VirtualBox 7 works in that state but runs secondary on the Hyper-V backend — slower, and flakier for long-running headless VMs. To get VirtualBox's fast native virtualization back, you'd have to remove Hyper-V, which breaks WSL2 and Docker. So on a modern dev box, Hyper-V is usually the right tool. It also attaches physical disks to VMs directly and runs headless VMs as a service.

The procedure

  1. Build a throwaway helper VM once: a minimal Ubuntu Server matching the target's release (match the major version — it keeps the chroot's glibc and initramfs tooling sane). Enable OpenSSH. This VM is your live USB; it persists and gets reused.
  2. Hand the real disk to it as a secondary disk (offline-in-Windows, then attach).
  3. Mount and chroot.
  4. Verify it is still bootable.
  5. Hand the disk back.

The disk hand-off, from an elevated PowerShell on the Windows host:

# Identify and verify the target by model BEFORE anything destructive
$disk = Get-Disk -Number 1
if ($disk.FriendlyName -notmatch 'Lexar SSD NM790') { throw 'Wrong disk!' }

# Release it from Windows, then attach to the (powered-off) helper VM
Set-Disk -Number 1 -IsOffline $true
Add-VMHardDiskDrive -VMName 'linux-helper' -DiskNumber 1 -ControllerType SCSI

Then, inside the helper VM over SSH, the offline patch itself — mount the real root, bind the kernel virtual filesystems, mount the real ESP, chroot, upgrade:

TARGET=/dev/sdb2          # the real Linux root (NOT the helper's own disk)
ESP=/dev/sdb1             # the real EFI System Partition
MNT=/mnt/target

sudo mount "$TARGET" "$MNT"
for v in proc sys dev dev/pts run; do sudo mount --rbind "/$v" "$MNT/$v" 2>/dev/null \
  || sudo mount -t ${v%%/*} "$v" "$MNT/$v"; done
sudo mount "$ESP" "$MNT/boot/efi"          # so kernel/grub hooks update the bootloader

sudo chroot "$MNT" /bin/bash -c '
  export DEBIAN_FRONTEND=noninteractive
  apt-get update && apt-get -y \
    -o Dpkg::Options::="--force-confold" full-upgrade
  apt-get -y autoremove --purge'

Gotcha: do NOT copy resolv.conf into the chroot. On systemd-resolved it's a symlink into /run, and since you bind-mounted /run, DNS already works. The copy errors with 'same file' and, under set -e, aborts your script mid-run.

Verify bootability — don't trust apt's exit code

A clean upgrade can still leave a system that won't boot. Before you believe it, confirm the artifacts on the target itself: the new initrd.img-<kernel> exists, grub.cfg references the new kernel, the root UUID matches fstab, dpkg -C is clean, and the ESP has its BOOT and distro EFI directories. Then believe it.

NEWK=$(ls /mnt/target/boot/vmlinuz-* | sed 's#.*/vmlinuz-##' | sort -V | tail -1)
test -f /mnt/target/boot/initrd.img-$NEWK   && echo 'initramfs: OK'
grep -q "vmlinuz-$NEWK" /mnt/target/boot/grub/grub.cfg && echo 'grub: OK'
sudo chroot /mnt/target dpkg -C && echo 'dpkg: clean'

Note: a depmod error inside the chroot for the HELPER's kernel version is benign — it leaks in via the bound /run. What matters is that the TARGET kernel's initramfs built. (You may also generate a stray initramfs for the helper's kernel on the target; clean it up.)

The access sub-saga

Getting into the helper headlessly surfaced some headaches that are worth their own section, because you will hit them too:

sshd installed does not mean sshd enabled. I checked 'Install OpenSSH server' at install time, yet port 22 timed out — the service wasn't enabled. A simple 'systemctl enable --now ssh' fixed it; confirm from Windows with Test-NetConnection <ip> -Port 22. The Hyper-V Default Switch is NAT, so the guest IP changes every boot — the install-time IP is stale by the time you SSH, and on recent builds the integration services report a blank IP back to Hyper-V, so you read it from the console. The basic VM console has no clipboard, so you hand-type passwords until key-based SSH works — get keys working fast. And adding your user to a local group like Hyper-V Administrators does not apply to already-running processes; only a Windows sign-out or reboot refreshes the logon token.

Agent-SSH tip: the key bootstrap can trip 'Too many authentication failures' because the SSH agent offers too many keys before password auth. Force the one bootstrap call with -o PubkeyAuthentication=no -o PreferredAuthentications=password -o IdentitiesOnly=yes. And never hand a user a public key or hash to TYPE — pipe it: type key.pub | ssh host 'cat >> ~/.ssh/authorized_keys'.

The payoff

In one session the offline install went from months behind to: 179 packages upgraded, the kernel bumped, the GPU driver updated, zero remaining upgradable — and verified bootable, without ever booting it. The firmware problem that started the whole thing is still unsolved, and it didn't matter: maintenance no longer depends on it. The helper VM is now a reusable patch appliance for a monthly run that takes a few minutes.

When to reach for this — and when not to

Reach for it when you have a dual-boot OS you rarely boot but must keep patched; an install that won't currently boot (firmware, bootloader, a botched upgrade) but whose filesystem is fine; or when you want to seed a Linux install's configuration from Windows before its first real boot. Any 'the disk is fine, I just can't conveniently boot it' situation. I want to emphasize that will all of the cybersecurity issues in the wild right now, you should not leave dusty bootable distros laying around unmaintained; either securely delete them or take on the maintenance responsibility. If it can be avoided, I'd honestly pick a team (Linux or Windows -- Linux being the more customizable, no-nonsense choice IMO). That will simplify your maintenance mental model and protect your sanity points.

When not to: if you need to verify the system actually boots and services start — a chroot can't tell you that; you need a real boot. And if the real fix is trivial (you can just boot it), do that instead.