kern runs untrusted images inside a sandbox and (optionally, from 0.9) interposes on the GPU driver. It will receive security reports, here is the model and how to report.
Please do not open a public issue for security bugs. Report privately via GitHub Security Advisories ("Report a vulnerability" on the repo) or email hello@getkern.dev. You'll get an acknowledgement and a coordinated-disclosure timeline.
In scope, kernel-enforced isolation must hold:
- A malicious OCI image /
--rootfsmust not read or write host files outside the rootfs (path traversal, cross-layer symlink escape, whiteout-through-symlink, tar traversal). - A box must not see or affect host processes, mounts, or other boxes (PID/mount/net ns).
- Resource limits (cgroups: memory/pids) must hold; fork bombs / OOM must be contained.
- seccomp must block the dangerous syscall set unconditionally.
Explicitly OUT of scope (cooperative, by design, not a boundary):
- GPU limits are not shipped, so there is nothing here to trust or to attack yet. When they land, expect them to be a cooperative governor for honest workloads rather than a boundary, and expect this file to say so precisely, with the bypasses named. Until then the answer to "how strong is the GPU cap" is "there is no GPU cap, and no GPU code".
A caveat we state plainly, an unprivileged user namespace is itself kernel attack surface. kern's isolation is built on an unprivileged user namespace, and userns has historically been a fertile source of kernel privilege-escalation CVEs. Running untrusted code in a box hands that code the in-kernel namespace/userns surface to probe. A kernel LPE is out of scope above (kern is not a hypervisor), but be honest about the exposure: where you must assume the workload is actively hostile and the kernel may be behind on patches, put a microVM (a real hardware-virtualization boundary) between it and you (see below). kern's sweet spot is your own or semi-trusted code, where that surface is an accepted, patched risk, not an adversary's playground.
kern isolates with Linux namespaces + seccomp + a read-only pivot: microsecond-to-millisecond start, ~1.8 MB, no VM, no daemon. That shared-kernel boundary is real, but its attack surface is the host kernel: a kernel privilege-escalation bug is an escape.
- Reach for kern when the code is yours or semi-trusted and you want speed, density and simplicity: CI jobs, build steps, dev sandboxes, edge services you operate, and running your own agents' tool-calls / LLM-generated code under your supervision. Thousands of short-lived, cheap, kernel-isolated boxes.
- Reach for a microVM (Firecracker / Kata) or a gVisor-style user-space kernel when you run actively hostile, multi-tenant code, arbitrary programs from strangers sharing one host, and a hardware-virtualization (or syscall-intercepted) boundary is worth the extra startup cost and footprint. That is not where kern competes.
kern's bet is startup latency, footprint and daemonless simplicity, not being a hypervisor. Pick the boundary your threat model needs; kern is honest about which one it is.
What is enforced now by kern box:
- user + PID + network (loopback-only) + UTS + IPC + mount namespaces;
pivot_rootinto the rootfs (the default root is a writable overlay whose scratch is discarded on exit;--read-onlyremounts the root read-only: and the mount ordering, read-only only after the pivot, is compile-enforced by a typestate);- least-privilege capabilities: 13 never-needed dangerous caps (module load, raw I/O,
SYS_TIME,SYSLOG,BPF,PERFMON, MAC/audit admin,SYS_BOOT, …) are dropped from the effective/permitted/inheritable and the bounding set just before exec, so neither the workload nor a setuid/file-capability binary can wield them (they're namespaced anyway, this shrinks the surface against cap-gated kernel bugs).--cap-drop CAP/--cap-drop ALLdrops more;--cap-add CAPkeeps one that would otherwise be dropped (add wins). Even a re-added cap (e.g.--cap-add SYS_ADMIN) is held only over the box's own user namespace and the always-on seccomp denylist still blocks the escape syscalls it would otherwise unlock, so--cap-addcannot breach the host, and an unknown cap name is a hard error (a typo can't silently leave a cap in place); - always-on seccomp denylist, 33 syscalls denied: 24 that hard-kill plus the 9 that return
ENOSYS(the split is described below); a rootless--privilegedbox denies 5 fewer. Do not take the number from this file, ask the binary:kern box <name> --image <ref> --show-configprintsseccomp_denied_syscalls, read from the live lists rather than from a constant. The set is (kexec(+_file_load) / module load-unload / ptrace +process_vm_readv/writev/ reboot / swap / the classic and new mount API, including the whole reconfiguration familymount_setattr/fspick/fsopen/fsconfig/fsmount/open_tree/move_mount, so a box cannot re-mount its own root writable /pivot_root/setns/unshare/bpf/perf_event_open/userfaultfd/syslog; the opt-in--privilegedflag re-allows exactly five of these,unshare/setns/mount/umount2/pivot_root, rootless-only, for nested boxes, see below), wrong-arch syscalls killed, and on x86_64 every x32-ABI syscall (the__X32_SYSCALL_BITvariant, which shares the x86_64 arch token) is killed too, closing the classic bypass where the x32 alias of a denied syscall number would otherwise slip past a number-only denylist; - device access is deny-by-default: the box's
/devis a fresh box-ownedtmpfsthat shadows the image's/dev, with only a minimal safe allowlist bound in from the host (null,zero,full,random,urandom). A raw disk,/dev/mem, or any other node is therefore simply absent: and a device node the box does fabricate is inert: a filesystem mounted inside an unprivileged user namespace is flaggedSB_I_NODEV, so amknod'd node can't be opened to reach any host device (on kernels < 5.11mknoditself is refusedEPERM; on newer ones it succeeds but the node is un-openable, same outcome). The box root,/dev, and every extra mount also carryMS_NODEV, so this holds without relying on the implicit userns behaviour. The boundary is the namespace + the allowlist, so no eBPF device-cgroup filter is needed (and none would load unprivileged); GPIO device nodes (via avgpio:profile) are added to this allowlist explicitly today, and GPU nodes will be at 0.9, never by opening/devup; - Landlock write-allowlist (
--landlock-rw <path>, opt-in): a kernel LSM confines the box's writes to the named paths while the root stays read+exec, a second boundary the workload cannot lift (symlinks are fail-safe, openedO_NOFOLLOW). Kernel-dependent (needs Landlock, Linux 5.13+); where it is absent the box still runs with the namespace + seccomp + cgroup boundary. This is a REAL boundary: verified that a box with--landlock-rw /tmpwrites/tmpbut is denied/etcand/root; - egress allowlist (
--egress-allow d1,d2, opt-in, foreground): the box runs in an isolated network namespace and reaches the internet only through a kern-run filtering proxy that permits the listed domains. SSRF-guarded: a domain that resolves to any non-public address (loopback, RFC1918, CGNAT, link-local, reserved) is refused at connect time, even if the caller allow-listed it. Honest residual: a domain sharing a CDN IP + SNI with an allowed one can be reached, so this is an application-layer allowlist for a semi-trusted workload, not a hard exfiltration boundary. Full threat model in docs/EGRESS.md.
What a denied syscall returns, and what that tells a prober. The filter has two verdicts, and the
split is deliberate. Real escape vectors (kexec, module load/unload, the mount API, bpf, ptrace,
setns/unshare/pivot_root) hard-kill the caller with SIGSYS. Nine, in the five families software merely
probes for an optional fast path (io_uring's three, userfaultfd, perf_event_open, the
keyring's three, syslog(2)), return ENOSYS instead. They are equally denied, the syscall never runs; the
difference is only what the caller sees, and it is the difference between Redis 8 falling back to its
epoll path and Redis 8 dying. The two sets are asserted disjoint by a test.
The obvious objection is that a survivable denial is easier to enumerate than a fatal one. Measured
from inside a box on x86_64, kernel 7.0, Seccomp: 2:
| syscall probed inside the box | result |
|---|---|
io_uring_setup (denied, degrade set) |
-1 ENOSYS, process survives |
| syscall number 998 (exists on no kernel) | -1 ENOSYS, process survives |
kexec_load, bpf (denied, kill set) |
killed by SIGSYS |
| the same calls with no kern filter (control) | a different errno, never ENOSYS |
So the errno itself discloses nothing: a filtered call is byte-identical to one this kernel does not
implement. What is cheap to enumerate is the permitted set, and it always was, because a permitted
syscall simply runs and returns its own errno. ENOSYS moves nine syscalls from "costs the prober a
process" to "free"; the 24 in the kill set still cost one process each, and 9 plus 24 is the 33. Whether mapping a filter
helps an attacker who already has code execution in the box is a separate question, and an open one:
mapping is not bypassing. It is written down as unresolved in
OPEN_ITEMS.md rather than argued either way here.
Read-only and cgroup-mask integrity. A --read-only box's root, and the masks over the host
cgroup tree, are protected by two independent layers. The always-on seccomp filter blocks the
mount-reconfiguration family (mount, mount_setattr, open_tree/move_mount/fsopen/fsconfig/
fsmount/fspick), so a box cannot re-mount its root writable; and the default capability drop removes
CAP_SYS_ADMIN, so a box cannot umount the cgroup masks to reach the host hierarchy. Both hold in the
default configuration. A box explicitly granted CAP_SYS_ADMIN (--cap-add SYS_ADMIN) or run with
--no-seccomp waives one of these layers by choice. A third, belt-and-suspenders hardening, locking
the mounts with MNT_LOCKED via a second user-namespace crossing, so the guarantees hold even for those
opt-in configurations, is tracked for a dedicated, runtime-validated release; it is deferred rather
than shipped untested because it reorders capability-sensitive setup that must be verified on real
namespaces. --uid-range boxes remain covered by the seccomp + capability layers (the mount-lock is
single-uid by construction).
Nested boxes (--privileged). By default the always-on filter blocks the namespace + mount
syscalls, so a full kern box cannot run inside another (it gets SIGSYS). --privileged relaxes
exactly five: unshare, setns, mount, umount2, pivot_root, so a nested box
(docker-in-docker style) can create its own namespaces and rootfs. Everything else in the denylist stays
blocked (kexec, module load/unload, bpf, io_uring, keyring, ptrace, perf_event_open, the new
mount API, mount_setattr), so a --privileged box is materially stronger than a Docker --privileged
container, which drops the seccomp filter wholesale. It also skips the /proc masking, because the
kernel refuses a nested /proc mount underneath the locked masks. Rootless-only, and gated on the
effective mapping, not the caller's euid: it is honoured only when the box's root maps to an
unprivileged host uid, decided by reading /proc/self/uid_map after the namespace is set up, so a
--pod box is judged by its holder's map, and it is refused outright when run as real root, where a
relaxed mount could reach the host-global /proc/sys knobs. In rootless mode those knobs stay
unwritable regardless (they are owned by the init user namespace): a --privileged box can read
/proc/sys but not write it (verified against core_pattern). Like --no-seccomp and
--cap-add SYS_ADMIN, this is an explicit opt-in that widens the box's syscall surface by choice.
Resource caps (memory + tasks): when a systemd user manager is present, kern box re-execs
inside a transient systemd-run --user --scope with MemoryMax/TasksMax, so fork-bomb / OOM
are cgroup-enforced. Without it, a best-effort cgroup v2 path applies where the hierarchy is
delegated, else it is skipped gracefully, so on a host with neither systemd-user nor a
delegated cgroup, containment is not guaranteed.
--pids-limit Nsets the box'spids.max(and the scope'sTasksMax), a fork-bomb ceiling. Default 512. Like the memory cap, it's cgroup-enforced where a scope / delegated hierarchy exists.kern execand the box's caps. Akern exec'd command joins the box's cgroup (so it inherits the box's--memory/--pids, likedocker exec) only where the box sits in a delegated cgroup kern can write - kern under the systemd user manager (user@<uid>.service), or kern as root. On the rootless per-box-scope path (e.g. an SSH login session on an edge board, whose shell is asession-*.scopesibling of the user manager), the box runs in a transientrun-*.scopethat the kernel will not letkern execmigrate into from another branch of the tree (moving between sibling scopes needs write on the common ancestoruser@<uid>.service, which systemd owns). There an exec'd command runs outside the box's--memory/--pidscaps; kern warns when the box has explicit caps rather than leak it silently. The box's OWN workload (kern box/ the SDK'srun_code) is always capped by its scope; namespaces + seccomp isolate the exec'd command regardless. (kern deliberately does not re-run each exec in its own capped scope: that would give every exec the box's full limit, so N concurrent execs could use N× the box's memory - breaking the aggregate guarantee the shared cgroup provides. An honest "not enforced" beats a false one.)--user UID[:GID]drops the workload to a uid/gid after all privileged setup and the capability drop, just before seccomp (setgroups→setgid→setuid). Only ids mapped into the box's user namespace work, so a non-root--userimplies the uid/gid-range mapping (like--ssh). It fails closed: if the requested id can't be mapped (e.g. a host withoutnewuidmap/newgidmap), the box refuses to start rather than silently running the workload as in-box root, dropping privilege never grants it. It reduces privilege inside the box; it is not itself a trust boundary (the box is already contained by the namespace/seccomp/cap-drop). Note a non-root--usersheds all capabilities, including any--cap-add(thesetuidclears them, and kern doesn't raise the ambient set), pair--cap-addwith the box's default root-in-userns user if the workload needs the cap.--tmpfs PATH[:size]mounts a freshNOSUID,NODEVtmpfs at PATH in the box. Mounting one over the sandbox's own hardened/proc,/sysor/devis refused (it would unmask them); the size is a real cap (ENOSPCpast it) but counts against RAM, so pair a large tmpfs with--memory.--hostname NAMEsets the box's UTS hostname (validated to a DNS-label charset). Cosmetic / scoping only, the box has its own UTS namespace, so it can't affect the host's hostname.
Opt-in flags that relax isolation (off by default, you ask for them):
--net(=--network host) shares the host network namespace instead of the isolated loopback-only one (--network none, the default). The box gains outbound connectivity, but there is then no network isolation: it can reach hostlocalhostservices, the host's networks, and every abstract-namespace UNIX socket (those live in the network namespace, not the filesystem, e.g. X11, some D-Bus/runtime sockets), and bind host-visible addresses. Use it for trusted build/fetch steps, not for untrusted code.--tunbinds/dev/net/tuninto the box so a WireGuard / userspace-VPN workload can create a tunnel. The box retainsCAP_NET_ADMIN, but a child user namespace's capabilities are not effective over a namespace owned by the initial user namespace, so even combined with--network host(box in the host netns) a--tunbox cannot reconfigure the host's interfaces (TUNSETIFF/ interface config on the host netns returnsEPERM). With the default isolated netns it manages only its own tunnel. The always-on seccomp denylist is what keeps holdingCAP_NET_ADMIN/CAP_SYS_ADMINin the box's userns safe (the escape syscalls are blocked).-v src:dstbinds a host path into the box. A writable volume is a hole through the sandbox by design, the box can modify those host files (use:rofor read-only).kernrejects a non-existent source and resolves it to an absolute, symlink-free path first. The bind is non-recursive: if the source has other filesystems mounted underneath it (e.g./mntwith a separate/mnt/usb), those submounts are not propagated into the box, mount them explicitly as their own-vif you need them. This is deliberate: a recursive bind would clone host submounts, and a:rovolume could then leave them writable (the read-only remount is per-mount), so we bind only the directory tree, matching the--rootfsbind's rationale.kern execjoins a running box's namespaces; it is restricted to the user who started the box (joining its user namespace requires being that namespace's owner). The exec'd process gets the same always-on seccomp filter (fail-closed: it won't run if the filter can't install) and the same always-dropped dangerous-cap baseline. A box's custom--cap-drop/--userare not reapplied on exec (they aren't recorded per box), so anexecruns at the box's baseline, not its tightened profile, the host boundary still holds (namespaced caps + seccomp block every escape syscall regardless).kern cp <box>:<src> <dst>/<src> <box>:<dst>copies a single file in or out of a running box. The in-box path is resolved withopenat2(RESOLVE_IN_ROOT | RESOLVE_NO_MAGICLINKS)against the box's root (/proc/<pid1>/root), so every symlink and..is reinterpreted as if that root were/: a hostile image cannot plant a symlink (absolute,../.., or a chain) that makes the copy read or write a host file outside the box (the CVE-2019-14271 class). kern never executes anything inside the box to do the copy. Restricted to your own boxes (/proc/<pid1>/rootneeds same-uid ptrace access, and the box is found via your registry). Copies regular files only (a box-planted FIFO/socket/device is refused, openedO_NONBLOCKso it can't hang the copy) with a 4 GiB size cap, so a hostile image can't stall or OOM yourcp. (Writing into the box still follows a final in-box symlink, but confined to the box, which the box could already write itself.)kern pause/kern unpausefreeze / thaw a box via its cgroup v2 freezer (cgroup.freeze), it writes only the box's own dedicated cgroup, and refuses (rather than freezing the session) when the box has no dedicated cgroup.kern attachstreams a detached box's log live and is read-only (a detached box has no stdin); Ctrl-C detaches without stopping the box.-p [ip:]host:boxpublishes a box port on the host via a rootless forwarder. It binds127.0.0.1by default (reachable only from the host);-p 0.0.0.0:H:Bbinds all interfaces and exposes the box's service to the LAN, a deliberate, warned-about choice. The forwarder runs in the host network namespace (it has to, to bind the host port); the box itself stays in its own isolated network namespace.
OCI pull (kern pull / --image):
- Integrity: each blob is verified to hash to its
sha256:digest (viasha256sum) before use, defends against a compromised/MITM registry and corrupt downloads, beyond TLS. Because this digest check runs before both the vetter and the extractor, and both read the same verified file, the two see byte-identical input, so any disagreement between them can only be interpretive (which the vetter fails closed on, below), never a difference in the bytes themselves. - Layer vetting (in-process; refuses ambiguity rather than out-guessing the extractor): absolute /
..paths, device nodes, hardlink targets that escape the rootfs, escaping symlink targets (on a non-GNU tar), a 2 GiB decompression-bomb cap and an entry-count (inode-bomb) cap are all rejected before anything is written. The decision is read from the raw tar headers in-process (fixed-offset name/prefix/linkname/typeflag, resolving ustar prefix + GNU long-name/link + PAX), withgzip -dcdoing only the decompression, not by parsingtar -tv's locale-dependent, delimiter-desyncable text (a member name containing->/link tocould otherwise hide an escaping link target). Because the vetter and the extractor (tar -xzf) are two separate parsers, the design principle is fail-closed on any construct where they could disagree, rather than trying to replicate a version-specific tar precedence rule: a member whose path/linkname is set from two sources (a GNUL/Kand a PAXpath=/linkpath=) is refused; a PAX global (g)path/linkpathoverride is refused; a GNU sparse ('S') / multivolume ('M') member, or aGNU.sparse.*PAX record, is refused (a sparse member'ssizeheader is the stored length, not the real data layout, skipping it would desync the vetter's cursor from the extractor and under-count a bomb); a base-256 size field too large for au64is refused (never silently wrapped); and the scan requires an all-zero tail so a member hidden after a stray zero block cannot slip past, while capping that tail so a multi-MiB zero flood can't turn the check itself into a DoS. This keeps the decision sound on GNU tar and on a BusyBox/edge tar without claiming to predict a hostile archive's every quirk. The member set is an explicit allow-list (regular / directory / symlink / hardlink); an unknown/vendor typeflag is refused rather than treated as a regular file. The trade is deliberate: a legit image built with an exotic-but-safe construct (sparse files, a global path record) is refused rather than extracted: we fail closed. A FIFO ('6') is refused by documented policy too: it is inert toward the host (it isn't a device node), so this is a compatibility choice, not a security boundary, a rootfs baked with a named pipe won't pull, and that's intentional for an ephemeral sandbox. The header/PAX parser operates on raw bytes (no panic on a crafted char boundary), is bounded, and is fuzzed. - Isolated-staging, no-follow merge: each layer extracts into a fresh staging dir, then merges into the rootfs refusing to traverse any symlink, the cross-layer escape class is closed structurally (not by trusting tar). The guard is a lexical check, which is sound here because extraction is single-threaded (no concurrency across the image's own layers) and the cache/scratch dirs are created mode 0700 and owned by the user, so no other local user can race a symlink into the paths. Whiteouts (incl. opaque dirs) are applied under the same guard.
- Image file modes are preserved as-is (extracted with
--same-permissions, and the merge copies each dir's real mode), so an image's/tmpkeeps its sticky world-writable1777, which a workload that drops to a non-root uid needs. Consequence, stated plainly: a mode the image sets is what the box sees, so an image that ships a world-writable system dir (e.g./etcat0777) leaves it world-writable inside the box. This is contained: it's the box's own rootfs (a private overlay upper on a 0700 host scratch), never the host, and a setuid/setgid bit on a rootfs file is inert because the box root isMS_NOSUID. It's the semi-trusted posture (the boundary is the namespace, not a normalized mode), the exact dual of the box root being0755: kern presents the image faithfully rather than silently rewriting its permissions. - Registry credentials (
kern login/logout, for private images): stored in an owner-only (0600) file under~/.config/kern/, base64-encoded (obfuscation, the0600mode is the real protection, not the encoding). The password is read from the terminal with echo off (or piped stdin), never taken as a flag. When kern authenticates a pull, the credential is handed tocurlvia a-K -stdin config, not a--userargument, so it never appears in/proc/<pid>/cmdlinewhere another same-uid process could read it; control characters are stripped so a crafted credential can't inject a curl directive. - Registry push (
kern push, shipped since 0.6.1): kern packs the local rootfs as a single OCI layer with ownership normalized touid/gid 0and setuid/setgid bits stripped, so an untrusted base can't smuggle a privilege bit into what you publish. The auth realm is pinned and a cross-host redirect during upload is refused (a CVE-2020-15157-class credential-leak guard); every request is TLS-pinned and credentials travel off-argv, exactly as on pull.
A vgpio: profile deliberately widens the box's device surface, it bind-mounts the profile's
listed peripherals (/dev/i2c-*, /dev/spi*, /dev/gpiochip*, camera/audio, and /sys dirs for
pwm/adc/1-wire/leds) into the box. Only the listed devices are exposed; deny-by-default still holds
for everything else, and the source paths are canonicalized and re-checked to stay under /dev/
(no symlink escape to a host file). Two honest limitations, by design:
- GPIO is chip-granular, not per-line. Requesting any
pinsbinds every/dev/gpiochipN, and a gpiochip character device exposes all of that controller's lines via ioctl.pins = [17]does not restrict the box to line 17, the kernel has no per-line mount boundary. The pin list is cooperative metadata (KERN_VGPIO_PINS), not a security boundary. --read-onlykeeps a vGPIO box's/syswritable. LED brightness / PWM control are writes, so the box-owned/systmpfs and the bound sysfs attribute dirs stay writable even under--read-only(the root filesystem is still read-only). This is intentional.
Grant a vgpio: profile only to workloads you'd trust with that hardware.
A vdisk: profile mounts a size-capped volume at /vdisk/<name>. Rootless it is a RAM-backed
tmpfs: the size is a real quota (a write past it fails ENOSPC), but it counts against RAM:
pair a large vdisk with --memory so a box that fills its vdisk can't drive the host to OOM (kern
warns for vdisks ≥ 1 GiB). The mount is created inside a fresh box-owned /vdisk tmpfs
(symlink-neutralized), so a hostile image shipping /vdisk as a symlink can't redirect it. Under
--read-only the vdisk stays writable by design (it's scratch). A disk-backed ext4-on-loop
backend (persistent, real disk quota) is used instead when kern runs privileged (root / disk
group, plain foreground box), the loop device is configured LO_FLAGS_AUTOCLEAR and any setup
failure unwinds immediately, so a half-built vdisk can't leak a loop device or a stray mount; on
any failure it falls back to tmpfs. iops/bandwidth I/O limits are recognised but not yet applied
(a cgroup-io increment), reported, never silently dropped.
--secret delivers a value into the box as /run/secrets/<name> (mode 0400) without it ever
landing in the image or the workload's environment:
- Three forms,
NAME=value(inline; note it's visible in the host'sps, so prefer a file/stdin for real secrets),NAME=-(read from kern's stdin, never inargv), andSRC[:NAME](a host file; a world-writable source is refused, a group/world-readable one is warned). The name is charset-validated to a single path component (no/, no..), and duplicate names are rejected. - The bytes are read on the host, before the fork; inside the box they're written to a RAM-backed
tmpfs(NOSUID|NODEV) mounted at/run/secrets, so a secret never touches the persisted overlay upper and is gone when the box exits. A hostile image shipping/run/secretsas a symlink is neutralised (the symlink is removed before the mount) and each file is createdO_EXCL | O_NOFOLLOWinside the box-owned tmpfs, so the write can't be redirected out.
--ssh PORT runs a throwaway sshd inside the box and publishes it on host PORT (→ box :22)
via the ordinary rootless port-forwarder; ssh/scp/sftp then reach the box. It's for interactive
box access, not a hardened bastion, grant it only to workloads you'd trust with a shell in the box.
- Keys never touch the image. Without
--ssh-key, kern generates a throwaway ed25519 keypair in the owner-only runtime dir and prints the ready-to-pastessh -i … root@127.0.0.1;--ssh-key FILEauthorizes your own public key instead. The host key,authorized_keys, and config live on the box's/runtmpfs (off the image), which is remounted read-only after setup. sshd is pubkey-only (PasswordAuthentication no,UsePAM no), binds127.0.0.1:22inside the box's own network namespace, and is a child of the box's PID 1 so it dies with the box. - Needs a group mapping. sshd's privilege separation calls
setgroups, which a single-uid user namespace forbids (/proc/self/setgroups=deny). So--sshimplies the uid/gid-range mapping (like--uid-range, vianewgidmap), with it, group ops succeed and login works with no in-box shim. On a host withoutnewuidmap, the box falls back to single-uid and relies on a tinysetgroups/initgroupsstub compiled in-box (only if a C compiler is present); otherwise SSH login won't complete and kern says so. The image must shipopenssh-server(sshd+ssh-keygen). - Honest scope: the forked sshd, and the shells it spawns per session, run without the box's
seccomp filter and with the pre-drop capability set (they're forked before both
drop_dangerous_capsand the seccomp install). Those caps are namespaced (checked against the initial user namespace for host-global effects, so largely inert against the host), but the SSH subtree is strictly more privileged than the box's main workload. The namespace / pivot / read-only-root / cgroup isolation still holds.--sshis an interactive-trust grant, treat it like handing out a shell in the box. --sshruns the image's own binaries. Standing up sshd executes the image'sssh-keygen,sshd, and (best-effort, only if present)ccto build thesetgroupsshim, all inside the already-pivoted box, as the box uid, but pre-seccomp/pre-cap-drop. A hostile image could ship a malicioussshd/cc; the exposure is the same interactive-trust surface you opted into (nothing runs host-side, and the shim/config live on a box-owned/runtmpfs remounted read-only after setup, so the workload can't swap them later).- The uid/gid-range mapping trades a little of the single-uid map's extra isolation for a working sshd,
a deliberate, documented choice scoped to
--ssh. --sshlogs in as (namespaced) root, even with--user. sshd is forked before the--userdrop and its config isPermitRootLogin yes, so an ssh session is box-root regardless of a--userset for the main workload. That root is your own unprivileged uid mapped to 0 inside the box's userns, no host privilege, but a--user-restricted box is still reachable as root over its SSH port. Grant--sshto workloads you'd trust with a root shell in the box.--ssh+--netputs the box sshd on the host loopback. With the isolated network (default) the sshd is reachable only via the-pforwarder on127.0.0.1:<port>. Under--network hostit binds127.0.0.1:22in the host network namespace directly, key-gated, but combine the two only when you intend host-loopback SSH reachability.
- Named volumes (
-v name:/dest) are directories under~/.local/share/kern/volumes, auto-created and bind-mounted. The name is charset-validated (single component) and the resolved path is canonicalized and confined under the volumes dir, so a planted symlink can't redirect the bind outside it. - Per-volume quota (
kern volume create <name> --size N) records a size limit. When the box runs privileged (root /diskgroup, plain foreground) the volume is backed by the ext4-on-loop disk (a real, disk-backed quota); otherwise it falls back to a plain directory and kern says the quota isn't enforced: never a silent drop. The requested size is clamped to a 64 TiB ceiling at create time (and re-clamped when read back), so a hand-editedmeta.jsoncan't drive a multi-EBmkfs. The enforced (ext4 image) and unenforced (plaindata/dir) backends are distinct on-disk locations; the first privileged mount seeds the fresh image fromdata/so upgrading a volume to the enforced backend doesn't hide files already written to it. - Network volumes (
-v nfs://…,smb://,sshfs://) mount rootless via FUSE/GVFS (sshfs/gio) into a per-box staging dir, then bind in. The host/path are strictly validated (no shell metacharacters, control chars, or a leading-that a tool would read as an option), everything is spawned via argv, never a shell. A mount that can't reach its server is killed after 25 s rather than hanging the launch, and the mount is unmounted when the box exits (its handle also cleans up on any error path).sshfsusesStrictHostKeyChecking=accept-new(trust-on-first-use) , an active MITM at first contact could impersonate the server; pin the host key beforehand for untrusted networks. Network volumes require a plain foreground box for now (not-d/-it).
- The auto-stop and health-action watchdogs run host-side, never inside the box. Each is forked
before the box's
unshare(CLONE_NEWPID), so it sits in the host (ancestor) pid namespace, the only position from which it can reliably signal the box's ns-init. An in-box process can't reach them: the foreground--timeoutpipe isFD_CLOEXEC(severed at the workload'sexecvp, so the box never holds it), and the target pid comes from the trustedfork()return / the host-only registry ($XDG_RUNTIME_DIR/kern/instances,0700, not bind-mounted into the box), never from anything the box can write. So an untrusted workload cannot forge a pid to make the host signal an arbitrary process. - The foreground
--timeoutwatchdog pins its target with apidfdtaken while the box is still alive, so the delayed SIGTERM/SIGKILL can never land on a reused pid (on a kernel too old forpidfd, < 5.3, it falls back tokill(pid)). The detached--timeoutstopper re-checks the box is the same instance (name + supervisor pid, andkern psalready validates the pid's start-time) before tearing it down. - Known, bounded limitation:
--health-action restartre-reads the box's PID 1 from the registry andSIGKILLs it; during a restart gap that pid could in principle be reused by another process of the same user before the kill lands. The window is sub-quantum and not attacker-targetable (an unprivileged kill only reaches same-uid processes, and an in-box workload can't create host-ns processes to steer the reuse), consistent with the cooperative, first-party trust model for the resource governor. It is not a cross-tenant boundary.
- Auth follows the standard registry-v2
WWW-Authenticatechallenge, so any compliant registry works (Docker Hub, GHCR, GitLab, quay, Harbor, self-hosted), anonymously, or withkern logincredentials for private repos. - Every request is TLS-pinned:
--proto =https(and--proto-redir =httpson the redirect- following ones), a bounded--max-redirs, and a--URL terminator, a hostile registry can't downgrade a manifest/blob/token fetch tohttp:///file://or smuggle a--leading URL into a flag. - Credentials never touch argv.
loginstores them0600(dir0700); on a pull they're fed tocurlvia a-KSTDIN config (Basic) or used only to fetch a short-lived Bearer token, so no same-uid process can read the password from/proc/<pid>/cmdline. A crafted credential can't inject a curl directive (control chars stripped, quotes/backslashes escaped). - Realm pinning (CVE-2020-15157 class). For a Bearer challenge, the stored password is sent to
the advertised token
realmonly if the realm host is the registry host or a subdomain of its parent domain (e.g.registry-1.docker.io↔auth.docker.io). A registry that points its auth realm at a foreign host gets an anonymous token instead (with a warning), so a compromised or impersonated registry can't harvest the credentials the Bearer flow is meant to keep away from it. The realm host is parsed exactly as curl dials it: userinfo (user:pass@host) and:portstripped, case-folded, so arealm="https://trusted:0@evil.com/…"(curl connects toevil.com) can't masquerade as trusted; and a common multi-label public suffix (co.uk,com.au, …) is never treated as a trustable parent domain, so two unrelated*.co.ukregistries can't cross-trust. - The short-lived Bearer token (not the stored secret) does travel in an
Authorizationheader; this is an accepted, standard trade-off.
- No vendor binary is modified, and no GPU code ships here at all: this edition has no GPU path to harden, so the strongest statement is the absence itself.
- Layer contents are vetted in-process from the raw tar headers before extraction (no
../ absolute paths, no device nodes, no escaping hardlink targets, size + entry-count caps), the security decision reads fixed-offset header fields, never the hosttar's locale-dependent text output, and fails closed on any construct where the vetter and the extractor could disagree (two path sources for one member, a PAX global path override, an over-wide size field), so it is sound on GNU and BusyBox alike without pretending to predict every extractor quirk; the byte-level parser is fuzzed. Extraction itself still runs the hosttarinto an isolated staging dir, then a no-follow merge. - Always-on seccomp blocks the dangerous syscall set regardless of flags.
The claims above are asserted by four adversarial suites in pentest/, which ask the
kernel what is true rather than asking kern to report on itself: that a published port cannot tunnel
into a host service, that --ssh does not hand out the host's shell, that kern exec does not escape
the box, that a box cannot raise its own memory.max and sees no cgroup above its own, that a device
not granted does not cross (asserted against a box WITHOUT the grant), and that a SIGKILLed
supervisor does not leave a host port held.
cargo build --release
sh pentest/run-with-local-registry.sh ./target/release/kern pentest/pentest-ports.shThat wrapper serves the test image from your own loopback, so nothing here needs a registry account
or a network. Exit status is 0 only if every asserted property held; a host that cannot answer a
question reports SKIP with the reason and never counts it as a pass. Measured results, and what is
deliberately NOT wired into CI, are in pentest/README.md.
Pre-1.0: only the latest 0.x is supported. Security fixes land on main.