Chainload GRUB 2 from GRUB 2

en

I recently needed to set up a PC to dual boot Windows and Linux. Legacy BIOS, no UEFI. Unfortunately Windows likes to do Windows things and override the first-stage GRUB bootloader in the MBR with its own, and thereafter booting Windows only.

As this PC needs to be used by different persons with different technological skillsets, and in order to make repairing the boot process easier even if e.g. no Linux live boot medium were available, I came up with the following solution which loads GRUB via PXE to repair the "real" bootloader.

This solution is quite easy for end users. In the case of this specific PC, PXE boot can be triggered by keeping F8 pressed during boot. The instructions to do so fit on a small post-it note which I attached to the PC's screen.

Set Up DHCP and TFTP

You'll need:

  • A configurable DHCP server
  • A TFTP server
  • A device with GRUB 2 already installed

All of those can of course be on the same device. Sometimes the first two even have to be, since a lot of consumer-grade network cards can only load data via TFTP from the same device that's running the TFTP server. Basically they ignore and don't request DHCP option 66 (server-name), and only handle DHCP option 67 (bootfile-name) and attempt to load it via TFTP from the address that offered the DHCP lease.

First we need to set up the TFTP server. I'll use dnsmasq for both TFTP and DHCP. TFTP configuration in dnsmasq is as easy as the following two lines in /etc/dnsmasq.conf:

enable-tftp
tftp-root=/srv/tftp

We then need to configure a static DHCP lease with additional DHCP options. Again in dnsmasq.conf:

# "pool" is the pool name
dhcp-range=pool,192.168.0.10,192.168.0.254,24h
# "target" is the target's host name. "tftp-grub" is a "tag" used in the next two lines.
dhcp-host=01:23:45:67:89:0a,set:tftp-grub,192.168.0.10,target
# Set TFTP server and bootfile path
dhcp-option=tag:tftp-grub,option:tftp-server,192.168.0.1
dhcp-option=tag:tftp-grub,option:bootfile-name,/grub/i386-pc/core.0"

Create a GRUB 2 Netboot Environment

On the DHCP server (can also be done on a different host and then copied over), run the following command:

grub-mknetdir --net-directory /srv/tftp --subdir grub -d /usr/lib/grub/i386-pc/

The main difference between a system's /boot/grub and this netboot environment is the grub/i386-pc/core.0 file. This is GRUB's first-stage loader, the same thing that normally gets written to the MBR, but here it's in a file instead, which can be loaded via TFTP.

If you now tell your target machine to boot from PXE, you should get a GRUB console.

Chainload GRUB 2 from GRUB 2

I searched around a long time to figure out how this is done, and most of the results i got said to "just load the second GRUB's config file into the first GRUB instance". This may work in most cases, but will eventually break in this case, since the second GRUB's config file may attempt to load additional modules from it's own prefix (/boot/grub/i386-pc), which will be incompatible with the PXE-loaded GRUB if they're not the same version.

Long story short, unlike GRUB 1, GRUB 2's second-stage loader (the core.img file) cannot be loaded on its own. Only the first-stage loader (from MBR or core.0) can be chainloaded. And since we're trying to cover the case where the firs-stage loader has been overwritten, we need to get it from somewhere else.

In this case I decided to set up a small loopback image in the target system's root partition which contained a copy of the system's GRUB installation.

To set up this loopback image, run the following commands on the target system:

dd if=/dev/zero of=/grubloop bs=100M count=1
parted /grubloop mklabel msdos
# Offset from the start is needed so that there's enough space for
# the first-stage loader.
parted /grubloop mkpart primary ext4 1048576B 100%
parted /grubloop set 1 boot on
losetup -fo 1048576 /grubloop
# Loop device number may be different
mkfs.ext4 /dev/loop0
losetup -d /dev/loop0
mkdir /altboot
mount -o offset=1048576 /grubloop /altboot

Also enter this into your /etc/fstab:

/grubloop   /altboot    ext4    loop,offset=1048576 0   0

Finally install GRUB to /grubloop resp. /altboot:

grub-install --boot-directory=/altboot --target=i386-pc --force /grubloop
grub-mkconfig -o /altboot/grub/grub.cfg

This image, which now contains the same boot config as your /boot/grub, can now be booted like you'd boot any hard disk. To chainload this GRUB image from the PXE grub, create the file /srv/tftp/grub/grub.cfg on your TFTP server and add the following:

insmod biosdisk
insmod part_msdos
insmod ext2
insmod chain

# Adapt to your real Linux root partition
set root=(h0,msdos4)
chainloader /grubloop +1
boot

I'm intentionally hardcoding the root partition here, instead of using GRUB's search module, since I want the PXE boot to be fast and simple. The search module, on the other hand, loads a LOT of other modules via TFTP, which may become a performance issue.

When you now boot your target device via PXE, it will load the first GRUB instance from TFTP, which will then chainload the second GRUB instance from the loopback image, which will then boot into Linux.

Automatically Re-Install GRUB

I'm doing all of the above not only to boot via PXE, but also to automatically re-install GRUB to the disk's MBR. I achieved this with the following systemd unit, which is executed on every boot of the Linux system:

[Unit]
Description=Reinstall GRUB 2 to MBR and loopback altboot

[Service]
Type=oneshot
ExecStart=/usr/sbin/grub-install --boot-directory=/altboot --target=i386-pc --force /grubloop
ExecStart=/usr/sbin/grub-install --boot-directory=/boot --target=i386-pc /dev/disk/by-id/<DISK_ID>
ExecStart=/usr/sbin/grub-mkconfig -o /altboot/grub/grub.cfg
ExecStart=/usr/sbin/grub-mkconfig -o /boot/grub/grub.cfg

[Install]
WantedBy=multi-user.target

Maybe a bit overkill, but it gets the job done and is easy to use for end users.