uefi: copy the kernel home and jump from a page it cannot reach

The staged segments were copied home after ExitBootServices by the
loader itself -- on code, a stack and a copy list the firmware had
placed wherever it liked. On an HP all-in-one some of that was in the
kernel's range: "Placing the kernel", then a triple fault and a reset.

The copy and the jump now run from one page below the kernel's lowest
address, allocated as loader code before the firmware exits: a short
position-independent routine, the copy list, and a stack. It switches
to the loader's page tables and its own stack before copying anything.
Called sysv_abi, because UEFI code is the Microsoft convention and the
first try loaded the framebuffer address into rsp.

The framebuffer address handed over in rbx is 64-bit now too; the
kernel already read the full one from the hand-off block.

Also write.sh: writes schos.iso to a USB stick, after showing the
device and asking for its name to be typed back; refuses partitions,
the system disk and non-removable disks; reads the result back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
This commit is contained in:
Nexoniarz 2026-10-24 11:00:02 +02:00
commit 726091000e
2 changed files with 239 additions and 45 deletions

View file

@ -582,15 +582,97 @@ claim_covers(UINT64 lo, UINT64 hi)
return 1;
}
/* After ExitBootServices: the staged segments, home. The buffers came from
* outside the kernel's range -- claim_span() took every free page inside it
* -- so no copy can land on a buffer still to be copied. */
/* ---------------------------------------------------------------------------
* The last jump, from somewhere the kernel cannot land
*
* The staged segments are copied home after ExitBootServices -- and this
* loader is still running then, on code, a stack and a copy list the firmware
* placed wherever it liked. On an HP all-in-one some of that was inside the
* kernel's range, so the copy overwrote the loader while it ran: the screen
* said "Placing the kernel" and the machine reset, a triple fault, with
* "Starting the kernel" never drawn.
*
* So the copy and the jump run from one page of their own, below the kernel's
* lowest address and so outside anything it occupies: this routine at the start of it,
* the copy list after it, a stack at the end. It is position independent --
* every address it uses arrives in a register -- so it runs wherever it is
* copied. It switches to the loader's page tables (which map all memory 1:1)
* and its own stack first, so nothing it needs afterwards is anywhere the
* copies can reach. The buffers being copied *from* are outside the kernel's
* range too: claim_span() took every free page inside it.
*
* rdi copy list: { dst, src, len } x n rsi n
* rdx kernel entry rcx framebuffer, for rbx
* r8 page tables r9 stack top
*
* RBX carries the framebuffer address into kmain and must never be 1 -- that
* value marks an application processor in entry64high. A physical address or
* zero cannot collide. (The jump this replaces was inline asm, where leaving
* rbx out of the clobbers once let clang put the jump target in it; written
* out in full here, every register is spelled.)
* ------------------------------------------------------------------------- */
__asm__(
".text\n"
".globl schos_tramp\n"
".globl schos_tramp_end\n"
"schos_tramp:\n"
" mov %r9, %rsp\n"
" mov %r8, %cr3\n"
" mov %rdx, %r10\n"
" mov %rcx, %rbx\n"
" mov %rdi, %r11\n"
" mov %rsi, %r9\n"
" cld\n"
"1: test %r9, %r9\n"
" jz 2f\n"
" mov 0(%r11), %rdi\n"
" mov 8(%r11), %rsi\n"
" mov 16(%r11), %rcx\n"
" rep movsb\n"
" add $24, %r11\n"
" dec %r9\n"
" jmp 1b\n"
"2: jmp *%r10\n"
"schos_tramp_end:\n");
extern const UINT8 schos_tramp[], schos_tramp_end[];
#define TRAMP_LIST 256 /* the copy list's offset in the page */
static EFI_PHYSICAL_ADDRESS tramp_page;
static UINT64 kern_lo; /* the kernel's lowest address; set by load_elf() */
/* Before ExitBootServices: the page, below the kernel and above the first
* megabyte -- the hand-off block and the page tables are written down there
* after the firmware exits. Loader *code*, not data: a firmware that maps
* data pages no-execute (OVMF does) would fault on the first instruction. */
static void
place_staged(void)
tramp_reserve(void)
{
for (UINTN i = 0; i < nlater; i++)
copy((void *)(UINTN)later[i].dst, (void *)(UINTN)later[i].src,
(UINTN)later[i].len);
EFI_PHYSICAL_ADDRESS a = kern_lo - 1;
if (EFI_ERROR(BS->AllocatePages(AllocateMaxAddress, EfiLoaderCode, 1, &a)) ||
a < 0x100000)
die(L"no page below 16 MB for the final jump", 0);
tramp_page = a;
}
/* After ExitBootServices, and the last thing this loader does. */
static void __attribute__((noreturn))
tramp_go(UINT64 entry, UINT64 fb)
{
UINT8 *page = (UINT8 *)(UINTN)tramp_page;
copy(page, schos_tramp, (UINTN)(schos_tramp_end - schos_tramp));
copy(page + TRAMP_LIST, later, nlater * sizeof later[0]);
// sysv_abi: the registers above are System V's argument order. This
// loader is compiled for UEFI, which is the *Microsoft* convention (rcx,
// rdx, r8, r9, then the stack), and without the attribute the first try
// loaded the framebuffer address into rsp and a kernel address into cr3.
typedef void __attribute__((sysv_abi)) tramp_fn(void *, UINT64, UINT64,
UINT64, UINT64, UINT64);
tramp_fn *go = (tramp_fn *)page;
go(page + TRAMP_LIST, nlater, entry, fb, PML4_ADDR, tramp_page + 4096);
for (;;)
__asm__ volatile("hlt");
}
// Load PT_LOAD segments to p_paddr. Physical addresses, not virtual: the
@ -639,6 +721,7 @@ load_elf(EFI_FILE_PROTOCOL *file)
if (span_hi)
claim_span(lowest_paddr & ~0xfffULL, (span_hi + 0xfff) & ~0xfffULL);
kern_lo = lowest_paddr & ~0xfffULL;
for (UINT16 i = 0; i < eh.e_phnum; i++) {
struct elf64_phdr ph;
@ -1758,7 +1841,7 @@ f8_window(EFI_GRAPHICS_OUTPUT_PROTOCOL *gop)
return 0;
}
static UINT32
static UINT64
setup_framebuffer(void)
{
EFI_GUID gop_guid = EFI_GRAPHICS_OUTPUT_PROTOCOL_GUID;
@ -2033,10 +2116,11 @@ EfiMain(EFI_HANDLE image, EFI_SYSTEM_TABLE *systab)
// and the display is the only thing that can report a failure afterwards.
open_boot_volume(image);
UINT32 fb = setup_framebuffer();
UINT64 fb = setup_framebuffer();
EFI_FILE_PROTOCOL *file = open_kernel(image);
UINT64 entry = load_elf(file);
tramp_reserve();
ui_progress_end();
file->Close(file);
@ -2166,11 +2250,6 @@ EfiMain(EFI_HANDLE image, EFI_SYSTEM_TABLE *systab)
__asm__ volatile("cli");
// The kernel segments that could not be read straight home: the firmware
// has let go of that memory now. See claim_span().
if (nlater)
ui_progress_label("Placing the kernel");
place_staged();
copy((void *)SCHBOOT_E820_MAP, e820, nent * sizeof e820[0]);
*(UINT32 *)SCHBOOT_E820_COUNT = nent;
@ -2220,35 +2299,10 @@ EfiMain(EFI_HANDLE image, EFI_SYSTEM_TABLE *systab)
for (int i = 0; i < 512; i++)
pdpt[i] = ((UINT64)i << 30) | PTE_P | PTE_PS | PTE_W;
ui_progress_label("Starting the kernel");
// RBX carries the framebuffer address into kmain, and must never be 1 --
// that value marks an application processor in entry64high. A physical
// MMIO address or zero, so it cannot collide.
//
// "rbx" is in the clobber list because this writes it, and leaving it out
// is not a tidiness problem -- it is a jump to the wrong address. Without
// it the compiler may allocate an *input* into rbx, and it did: clang 21
// put `entry` there, so the second instruction overwrote the jump target
// with the framebuffer address and the third jumped into video memory. The
// machine then executed pixels until KVM gave up.
//
// movq %rax, %cr3 <- %0, the page tables
// movq %rcx, %rbx <- %1, the framebuffer, into rbx
// jmpq *%rbx <- %2 was rbx, and now holds the framebuffer
//
// It had worked for as long as the compiler happened to choose differently.
// That is the same failure as the BIOS loader's EAX hand-off in
// docs/history/kernel.md section 4: a register the code depends on, that
// nothing in the source actually asks for.
__asm__ volatile(
"mov %0, %%cr3\n\t"
"mov %1, %%rbx\n\t"
"jmp *%2\n\t"
:
: "r"((UINT64)PML4_ADDR), "r"((UINT64)fb), "r"(entry)
: "rbx", "memory");
for (;;)
__asm__ volatile("hlt");
// The kernel segments that could not be read straight home are copied
// there now, by tramp_go(), from a page they cannot overwrite -- and the
// jump into the kernel is made from the same page.
ui_progress_label(nlater ? "Placing and starting the kernel"
: "Starting the kernel");
tramp_go(entry, fb);
}

140
write.sh Normal file
View file

@ -0,0 +1,140 @@
#!/bin/sh
# Write schos.iso to a USB stick.
#
# ./write.sh sdc or ./write.sh /dev/sdc
# ./write.sh sdc other.iso to write a different image
#
# Shows what is on the device, asks you to type the device's name back, then
# asks for your password and writes. Everything on the device is destroyed;
# there is no undo.
#
# Linux only: it reads the device list from lsblk. It refuses a partition
# (sdc1 -- the stick is written whole), anything holding a mounted system
# path, and anything that is not a removable or USB disk unless you add
# --any-disk, because the one mistake this script exists to stop is writing
# over the disk you are running from.
set -eu
if [ -t 1 ]; then
R=$(printf '\033[1;31m'); G=$(printf '\033[1;32m')
B=$(printf '\033[1m'); D=$(printf '\033[2m'); N=$(printf '\033[0m')
else
R='' G='' B='' D='' N=''
fi
die() { printf '%swrite.sh:%s %s\n' "$R" "$N" "$*" >&2; exit 1; }
usage() {
echo "usage: ./write.sh <device> [image.iso] [--any-disk]"
echo " ./write.sh sdc writes schos.iso to /dev/sdc"
echo
echo "Removable disks on this machine:"
lsblk -d -n -o NAME,SIZE,TRAN,RM,MODEL 2>/dev/null |
awk '$3 == "usb" || $4 == "1" { printf " /dev/%-8s %8s %s\n", $1, $2, substr($0, index($0,$5)) }'
exit 2
}
ANY=0
DEV=
ISO=
for a in "$@"; do
case "$a" in
--any-disk) ANY=1 ;;
-h|--help) usage ;;
*) if [ -z "$DEV" ]; then DEV=$a; elif [ -z "$ISO" ]; then ISO=$a; else usage; fi ;;
esac
done
[ -n "$DEV" ] || usage
HERE=$(cd "$(dirname "$0")" && pwd)
[ -n "$ISO" ] || ISO=$HERE/schos.iso
command -v lsblk >/dev/null 2>&1 || die "needs lsblk (util-linux); this script is Linux only"
# --- the image --------------------------------------------------------------
[ -f "$ISO" ] || die "no image at $ISO -- run 'make iso' first"
ISO_BYTES=$(wc -c < "$ISO" | tr -d ' ')
ISO_DATE=$(date -r "$ISO" '+%Y-%m-%d %H:%M' 2>/dev/null || echo '?')
# --- the device -------------------------------------------------------------
case "$DEV" in
/dev/*) ;;
*) DEV=/dev/$DEV ;;
esac
[ -b "$DEV" ] || die "$DEV is not a block device"
NAME=$(basename "$DEV")
TYPE=$(lsblk -d -n -o TYPE "$DEV")
[ "$TYPE" = disk ] || die "$DEV is a $TYPE, not a whole disk -- give the disk (sdc), not a partition (sdc1)"
SIZE_BYTES=$(lsblk -d -n -b -o SIZE "$DEV" | tr -d ' ')
SIZE=$(lsblk -d -n -o SIZE "$DEV" | tr -d ' ')
TRAN=$(lsblk -d -n -o TRAN "$DEV" | tr -d ' ')
RM=$(lsblk -d -n -o RM "$DEV" | tr -d ' ')
MODEL=$(lsblk -d -n -o MODEL "$DEV" | sed 's/ *$//')
# The disk this system runs from, or anything with a system path on it.
for mp in $(lsblk -n -o MOUNTPOINTS "$DEV" 2>/dev/null); do
case "$mp" in
/|/boot|/boot/*|/efi|/home|/nix|/nix/*|/usr|/var|\[SWAP\])
die "$DEV holds $mp -- that is this computer's own disk, not a stick" ;;
esac
done
if [ "$TRAN" != usb ] && [ "$RM" != 1 ] && [ "$ANY" != 1 ]; then
die "$DEV ($MODEL, $SIZE, ${TRAN:-no transport}) is not a USB or removable disk.
If you are sure, run it again with --any-disk."
fi
[ "$SIZE_BYTES" -ge "$ISO_BYTES" ] || die "$DEV is $SIZE, smaller than the image"
# --- show, and ask ----------------------------------------------------------
echo
echo "${B}Image${N} $ISO"
echo " $((ISO_BYTES / 1048576)) MB, built $ISO_DATE"
echo
echo "${B}Device${N} $DEV ${B}$MODEL${N} $SIZE ${D}(${TRAN:-?}, removable=$RM)${N}"
lsblk -n -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINTS "$DEV" | sed '1d; s/^/ /'
USED=$(lsblk -n -o NAME "$DEV" | sed 1d | wc -l | tr -d ' ')
[ "$USED" -gt 0 ] || echo " ${D}(no partitions)${N}"
echo
echo "${R}Everything on $DEV will be destroyed. This cannot be undone.${N}"
printf 'Type %s%s%s to write, anything else to stop: ' "$B" "$NAME" "$N"
read -r answer
[ "$answer" = "$NAME" ] || { echo "Stopped. Nothing was written."; exit 1; }
# --- write ------------------------------------------------------------------
echo
echo "Your password is needed to write to the device."
sudo -v || die "no permission; nothing was written"
# Unmount whatever the desktop mounted, or the kernel keeps writing its own
# cached view of the old filesystem over the new image afterwards.
for part in $(lsblk -n -l -o NAME,MOUNTPOINTS "$DEV" | awk 'NF > 1 { print $1 }'); do
echo "Unmounting /dev/$part"
sudo umount "/dev/$part" || die "could not unmount /dev/$part; nothing was written"
done
echo "Writing..."
sudo dd if="$ISO" of="$DEV" bs=4M conv=fsync oflag=direct status=progress
sync
# --- check ------------------------------------------------------------------
# Read it back. A stick that takes the write and keeps none of it is common
# enough among cheap ones to be worth thirty seconds.
echo "Checking what was written..."
if sudo cmp -n "$ISO_BYTES" "$ISO" "$DEV"; then
echo "${G}Done.${N} $DEV holds $(basename "$ISO") and can be removed."
else
die "the device does not read back what was written -- the stick may be failing"
fi
# The kernel re-reads the partition table so the new layout shows up here
# too; harmless if it cannot.
sudo partprobe "$DEV" 2>/dev/null || sudo blockdev --rereadpt "$DEV" 2>/dev/null || true