#!/bin/sh # ---------------------------------------------------------------------- # extract-arm64-image - Extract a raw Linux/arm64 "Image" from a distro vmlinuz # # Firecracker's aarch64 loader (rust-vmm linux-loader) boots exactly one # kernel format: a raw Linux/arm64 Image. It checks ARM64_IMAGE_MAGIC # ("ARMd", 4 bytes at offset 0x38, per Documentation/arch/arm64/booting.rst) # and then hands the guest CPU the start of the file. A distro's # /boot/vmlinuz on arm64 is usually *not* that file. It comes in one of # three shapes: # # raw Image "ARMd" already at 0x38. Passed through untouched. # # EFI zboot "MZ" at 0x00 and "zimg" at 0x04 (CONFIG_EFI_ZBOOT, Linux # 6.1+). A PE application wrapping a *compressed* Image plus a # decompressor stub that runs under EFI. Header layout, from # drivers/firmware/efi/libstub/zboot-header.S: payload offset # u32 @0x08, payload size u32 @0x0c, NUL-terminated compression # name @0x18, and LINUX_PE_MAGIC (0x818223cd) — not "ARMd" — # at 0x38. Shipped by Alpine (gzip) and Rocky (gzip). # # UKI A PE carrying the kernel in a ".linux" section, next to # .dtbauto/.uname/.sbat (systemd-stub layout). Ubuntu 26.04 # ships its arm64 vmlinuz this way, and the .linux payload is # itself an EFI zboot image (zstd), so it unwraps twice. # # Only the first shape boots under Firecracker. Writing "ARMd" over offset # 0x38 of either of the other two makes the loader *accept* the file and then # jump into an EFI stub that never receives EFI boot services: the guest dies # before it emits a single console byte, and the only symptom upstack is a # VM that boots to silence. So unwrap the real Image instead, and verify the # magic is genuinely present rather than stamping it on. # # Usage: extract-arm64-image # raw Image on stdout # ---------------------------------------------------------------------- set -u me=${0##*/} # ARM64_IMAGE_MAGIC ("ARMd") and the "zimg"/"MZ" tags, as hex, so that the # comparisons never carry NUL or non-printable bytes through a shell variable. arm64_image_magic=41524d64 zboot_magic=7a696d67 mz_magic=4d5a pe_magic=50450000 arm64_image_magic_offset=56 # 0x38 fail() { echo "${me}: $1" >&2 exit 1 } # Hex of $3 bytes at offset $2 of file $1, lowercase and unseparated. Reading # past the end of a file that is too short to hold the field yields an empty # string, which never equals a magic the callers look for. hex_at() { od -An -tx1 -j "$2" -N "$3" "$1" 2>/dev/null | tr -d ' \n' } # Little-endian u16 at offset $2 of file $1, as a decimal. Assembled from # individual bytes so the result does not depend on the host's endianness. # A field that runs past the end of the file reads as 0, which every caller # already has to treat as a malformed header. u16_at() { _f=$1 _o=$2 # shellcheck disable=SC2046 # word splitting is how the bytes arrive set -- $(od -An -tu1 -j "$_o" -N 2 "$_f" 2>/dev/null) if [ "$#" -lt 2 ]; then echo 0 return fi echo $(( $2 * 256 + $1 )) } # Little-endian u32 at offset $2 of file $1, as a decimal. u32_at() { _f=$1 _o=$2 # shellcheck disable=SC2046 # word splitting is how the bytes arrive set -- $(od -An -tu1 -j "$_o" -N 4 "$_f" 2>/dev/null) if [ "$#" -lt 4 ]; then echo 0 return fi echo $(( $4 * 16777216 + $3 * 65536 + $2 * 256 + $1 )) } # Writes $3 bytes from offset $2 of file $1 to stdout. slice() { tail -c "+$(( $2 + 1 ))" "$1" | head -c "$3" } is_arm64_image() { [ -s "$1" ] && [ "$(hex_at "$1" "$arm64_image_magic_offset" 4)" = "$arm64_image_magic" ] } # Decompresses stdin using the named algorithm. The decompressor's own exit # status is deliberately ignored by the callers: EFI zboot's declared payload # size excludes gzip's 4-byte trailer (ZBOOT_SIZE_LEN), so gzip reports # "unexpected end of file" after having already written every byte of the # kernel. The extracted Image is validated on its own merits instead, the same # way extract-vmlinux validates its output rather than trusting exit codes. decompress_stream() { case $1 in gzip) gzip -dc 2>/dev/null ;; lzma) xz -dc --format=lzma 2>/dev/null ;; xz) xz -dc 2>/dev/null ;; lz4) lz4 -dc 2>/dev/null ;; lzo) lzop -dc 2>/dev/null ;; zstd) zstd -dc 2>/dev/null ;; *) return 1 ;; esac } # EFI zboot: decompress the payload the header points at. unwrap_zboot() { src=$1 dest=$2 payload_offset=$(u32_at "$src" 8) compression=$(od -An -c -j 24 -N 8 "$src" | tr -d ' \n' | sed 's/\\0.*$//') if [ -z "$payload_offset" ] || [ "$payload_offset" -le 0 ]; then fail "EFI zboot header in ${source_image} declares no payload offset" fi case $compression in gzip | lzma | xz | lz4 | lzo | zstd) ;; *) fail "unsupported EFI zboot compression '${compression}' in ${source_image}" ;; esac echo "${me}: EFI zboot payload at offset ${payload_offset}, ${compression}-compressed" >&2 # Fed to the end of the file rather than the declared payload size: the # decompressor stops at the end of its own stream, and the declared size # is short by the trailer for some algorithms (see decompress_stream). tail -c "+$(( payload_offset + 1 ))" "$src" | decompress_stream "$compression" >"$dest" } # UKI: lift out the ".linux" PE section holding the kernel. unwrap_uki() { src=$1 dest=$2 pe_offset=$(u32_at "$src" 60) # 0x3c if [ "$(hex_at "$src" "$pe_offset" 4)" != "$pe_magic" ]; then fail "${source_image} is an MZ file with no PE header and no EFI zboot payload" fi section_count=$(u16_at "$src" $(( pe_offset + 6 ))) optional_header_size=$(u16_at "$src" $(( pe_offset + 20 ))) section_table=$(( pe_offset + 24 + optional_header_size )) index=0 while [ "$index" -lt "$section_count" ]; do entry=$(( section_table + index * 40 )) index=$(( index + 1 )) # ".linux", NUL-padded to the 8-byte PE section name field. [ "$(hex_at "$src" "$entry" 8)" = 2e6c696e75780000 ] || continue virtual_size=$(u32_at "$src" $(( entry + 8 ))) raw_size=$(u32_at "$src" $(( entry + 16 ))) raw_offset=$(u32_at "$src" $(( entry + 20 ))) # SizeOfRawData is padded up to the file alignment; VirtualSize is # the payload's real length, but never trust it past the data that # is actually in the file. size=$raw_size if [ "$virtual_size" -gt 0 ] && [ "$virtual_size" -lt "$raw_size" ]; then size=$virtual_size fi if [ "$size" -le 0 ]; then fail "${source_image} has an empty .linux section" fi echo "${me}: UKI .linux section at offset ${raw_offset}, ${size} bytes" >&2 slice "$src" "$raw_offset" "$size" >"$dest" return 0 done fail "${source_image} is a PE image with neither an EFI zboot payload nor a .linux section" } # A bare Image.gz and friends: no PE wrapper, just a compressed stream. unwrap_compressed_image() { src=$1 dest=$2 for compression in gzip zstd xz lzma lz4 lzo; do # A missing decompressor just yields no output, same as a stream that # was not compressed that way — both mean "try the next one". decompress_stream "$compression" <"$src" >"$dest" if is_arm64_image "$dest"; then echo "${me}: bare ${compression}-compressed Image" >&2 return 0 fi done fail "${source_image} is neither a raw ARM64 Image, an EFI zboot image, a UKI, nor a compressed Image" } if [ "$#" -ne 1 ] || [ ! -s "$1" ]; then echo "Usage: ${me} " >&2 exit 2 fi source_image=$1 work_dir=$(mktemp -d "${TMPDIR:-/tmp}/extract-arm64-image-XXXXXX") || exit 1 trap 'rm -rf "$work_dir"' 0 INT HUP TERM current=$source_image # Ubuntu needs two (UKI then zboot); the bound just stops a malformed image # from looping. No real kernel nests deeper than that. depth=0 while [ "$depth" -lt 4 ]; do # Every shape below carries a 64-byte header, so anything shorter cannot # be a kernel in any of them — say so rather than reading past the end. if [ "$(wc -c <"$current")" -lt 64 ]; then fail "${source_image} is too small to hold an ARM64 kernel header" fi if is_arm64_image "$current"; then cat "$current" [ "$depth" -eq 0 ] && echo "${me}: already a raw ARM64 Image" >&2 exit 0 fi next="${work_dir}/stage${depth}" depth=$(( depth + 1 )) if [ "$(hex_at "$current" 0 2)" = "$mz_magic" ]; then if [ "$(hex_at "$current" 4 4)" = "$zboot_magic" ]; then unwrap_zboot "$current" "$next" else unwrap_uki "$current" "$next" fi else unwrap_compressed_image "$current" "$next" fi [ -s "$next" ] || fail "extracting the kernel from ${source_image} produced no data" current=$next done fail "could not reach a raw ARM64 Image in ${source_image} after ${depth} unwrapping passes"