Skip to content

Commit 9f55ec3

Browse files
committed
tests/vm: validate the Alpine-gap architectures from kernel.org
New tests/vm/build-kernel builds bootable kernels (pinned kernel.org source + stock defconfig + endianness/devtmpfs overlay) for the arches Alpine doesn't port — mips, mipsel, riscv32, ppc32 — which tests/vm/run now boots (local flavor, riscv32 Image+OpenSBI, ppc32 g3beige/ttyS0, QEMU_DIR falls back to PATH). All four fold into the soundness matrix: 48 cells (12 arches x 4 profiles), all sound, reproducible verbatim from `tests/vm/run table`. armeb stays unsupported — the only big-endian arm toolchain is ARMv5 BE32, usable on neither an ARMv7 BE8 kernel nor a BE32 qemu boot. Matrix clarity: residual bits read 0 uniformly when KASLR is off (was a mix of 0 and —), run table flags timing-side-channel recoveries with `*`, and the docs gain a reader-profile legend (defining hide/hardened/…) plus caveats for the x86_64 timing oracle and the disabled-base pins. Docs updated: reproducibility, testing, tests/vm/README.
1 parent 0515f55 commit 9f55ec3

7 files changed

Lines changed: 391 additions & 69 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ details, explain mode, and hardening assessment.
9999
| Component / rule author | [CONTRIBUTING.md](CONTRIBUTING.md) — writing a component or rule, emitter API, exit codes, metadata |
100100
| Architecture / internals | [docs/architecture.md](docs/architecture.md) — the inference engine, data-flow seams, tagged-line protocol, cross-region derivation |
101101
| Test runner / CI | [docs/testing.md](docs/testing.md) — host tests, replay fixtures, cross-arch under qemu-user, coverage |
102+
| Reproducibility | [docs/reproducibility.md](docs/reproducibility.md) — independent verification: on the local kernel, live across architectures, or over the captured corpus |
102103
| KASLR primer | [docs/kaslr.md](docs/kaslr.md) — per-arch KASLR history, default text base, vmsplit, FG-KASLR |
103104
| Bypass techniques | [docs/bypass-techniques.md](docs/bypass-techniques.md) — filesystem leaks, side-channels, syscall / ioctl leaks, weak entropy, patched CVEs, arbitrary read |
104105

docs/reproducibility.md

Lines changed: 126 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ kernel's real symbol addresses. `validate-bundle` runs the matching kasld binary
4343
over that bundle offline and checks every inferred range against the captured
4444
ground truth. It exits non-zero if any range excludes the truth. No root needed.
4545

46+
`--kallsyms` can only record the ground truth when kallsyms is readable
47+
(`kptr_restrict=0`, or root). Without it the bundle carries no truth and the
48+
checks report `N/A` rather than `PASS` — still not a failure, just nothing to
49+
compare against.
50+
4651
## 2. Live, across architectures
4752

4853
[`tests/vm/run`](../tests/vm) boots real, publicly-fetchable kernels under
@@ -56,62 +61,135 @@ tests/vm/run all hardened # repeat under the unprivileged floor
5661
```
5762

5863
It needs `qemu-system-<arch>` and the cross toolchains on PATH; an architecture
59-
is skipped (not failed) when either is missing. Profiles range from `default`
60-
(kallsyms readable) to `hardened` (uid 1000, `kptr_restrict=2`,
61-
`dmesg_restrict=1`, `perf_event_paranoid=3` — the realistic unprivileged floor,
62-
where the range widens but must still contain the truth). See
63-
[tests/vm/README.md](../tests/vm/README.md) for the full arch list and options.
64+
is skipped (not failed) when either is missing. The reader profiles (the
65+
`scenario` column of the matrix) escalate how little kasld is allowed to read:
66+
67+
- `default` — root, `kptr_restrict=0`: kallsyms and everything else readable.
68+
- `hide` — root, but `kptr_restrict=2`: kernel pointers are *hidden*, so kallsyms
69+
is unusable and the base must come from inference or other leaks (e.g. dmesg),
70+
not the symbol table.
71+
- `hardened` — unprivileged (uid 1000) with `kptr_restrict=2`,
72+
`dmesg_restrict=1`, `perf_event_paranoid=3`: the realistic attacker floor,
73+
where only file-derived facts survive.
74+
- `nokaslr` — KASLR disabled on the kernel command line.
75+
76+
Under the tighter profiles the window may widen but must still contain the truth.
77+
See [tests/vm/README.md](../tests/vm/README.md) for the full arch list and options.
78+
79+
Eight architectures boot from publicly-fetchable Alpine kernels. Four more that
80+
Alpine does not port — `mips`, `mipsel`, `riscv32`, `ppc32` — are built from a
81+
pinned kernel.org source by `tests/vm/build-kernel` (a stock upstream defconfig
82+
plus a fixed endianness/devtmpfs overlay) and booted the same way:
83+
84+
```sh
85+
tests/vm/build-kernel mips mipsel riscv32 ppc32 # cross-build -> cache (slow)
86+
tests/vm/run mips # boot it, verdict
87+
```
88+
89+
`armeb` is not covered: the only big-endian arm toolchain in the cross set emits
90+
ARMv5 BE32 code, which can neither run on an ARMv7 BE8 kernel nor boot a BE32
91+
kernel under qemu.
6492

6593
### Results matrix
6694

6795
The snapshot below is generated by `tests/vm/run all <scenario>` followed by
6896
`tests/vm/run table`. `recovered` = the window collapsed to the exact base;
69-
`residual bits` = remaining KASLR entropy in the inferred window (`0` when
70-
recovered, `` when KASLR is off so the figure does not apply); `sound` = the
71-
true base lies inside the inferred window. The axis is architecture × reader
72-
profile; a kernel-config axis is out of scope (see Scope below).
97+
`residual bits` = remaining KASLR entropy in the inferred window: `0` when none
98+
is left — KASLR off (no entropy to begin with) or a randomized base fully
99+
recovered; a positive number when randomized and the window is wider than a
100+
point; `` only when randomized-but-not-recovered *and* the architecture emits no
101+
separate virtual-KASLR entropy figure (the count is unavailable, not zero — see
102+
`mips`/`mipsel` under `hide`). `sound` = the true base lies inside the inferred
103+
window. The axis is architecture × reader profile; a kernel-config axis is out
104+
of scope (see Scope below). The first eight architectures boot Alpine kernels;
105+
the last four (`mips`, `mipsel`, `riscv32`, `ppc32`) are kernel.org builds from
106+
`tests/vm/build-kernel`.
73107

74108
| arch | release | scenario | KASLR | recovered | residual bits | sound |
75109
|------|---------|----------|-------|-----------|---------------|-------|
76-
| x86_64 | 6.12.81 | default | on | yes | 0 | yes |
77-
| x86_64 | 6.12.81 | hide | on | yes | 0 | yes |
78-
| x86_64 | 6.12.81 | hardened | on | yes | 0 | yes |
79-
| x86_64 | 6.12.81 | nokaslr | off | yes | 0 | yes |
80-
| i686 | 6.12.81 | default | on | yes | 0 | yes |
81-
| i686 | 6.12.81 | hide | on | yes | 0 | yes |
82-
| i686 | 6.12.81 | hardened | on | no | 5 | yes |
83-
| i686 | 6.12.81 | nokaslr | off | yes | 0 | yes |
84-
| aarch64 | 6.12.81 | default | on | yes | 0 | yes |
85-
| aarch64 | 6.12.81 | hide | on | yes | 0 | yes |
86-
| aarch64 | 6.12.81 | hardened | on | no | 26 | yes |
87-
| aarch64 | 6.12.81 | nokaslr | off | yes | 0 | yes |
88-
| armv7 | 6.12.81 | default | off | yes | 0 | yes |
89-
| armv7 | 6.12.81 | hide | off | yes | 0 | yes |
90-
| armv7 | 6.12.81 | hardened | off | no || yes |
91-
| armv7 | 6.12.81 | nokaslr | off | yes | 0 | yes |
92-
| riscv64 | 6.18.35 | default | off | yes | 0 | yes |
93-
| riscv64 | 6.18.35 | hide | off | yes | 0 | yes |
94-
| riscv64 | 6.18.35 | hardened | off | yes | 0 | yes |
95-
| riscv64 | 6.18.35 | nokaslr | off | yes | 0 | yes |
96-
| loongarch64 | 6.18.35 | default | on | yes | 0 | yes |
97-
| loongarch64 | 6.18.35 | hide | on | no | 3 | yes |
98-
| loongarch64 | 6.18.35 | hardened | on | no | 14 | yes |
99-
| loongarch64 | 6.18.35 | nokaslr | off | yes | 0 | yes |
100-
| ppc64le | 6.12.81 | default | off | yes | 0 | yes |
101-
| ppc64le | 6.12.81 | hide | off | no | 0 | yes |
102-
| ppc64le | 6.12.81 | hardened | off | no || yes |
103-
| ppc64le | 6.12.81 | nokaslr | off | yes | 0 | yes |
104-
| s390x | 6.12.81 | default | on | yes | 0 | yes |
105-
| s390x | 6.12.81 | hide | on | no | 9 | yes |
106-
| s390x | 6.12.81 | hardened | on | no | 39 | yes |
107-
| s390x | 6.12.81 | nokaslr | off | yes | 0 | yes |
108-
109-
All 32 cells are sound. Under `hardened` the window widens (s390x to 39 bits) but
110-
still contains the truth — the intended outcome at the unprivileged floor. Some
111-
architectures show KASLR `off` outside the `nokaslr` column: under the default
112-
qemu machine they receive no KASLR seed, so the kernel boots unrandomized and
113-
kasld pins the default base via the disabled-base path — a soundness case, not a
114-
gap.
110+
| x86_64 | 6.12.81-0-virt | default | on | yes | 0 | yes |
111+
| x86_64 | 6.12.81-0-virt | hide | on | yes | 0 | yes |
112+
| x86_64 | 6.12.81-0-virt | hardened | on | yes * | 0 | yes |
113+
| x86_64 | 6.12.81-0-virt | nokaslr | off | yes | 0 | yes |
114+
| i686 | 6.12.81-0-lts | default | on | yes | 0 | yes |
115+
| i686 | 6.12.81-0-lts | hide | on | yes | 0 | yes |
116+
| i686 | 6.12.81-0-lts | hardened | on | no | 5 | yes |
117+
| i686 | 6.12.81-0-lts | nokaslr | off | yes | 0 | yes |
118+
| aarch64 | 6.12.81-0-virt | default | on | yes | 0 | yes |
119+
| aarch64 | 6.12.81-0-virt | hide | on | yes | 0 | yes |
120+
| aarch64 | 6.12.81-0-virt | hardened | on | no | 26 | yes |
121+
| aarch64 | 6.12.81-0-virt | nokaslr | off | yes | 0 | yes |
122+
| armv7 | 6.12.81-0-lts | default | off | yes | 0 | yes |
123+
| armv7 | 6.12.81-0-lts | hide | off | yes | 0 | yes |
124+
| armv7 | 6.12.81-0-lts | hardened | off | no | 0 | yes |
125+
| armv7 | 6.12.81-0-lts | nokaslr | off | yes | 0 | yes |
126+
| riscv64 | 6.18.35-0-lts | default | off | yes | 0 | yes |
127+
| riscv64 | 6.18.35-0-lts | hide | off | yes | 0 | yes |
128+
| riscv64 | 6.18.35-0-lts | hardened | off | yes | 0 | yes |
129+
| riscv64 | 6.18.35-0-lts | nokaslr | off | yes | 0 | yes |
130+
| loongarch64 | 6.18.35-0-lts | default | on | yes | 0 | yes |
131+
| loongarch64 | 6.18.35-0-lts | hide | on | no | 3 | yes |
132+
| loongarch64 | 6.18.35-0-lts | hardened | on | no | 14 | yes |
133+
| loongarch64 | 6.18.35-0-lts | nokaslr | off | yes | 0 | yes |
134+
| ppc64le | 6.12.81-0-lts | default | off | yes | 0 | yes |
135+
| ppc64le | 6.12.81-0-lts | hide | off | no | 0 | yes |
136+
| ppc64le | 6.12.81-0-lts | hardened | off | no | 0 | yes |
137+
| ppc64le | 6.12.81-0-lts | nokaslr | off | yes | 0 | yes |
138+
| s390x | 6.12.81-0-lts | default | on | yes | 0 | yes |
139+
| s390x | 6.12.81-0-lts | hide | on | no | 9 | yes |
140+
| s390x | 6.12.81-0-lts | hardened | on | no | 39 | yes |
141+
| s390x | 6.12.81-0-lts | nokaslr | off | yes | 0 | yes |
142+
| mips | 6.15.6 | default | on | yes | 0 | yes |
143+
| mips | 6.15.6 | hide | on | no || yes |
144+
| mips | 6.15.6 | hardened | on | no | 9 | yes |
145+
| mips | 6.15.6 | nokaslr | off | yes | 0 | yes |
146+
| mipsel | 6.15.6 | default | on | yes | 0 | yes |
147+
| mipsel | 6.15.6 | hide | on | no || yes |
148+
| mipsel | 6.15.6 | hardened | on | no | 9 | yes |
149+
| mipsel | 6.15.6 | nokaslr | off | yes | 0 | yes |
150+
| riscv32 | 6.15.6 | default | off | yes | 0 | yes |
151+
| riscv32 | 6.15.6 | hide | off | yes | 0 | yes |
152+
| riscv32 | 6.15.6 | hardened | off | no | 0 | yes |
153+
| riscv32 | 6.15.6 | nokaslr | off | yes | 0 | yes |
154+
| ppc32 | 6.15.6 | default | on | yes | 0 | yes |
155+
| ppc32 | 6.15.6 | hide | on | no | 2 | yes |
156+
| ppc32 | 6.15.6 | hardened | on | no | 15 | yes |
157+
| ppc32 | 6.15.6 | nokaslr | off | yes | 0 | yes |
158+
159+
`*` reached via a timing side-channel (e.g. prefetch) — hardware/KVM-dependent, not available under pure emulation.
160+
161+
All 48 cells are sound. Under `hardened` the window widens but still contains the
162+
truth — the intended outcome at the unprivileged floor.
163+
164+
`recovered` and `residual bits` measure different things, which is why a row can
165+
read `off / no / 0`. `residual bits` is *KASLR entropy* left in the window, so it
166+
is `0` whenever KASLR is off — there was no entropy to start with. `recovered` is
167+
whether the window collapsed to a single address. A KASLR-`off` row that is
168+
`recovered=no` (e.g. `ppc64le` or `riscv32` under `hardened`) therefore shows
169+
`0` residual but a non-point window: the base is fixed (0 entropy), yet from
170+
file-only facts kasld can only *bound* it — between the true base and that base
171+
plus the image-base offset — rather than read it exactly. That width is inference
172+
uncertainty, not entropy. It still contains the truth, which is the property
173+
being checked.
174+
175+
Several architectures show KASLR `off` outside the `nokaslr` column: under the
176+
default qemu machine they receive no KASLR seed (or the port has no text KASLR),
177+
so the kernel boots unrandomized and kasld bounds or pins the base via the
178+
disabled-base path — a soundness case, not a gap. Where that disabled base is a
179+
fixed constant (e.g. seedless `riscv64` under `-M virt`), kasld pins it exactly
180+
from arch constants plus the world-readable device-tree, so `recovered=yes` holds
181+
even under `hardened` — no kallsyms or leak is needed when there is no randomness
182+
to recover.
183+
184+
x86_64 is the opposite case and deserves a caveat: its exact recovery under
185+
`hardened` (KASLR *on*, kallsyms and dmesg both unreadable) comes from the
186+
`prefetch` **timing side-channel**, not a file-derived fact. (Under `hide` the
187+
pin is still a parsed leak — root can read dmesg there — so only `hardened`
188+
leans on timing.) That oracle is hardware- and environment-dependent: it needs a
189+
real or KVM-backed CPU (these boots had `/dev/kvm`) and does not fire under pure
190+
emulation or with some mitigations. Where it is unavailable the x86_64 `hardened`
191+
window stays wider-but-sound rather than collapsing to the exact base; that
192+
`0`-bit cell is the oracle's best case, not a guarantee.
115193

116194
## 3. Offline, over a captured corpus
117195

docs/testing.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ KASLD has six test layers, in increasing order of setup cost:
1010
3. **Cross-arch engine tests** — the unit tests run on each architecture under
1111
qemu-user, so arch-gated rule bodies execute their real path.
1212
4. **Coverage reports** — optional, gcov-based.
13-
5. **Live cross-architecture validation** (`tests/vm/run`) — boots real Alpine
14-
kernels under `qemu-system` and checks the inferred range contains the live
15-
kernel's true base, across arches and privilege profiles.
13+
5. **Live cross-architecture validation** (`tests/vm/run`) — boots real
14+
publicly-fetchable kernels under `qemu-system` and checks the inferred range
15+
contains the live kernel's true base, across arches and privilege profiles.
1616
6. **Parser fuzz harnesses** (`tests/fuzz/`) — libFuzzer harnesses for the four
1717
pure string→struct parsers in `src/orchestrator.c`. Opt-in (`make fuzz`),
1818
not part of CI.
@@ -296,6 +296,21 @@ sound` matrix from the boot logs; the published snapshot is in
296296
[reproducibility.md](reproducibility.md). See
297297
[tests/vm/README.md](../tests/vm/README.md) for the full arch list and options.
298298

299+
Architectures Alpine does not port (`mips`, `mipsel`, `riscv32`, `ppc32`) are
300+
built from a pinned kernel.org source by `tests/vm/build-kernel` — a stock
301+
upstream defconfig plus a fixed endianness/devtmpfs overlay — then booted by
302+
`tests/vm/run` the same way:
303+
304+
```sh
305+
tests/vm/build-kernel mipsel # download source + cross-build -> cache (slow)
306+
tests/vm/run mipsel # boot it, verdict
307+
```
308+
309+
This is manual and slow; the arch-gated rule *logic* is covered per-push by
310+
`make test-cross`. `armeb` is not validated: the only
311+
big-endian arm toolchain in the cross set is ARMv5 BE32, which can neither run on
312+
an ARMv7 BE8 kernel nor boot a BE32 kernel under qemu.
313+
299314
---
300315

301316
## 6. Parser fuzz harnesses (`tests/fuzz/`)

tests/vm/README.md

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,11 @@ it is wider but still contains the truth — the correct outcome under restricti
6262

6363
## Architectures
6464

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

6971
| arch | kernel source | qemu |
7072
|------|---------------|------|
@@ -83,6 +85,56 @@ the recipe auto-discovers one next to the qemu binary (`pc-bios/`) or in the
8385
usual `share` dirs, or set `LOONGARCH_BIOS` explicitly — and the firmware must
8486
match the qemu that loads it.
8587

88+
### Gap architectures (built from kernel.org)
89+
90+
Alpine has no port for some arches; their kernel is built from source by
91+
`tests/vm/build-kernel` — a pinned kernel.org tarball + a stock upstream
92+
defconfig + a fixed one-line endianness overlay where the byte order differs
93+
from the base defconfig. The result is staged into the cache and booted by
94+
`tests/vm/run` with the same `init.c` as the Alpine flavors. Reproducible but
95+
slow; run it once per arch, manually. The arch-gated rule logic is covered
96+
per-push by `tests/test-cross`.
97+
98+
```sh
99+
tests/vm/build-kernel mipsel # download source + cross-build -> cache (slow)
100+
tests/vm/run mipsel # boot it, verdict
101+
```
102+
103+
| arch | kernel-ARCH / defconfig | qemu |
104+
|------|-------------------------|------|
105+
| mips | `mips` / `malta_defconfig` + BE | `qemu-system-mips -M malta` |
106+
| mipsel | `mips` / `malta_defconfig` (LE) | `qemu-system-mipsel -M malta` |
107+
| riscv32 | `riscv` / `rv32_defconfig` | `qemu-system-riscv32 -M virt` |
108+
| ppc32 | `powerpc` / `pmac32_defconfig` (BE) | `qemu-system-ppc -M g3beige` |
109+
| armeb (blocked) | `arm` / `multi_v7_defconfig` + BE | `qemu-system-arm -M virt` |
110+
111+
Validation status of the gap arches (built fresh from kernel.org, booted here):
112+
113+
- `mips`, `mipsel`, `riscv32`, `ppc32` — verified end-to-end, boots PASS, base
114+
recovered exactly. `malta_defconfig` is little-endian, so `mips` exercises the
115+
big-endian overlay (and `mipsel` boots the native byte order); `riscv32` is
116+
staged as the flat `Image` (the `virt` board rejects the raw `vmlinux` ELF) and
117+
needs the 32-bit OpenSBI firmware (auto-discovered, see below); `ppc32` needs
118+
`qemu-system-ppc` (the `qemu-system-misc`/`-ppc` package) and its console is
119+
`ttyS0` (pmac zilog registers in the `ttyS` namespace).
120+
- `armeb` — blocked on both ends, by the toolchain and by qemu, not the recipe.
121+
The only big-endian arm toolchain `make cross` provides
122+
(`armeb-linux-musleabi`) emits **ARMv5 BE32** code, so: against an ARMv7 **BE8**
123+
`multi_v7` kernel every instruction is byte-swapped and init SIGILLs; against a
124+
byte-order-matched v5 kernel (`versatile_defconfig` on `-M versatilepb`) qemu
125+
produces no output at all — it cannot boot a BE32 ARM Linux kernel (confirmed
126+
with an uncompressed `Image` + `earlycon`). Validating armeb needs a BE8-capable
127+
armv7 toolchain, which is not among the musl-cross set.
128+
129+
These rows skip cleanly in `tests/vm/run` until `build-kernel` populates the
130+
cache, so the Alpine arches are unaffected. Stock upstream defconfigs are used
131+
throughout; fall back to a Buildroot `qemu_*` defconfig if a vanilla one won't
132+
boot.
133+
134+
The pinned source is a 6.15.x tarball: the 6.12 LTS tree does not build with a
135+
C23-default compiler (gcc 15 makes `true`/`false`/`bool` keywords, which the
136+
pre-6.13 MIPS vdso clashes with). Override with `LINUX_VERSION` if needed.
137+
86138
## Notes and limitations
87139

88140
- Soundness, not tightness: the check is that the truth is inside the window,
@@ -95,5 +147,11 @@ match the qemu that loads it.
95147
binary or set via `LOONGARCH_BIOS`. Under `qemu -M virt` some arches (e.g.
96148
riscv64) are seedless, so KASLR is off and the result is the disabled-base
97149
pin — still a soundness point.
150+
- `riscv32` needs 32-bit OpenSBI, which most qemu builds do not bundle (only the
151+
riscv64 image). It is auto-discovered next to the qemu binary, in the system
152+
share dir, or in the distro cross package
153+
(`/usr/lib/riscv32-linux-gnu/opensbi/generic/fw_dynamic.bin`), or set via
154+
`RISCV32_BIOS`.
98155
- Useful overrides: `QEMU_DIR` (qemu not on PATH), `ALPINE_VER`, `BUILD_DIR`,
99-
`TIMEOUT`.
156+
`TIMEOUT`, `LOONGARCH_BIOS`, `RISCV32_BIOS`, and `LINUX_VERSION` (for
157+
`build-kernel`).

0 commit comments

Comments
 (0)