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.
make crossalready run, with the same cross toolchains on PATH (see the project docs). The static per-archbuild/<triple>/kasldbinaries 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.
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 # results matrix as an SVG range 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 netbootmodloopor thelinux-ltsapk — 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 forcesMODULES/DUMMY/VETHmodular and stages them beside the kernel, so any rebuilt cell comes back with modules.tests/vm/stage-modules <cell>rebuilds them against an existing.configwithout 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 the same rows as an SVG range chart, one row per
architecture, written to stdout — the committed copy is
docs/diagrams/entropy-by-arch.svg. It reads cmd_table's output rather than
the boot logs, so the chart and the matrix cannot disagree.
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).
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.
| mode | sysctls (at uid 1000) | what it exercises |
|---|---|---|
default |
booted compile-time defaults (kptr=0, perf=2; dmesg per .config) |
a stock kernel — kallsyms zeroed by perf=2, so inference alone |
kptr-hidden |
default + kptr_restrict=2 |
pointers hidden; isolates kptr's effect (perf already gates kallsyms) |
perf-open |
default + perf_event_paranoid=0 |
perf relaxed — unlocks /proc/kallsyms and the perf_event_open text-poke leak (exact) |
dmesg-open |
default + dmesg_restrict=0 |
world-readable dmesg (differs from default only where the kernel ships dmesg_restrict=1) |
bpf-open |
default + unprivileged_bpf_disabled=0 |
unprivileged 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 |
hardened |
kptr=2 + dmesg_restrict=1 + perf=3 + unprivileged_bpf_disabled=2 |
the realistic unprivileged floor (file-derived facts only) |
nokaslr |
nokaslr on the cmdline |
the KASLR-disabled pin |
no5lvl |
no5lvl on the cmdline (x86_64, riscv64) |
the kernel's fallback paging level when 5-level is disabled |
no4lvl |
no4lvl 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.
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.
| arch | kernel source | qemu |
|---|---|---|
| x86_64 | netboot virt |
qemu-system-x86_64 |
| i686 | netboot lts |
qemu-system-i386 |
| aarch64 | netboot virt |
qemu-system-aarch64 |
| armv7 | netboot lts |
qemu-system-arm |
| riscv64 | linux-lts apk |
qemu-system-riscv64 |
| loongarch64 | linux-lts apk |
qemu-system-loongarch64 + UEFI firmware |
| ppc64le | netboot lts |
qemu-system-ppc64 |
| s390x | netboot lts |
qemu-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.
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| arch | kernel-ARCH / defconfig | qemu |
|---|---|---|
| mips | mips / malta_defconfig + BE |
qemu-system-mips -M malta |
| mipsel | mips / malta_defconfig (LE) |
qemu-system-mipsel -M malta |
| mips64el | mips / malta_defconfig + 64-bit (LE) |
qemu-system-mips64el -M malta |
| riscv32 | riscv / defconfig + 32-bit.config |
qemu-system-riscv32 -M virt |
| ppc32 | powerpc / mpc85xx_defconfig (BE) |
qemu-system-ppc -M ppce500 |
| powerpc64 | powerpc / ppc64_defconfig (BE) |
qemu-system-ppc64 -M pseries |
| armeb | arm / multi_v7_defconfig + BE |
qemu-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_defconfigis little-endian, somipsexercises the big-endian overlay (andmipselboots the native byte order);mips64elpromotes the same board to a 64-bit CPU (MIPS64R2-generic);riscv32is staged as the flatImage(thevirtboard rejects the rawvmlinuxELF) and needs the 32-bit OpenSBI firmware (auto-discovered, see below).ppc32— the Freescale e500v2 target on qemu's generic-M ppce500board (qemu-system-ppcfrom theqemu-system-misc/-ppcpackage, consolettyS0on an 8250 UART). Built frommpc85xx_defconfigwith 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, consolehvc0), 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-mbe8on both payloads. A big-endian arm kernel runs its userspace BE8 (byte-invariant) from ARMv6 on, whilemake 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 isinit, the kernel panics killing pid 1 and the run reads as a boot failure rather than a build one.-mbe8produces BE8 instead; the Makefile passes it for thearmeb-*triple when buildingkasld, andrunpasses 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.
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, verdictWired 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.
- 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
hardenedis 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. loongarch64boots via UEFI; the firmware is auto-discovered next to the qemu binary or set viaLOONGARCH_BIOS.- riscv text KASLR needs both a kernel built with
RANDOMIZE_BASEand a boot-supplied seed, andqemu -M virtprovides neither by default (its DTB carries onlyrng-seed, notkaslr-seed, and the cells run without-cpu maxso there is no Zkr self-seed). For the cells actually built with it — the mainlineriscv646.6/7.0 kernels —rundumps qemu's generated DTB, splices a fresh per-boot/chosen/kaslr-seed, and boots from the patched blob (dtcrequired), so the base is randomized like the other 64-bit arches. This is gated on the staged.confighavingCONFIG_RANDOMIZE_BASE=y, so the Alpineriscv64kernel (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 (ppc64leon-M pseries, etc.) likewise land on the disabled-base pin. riscv32needs 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 viaRISCV32_BIOS.- Useful overrides:
QEMU_DIR(qemu not on PATH),ALPINE_VER,BUILD_DIR,TIMEOUT,LOONGARCH_BIOS,RISCV32_BIOS, andLINUX_VERSION(forbuild-kernel).