Recreating ISOs that boot from both DVD and mass storage such as USB sticks and in both legacy BIOS and UEFI environments

This may seem to some of you like a blast from a long forgotten past but that’s what an Enterprise world often looks like: I’ve been seeing scripting that creates a custom Linux installation ISO from given public images, adds extra software repositories, install automation, provisioning for delegating to a dedicated config management tool (Puppet, Ansible, Chef, Salt, Mgmt, …) run after installation, you get the picture. This code, although rewritten not too long ago, still uses principles from the late 2000s: a.) you still need to have the original ISOs mounted, meaning you can’t easily run it without being root or using sudo or some proper, but complicated security framework and b.) it still uses mkisofs.

mkisofs and cdrecord were the go-to tools in a time when you still preferred your CD burner on a SCSI bus for stability’s sake but in 2020 we’re not really talking optical media anymore (although even current servers such as the Dell PowerEdge R740 continue to have optical drives), we want ISOs that can be written to mass storage devices such as USB sticks and hard disks. (Yes, actually we want network-based installs, but that isn’t always an option and even your PXE server needs to be bootstrapped somehow.) We could use isohybrid here, yes, but that’s still clinging to an outdated toolchain. And we have been talking UEFI since 1998 now, there is hardware that doesn’t come with legacy BIOS emulation anymore, so we also want ISOs that can boot in both BIOS and UEFI environments. And, best of all, we want one single ISO for all these four scenarios to avoid having to juggle with different ISOs for different use cases. If we could then even use the same ISO for PXE installs with next to know modifications, even better!

Now, guess what, of course your favorite Linux distribution’s vendor already solved this for you for the case of creating a new ISO from scratch but I wanted to demonstrate how I’m tackling the job of recreating an existing bootable ISO in 2020. Note that for SUSE distributions you could actually use mksusecd, but in this post I want to focus on an distro-independent tool.

Introducing xorriso

The toolset I use is xorriso from the libburnia project. xorriso is the tool exposing the libraries’ features as a command-line tool for multi-session and ISO manipulations. It’s also quite old already, with first releases dating back as far as 2006, but has been continuously updated.

xorriso supports a compatibility mode with the -as mkisofs parameter with which is accepts a subset of mkisofs parameters, but I’m not going to use that in my examples because that would be contraproductive when I encourage switching over to xorriso completely.

In the examples following I’m going to assume we’re using a recent OpenSUSE version but the general principles apply to other distros as well, though you may need to inspect how your particular ISO is built in detail. I will also be using some pretty self-explainatory environment variables that you should set suitably, e.g. SRC_ISO should point to the ISO that you’re using as base for our custom ISO.

Extracting only needed files from the source ISO

Instead of having the entire source ISO mounted, we’re extracting only the files we actually need for recreating the bootability.

The following command will extract the initrd (yes, I know, technically it’s initramfs, but everyone keeps calling it initrd), the isolinux and Grub2 configuration files and EFI boot files into a build/ directory that you should create beforehand (osirrox is an acronym for xorriso, namely the reverse of it, because here we get something out of the ISO instead of putting something into it):

osirrox -indev ${SRC_ISO} \
         -extract boot/x86_64/loader/initrd build/initrd.cpio.xz \
         -extract boot/x86_64/loader/isolinux.cfg build/isolinux.cfg \
         -extract EFI/BOOT/grub.cfg build/grub.cfg \
         -extract boot/x86_64/efi build/efi

Modifying the initrd

Why did we extract the initrd? Because we want add files to it that need to be accessible during the installation. So why don’t we just add those files to the ISO? Because we’re trying to build an ISO that installs from multiple possible media, DVDs and USB sticks/hard drives, possibly even PXE installs, and you’d otherwise need a way to access that installation medium from within your installation automation solution, e.g. an Autoyast control file. You would have to a.) know the installation source (/proc/cmdline does not always have a install= parameter) and b.) know if it is already mounted and c.) how to mount it, if at all (kernel and initrd may have come via TFTP but the package repositories and your files could reside on a Webserver). So up to a certain size just sticking them into the initrd is the more feasible solution.

The following commands unpack the initrd, which is a xz-compressed cpio archive:

rm build/initrd.cpio 2>/dev/null || true
xz --decompress build/initrd.cpio.xz
chmod u+w build/initrd.cpio

In my case, I put autoinst.xml, the Autoyast control file itself, into the initrd, because in my experience autoyast=default doesn’t work as advertised, it will find the control file when booting off a DVD but not when booting from a USB stick. Putting it into the initrd makes sure if will always be found:

cp /your/source/autoinst.xml build/
cd build/ && echo "autoinst.xml" | cpio -o --append -F initrd.cpio -H newc --quiet

When we’re done modifying the initrd and medium size is an issue, we can optionally compress the new initrd with a command such as:

xz --compress --check=crc32 --threads=0 build/initrd.cpio

The --check=crc32 parameter is required here because the Linux kernel’s decompression routines don’t support the CRC64 checksums that xz adds by default. --threads=0 just makes sure that all CPU cores get used for compression which can take a while.

Modifying the boot loader menus

You’ll probably want to modify the boot loader menus, customize the text displayed so that when booting from the ISO you know what you’re going to install, or add custom kernel, linuxrc oder YaST command line options. The following example was not taken from the restricted Enterprise context described above where we can’t do network installs but it fits here well enough if only to show off some fancy sed skills:

sed -i "/^label linux/,/^$$/{\
           /^label linux/ {s,^.*$$,label Auto-Install ${TARGET_HOSTNAME} [${BUILD_DATE}],}; \
           /^  append/ {s,$$, install=http://download.opensuse.org/${INSTALL_URL},}; \
        }" \
        build/isolinux.cfg ; \
sed -i "/^menuentry 'Installation'/,/^}$$/{ \
             /^menuentry 'Installation' / {s,'Installation','Auto-Install ${TARGET_HOSTNAME} [${BUILD_DATE}]',}; \
             /[[:space:]]*linuxefi / {s,$$, install=http://download.opensuse.org/${INSTALL_URL},} \
        } ; \
        /^default=1$$/{s,1,0,}" \
       build/grub.cfg

Note how we have to modify both isolinux.cfg and grub.cfg because the former gets used for legacy BIOS boots only while the latter will be used in UEFI environments.

Because the way EFI booting works, having a dedicated EFI boot partition, we also need one more step for these environments that uses guestfish, a tool from the libvirt ecosystem originally intended to modify virtual machine filesystems. The following command will place the updated grub.cfg file into build/efi, which is the filesystem used as EFI boot partition when booting off the ISO in UEFI mode:

guestfish -a build/efi -m /dev/sda --rw copy-in build/grub.cfg /EFI/BOOT/

Recreating the bootable ISO with the modified files

Now this is where it gets really interesting as this upcoming loooong commandline will give you an “OMG” look at its best. I discuss it part-by-part here, the full commandline follows at the end.

rm -f build/${TARGET_ISO} 2>/dev/null || true

First, we remove ${TARGET_ISO} should it already exist. Again, you could set ${TARGET_ISO} to a speaking name such as openSUSE-Leap-15.1-NET-x86_64-my.host.domain.com.iso or similar if your ${SRC_ISO} is something like ~/isos/openSUSE-Leap-15.1-NET-x86_64.iso.

xorriso -indev ${SRC_ISO} \
        -outdev build/${TARGET_ISO} \

The first two xorriso parameters, -indev and -outdev, carry their names for historical reasons. In past times you’d probably have specified your CD reader/burner devices here, now we simply pass ${SRC_ISO} and ${TARGET_ISO}.

        -volid "${TARGET_HOSTNAME}-Inst-${SRC_ISO_VER}" \

-volid is the equivalent to mkisofs‘s -V parameter, specifying the volume ID, or “name”, for the ISO image. In this example we construct it from the target hostname stored in ${TARGET_HOSTNAME}, “Inst” for Installation and a version string ${SRC_ISO_VER} somehow determined from ${SRC_ISO}.

        -map build/initrd.cpio boot/x86_64/loader/initrd \
        -map build/isolinux.cfg boot/x86_64/loader/isolinux.cfg \
        -map build/grub.cfg EFI/BOOT/grub.cfg \
        -map build/efi boot/x86_64/efi \

Then we -map our modified files from the build/ subdirectories into the new ISO at the given locations. For example, build/initrd.cpio becomes boot/x86_64/loader/initrd inside the ISO. This is comparable to mkisofs‘s -graft-points. Note there is a second way to add files with xorriso, -add transfers specified filesystems paths 1:1 into the ISO, which is rarely what you want.

Now we get to the really interesting part, the parameters making the whole ISO bootable again. xorriso‘s -boot_image parameters are both powerful and thereby complex at the same time but that’s the price to pay if you want a universally bootable ISO.

Most of the parameters here were obtained by inspecting the given ${SRC_ISO} with a command such as:

xorriso -indev ${SRC_ISO} -report_system_area plain -report_el_torito plain

You can also try cmd instead of plain, then xorriso will try to output the parameters needed to reproduce ${SRC_ISO}‘s boot features.

But what is this all about? El Torito is a particular type of boot records inside an ISO9660 image (exactly what we call here “ISO” all the time) that points a BIOS bootstrapping facility to one or more boot images (binaries inside the ISO, such as isolinux or Grub). The System area refers to the first 32K of an ISO image that are unused by the ISO9660 spec and is what is commonly used to store boot information for the case when the ISO is not booted from optical media, such as a Master Boot Record (MBR) for BIOS booting and/or a GUID Partition Table (GPT) for UEFI environments. We’ll need both working hand-in-hand to get the desired bootability features.

We’ll begin with the first block of -boot_image parameters which looks like this:

        -boot_image any partition_cyl_align=off \
        -boot_image any cat_hidden=on \

These set some general options: aligning image sizes in the MBR to a certain number of cyclinders gets disabled, for the simple reason that it was disabled in all source ISOs I’ve been looking at. Same for hiding the El Torito boot catalog file.

        -boot_image isolinux platform_id=0x00 \
        -boot_image isolinux bin_path='/boot/x86_64/loader/isolinux.bin' \
        -boot_image isolinux boot_info_table=on \

This tells xorriso to create an El Torito boot record for the file /boot/x86_64/loader/isolinux.bin within the ISO, which is the Isolinux bootloader. The binary will have its bytes 8 to 63 patched with the boot info table (information of the CD-ROM layout) and the boot entry will have the ID 0x00 (“80×86 PC-BIOS”).

Note that this is exactly the equivalent of the commonly used mkisofs options -b /boot/x86_64/loader/isolinux.bin -boot-info-table. We didn’t have to specify the equivalents for -no-emul-boot -boot-load-size 4 because these are used by xorriso by default.

So this block already gave us BIOS-bootability when written to a DVD. But for the isohybrid feature, giving bootability from USB sticks and hard disks, we need another line:

        -boot_image isolinux system_area=/usr/share/syslinux/isohdpfx.bin \

This tells xorriso to augment the El Torito boot table with the file /usr/share/syslinux/isohdpfx.bin being put in the system area, effectively putting a MBR at the start of the ISO image.

        -boot_image any next \
        -boot_image any platform_id=0xef \
        -boot_image any efi_path='/boot/x86_64/efi' \
        -boot_image isolinux partition_entry=gpt_basdat

This creates another El Torito boot record for the file /boot/x86_64/efi within the ISO, this time with platform ID 0xEF (EFI). The partiton_entry=gpt_basdat parameter has the effect of creating another MBR partition of type 0xEF as well as an (unused) GPT. This is what’s required for EFI-bootability when written to a USB stick.

Here’s the complete xorriso command:

xorriso -indev ${SRC_ISO} \
        -outdev build/${TARGET_ISO} \
        -volid "${TARGET_HOSTNAME}-Inst-${SRC_ISO_VER}" \
        -map build/initrd.cpio boot/x86_64/loader/initrd \
        -map build/isolinux.cfg boot/x86_64/loader/isolinux.cfg \
        -map build/grub.cfg EFI/BOOT/grub.cfg \
        -map build/efi boot/x86_64/efi \
        -boot_image any partition_cyl_align=off \
        -boot_image any cat_hidden=on \
        -boot_image isolinux platform_id=0x00 \
        -boot_image isolinux bin_path='/boot/x86_64/loader/isolinux.bin' \
        -boot_image isolinux boot_info_table=on \
        -boot_image isolinux system_area=/usr/share/syslinux/isohdpfx.bin \
        -boot_image any next \
        -boot_image any platform_id=0xef \
        -boot_image any efi_path='/boot/x86_64/efi' \
        -boot_image isolinux partition_entry=gpt_basdat

Naturally no one would enter a line like this manually, put it in a script and forget about it and come back to this post, when you’re wondering about the parameters ;)