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:
parent
6b543cb17f
commit
853249dec2
8 changed files with 1177 additions and 63 deletions
|
|
@ -14,6 +14,8 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"banger/internal/config"
|
||||
ws "banger/internal/daemon/workspace"
|
||||
"banger/internal/installmeta"
|
||||
|
|
@ -259,6 +261,13 @@ func (d *Daemon) Serve(ctx context.Context) error {
|
|||
|
||||
func (d *Daemon) handleConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
if err := d.authorizeConn(conn); err != nil {
|
||||
if d.logger != nil {
|
||||
d.logger.Warn("daemon connection rejected", "remote", conn.RemoteAddr().String(), "error", err.Error())
|
||||
}
|
||||
_ = json.NewEncoder(conn).Encode(rpc.NewError("unauthorized", err.Error()))
|
||||
return
|
||||
}
|
||||
reader := bufio.NewReader(conn)
|
||||
var req rpc.Request
|
||||
if err := json.NewDecoder(reader).Decode(&req); err != nil {
|
||||
|
|
@ -281,6 +290,44 @@ func (d *Daemon) handleConn(conn net.Conn) {
|
|||
}
|
||||
}
|
||||
|
||||
// authorizeConn enforces SO_PEERCRED on the daemon socket as a
|
||||
// belt-and-braces check on top of filesystem perms (0600 + chowned to
|
||||
// the owner). Filesystem perms already prevent other host users from
|
||||
// connecting; the peer-cred read closes the door on any path that
|
||||
// might leak the socket FD to a non-owner process. Mirrors the
|
||||
// equivalent check in roothelper.authorizeConn.
|
||||
func (d *Daemon) authorizeConn(conn net.Conn) error {
|
||||
unixConn, ok := conn.(*net.UnixConn)
|
||||
if !ok {
|
||||
return errors.New("daemon requires unix connections")
|
||||
}
|
||||
rawConn, err := unixConn.SyscallConn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cred *unix.Ucred
|
||||
var controlErr error
|
||||
if err := rawConn.Control(func(fd uintptr) {
|
||||
cred, controlErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if controlErr != nil {
|
||||
return controlErr
|
||||
}
|
||||
if cred == nil {
|
||||
return errors.New("missing peer credentials")
|
||||
}
|
||||
expected := d.clientUID
|
||||
if expected < 0 {
|
||||
expected = os.Getuid()
|
||||
}
|
||||
if int(cred.Uid) == 0 || int(cred.Uid) == expected {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("uid %d is not allowed to use the daemon", cred.Uid)
|
||||
}
|
||||
|
||||
func (d *Daemon) watchRequestDisconnect(conn net.Conn, reader *bufio.Reader, method string, cancel context.CancelFunc) func() {
|
||||
if conn == nil || reader == nil {
|
||||
return func() {}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
|
@ -202,18 +203,57 @@ func (m *Manager) ensureSocketAccessFor(ctx context.Context, socketPath, label s
|
|||
if err := pollPath(ctx, socketPath, timeout, interval, label); err != nil {
|
||||
return err
|
||||
}
|
||||
if os.Geteuid() == 0 {
|
||||
if _, err := m.runner.Run(ctx, "chmod", "600", socketPath); err != nil {
|
||||
return chownChmodNoFollow(ctx, m.runner, socketPath, uid, gid, 0o600)
|
||||
}
|
||||
|
||||
// chownChmodNoFollow sets owner/group/mode on path without following
|
||||
// symlinks at the leaf. Required because the helper RPCs that drive
|
||||
// socket access run as root: a follow-symlink chmod/chown becomes an
|
||||
// arbitrary file-ownership primitive if the caller can plant a symlink
|
||||
// at the target.
|
||||
//
|
||||
// Linux idiom: open with O_PATH|O_NOFOLLOW (errors out if the leaf is a
|
||||
// symlink), Fstat the fd to confirm the file is a unix socket, then
|
||||
// chown via Fchownat(AT_EMPTY_PATH) and chmod via /proc/self/fd/N
|
||||
// (fchmod on an O_PATH fd returns EBADF, but the /proc path resolves
|
||||
// straight back to the inode the fd already pins, so no leaf re-traversal
|
||||
// happens).
|
||||
//
|
||||
// Falls back to `sudo chown -h` + `sudo chmod` for the local-priv mode
|
||||
// where the daemon isn't root and can't issue the syscalls itself; the
|
||||
// `-h` flag still avoids the symlink-follow on the chown side.
|
||||
func chownChmodNoFollow(ctx context.Context, runner Runner, path string, uid, gid int, mode os.FileMode) error {
|
||||
if os.Geteuid() != 0 {
|
||||
// Mode-then-owner ordering preserves the pre-existing failure
|
||||
// semantics of the legacy `chmod 600 / chown` shell-out path
|
||||
// (chmod-failure tests expect chown to be skipped). `chown -h`
|
||||
// keeps the symlink-no-follow guarantee on this branch.
|
||||
if _, err := runner.RunSudo(ctx, "chmod", fmt.Sprintf("%o", mode.Perm()), path); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := m.runner.Run(ctx, "chown", fmt.Sprintf("%d:%d", uid, gid), socketPath)
|
||||
_, err := runner.RunSudo(ctx, "chown", "-h", fmt.Sprintf("%d:%d", uid, gid), path)
|
||||
return err
|
||||
}
|
||||
if _, err := m.runner.RunSudo(ctx, "chmod", "600", socketPath); err != nil {
|
||||
return err
|
||||
fd, err := unix.Open(path, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
_, err := m.runner.RunSudo(ctx, "chown", fmt.Sprintf("%d:%d", uid, gid), socketPath)
|
||||
return err
|
||||
defer unix.Close(fd)
|
||||
var st unix.Stat_t
|
||||
if err := unix.Fstat(fd, &st); err != nil {
|
||||
return fmt.Errorf("fstat %s: %w", path, err)
|
||||
}
|
||||
if st.Mode&unix.S_IFMT != unix.S_IFSOCK {
|
||||
return fmt.Errorf("%s is not a unix socket (mode %#o)", path, st.Mode&unix.S_IFMT)
|
||||
}
|
||||
procPath := "/proc/self/fd/" + strconv.Itoa(fd)
|
||||
if err := unix.Fchmodat(unix.AT_FDCWD, procPath, uint32(mode.Perm()), 0); err != nil {
|
||||
return fmt.Errorf("chmod %s: %w", path, err)
|
||||
}
|
||||
if err := unix.Fchownat(fd, "", uid, gid, unix.AT_EMPTY_PATH); err != nil {
|
||||
return fmt.Errorf("chown %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindPID returns the PID of the firecracker process listening on apiSock,
|
||||
|
|
@ -447,23 +487,84 @@ func (m *Manager) CleanupJailerChroot(ctx context.Context, chrootRoot string) er
|
|||
if strings.TrimSpace(chrootRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(chrootRoot); os.IsNotExist(err) {
|
||||
return nil
|
||||
// Lstat (not Stat): if chrootRoot is a symlink the umount/rm shell-outs
|
||||
// below would chase it. The handler-side validateNotSymlink also catches
|
||||
// this, but lifting the check inside fcproc closes the TOCTOU window
|
||||
// between the handler check and our umount command.
|
||||
info, err := os.Lstat(chrootRoot)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("inspect chroot %s: %w", chrootRoot, err)
|
||||
}
|
||||
// Best-effort umount: for chroots that were never bind-mounted (a
|
||||
// stale install pre-bind-mount work, say) this fails — that's fine,
|
||||
// the findmnt guard below is what enforces safety.
|
||||
_ = m.sudoIgnore(ctx, "umount", "--recursive", "--lazy", chrootRoot)
|
||||
if mounts, err := m.mountsUnder(ctx, chrootRoot); err != nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("refusing to clean up %q: path is a symlink", chrootRoot)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("refusing to clean up %q: not a directory", chrootRoot)
|
||||
}
|
||||
// Resolve any intermediate symlinks and require the result equals the
|
||||
// input — that catches a planted `…/jail/firecracker/<vmid> → /` even
|
||||
// though the leaf "/root" component is itself a real directory inside
|
||||
// the redirected target. Equality + Lstat together cover both top and
|
||||
// intermediate symlink shapes.
|
||||
resolved, err := filepath.EvalSymlinks(chrootRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve chroot %s: %w", chrootRoot, err)
|
||||
}
|
||||
if filepath.Clean(resolved) != filepath.Clean(chrootRoot) {
|
||||
return fmt.Errorf("refusing to clean up %q: resolves to %q via symlink", chrootRoot, resolved)
|
||||
}
|
||||
// Switch from `umount --recursive --lazy <chrootRoot>` (shell-resolved,
|
||||
// follows symlinks at exec time) to direct umount2() syscalls per child
|
||||
// mount with UMOUNT_NOFOLLOW. That fully closes the residual TOCTOU
|
||||
// between the EvalSymlinks check above and the unmount: even if a daemon-
|
||||
// uid attacker swapped a child mount's path to a symlink in the gap, the
|
||||
// kernel refuses to follow it. The findmnt guard below still catches any
|
||||
// mount we couldn't detach.
|
||||
mounts, err := m.mountsUnder(ctx, chrootRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect chroot mounts: %w", err)
|
||||
} else if len(mounts) > 0 {
|
||||
return fmt.Errorf("refusing to rm -rf %q: still has %d mount(s): %v", chrootRoot, len(mounts), mounts)
|
||||
}
|
||||
// Deepest-first so child mounts come off before parents; otherwise a
|
||||
// parent unmount would EBUSY against in-use children.
|
||||
sort.Slice(mounts, func(i, j int) bool {
|
||||
return strings.Count(mounts[i], "/") > strings.Count(mounts[j], "/")
|
||||
})
|
||||
for _, mt := range mounts {
|
||||
if err := m.detachMount(ctx, mt); err != nil {
|
||||
return fmt.Errorf("detach %q: %w", mt, err)
|
||||
}
|
||||
}
|
||||
if remaining, err := m.mountsUnder(ctx, chrootRoot); err != nil {
|
||||
return fmt.Errorf("re-inspect chroot mounts: %w", err)
|
||||
} else if len(remaining) > 0 {
|
||||
return fmt.Errorf("refusing to rm -rf %q: still has %d mount(s): %v", chrootRoot, len(remaining), remaining)
|
||||
}
|
||||
return m.sudo(ctx, "rm", "-rf", "--", chrootRoot)
|
||||
}
|
||||
|
||||
func (m *Manager) sudoIgnore(ctx context.Context, name string, args ...string) error {
|
||||
err := m.sudo(ctx, name, args...)
|
||||
// detachMount tears down a single mount target with MNT_DETACH (lazy) +
|
||||
// UMOUNT_NOFOLLOW (refuse symlinks). Falls back to `sudo umount --lazy`
|
||||
// when not running as root, since umount2() requires CAP_SYS_ADMIN.
|
||||
//
|
||||
// ENOENT and EINVAL on the syscall path are treated as "already gone" —
|
||||
// findmnt's snapshot can race with parallel cleanups, and a missing
|
||||
// mount is the desired end state.
|
||||
func (m *Manager) detachMount(ctx context.Context, target string) error {
|
||||
if os.Geteuid() == 0 {
|
||||
err := unix.Unmount(target, unix.MNT_DETACH|unix.UMOUNT_NOFOLLOW)
|
||||
if err == nil || errors.Is(err, unix.ENOENT) || errors.Is(err, unix.EINVAL) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Local-priv fallback: shell `umount --lazy` resolves the path through
|
||||
// the kernel without UMOUNT_NOFOLLOW, but the EvalSymlinks check earlier
|
||||
// already constrained the chroot tree. The dev-mode caveat in
|
||||
// docs/privileges.md covers this branch's looser guarantees.
|
||||
_, err := m.runner.RunSudo(ctx, "umount", "--lazy", target)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -232,6 +233,234 @@ func TestEnsureSocketAccessForAsyncWaitsForSocketThenChowns(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// recordingRunner captures every Run/RunSudo invocation's full
|
||||
// argv. Used to assert that ensureSocketAccessFor's fallback path
|
||||
// passes `chown -h` rather than the symlink-following plain `chown`.
|
||||
type recordingRunner struct {
|
||||
sudos [][]string
|
||||
runs [][]string
|
||||
}
|
||||
|
||||
func (r *recordingRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
r.runs = append(r.runs, append([]string{name}, args...))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *recordingRunner) RunSudo(_ context.Context, args ...string) ([]byte, error) {
|
||||
r.sudos = append(r.sudos, append([]string(nil), args...))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestCleanupJailerChrootRejectsSymlink pins the TOCTOU-closing
|
||||
// fcproc-side check: even if a daemon-uid attacker somehow bypasses
|
||||
// the helper handler's validateNotSymlink (or races it), the cleanup
|
||||
// itself refuses a symlinked path before any umount/rm shells.
|
||||
func TestCleanupJailerChrootRejectsSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "real")
|
||||
if err := os.Mkdir(target, 0o700); err != nil {
|
||||
t.Fatalf("mkdir target: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "link")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
|
||||
// scriptedRunner with no scripted calls — any shell invocation
|
||||
// trips r.t.Fatalf, proving rejection happened before umount/rm.
|
||||
runner := &scriptedRunner{t: t}
|
||||
mgr := New(runner, Config{}, slog.Default())
|
||||
if err := mgr.CleanupJailerChroot(context.Background(), link); err == nil {
|
||||
t.Fatal("CleanupJailerChroot(symlink) succeeded, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupJailerChrootRejectsIntermediateSymlink covers the
|
||||
// `/jail/firecracker/<vmid> → /` shape: the leaf "/root" component
|
||||
// is a real directory inside the redirected target, but EvalSymlinks
|
||||
// resolves to a different path so we still bail.
|
||||
func TestCleanupJailerChrootRejectsIntermediateSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
realParent := filepath.Join(dir, "real-parent")
|
||||
if err := os.MkdirAll(filepath.Join(realParent, "root"), 0o700); err != nil {
|
||||
t.Fatalf("mkdir real: %v", err)
|
||||
}
|
||||
linkParent := filepath.Join(dir, "link-parent")
|
||||
if err := os.Symlink(realParent, linkParent); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
chrootViaSymlink := filepath.Join(linkParent, "root")
|
||||
|
||||
runner := &scriptedRunner{t: t}
|
||||
mgr := New(runner, Config{}, slog.Default())
|
||||
if err := mgr.CleanupJailerChroot(context.Background(), chrootViaSymlink); err == nil {
|
||||
t.Fatal("CleanupJailerChroot(symlinked-parent) succeeded, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupJailerChrootHappyPathWithoutMounts pins the no-leak case:
|
||||
// when findmnt reports zero mounts under the chroot, the cleanup
|
||||
// skips straight to `sudo rm -rf` without invoking umount2 / sudo
|
||||
// umount at all. Regression guard for the umount2 rewrite — if the
|
||||
// new logic leaks an extra runner call here, this test will fail.
|
||||
func TestCleanupJailerChrootHappyPathWithoutMounts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
chroot := filepath.Join(dir, "root")
|
||||
if err := os.Mkdir(chroot, 0o700); err != nil {
|
||||
t.Fatalf("mkdir chroot: %v", err)
|
||||
}
|
||||
runner := &scriptedRunner{
|
||||
t: t,
|
||||
runs: []scriptedCall{
|
||||
// First mountsUnder() — pre-detach. Empty stdout = no mounts.
|
||||
{matchName: "findmnt", out: nil},
|
||||
// Second mountsUnder() — post-detach guard. Same.
|
||||
{matchName: "findmnt", out: nil},
|
||||
},
|
||||
// sudo rm -rf -- chroot.
|
||||
sudos: []scriptedCall{{}},
|
||||
}
|
||||
mgr := New(runner, Config{}, slog.Default())
|
||||
if err := mgr.CleanupJailerChroot(context.Background(), chroot); err != nil {
|
||||
t.Fatalf("CleanupJailerChroot: %v", err)
|
||||
}
|
||||
if len(runner.runs) != 0 {
|
||||
t.Fatalf("findmnt scripted calls left over: %d", len(runner.runs))
|
||||
}
|
||||
if len(runner.sudos) != 0 {
|
||||
t.Fatalf("sudo scripted calls left over: %d", len(runner.sudos))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupJailerChrootDetachesMountsDeepestFirst pins the ordering
|
||||
// contract for the umount2 rewrite: child mounts come off before
|
||||
// parents, otherwise the parent unmount would race against in-use
|
||||
// children. The non-root code path shells `sudo umount --lazy`, which
|
||||
// the recording runner captures so we can assert order + the --lazy
|
||||
// flag.
|
||||
func TestCleanupJailerChrootDetachesMountsDeepestFirst(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("euid 0 takes the umount2 syscall branch; this test exercises the sudo fallback")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
chroot := filepath.Join(dir, "root")
|
||||
if err := os.Mkdir(chroot, 0o700); err != nil {
|
||||
t.Fatalf("mkdir chroot: %v", err)
|
||||
}
|
||||
parent := chroot
|
||||
child := filepath.Join(chroot, "lib")
|
||||
deep := filepath.Join(child, "deep")
|
||||
findmntOut := []byte(strings.Join([]string{parent, child, deep}, "\n"))
|
||||
runner := &mountRecordingRunner{findmntOut: findmntOut}
|
||||
mgr := New(runner, Config{}, slog.Default())
|
||||
if err := mgr.CleanupJailerChroot(context.Background(), chroot); err != nil {
|
||||
t.Fatalf("CleanupJailerChroot: %v", err)
|
||||
}
|
||||
// Three umount + final rm -rf. The umount targets must be deep,
|
||||
// child, parent in that order.
|
||||
wantTargets := []string{deep, child, parent}
|
||||
if len(runner.umountTargets) != len(wantTargets) {
|
||||
t.Fatalf("umount calls = %v, want %d", runner.umountTargets, len(wantTargets))
|
||||
}
|
||||
for i, want := range wantTargets {
|
||||
if runner.umountTargets[i] != want {
|
||||
t.Fatalf("umount[%d] = %q, want %q", i, runner.umountTargets[i], want)
|
||||
}
|
||||
}
|
||||
if !runner.lazyFlagSeen {
|
||||
t.Fatalf("expected umount --lazy on the sudo branch, args = %v", runner.umountArgs)
|
||||
}
|
||||
if !runner.rmCalled {
|
||||
t.Fatal("rm -rf was never invoked after the umount sweep")
|
||||
}
|
||||
}
|
||||
|
||||
// mountRecordingRunner stubs out findmnt + sudo for the cleanup path:
|
||||
// the first findmnt call returns the canned mount list (pre-detach),
|
||||
// subsequent calls return empty to simulate the kernel having dropped
|
||||
// each mount as we asked. sudo umount/rm calls are captured and
|
||||
// answer success.
|
||||
type mountRecordingRunner struct {
|
||||
findmntOut []byte
|
||||
findmntCalls int
|
||||
umountTargets []string
|
||||
umountArgs [][]string
|
||||
lazyFlagSeen bool
|
||||
rmCalled bool
|
||||
}
|
||||
|
||||
func (r *mountRecordingRunner) Run(_ context.Context, name string, _ ...string) ([]byte, error) {
|
||||
if name == "findmnt" {
|
||||
r.findmntCalls++
|
||||
if r.findmntCalls == 1 {
|
||||
return r.findmntOut, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *mountRecordingRunner) RunSudo(_ context.Context, args ...string) ([]byte, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "umount":
|
||||
// Last arg is the target. Earlier args are flags.
|
||||
if len(args) >= 2 {
|
||||
r.umountTargets = append(r.umountTargets, args[len(args)-1])
|
||||
}
|
||||
r.umountArgs = append(r.umountArgs, append([]string(nil), args...))
|
||||
for _, a := range args[1 : len(args)-1] {
|
||||
if a == "--lazy" || a == "-l" {
|
||||
r.lazyFlagSeen = true
|
||||
}
|
||||
}
|
||||
case "rm":
|
||||
r.rmCalled = true
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestEnsureSocketAccessSudoBranchUsesChownNoFollow pins the
|
||||
// symlink-defence on the local-priv (non-root) path: a follow-symlink
|
||||
// chown on a daemon-uid attacker-planted symlink is the same arbitrary
|
||||
// file-ownership primitive we close in the root branch via
|
||||
// O_PATH|O_NOFOLLOW. Test only runs as non-root (the syscall branch is
|
||||
// taken when euid == 0, which CI doesn't see).
|
||||
func TestEnsureSocketAccessSudoBranchUsesChownNoFollow(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("euid 0 takes the syscall branch; the sudo branch is only reachable as a regular user")
|
||||
}
|
||||
socketPath := filepath.Join(t.TempDir(), "present.sock")
|
||||
if err := os.WriteFile(socketPath, []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
runner := &recordingRunner{}
|
||||
mgr := New(runner, Config{}, slog.Default())
|
||||
|
||||
if err := mgr.EnsureSocketAccess(context.Background(), socketPath, "api socket"); err != nil {
|
||||
t.Fatalf("EnsureSocketAccess: %v", err)
|
||||
}
|
||||
if len(runner.sudos) != 2 {
|
||||
t.Fatalf("got %d sudo calls, want 2 (chmod, chown)", len(runner.sudos))
|
||||
}
|
||||
chown := runner.sudos[1]
|
||||
if len(chown) < 2 || chown[0] != "chown" {
|
||||
t.Fatalf("second sudo call = %v, want chown", chown)
|
||||
}
|
||||
hasNoFollow := false
|
||||
for _, arg := range chown[1:] {
|
||||
if arg == "-h" {
|
||||
hasNoFollow = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasNoFollow {
|
||||
t.Fatalf("chown args = %v, missing the -h symlink-no-follow flag", chown)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
|
|
|
|||
|
|
@ -428,7 +428,7 @@ func TestHealthVMReturnsHealthyForRunningGuest(t *testing.T) {
|
|||
t: t,
|
||||
steps: []runnerStep{
|
||||
sudoStep("", nil, "chmod", "600", vsockSock),
|
||||
sudoStep("", nil, "chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), vsockSock),
|
||||
sudoStep("", nil, "chown", "-h", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), vsockSock),
|
||||
},
|
||||
}
|
||||
d := &Daemon{store: db, runner: runner}
|
||||
|
|
@ -492,7 +492,7 @@ func TestPingVMAliasReturnsAliveForHealthyVM(t *testing.T) {
|
|||
t: t,
|
||||
steps: []runnerStep{
|
||||
sudoStep("", nil, "chmod", "600", vsockSock),
|
||||
sudoStep("", nil, "chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), vsockSock),
|
||||
sudoStep("", nil, "chown", "-h", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), vsockSock),
|
||||
},
|
||||
}
|
||||
d := &Daemon{store: db, runner: runner}
|
||||
|
|
@ -692,7 +692,7 @@ func TestPortsVMReturnsEnrichedPortsAndWebSchemes(t *testing.T) {
|
|||
t: t,
|
||||
steps: []runnerStep{
|
||||
sudoStep("", nil, "chmod", "600", vsockSock),
|
||||
sudoStep("", nil, "chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), vsockSock),
|
||||
sudoStep("", nil, "chown", "-h", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), vsockSock),
|
||||
},
|
||||
}
|
||||
d := &Daemon{store: db, runner: runner}
|
||||
|
|
@ -1623,7 +1623,7 @@ func TestStopVMFallsBackToForcedCleanupAfterGracefulTimeout(t *testing.T) {
|
|||
t: t,
|
||||
steps: []runnerStep{
|
||||
sudoStep("", nil, "chmod", "600", apiSock),
|
||||
sudoStep("", nil, "chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), apiSock),
|
||||
sudoStep("", nil, "chown", "-h", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), apiSock),
|
||||
{call: runnerCall{name: "pgrep", args: []string{"-n", "-f", apiSock}}, out: []byte(strconv.Itoa(fake.Process.Pid) + "\n")},
|
||||
sudoStep("", nil, "kill", "-KILL", strconv.Itoa(fake.Process.Pid)),
|
||||
},
|
||||
|
|
@ -2068,14 +2068,16 @@ func (r *filesystemRunner) RunSudo(ctx context.Context, args ...string) ([]byte,
|
|||
}
|
||||
return nil, os.WriteFile(dst, data, os.FileMode(mode))
|
||||
case "chown":
|
||||
// Recognised forms, both no-op under test (we run as the test
|
||||
// Recognised forms, all no-op under test (we run as the test
|
||||
// user and os.Chown would need CAP_CHOWN):
|
||||
// chown OWNER TARGET
|
||||
// chown -R OWNER TARGET
|
||||
// chown -h OWNER TARGET (symlink-no-follow; required by
|
||||
// fcproc.chownChmodNoFollow)
|
||||
switch {
|
||||
case len(args) == 3:
|
||||
return nil, nil
|
||||
case len(args) == 4 && args[1] == "-R":
|
||||
case len(args) == 4 && (args[1] == "-R" || args[1] == "-h"):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected chown args: %v", args)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue