roothelper: tighten input validation across privileged RPCs

Defence-in-depth pass over every helper method that touches the host
as root. Each fix narrows what a compromised owner-uid daemon could
ask the helper to do; many close concrete file-ownership and DoS
primitives that the previous validators didn't reach.

Path / identifier validation:
  * priv.fsck_snapshot now requires /dev/mapper/fc-rootfs-* (was
    "is the string non-empty"). e2fsck -fy on /dev/sda1 was the
    motivating exploit.
  * priv.kill_process and priv.signal_process now read
    /proc/<pid>/cmdline and require a "firecracker" substring before
    sending the signal. Killing arbitrary host PIDs (sshd, init, …)
    is no longer a one-RPC primitive.
  * priv.read_ext4_file and priv.write_ext4_files now require the
    image path to live under StateDir or be /dev/mapper/fc-rootfs-*.
  * priv.cleanup_dm_snapshot validates every non-empty Handles field:
    DM name fc-rootfs-*, DM device /dev/mapper/fc-rootfs-*, loops
    /dev/loopN.
  * priv.remove_dm_snapshot accepts only fc-rootfs-* names or
    /dev/mapper/fc-rootfs-* paths.
  * priv.ensure_nat now requires a parsable IPv4 address and a
    banger-prefixed tap.
  * priv.sync_resolver_routing and priv.clear_resolver_routing now
    require a Linux iface-name-shaped bridge name (1–15 chars, no
    whitespace/'/'/':') and, for sync, a parsable resolver address.

Symlink defence:
  * priv.ensure_socket_access now validates the socket path is under
    RuntimeDir and not a symlink. The fcproc layer's chown/chmod
    moves to unix.Open(O_PATH|O_NOFOLLOW) + Fchownat(AT_EMPTY_PATH)
    + Fchmodat via /proc/self/fd, so even a swap of the leaf into a
    symlink between validation and the syscall is refused. The
    local-priv (non-root) fallback uses `chown -h`.
  * priv.cleanup_jailer_chroot rejects symlinks at both the leaf
    (os.Lstat) and intermediate path components (filepath.EvalSymlinks
    + clean-equality). The umount sweep was rewritten from shell
    `umount --recursive --lazy` to direct unix.Unmount(MNT_DETACH |
    UMOUNT_NOFOLLOW) per child mount, deepest-first; the findmnt
    guard remains as the rm-rf safety net. Local-priv mode falls
    back to `sudo umount --lazy`.

Binary validation:
  * validateRootExecutable now opens with O_PATH|O_NOFOLLOW and
    Fstats through the resulting fd. Rejects path-level symlinks and
    narrows the TOCTOU window between validation and the SDK's exec
    to fork+exec time on a healthy host.

Daemon socket:
  * The owner daemon now reads SO_PEERCRED on every accepted
    connection and refuses any UID that isn't 0 or the registered
    owner. Filesystem perms (0600 + ownerUID) already enforced this;
    the check is belt-and-braces in case the socket FD is ever
    leaked to a non-owner process.

Docs:
  * docs/privileges.md walked end-to-end. Each helper RPC's
    Validation gate row reflects what the code actually enforces.
    New section "Running outside the system install" calls out the
    looser dev-mode trust model (NOPASSWD sudoers, helper hardening
    bypassed) so users don't deploy that path on shared hosts.
    Trust list updated to include every new validator.

Tests added: validators (DM-loop, DM-remove-target, DM-handles,
ext4-image-path, iface-name, IPv4, resolver-addr, not-symlink,
firecracker-PID, root-executable variants), the daemon's authorize
path (non-unix conn rejection + unix conn happy path), the umount2
ordering contract (deepest-first + --lazy on the sudo branch), and
positive/negative cases for the chown-no-follow fallback.

Verified end-to-end via `make smoke JOBS=4` on a KVM host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-28 14:39:41 -03:00
parent 6b543cb17f
commit 853249dec2
No known key found for this signature in database
GPG key ID: 33112E6833C34679
8 changed files with 1177 additions and 63 deletions

View file

@ -22,6 +22,65 @@ import (
"banger/internal/system"
)
// TestAuthorizeConnRejectsNonUnixConn pins the type guard at the top
// of authorizeConn: SO_PEERCRED only makes sense on a unix socket, so
// anything else must be refused outright. net.Pipe gives us a
// connection that satisfies net.Conn but isn't a *net.UnixConn, which
// is exactly the shape we need to exercise the early-return.
func TestAuthorizeConnRejectsNonUnixConn(t *testing.T) {
d := &Daemon{}
pipeA, pipeB := net.Pipe()
defer pipeA.Close()
defer pipeB.Close()
if err := d.authorizeConn(pipeA); err == nil {
t.Fatal("authorizeConn(pipe) succeeded, want error")
}
}
// TestAuthorizeConnAcceptsOwnerUIDOverUnixSocket pins the happy path:
// when the test process connects to a freshly bound unix socket as
// itself, the daemon's peer-cred check matches d.clientUID and lets
// the connection through.
func TestAuthorizeConnAcceptsOwnerUIDOverUnixSocket(t *testing.T) {
dir := t.TempDir()
sockPath := filepath.Join(dir, "test.sock")
listener, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
type result struct {
err error
}
got := make(chan result, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
got <- result{err: err}
return
}
defer conn.Close()
d := &Daemon{clientUID: os.Getuid()}
got <- result{err: d.authorizeConn(conn)}
}()
client, err := net.Dial("unix", sockPath)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer client.Close()
select {
case r := <-got:
if r.err != nil {
t.Fatalf("authorizeConn(unix self) = %v, want nil", r.err)
}
case <-time.After(2 * time.Second):
t.Fatal("authorizeConn never returned")
}
}
func TestRegisterImageRequiresKernel(t *testing.T) {
rootfs := filepath.Join(t.TempDir(), "rootfs.ext4")
if err := os.WriteFile(rootfs, []byte("rootfs"), 0o644); err != nil {