Live cross-architecture validation (tests/vm)

September 20, 2026 · View on GitHub

tests/vm/run boots a real, publicly-fetchable kernel under qemu-system, captures the kernel's ground-truth text base, runs the cross-built kasld against the running kernel, and checks the invariant the tool rests on:

truth ∈ [virt_image_base_min, virt_image_base_max]

It is the cross-architecture, end-to-end counterpart of the single-host check extra/collect + extra/validate-bundle, and of the offline tests/replay corpus. Where tests/replay proves kasld parses and runs over captured fixtures, this proves the inferred window contains the real base on a live kernel, across architectures and attacker profiles.

Table of Contents

Prerequisites

  • make cross already run, with the same cross toolchains on PATH (see the project docs). The static per-arch build/<triple>/kasld binaries and the matching <triple>-gcc (used here to build the tiny init) are both required.
  • qemu-system-<arch> on PATH. On Debian/Ubuntu: apt install qemu-system-x86 qemu-system-arm qemu-system-misc.
  • curl, cpio, gzip.

No root is required, and nothing on the host is modified — each arch runs in a throwaway VM.

Usage

Cells are named <arch>-<distro>-<kernel maj.minor> (e.g. aarch64-alpine-6.12, aarch64-mainline-7.0); run with no cell lists them.

tests/vm/run                          # every supported cell, default profile
tests/vm/run aarch64-alpine-6.12      # one cell
tests/vm/run aarch64-alpine-6.12 hardened
tests/vm/run all hardened             # every cell in one profile
tests/vm/run table                    # results matrix + speculative-narrowing table
tests/vm/run chart                    # one kernel line as an SVG bar chart (stdout)
tests/vm/run spec-table               # only the speculative-narrowing table
tests/vm/run spec-table --with-timing # ...also listing timing/side-channel rows
tests/vm/run aarch64-alpine-6.12 capture # build a truth-bearing fixture from a live boot
tests/vm/run fetch-modules            # stage a distro cell's own modules (see below)

Every module-region component reads an empty /proc/modules on a cell with no module loaded, which is indistinguishable from a component that does not work, so each cell carries two real in-tree modules (dummy, veth) that init loads before the run. How they are staged depends on the kernel's origin:

  • distro cells (Alpine, Debian): tests/vm/run fetch-modules [cell ...] takes the modules from the same published build the kernel came from — the netboot modloop or the linux-lts apk — so vermagic matches by construction. It reads the release from the artefact and refuses when it disagrees with the staged kernel, since the mirror moves independently of the cache.
  • mainline cells (tests/vm/build-kernel): the build forces MODULES/DUMMY/ VETH modular and stages them beside the kernel, so any rebuilt cell comes back with modules. tests/vm/stage-modules <cell> rebuilds them against an existing .config without re-staging the kernel — usable only when the build tree still matches the staged image (it checks, and refuses otherwise).

The capture mode is a maintainer workflow, not a validation profile: it boots the kernel as root with kptr_restrict=0, frames the real /proc + /sys + /boot fact-set (kallsyms landmarks + iomem = ground truth) back over the serial console, and reconstructs a fixture under tests/fixtures/<arch>/<host>/ — host identity (CPU brand, build tag, device-tree serial/MAC) scrubbed. Those fixtures are then checked offline by make test-fixtures (see docs/testing.md).

Each run prints a per-arch verdict and a summary; the exit status is non-zero if any arch produced an unsound or incomplete result. After running the scenarios, tests/vm/run table reads the boot logs and emits a markdown matrix (arch × scenario → KASLR / virt residual / phys residual; soundness is a gate, not a column), then a speculative-narrowing table showing any cells where the likely best-guess window beats the guaranteed one and what signal drove it — the published tables in docs/reproducibility.md are generated this way.

tests/vm/run chart renders one kernel line of those rows as an SVG bar chart, one bar per architecture, written to stdout — the committed copy is docs/diagrams/residual-entropy-by-arch.svg. It reads cmd_table's output rather than the boot logs, so the chart and the matrix cannot disagree.

The chart plots a single kernel line at the default vantage, each architecture on its own upstream defconfig, so that the bars differ by architecture alone; the matrix remains the place where the kernel line, the configuration and the vantage vary. CHART_BASELINE selects the line, and a value absent from the matrix is an error rather than an empty chart. More than one line currently spans every architecture, so the choice is editorial: deriving it would mean ordering release strings, which is not something this harness does.

The speculative-narrowing table excludes microarchitectural side-channel narrowings (method:timing — cache/speculation oracles such as prefetch and entrybleed) by default: their success depends on the host CPU and varies from run to run, so a table listing them would not be reproducible. This is not a claim that they fail — they work, and can recover more than the guaranteed matrix shows (e.g. pinning a hardened base to exact where no file-derived leak can); they are withheld only for reproducibility. --with-timing (or SPEC_INCLUDE_TIMING=1) lists them. The guaranteed results matrix is resolved only at the sound floor and never depends on timing regardless.

aarch64     PASS  truth=0xffff800080000000 ∈ [0x...,0x...] recovered=Y
x86_64      PASS  truth=0xffffffff88000000 ∈ [0x...81000000,0x...bd000000] recovered=N

recovered=Y means the window collapsed to the exact base; recovered=N means it is wider but still contains the truth — the correct outcome under restriction. N/A means the boot produced no comparable window/truth (not a failure).

Profiles

The analysis always runs unprivileged (uid 1000, with all supplementary groups dropped) — the threat model is an unprivileged local attacker — so the modes differ only in the sysctl vector applied, never in the reader's identity. The one privileged step is the per-boot ground-truth capture the check compares against.

default is the kernel's own compile-time sysctl posture, read back at boot and left as booted (kptr_restrict=0 and perf_event_paranoid=2 upstream; dmesg_restrict whatever the .config sets — 0 on the mainline builds, 1 on Alpine). Each other mode moves exactly one axis off that baseline. A key consequence: kptr_restrict=0 alone does not expose symbol values — at the upstream perf_event_paranoid=2, /proc/kallsyms is zeroed for an unprivileged reader (kallsyms_show_value() needs perf<=1 or CAP_SYSLOG), so on a stock kernel the base comes from inference, not the symbol table.

modesysctls (at uid 1000)what it exercises
defaultbooted compile-time defaults (kptr=0, perf=2; dmesg per .config)a stock kernel — kallsyms zeroed by perf=2, so inference alone
kptr-hiddendefault + kptr_restrict=2pointers hidden; isolates kptr's effect (perf already gates kallsyms)
perf-opendefault + perf_event_paranoid=0perf relaxed — unlocks /proc/kallsyms and the perf_event_open text-poke leak (exact)
dmesg-opendefault + dmesg_restrict=0world-readable dmesg (differs from default only where the kernel ships dmesg_restrict=1)
bpf-opendefault + unprivileged_bpf_disabled=0unprivileged bpf() permitted (the BPF verifier-log leaks). Every cell boots with it disabled — distro cells ship CONFIG_BPF_UNPRIV_DEFAULT_OFF, mainline cells are built with it forced on — so this is a real posture change everywhere. Shown on every cell like the other sysctl profiles, including where it reads the same as default — that is a result, not an absence. On the mainline cells it yields nothing because the verifier-log offset table is uname-keyed and holds no locally built kernel, not because bpf() was already permitted
hardenedkptr=2 + dmesg_restrict=1 + perf=3 + unprivileged_bpf_disabled=2the realistic unprivileged floor (file-derived facts only)
nokaslrnokaslr on the cmdlinethe KASLR-disabled pin
no5lvlno5lvl on the cmdline (x86_64, riscv64)the kernel's fallback paging level when 5-level is disabled
no4lvlno4lvl on the cmdline (riscv64)a third paging level from one image — a distinct PAGE_OFFSET
la57-cpu max under -accel tcg (x86_64)5-level paging emulated where the host lacks la57; the only mode that varies QEMU, not the cmdline — slow (TCG)

no5lvl/no4lvl/la57 resolve a different PAGE_OFFSET from the same kernel image by changing the paging level, so one build exercises several linear-map bases. la57 forces TCG because KVM cannot expose a CPU feature the host lacks.

Architectures

Alpine kernels cover the arches it ports (below); arches it doesn't are built from kernel.org by tests/vm/build-kernel (see "Gap architectures"). Kernels come from a netboot image where one exists, otherwise the linux-lts apk (flavor=apk). An arch is skipped (not failed) when its qemu-system-* or its static build/<prefix>-*/kasld is unavailable.

archkernel sourceqemu
x86_64netboot virtqemu-system-x86_64
i686netboot ltsqemu-system-i386
aarch64netboot virtqemu-system-aarch64
armv7netboot ltsqemu-system-arm
riscv64linux-lts apkqemu-system-riscv64
loongarch64linux-lts apkqemu-system-loongarch64 + UEFI firmware
ppc64lenetboot ltsqemu-system-ppc64
s390xnetboot ltsqemu-system-s390x

All eight have been verified PASS. ppc64le/s390x need qemu-system-misc (or any qemu with those targets on PATH). loongarch64 needs an edk2 firmware image: the recipe auto-discovers one next to the qemu binary (pc-bios/) or in the usual share dirs, or set LOONGARCH_BIOS explicitly — and the firmware must match the qemu that loads it.

Gap architectures (built from kernel.org)

Alpine has no port for some arches; their kernel is built from source by tests/vm/build-kernel — a pinned kernel.org tarball + a stock upstream defconfig + fixed config overlays (endianness where the byte order differs from the base defconfig, devtmpfs for an init console, and RANDOMIZE_BASE for riscv64 so its text KASLR is compiled in). The result is staged into the cache and booted by tests/vm/run with the same init.c as the Alpine flavors. Reproducible but slow; run it once per arch, manually. The arch-gated rule logic is covered per-push by tests/test-cross.

tests/vm/build-kernel mipsel-mainline-7.0  # download source + cross-build -> cache (slow)
tests/vm/run mipsel-mainline-7.0           # boot it, verdict
archkernel-ARCH / defconfigqemu
mipsmips / malta_kvm_defconfig + BEqemu-system-mips -M malta
mipselmips / malta_kvm_defconfig (LE)qemu-system-mipsel -M malta
mips64elmips / malta_kvm_defconfig + 64-bit (LE)qemu-system-mips64el -M malta
riscv32riscv / defconfig + 32-bit.configqemu-system-riscv32 -M virt
ppc32powerpc / mpc85xx_defconfig (BE)qemu-system-ppc -M ppce500
powerpc64powerpc / ppc64_defconfig (BE)qemu-system-ppc64 -M pseries
armebarm / multi_v7_defconfig + BEqemu-system-arm -M virt

Validation status of the gap arches (built fresh from kernel.org, booted here):

  • mips, mipsel, mips64el, riscv32 — verified end-to-end, boots PASS. malta_kvm_defconfig is little-endian, so mips exercises the big-endian overlay (and mipsel boots the native byte order); mips64el promotes the same board to a 64-bit CPU (MIPS64R2-generic); riscv32 is staged as the flat Image (the virt board rejects the raw vmlinux ELF) and needs the 32-bit OpenSBI firmware (auto-discovered, see below).
  • ppc32 — the Freescale e500v2 target on qemu's generic -M ppce500 board (qemu-system-ppc from the qemu-system-misc/-ppc package, console ttyS0 on an 8250 UART). Built from mpc85xx_defconfig with text KASLR enabled (see "Gap architectures"), so it boots PASS and randomizes for real. pmac/g3beige (book3s32) is not used: its kernel has no text KASLR, so it could only report the disabled-base pin.
  • powerpc64 — boots PASS on -M pseries (power9, console hvc0), but the board delivers no KASLR seed, so the kernel boots unrandomized; the base is pinned via the disabled-base path, not a KASLR defeat.
  • armeb — verified end-to-end on -M virt, boots PASS. It needs -mbe8 on both payloads. A big-endian arm kernel runs its userspace BE8 (byte-invariant) from ARMv6 on, while make cross's only big-endian arm toolchain (armeb-linux-musleabi) emits BE32 (word-invariant) unless asked: a BE32 binary faults on its first instruction there, and since the faulting binary is init, the kernel panics killing pid 1 and the run reads as a boot failure rather than a build one. -mbe8 produces BE8 instead; the Makefile passes it for the armeb-* triple when building kasld, and run passes it when building the init it stages alongside. Under qemu-user a BE32 binary runs either way, which is why only a full VM boot surfaces the difference.

These rows skip cleanly in tests/vm/run until build-kernel populates the cache, so the Alpine arches are unaffected. Stock upstream defconfigs are used throughout; fall back to a Buildroot qemu_* defconfig if a vanilla one won't boot.

The pinned source is current mainline (7.0): it builds clean with a C23-default compiler (gcc 15), unlike the 6.12 LTS tree whose pre-6.13 MIPS vdso clashed with true/false/bool becoming keywords. riscv32 has no standalone rv32_defconfig on this tree (removed after 6.15); it is configured from defconfig plus the 32-bit.config fragment. Override the version with LINUX_VERSION if needed.

Two kernel versions per arch (the -mainline cells)

Each arch Alpine ships gets a second cell, <arch>-mainline-<version>, that boots a mainline (kernel.org) build of the same arch instead of the Alpine distro kernel — so the matrix carries an older distro kernel and current mainline per arch, the axis that surfaced most historical soundness bugs (VA-layout floors, module-region size, TEXT_OFFSET). The Alpine cell (<arch>-alpine-<version>) and the mainline cell (<arch>-mainline-<version>) render under the same arch in run table, distinguished by the source and release columns. Build and boot a mainline cell like any gap arch:

tests/vm/build-kernel aarch64-mainline-7.0  # mainline build -> cache (slow)
tests/vm/run aarch64-mainline-7.0           # boot it, verdict

Wired for x86_64, aarch64, armv7, i686, s390x, riscv64, ppc64le. ppc64le has no ppc64le_defconfig on this tree, so it builds ppc64_defconfig with the little-endian overlay (CPU_LITTLE_ENDIAN, which selects the ELF v2 ABI). The mainline cells are built on request, never part of the default gap set.

Notes and limitations

  • Soundness, not tightness: the check is that the truth is inside the window, not how small the window is. A wider-but-sound window under hardened is the expected result.
  • One stock kernel config per arch. Config-gated paths (VA-bits, endianness, VMSPLIT, CONFIG_* toggles) need purpose-built kernels and are out of scope here.
  • loongarch64 boots via UEFI; the firmware is auto-discovered next to the qemu binary or set via LOONGARCH_BIOS.
  • riscv text KASLR needs both a kernel built with RANDOMIZE_BASE and a boot-supplied seed, and qemu -M virt provides neither by default (its DTB carries only rng-seed, not kaslr-seed, and the cells run without -cpu max so there is no Zkr self-seed). For the cells actually built with it — the mainline riscv64 6.6/7.0 kernels — run dumps qemu's generated DTB, splices a fresh per-boot /chosen/kaslr-seed, and boots from the patched blob (dtc required), so the base is randomized like the other 64-bit arches. This is gated on the staged .config having CONFIG_RANDOMIZE_BASE=y, so the Alpine riscv64 kernel (not built with it) and 5.15 (predates riscv KASLR, added in 5.18) stay seedless — KASLR off, the disabled-base pin, still a soundness point. Other seedless arches (ppc64le on -M pseries, etc.) likewise land on the disabled-base pin.
  • riscv32 needs 32-bit OpenSBI, which most qemu builds do not bundle (only the riscv64 image). It is auto-discovered next to the qemu binary, in the system share dir, or in the distro cross package (/usr/lib/riscv32-linux-gnu/opensbi/generic/fw_dynamic.bin), or set via RISCV32_BIOS.
  • Useful overrides: QEMU_DIR (qemu not on PATH), ALPINE_VER, BUILD_DIR, TIMEOUT, LOONGARCH_BIOS, RISCV32_BIOS, and LINUX_VERSION (for build-kernel). chart takes two more: CHART_BASELINE for the kernel line to plot, and CHART_DEFAULT_OFF for the architectures whose upstream default leaves the kernel image unrandomised, which the footer names apart from those randomising as shipped.