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

PASSa property was asserted and held
FAILa property was asserted and did NOT hold
FACTa measurement with no pass/fail claim: an exposure, a timing, a capability
SKIPthis 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 ps prints 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 --ssh must not hand out the host's shell: this one found a real defect, --ssh combined with --net published the HOST's sshd, banner byte-identical.
  • D kern exec must 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 or ptrace the forwarder's helper.
  • I malformed and abusive -p specs 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 never must 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 rm reports 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/drm and not from kern's own output, the reserved vocabulary (isolation, secure, hard) refused on every row below TIER-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 with LD_*, KERN_SECCOMP and KERN_CONFIG set, every promotion naming its evidence, and the GPU scan asserted read-only against strace on 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/maps before 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 with SCM_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, a TIER-HW row must name a partition, and dmem on the kernel must appear as a fact beside the card without moving the tier. This is the only hard FAIL in 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:

suitePASSFAILSKIP
pentest-ports.sh3200
pentest-cache-edge.sh2400
pentest-vdisk-web-ssh.sh1002
pentest-combo.sh800
pentest-gpu-claims.sh1701

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:

hostPASSFAILSKIPwhat T5 reached
Jetson Orin Nano (5.15-tegra)1503nvidia 540.4.0, tegra 1.0.99, nvidia-drm
Raspberry Pi 5 (6.6)1403v3d 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 echo after a command separated by ;: that once reported a /dev/shm memory leak that did not exist, because dd failed with ENOSPC and the echo ran anyway.
  • A plausible range on BOTH sides. Not only <= max but 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.