Penetration and edge suites
August 28, 2026 ยท View on GitHub
The runnable evidence behind the isolation claims in SECURITY.md and the README.
Five adversarial suites, POSIX sh, no dependencies beyond the kern binary and coreutils. They ask
the kernel what is true rather than asking kern to report on itself.
cargo build --release
sh pentest/pentest-ports.sh ./target/release/kern alpine
sh pentest/pentest-cache-edge.sh ./target/release/kern alpine
sh pentest/pentest-vdisk-web-ssh.sh ./target/release/kern alpine
sh pentest/pentest-combo.sh ./target/release/kern alpine
sh pentest/pentest-gpu-claims.sh ./target/release/kern
Exit status is 0 only if every asserted property held. Every suite takes the same two arguments,
<kern> [image], so a wrapper can drive all five the same way. The GPU suite ignores the image
because it starts no box: it needs a compiler for its raw-ioctl probe and nothing else, and it runs
on a host with an empty cache and no network.
No registry account, no quota, no network
sh pentest/run-with-local-registry.sh ./target/release/kern pentest/pentest-ports.sh
toy-registry.py builds a one-layer OCI image from a directory already on disk and serves it
read-only on 127.0.0.1. kern speaks plain HTTP only to loopback hosts and pins TLS everywhere else,
so the fixture is reachable exactly where that is safe to be. If the image cache has nothing usable,
the wrapper builds a rootfs from the host's own busybox plus the libraries its loader needs, with a
symlink for every applet, so the suites run on a machine with an empty cache.
This exists because the alternative failed in a way worth recording: running these suites against Docker Hub across six machines exhausted the unauthenticated pull limit, and for hours afterwards every host answered HTTP 429. The suites could not obtain the image they exist to test with, and one of them reported eight FAILs that were entirely the registry saying no.
For a board on the LAN, run the wrapper on the board (the layer is then native to its architecture
because it never left it), or forward the fixture onto the board's own loopback with
ssh -R 5099:127.0.0.1:5099 <board>. A LAN address is not loopback, and kern will require TLS for it.
Four verdicts, kept distinct
PASS | a property was asserted and held |
FAIL | a property was asserted and did NOT hold |
FACT | a measurement with no pass/fail claim: an exposure, a timing, a capability |
SKIP | this host cannot answer the question, with the reason. Never counted as a pass |
The SKIP/FAIL line matters more than it looks. A rate-limited registry, a board with no
newuidmap, an image with no sshd: none of those are defects in the runtime, and reporting them as
failures says "kern is broken" about the environment. Every one of those confusions happened here
before it was fixed, and each cost a re-measurement.
What each suite asserts
pentest-ports.sh (32 cases) is the port-publishing, exec and isolation surface.
- A publishing works in every network mode; H a mapping
kern psprints is a fact the kernel agrees with, not a request that was recorded. - B a published port cannot be made to tunnel into a HOST service. C
--sshmust not hand out the host's shell: this one found a real defect,--sshcombined with--netpublished the HOST's sshd, banner byte-identical. - D
kern execmust not escape the box; E seccomp and the kernel boundary with a port published; F the box must not inherit kern's control file descriptors; G the box must not see orptracethe forwarder's helper. - I malformed and abusive
-pspecs are refused; J two boxes cannot claim one host port; K an unbindable privileged port refuses the box instead of starting it; L every port of a published range is really bound. - M the cgroup boundary read from inside a publishing box: the box's own
memory.max, that it cannot raise it, and that nothing above its cgroup is a cgroup at all. - N a SIGKILLed supervisor must not leave the host port held. Written after six orphaned forwarders were found alive, holding host ports, more than an hour after their supervisors died.
pentest-cache-edge.sh (24 cases) is the image cache, build history and volume quota.
- A an entry is complete only when all THREE parts are there: rootfs, sentinel, config sidecar. Each case removes exactly one and asserts kern notices, names which, and ends with a runnable image.
- B
--pull nevermust never reach the network, with a positive control so it cannot pass on a kern that refuses everything. - C concurrent resolves of one image; D
build rmreports what happened, not what was there; E a volume quota is written or the command fails. - F malformed refs are refused at the boundary. The assertion is not "the command failed", which would pass on a kern with no sanitising at all, but that nothing was CREATED: not in the cache directory, and not in its parent.
- G the suite left the machine as it found it. Owed, because an earlier version reset between
cases with
rmi+pull, and on a rate-limited host the delete succeeded and the re-pull did not: it destroyed two images from the cache it was auditing, printed a green total, and exited. Every damaging case now backs the entry up and restores it on every exit path.
pentest-vdisk-web-ssh.sh (12 cases) is a vdisk under a web server and an ssh box at once: that
the vdisk is the KIND this host can actually enforce, that the 64m cap stops a real write, and that
publishing and --ssh do not disturb either.
pentest-gpu-claims.sh is the odd one out: it attacks a CLAIM rather than a mechanism. Its case
count is not fixed, and deliberately so: T5 emits one verdict per GPU entry point the host actually
has, which is five on a Jetson, three on a Raspberry Pi 5 and none on a machine with no GPU. A fixed
number here would be a number that has to be wrong on some host. kern slices no GPU, so there is no cap here to break. What ships is kern doctor's verdict
about what a cap on each card WOULD be worth, and a verdict is exactly the kind of thing that rots
quietly, so the suite checks it from both ends and fails if they disagree.
- A what kern says: one tier row per DRM card counted from
/sys/class/drmand not from kern's own output, the reserved vocabulary (isolation,secure,hard) refused on every row belowTIER-HW, with a positive control so a matcher that stopped matching fails instead of passing, the disclaimer present on every cooperative row, the verdict identical across five runs and unchanged withLD_*,KERN_SECCOMPandKERN_CONFIGset, every promotion naming its evidence, and the GPU scan asserted read-only againststraceon whatever host runs the suite, which is one x86 machine so far: the read-only property belongs to the code, and A8 is a machine agreeing with it rather than a proof it holds everywhere. - B what is true: battery B of the GPU isolation spec, T1 to T9, run against this host's own
driver by
gpu-raw-ioctl.c, a probe that links libc and nothing else and checks its own/proc/self/mapsbefore it counts anything. T5 is the decisive one, and publishing its result is the point of the file: a process with no vendor library in its address space reaches the driver with a raw ioctl, so no userspace VRAM cap on this host can be a boundary, whoever writes it. T7 settles the granularity withSCM_RIGHTS: the descriptor answers the same ioctl in a process that never opened the device. - C the two halves against each other. A card with no partition evidence must be
TIER-SOFT, aTIER-HWrow must name a partition, anddmemon the kernel must appear as a fact beside the card without moving the tier. This is the only hardFAILin the file: everything B measures is a property of the driver, and only a disagreement between the claim and the measurement is kern's defect.
It needs a C compiler for the probe and skips with the reason without one. It starts no box, so it does not go through the registry fixture.
pentest-combo.sh (8 cases) is one box holding all three at once: a published web server, a vGPIO
device grant and ssh. Every grant is asserted against a NEGATIVE CONTROL, a box without the profile,
because "the device is in the box" means nothing unless a box without the grant does not have it.
Section D proves the ssh session lands in the BOX and sees the granted device, so the three are one
box rather than three. It needs an image with an sshd: SSHIMG=<ref>, default alpine-ssh:test.
Measured
x86_64, Intel i7-14700KF, Linux 7.0.0, rootless, 2026-08-01. Identical results whether
driven against alpine or against the loopback fixture:
| suite | PASS | FAIL | SKIP |
|---|---|---|---|
pentest-ports.sh | 32 | 0 | 0 |
pentest-cache-edge.sh | 24 | 0 | 0 |
pentest-vdisk-web-ssh.sh | 10 | 0 | 2 |
pentest-combo.sh | 8 | 0 | 0 |
pentest-gpu-claims.sh | 17 | 0 | 1 |
The two skips are named, not silent: this host has no disk backend for the vdisk fallback case, and
alpine ships no /usr/sbin/sshd.
The GPU row was measured on 2026-08-28 on the same machine, one NVIDIA RTX 5060 Ti, driver
580.173.02, kernel 7.0.0. Its single skip is /dev/kfd, which is not present because there is no AMD
card in this host. Identical totals under dash and busybox sh. What T5 found there: the NVIDIA
resource manager answered a version handshake and the DRM render node answered DRM_IOCTL_VERSION,
both from a binary linking only libc, and both again through a descriptor donated over a unix socket
to a process that had never opened the device.
The same suite on aarch64 the same day, against the binary built from this tree:
| host | PASS | FAIL | SKIP | what T5 reached |
|---|---|---|---|---|
| Jetson Orin Nano (5.15-tegra) | 15 | 0 | 3 | nvidia 540.4.0, tegra 1.0.99, nvidia-drm |
| Raspberry Pi 5 (6.6) | 14 | 0 | 3 | v3d 1.0.0, vc4 |
Those two runs are why the probe has a vendor-neutral arm. DRM_IOCTL_VERSION is uAPI and every DRM
driver answers it, including the platform-bus ones with no NVIDIA resource manager and no vendor
library installed at all: the Pi's three skips include T2, because there is no vendor library on that
machine to load by path. The Jetson matters for a different reason. It is a SECOND NVIDIA driver
series, 540 against the desktop's 580, and it answered the same handshake from the same libc-only
binary, so T5 is not a property of one driver build.
On both boards T8 found the real ceiling rather than the probe's: 1021 concurrent handles, stopped by
Too many open files at RLIMIT_NOFILE 1024. That is the sharpest form of the finding. The limit
that stops a tenant from holding device handles is the file-descriptor limit it inherited from its
shell, and there is no device quota underneath it.
On aarch64 the same day, with the binary built from commit 6c395a0: pentest-cache-edge.sh 24/0/0
on a Raspberry Pi 5 (kernel 6.6), an Arduino UNO Q (kernel 6.16) and a Jetson Orin Nano (kernel
5.15-tegra); pentest-ports.sh 32/0/0 on the Pi 5 and 31/0/1 on the other two, the single skip being
--ssh on a host with no newuidmap. The Pi 5 ran with an empty image cache, on the busybox fixture.
What runs in CI, what does not, and why
Four of the five do not. They start real boxes, publish real host ports and read real cgroups. The
GitHub runner refuses unprivileged user namespaces under its AppArmor profile (setgroups returns
EACCES), so most cases would skip there and the green would mean nothing. The Rust suite that DOES
run in CI covers the same fixes at unit level. Run these on a machine that can actually hold a box.
pentest-gpu-claims.sh does run in CI, on every push. It starts no box, so nothing about the
runner's AppArmor stops it, and it covers more there than was expected. The runner has one virtual
DRM card and no /dev/dri node this user can open, so the whole of section A runs against a real
card: 10 PASS on the first run. Two of those are worth naming. A3 is the positive control that keeps
the claim gate from going quietly vacuous, and A8 asserts against strace that the GPU scan opens
nothing under /sys/class/drm or /proc/driver for writing, a property that had been checked on one
x86 desktop and is now checked on a second machine of a different shape on every push.
Battery B skips there for want of an openable device node. That is a host that CANNOT answer, not one that declined to, so the suite exits 0; it exits 3 only when entry points exist and none of them answers. Getting that distinction backwards turned the step red on its first CI run, which is recorded here because the fix is the interesting part: a machine with a GPU you cannot open is not a machine where verification failed.
Why the split matters more than the coverage it buys. A .last-run stamp is a promise that a person
did something, and a promise decays: a successor who inherits a red freshness check for suites the CI
structurally cannot run will raise the threshold or silence it, which is the honest prediction and
not a slur. So whatever can be automated is, whatever cannot is named as such, and the stamp covers
the remainder instead of standing in for all of it.
Rules these suites follow
- Red on the parent. A case written for a fix must FAIL against the commit before it. Green on both means it tests nothing.
- A positive control in the same run, so a suite that stops exercising something fails on the control instead of passing.
- Measure the fact, never a proxy. Bytes written, the pid, the value read from the kernel. Never
an
echoafter a command separated by;: that once reported a/dev/shmmemory leak that did not exist, becauseddfailed with ENOSPC and theechoran anyway. - A plausible range on BOTH sides. Not only
<= maxbut also> 0: a harness that has stopped measuring produces zero and must fail, not pass. - Never destroy what cannot be restored, and assert the restoration rather than assume it.