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
|
|
@ -12,7 +12,6 @@ import (
|
|||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
|
@ -463,6 +462,18 @@ func (s *Server) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
if err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
// syncResolverRouting short-circuits on empty input; only
|
||||
// validate when actually doing something. This stops a
|
||||
// compromised daemon from flapping arbitrary system-managed
|
||||
// links via resolvectl.
|
||||
if strings.TrimSpace(params.BridgeName) != "" || strings.TrimSpace(params.ServerAddr) != "" {
|
||||
if err := validateLinuxIfaceName(params.BridgeName); err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
if err := validateResolverAddr(params.ServerAddr); err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
}
|
||||
return marshalResultOrError(struct{}{}, s.syncResolverRouting(ctx, params.BridgeName, params.ServerAddr))
|
||||
case methodClearResolverRouting:
|
||||
params, err := rpc.DecodeParams[struct {
|
||||
|
|
@ -471,6 +482,11 @@ func (s *Server) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
if err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
if strings.TrimSpace(params.BridgeName) != "" {
|
||||
if err := validateLinuxIfaceName(params.BridgeName); err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
}
|
||||
return marshalResultOrError(struct{}{}, s.clearResolverRouting(ctx, params.BridgeName))
|
||||
case methodEnsureNAT:
|
||||
params, err := rpc.DecodeParams[struct {
|
||||
|
|
@ -481,6 +497,16 @@ func (s *Server) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
if err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
// Without these the helper installs iptables rules with
|
||||
// daemon-supplied identifiers; argv-style exec rules out
|
||||
// command injection, but a compromised daemon could still
|
||||
// install MASQUERADE rules tied to arbitrary IPs/interfaces.
|
||||
if err := validateIPv4(params.GuestIP); err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
if err := validateTapName(params.Tap); err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
return marshalResultOrError(struct{}{}, hostnat.Ensure(ctx, s.runner, params.GuestIP, params.Tap, params.Enable))
|
||||
case methodCreateDMSnapshot:
|
||||
params, err := rpc.DecodeParams[struct {
|
||||
|
|
@ -507,6 +533,13 @@ func (s *Server) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
if err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
// Each Handles field flows into a `dmsetup remove` /
|
||||
// `losetup -d` shell-out as root. Without these checks a
|
||||
// compromised daemon could ask the helper to detach
|
||||
// arbitrary loop devices or remove unrelated DM targets.
|
||||
if err := validateDMSnapshotHandles(params); err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
return marshalResultOrError(struct{}{}, dmsnap.Cleanup(ctx, s.runner, params))
|
||||
case methodRemoveDMSnapshot:
|
||||
params, err := rpc.DecodeParams[struct {
|
||||
|
|
@ -515,6 +548,9 @@ func (s *Server) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
if err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
if err := validateDMRemoveTarget(params.Target); err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
return marshalResultOrError(struct{}{}, dmsnap.Remove(ctx, s.runner, params.Target))
|
||||
case methodFsckSnapshot:
|
||||
params, err := rpc.DecodeParams[struct {
|
||||
|
|
@ -532,6 +568,13 @@ func (s *Server) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
if err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
// Without this validation a compromised daemon can drive
|
||||
// debugfs as root against any path on the host; it would have
|
||||
// to be a real ext4 image to leak data, but the constraint is
|
||||
// trivially expressed and adds no operational cost.
|
||||
if err := s.validateExt4ImagePath(params.ImagePath); err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
data, readErr := system.ReadExt4File(ctx, s.runner, params.ImagePath, params.GuestPath)
|
||||
return marshalResultOrError(readExt4FileResult{Data: data}, readErr)
|
||||
case methodWriteExt4Files:
|
||||
|
|
@ -542,6 +585,9 @@ func (s *Server) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
if err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
if err := s.validateExt4ImagePath(params.ImagePath); err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
return marshalResultOrError(struct{}{}, s.writeExt4Files(ctx, params.ImagePath, params.Files))
|
||||
case methodResolveFirecrackerBin:
|
||||
params, err := rpc.DecodeParams[struct {
|
||||
|
|
@ -567,6 +613,20 @@ func (s *Server) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
if err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
// Without these checks the helper's chown/chmod becomes an
|
||||
// arbitrary file-ownership primitive: a daemon-uid attacker
|
||||
// could plant a symlink at any path under RuntimeDir (or just
|
||||
// pass /etc/shadow) and have the helper transfer ownership to
|
||||
// the daemon UID. The fcproc layer also chowns/chmods via
|
||||
// O_PATH|O_NOFOLLOW so the leaf can't be a symlink at the time
|
||||
// of the syscall — these checks are belt + braces and give a
|
||||
// clear error before we even open the path.
|
||||
if err := s.validateManagedPath(params.SocketPath, paths.ResolveSystem().RuntimeDir); err != nil {
|
||||
return rpc.NewError("invalid_path", err.Error())
|
||||
}
|
||||
if err := validateNotSymlink(params.SocketPath); err != nil {
|
||||
return rpc.NewError("invalid_path", err.Error())
|
||||
}
|
||||
return marshalResultOrError(struct{}{}, s.ensureSocketAccess(ctx, params.SocketPath, params.Label))
|
||||
case methodFindFirecrackerPID:
|
||||
params, err := rpc.DecodeParams[struct {
|
||||
|
|
@ -584,6 +644,9 @@ func (s *Server) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
if err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
if err := validateFirecrackerPID(params.PID); err != nil {
|
||||
return rpc.NewError("invalid_pid", err.Error())
|
||||
}
|
||||
_, killErr := s.runner.Run(ctx, "kill", "-KILL", strconv.Itoa(params.PID))
|
||||
return marshalResultOrError(struct{}{}, killErr)
|
||||
case methodSignalProcess:
|
||||
|
|
@ -594,6 +657,9 @@ func (s *Server) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
if err != nil {
|
||||
return rpc.NewError("bad_params", err.Error())
|
||||
}
|
||||
if err := validateFirecrackerPID(params.PID); err != nil {
|
||||
return rpc.NewError("invalid_pid", err.Error())
|
||||
}
|
||||
signal := strings.TrimSpace(params.Signal)
|
||||
if signal == "" {
|
||||
signal = "TERM"
|
||||
|
|
@ -620,6 +686,14 @@ func (s *Server) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
if err := s.validateManagedPath(params.ChrootRoot, systemLayout.StateDir, systemLayout.RuntimeDir); err != nil {
|
||||
return rpc.NewError("invalid_path", err.Error())
|
||||
}
|
||||
// validateManagedPath only does textual prefix matching. A
|
||||
// symlink at e.g. /var/lib/banger/jail/x → / would pass the
|
||||
// prefix check, and the subsequent `umount --recursive --lazy`
|
||||
// would detach real host mounts. Reject leaf symlinks before
|
||||
// we go anywhere near unmount/rm.
|
||||
if err := validateNotSymlink(params.ChrootRoot); err != nil {
|
||||
return rpc.NewError("invalid_path", err.Error())
|
||||
}
|
||||
err = fcproc.New(s.runner, fcproc.Config{}, s.logger).CleanupJailerChroot(ctx, params.ChrootRoot)
|
||||
return marshalResultOrError(struct{}{}, err)
|
||||
default:
|
||||
|
|
@ -683,8 +757,11 @@ func (s *Server) clearResolverRouting(ctx context.Context, bridgeName string) er
|
|||
}
|
||||
|
||||
func (s *Server) fsckSnapshot(ctx context.Context, dmDev string) error {
|
||||
if strings.TrimSpace(dmDev) == "" {
|
||||
return errors.New("dm device is required")
|
||||
// Helper runs as root with -fy (auto-yes); without the prefix check
|
||||
// a compromised daemon could fsck arbitrary block devices like
|
||||
// /dev/sda1 and corrupt the host filesystem.
|
||||
if err := validateDMDevicePath(dmDev); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.runner.Run(ctx, "e2fsck", "-fy", dmDev); err != nil {
|
||||
if code := system.ExitCode(err); code < 0 || code > 1 {
|
||||
|
|
@ -973,6 +1050,143 @@ func (s *Server) validateManagedPath(path string, roots ...string) error {
|
|||
return fmt.Errorf("path %q is outside banger-managed directories", path)
|
||||
}
|
||||
|
||||
// validateExt4ImagePath accepts a path that is either inside the
|
||||
// banger StateDir (regular ext4 image files we manage) or a managed
|
||||
// DM-snapshot device (/dev/mapper/fc-rootfs-*). Both shapes are
|
||||
// legitimate inputs for the helper's debugfs/e2cp/e2rm RPCs; anything
|
||||
// else would let a compromised daemon point those tools at arbitrary
|
||||
// host files.
|
||||
func (s *Server) validateExt4ImagePath(path string) error {
|
||||
if err := s.validateManagedPath(path, paths.ResolveSystem().StateDir); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := validateDMDevicePath(path); err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("path %q is not a banger-managed ext4 image", path)
|
||||
}
|
||||
|
||||
// validateLoopDevicePath confirms path is `/dev/loopN` for some N≥0.
|
||||
// dmsnap.Cleanup detaches loops via `losetup -d <path>`; without this
|
||||
// a compromised daemon could ask the helper to detach an arbitrary
|
||||
// device node.
|
||||
func validateLoopDevicePath(path string) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return errors.New("loop device path is required")
|
||||
}
|
||||
const prefix = "/dev/loop"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return fmt.Errorf("loop device %q must live under /dev/loop", path)
|
||||
}
|
||||
suffix := path[len(prefix):]
|
||||
if suffix == "" {
|
||||
return fmt.Errorf("loop device %q is missing its index", path)
|
||||
}
|
||||
for _, r := range suffix {
|
||||
if r < '0' || r > '9' {
|
||||
return fmt.Errorf("loop device %q has non-numeric suffix", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDMSnapshotHandles checks every non-empty field on a Handles
|
||||
// passed to priv.cleanup_dm_snapshot. Empty fields are tolerated (the
|
||||
// dmsnap layer treats them as "nothing to clean here") but anything
|
||||
// set must look like a banger-managed object.
|
||||
func validateDMSnapshotHandles(h dmsnap.Handles) error {
|
||||
if h.DMName != "" {
|
||||
if err := validateDMName(h.DMName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if h.DMDev != "" {
|
||||
if err := validateDMDevicePath(h.DMDev); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if h.BaseLoop != "" {
|
||||
if err := validateLoopDevicePath(h.BaseLoop); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if h.COWLoop != "" {
|
||||
if err := validateLoopDevicePath(h.COWLoop); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDMRemoveTarget covers the union accepted by `dmsetup remove`:
|
||||
// either the bare DM name or the /dev/mapper/<name> path. Both shapes
|
||||
// are produced by dmsnap.Cleanup; nothing else should reach the helper.
|
||||
func validateDMRemoveTarget(target string) error {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return errors.New("dm target is required")
|
||||
}
|
||||
if strings.HasPrefix(target, "/dev/mapper/") {
|
||||
return validateDMDevicePath(target)
|
||||
}
|
||||
return validateDMName(target)
|
||||
}
|
||||
|
||||
// validateLinuxIfaceName mirrors the kernel's __dev_valid_name rules
|
||||
// in a permissive subset: 1-15 chars, no whitespace, no slash, no
|
||||
// colon, and not the special "." or "..". Used for bridge-name
|
||||
// arguments to resolvectl. argv-style exec already prevents shell
|
||||
// injection, but a compromised daemon could otherwise flap any
|
||||
// system-managed link by passing its name here.
|
||||
func validateLinuxIfaceName(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return errors.New("interface name is required")
|
||||
}
|
||||
if len(name) > 15 {
|
||||
return fmt.Errorf("interface %q exceeds 15 chars", name)
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
return fmt.Errorf("interface name %q is reserved", name)
|
||||
}
|
||||
for _, r := range name {
|
||||
if r <= ' ' || r == '/' || r == ':' || r == 0x7f {
|
||||
return fmt.Errorf("interface %q contains invalid char %q", name, r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateIPv4 confirms ip parses as an IPv4 address. The NAT helpers
|
||||
// build /32 iptables rules from this string; non-v4 input would
|
||||
// produce malformed rules at best and unexpected ones at worst.
|
||||
func validateIPv4(ip string) error {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return errors.New("ipv4 address is required")
|
||||
}
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil || parsed.To4() == nil {
|
||||
return fmt.Errorf("invalid ipv4 address %q", ip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateResolverAddr confirms s parses as an IP address (v4 or v6).
|
||||
// resolvectl accepts either; reject anything that doesn't parse so a
|
||||
// compromised daemon can't wedge resolved with garbage input.
|
||||
func validateResolverAddr(s string) error {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return errors.New("resolver address is required")
|
||||
}
|
||||
if net.ParseIP(s) == nil {
|
||||
return fmt.Errorf("invalid resolver address %q", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTapName(tapName string) error {
|
||||
tapName = strings.TrimSpace(tapName)
|
||||
if strings.HasPrefix(tapName, vmTapPrefix) || strings.HasPrefix(tapName, tapPoolPrefix) {
|
||||
|
|
@ -1004,25 +1218,80 @@ func validateDMDevicePath(path string) error {
|
|||
return validateDMName(filepath.Base(cleaned))
|
||||
}
|
||||
|
||||
func validateRootExecutable(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
// validateNotSymlink rejects paths whose final component is a symlink.
|
||||
// validateManagedPath does textual prefix matching only; pairing it
|
||||
// with an Lstat check stops a daemon-uid attacker from planting a
|
||||
// symlink at a managed path and using helper RPCs that operate on
|
||||
// that path (chown/chmod sockets, umount/rm chroot trees) to reach
|
||||
// arbitrary host objects. There is a small TOCTOU window between
|
||||
// this check and the syscall that follows; for sockets the
|
||||
// fcproc-level O_PATH|O_NOFOLLOW open closes that window, and for
|
||||
// the chroot cleanup the umount step is bracketed by a findmnt
|
||||
// guard inside fcproc.CleanupJailerChroot.
|
||||
func validateNotSymlink(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("inspect %s: %w", path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("path %q must not be a symlink", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFirecrackerPID confirms pid refers to a running process whose
|
||||
// /proc/<pid>/cmdline mentions "firecracker". Both jailer and direct
|
||||
// firecracker launches keep the binary name in cmdline, so substring
|
||||
// match catches both. PID reuse is theoretically racey but the kill
|
||||
// follows immediately, so the window is too narrow to weaponise.
|
||||
func validateFirecrackerPID(pid int) error {
|
||||
if pid <= 0 {
|
||||
return fmt.Errorf("pid %d is invalid", pid)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect pid %d: %w", pid, err)
|
||||
}
|
||||
cmdline := strings.ReplaceAll(string(data), "\x00", " ")
|
||||
if !strings.Contains(cmdline, "firecracker") {
|
||||
return fmt.Errorf("pid %d is not a banger-managed firecracker process", pid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRootExecutable opens the path with O_PATH|O_NOFOLLOW and re-checks
|
||||
// every constraint via Fstat on the resulting fd. Going through O_PATH (rather
|
||||
// than the previous os.Stat) gives two improvements:
|
||||
//
|
||||
// - O_NOFOLLOW rejects path-level symlinks outright, so a swap of the
|
||||
// binary's path component to point at an attacker-controlled target is
|
||||
// caught here rather than slipping through to the SDK.
|
||||
// - Fstat reads metadata from the inode the kernel just resolved, narrowing
|
||||
// the TOCTOU window between validation and exec to the time it takes the
|
||||
// SDK to fork+exec — sub-millisecond on a healthy host. The window can't
|
||||
// be fully closed without re-pointing the SDK at /proc/self/fd/N (the
|
||||
// known-good idiom), which would require keeping the fd alive across
|
||||
// fork+exec; we accept the tiny residual window for the simpler shape.
|
||||
func validateRootExecutable(path string) error {
|
||||
fd, err := unix.Open(path, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open executable %q: %w", path, err)
|
||||
}
|
||||
defer unix.Close(fd)
|
||||
var st unix.Stat_t
|
||||
if err := unix.Fstat(fd, &st); err != nil {
|
||||
return fmt.Errorf("fstat executable %q: %w", path, err)
|
||||
}
|
||||
if st.Mode&unix.S_IFMT != unix.S_IFREG {
|
||||
return fmt.Errorf("firecracker binary %q is not a regular file", path)
|
||||
}
|
||||
if info.Mode().Perm()&0o111 == 0 {
|
||||
if st.Mode&0o111 == 0 {
|
||||
return fmt.Errorf("firecracker binary %q is not executable", path)
|
||||
}
|
||||
if info.Mode().Perm()&0o022 != 0 {
|
||||
if st.Mode&0o022 != 0 {
|
||||
return fmt.Errorf("firecracker binary %q must not be group/world writable", path)
|
||||
}
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return fmt.Errorf("inspect owner for %q: unsupported file metadata", path)
|
||||
}
|
||||
if stat.Uid != 0 {
|
||||
if st.Uid != 0 {
|
||||
return fmt.Errorf("firecracker binary %q must be root-owned in system mode", path)
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
package roothelper
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"banger/internal/daemon/dmsnap"
|
||||
"banger/internal/firecracker"
|
||||
"banger/internal/paths"
|
||||
)
|
||||
|
||||
func TestValidateDMDevicePath(t *testing.T) {
|
||||
|
|
@ -33,6 +37,361 @@ func TestValidateDMDevicePath(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestValidateFirecrackerPID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := validateFirecrackerPID(0); err == nil {
|
||||
t.Fatal("validateFirecrackerPID(0) succeeded, want error")
|
||||
}
|
||||
if err := validateFirecrackerPID(-1); err == nil {
|
||||
t.Fatal("validateFirecrackerPID(-1) succeeded, want error")
|
||||
}
|
||||
// Self pid points at the go test binary, whose cmdline does not
|
||||
// contain "firecracker" — rejection proves the helper would refuse
|
||||
// to kill arbitrary host processes.
|
||||
if err := validateFirecrackerPID(os.Getpid()); err == nil {
|
||||
t.Fatal("validateFirecrackerPID(test pid) succeeded, want error")
|
||||
}
|
||||
// PID 1 is init/systemd on Linux — a juicy target for a compromised
|
||||
// daemon, and definitely not firecracker. Make sure we'd refuse.
|
||||
if err := validateFirecrackerPID(1); err == nil {
|
||||
t.Fatal("validateFirecrackerPID(1) succeeded, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateRootExecutableRejectsSymlink pins the O_NOFOLLOW
|
||||
// guarantee: even if the path string passes a textual check, a symlink
|
||||
// at the leaf is refused before we ever stat the target.
|
||||
func TestValidateRootExecutableRejectsSymlink(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
regular := filepath.Join(dir, "real")
|
||||
if err := os.WriteFile(regular, []byte{}, 0o755); err != nil {
|
||||
t.Fatalf("write regular: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "link")
|
||||
if err := os.Symlink(regular, link); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
if err := validateRootExecutable(link); err == nil {
|
||||
t.Fatal("validateRootExecutable(symlink) succeeded, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateRootExecutableRejectsNonRootOwned exercises the Fstat
|
||||
// uid check on a file the test user just created: it can't possibly
|
||||
// be uid 0, so the validator must refuse it. This is the regression
|
||||
// guard against the previous os.Stat code path drifting back in.
|
||||
func TestValidateRootExecutableRejectsNonRootOwned(t *testing.T) {
|
||||
t.Parallel()
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("test runs as root; cannot construct a non-root-owned file in a tempdir we can write")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "binary")
|
||||
if err := os.WriteFile(path, []byte{}, 0o755); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
err := validateRootExecutable(path)
|
||||
if err == nil {
|
||||
t.Fatal("validateRootExecutable(user-owned) succeeded, want error")
|
||||
}
|
||||
if !contains(err.Error(), "root-owned") {
|
||||
t.Fatalf("err = %v, want root-owned rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRootExecutableRejectsGroupWritable(t *testing.T) {
|
||||
t.Parallel()
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("test runs as root; can't construct a non-root-owned file")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "binary")
|
||||
if err := os.WriteFile(path, []byte{}, 0o775); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
err := validateRootExecutable(path)
|
||||
if err == nil {
|
||||
t.Fatal("validateRootExecutable(group-writable) succeeded, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// contains is a local substring helper that mirrors strings.Contains
|
||||
// without pulling in the package — kept tiny so the test file's
|
||||
// dependency surface stays close to the thing being tested.
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestValidateLoopDevicePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
arg string
|
||||
ok bool
|
||||
}{
|
||||
{name: "loop0", arg: "/dev/loop0", ok: true},
|
||||
{name: "loop12", arg: "/dev/loop12", ok: true},
|
||||
{name: "no_index", arg: "/dev/loop", ok: false},
|
||||
{name: "non_numeric", arg: "/dev/loop-x", ok: false},
|
||||
{name: "wrong_prefix", arg: "/dev/sda1", ok: false},
|
||||
{name: "empty", arg: "", ok: false},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateLoopDevicePath(tc.arg)
|
||||
if tc.ok && err != nil {
|
||||
t.Fatalf("validateLoopDevicePath(%q) = %v, want nil", tc.arg, err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatalf("validateLoopDevicePath(%q) succeeded, want error", tc.arg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDMRemoveTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
arg string
|
||||
ok bool
|
||||
}{
|
||||
{name: "dm_name", arg: "fc-rootfs-abc", ok: true},
|
||||
{name: "dm_device_path", arg: "/dev/mapper/fc-rootfs-abc", ok: true},
|
||||
{name: "wrong_prefix", arg: "not-banger", ok: false},
|
||||
{name: "device_wrong_prefix", arg: "/dev/mapper/not-banger", ok: false},
|
||||
{name: "empty", arg: "", ok: false},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateDMRemoveTarget(tc.arg)
|
||||
if tc.ok && err != nil {
|
||||
t.Fatalf("validateDMRemoveTarget(%q) = %v, want nil", tc.arg, err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatalf("validateDMRemoveTarget(%q) succeeded, want error", tc.arg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDMSnapshotHandles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Empty handles are tolerated — the dmsnap layer treats every
|
||||
// missing field as a no-op for that step.
|
||||
if err := validateDMSnapshotHandles(dmsnap.Handles{}); err != nil {
|
||||
t.Fatalf("validateDMSnapshotHandles(empty) = %v, want nil", err)
|
||||
}
|
||||
good := dmsnap.Handles{
|
||||
BaseLoop: "/dev/loop0",
|
||||
COWLoop: "/dev/loop1",
|
||||
DMName: "fc-rootfs-abc",
|
||||
DMDev: "/dev/mapper/fc-rootfs-abc",
|
||||
}
|
||||
if err := validateDMSnapshotHandles(good); err != nil {
|
||||
t.Fatalf("validateDMSnapshotHandles(good) = %v, want nil", err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mutate func(dmsnap.Handles) dmsnap.Handles
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "bad_dm_name", mutate: func(h dmsnap.Handles) dmsnap.Handles {
|
||||
h.DMName = "rogue"
|
||||
return h
|
||||
}, wantErr: true},
|
||||
{name: "bad_dm_device", mutate: func(h dmsnap.Handles) dmsnap.Handles {
|
||||
h.DMDev = "/dev/sda1"
|
||||
return h
|
||||
}, wantErr: true},
|
||||
{name: "bad_base_loop", mutate: func(h dmsnap.Handles) dmsnap.Handles {
|
||||
h.BaseLoop = "/dev/sda1"
|
||||
return h
|
||||
}, wantErr: true},
|
||||
{name: "bad_cow_loop", mutate: func(h dmsnap.Handles) dmsnap.Handles {
|
||||
h.COWLoop = "/etc/shadow"
|
||||
return h
|
||||
}, wantErr: true},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateDMSnapshotHandles(tc.mutate(good))
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatalf("validateDMSnapshotHandles(%s) succeeded, want error", tc.name)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("validateDMSnapshotHandles(%s) = %v, want nil", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLinuxIfaceName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
arg string
|
||||
ok bool
|
||||
}{
|
||||
{name: "typical_bridge", arg: "br-banger", ok: true},
|
||||
{name: "uplink", arg: "enp5s0", ok: true},
|
||||
{name: "max_len", arg: "a234567890abcde", ok: true}, // 15 chars
|
||||
{name: "empty", arg: "", ok: false},
|
||||
{name: "too_long", arg: "a234567890abcdef", ok: false},
|
||||
{name: "with_slash", arg: "br/0", ok: false},
|
||||
{name: "with_space", arg: "br 0", ok: false},
|
||||
{name: "with_colon", arg: "br:0", ok: false},
|
||||
{name: "dot", arg: ".", ok: false},
|
||||
{name: "dotdot", arg: "..", ok: false},
|
||||
{name: "control_char", arg: "br\x01", ok: false},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateLinuxIfaceName(tc.arg)
|
||||
if tc.ok && err != nil {
|
||||
t.Fatalf("validateLinuxIfaceName(%q) = %v, want nil", tc.arg, err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatalf("validateLinuxIfaceName(%q) succeeded, want error", tc.arg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIPv4(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
arg string
|
||||
ok bool
|
||||
}{
|
||||
{name: "valid", arg: "172.16.0.2", ok: true},
|
||||
{name: "with_whitespace", arg: " 10.0.0.1 ", ok: true},
|
||||
{name: "empty", arg: "", ok: false},
|
||||
{name: "ipv6", arg: "::1", ok: false},
|
||||
{name: "garbage", arg: "not-an-ip", ok: false},
|
||||
{name: "with_cidr", arg: "10.0.0.1/24", ok: false},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateIPv4(tc.arg)
|
||||
if tc.ok && err != nil {
|
||||
t.Fatalf("validateIPv4(%q) = %v, want nil", tc.arg, err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatalf("validateIPv4(%q) succeeded, want error", tc.arg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResolverAddr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
arg string
|
||||
ok bool
|
||||
}{
|
||||
{name: "ipv4", arg: "192.168.1.1", ok: true},
|
||||
{name: "ipv6", arg: "fe80::1", ok: true},
|
||||
{name: "empty", arg: "", ok: false},
|
||||
{name: "garbage", arg: "resolver.example", ok: false},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateResolverAddr(tc.arg)
|
||||
if tc.ok && err != nil {
|
||||
t.Fatalf("validateResolverAddr(%q) = %v, want nil", tc.arg, err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatalf("validateResolverAddr(%q) succeeded, want error", tc.arg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExt4ImagePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := &Server{}
|
||||
stateDir := paths.ResolveSystem().StateDir
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
arg string
|
||||
ok bool
|
||||
}{
|
||||
{name: "managed_image", arg: filepath.Join(stateDir, "vms", "abc", "rootfs.ext4"), ok: true},
|
||||
{name: "managed_dm_device", arg: "/dev/mapper/fc-rootfs-test", ok: true},
|
||||
{name: "outside_state", arg: "/etc/shadow", ok: false},
|
||||
{name: "wrong_dm", arg: "/dev/mapper/not-banger", ok: false},
|
||||
{name: "relative", arg: "rootfs.ext4", ok: false},
|
||||
{name: "empty", arg: "", ok: false},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := srv.validateExt4ImagePath(tc.arg)
|
||||
if tc.ok && err != nil {
|
||||
t.Fatalf("validateExt4ImagePath(%q) = %v, want nil", tc.arg, err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatalf("validateExt4ImagePath(%q) succeeded, want error", tc.arg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNotSymlink(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
regular := filepath.Join(dir, "real")
|
||||
if err := os.WriteFile(regular, []byte("ok"), 0o600); err != nil {
|
||||
t.Fatalf("write regular: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "link")
|
||||
if err := os.Symlink(regular, link); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
|
||||
if err := validateNotSymlink(regular); err != nil {
|
||||
t.Fatalf("validateNotSymlink(real) = %v, want nil", err)
|
||||
}
|
||||
if err := validateNotSymlink(link); err == nil {
|
||||
t.Fatal("validateNotSymlink(symlink) succeeded, want error")
|
||||
}
|
||||
if err := validateNotSymlink(filepath.Join(dir, "missing")); err == nil {
|
||||
t.Fatal("validateNotSymlink(missing) succeeded, want error")
|
||||
}
|
||||
// Symlink pointing into the system tree is the threat we care about.
|
||||
// A daemon-uid attacker plants this kind of link and hopes the helper
|
||||
// follows it; this test pins the rejection.
|
||||
hostileLink := filepath.Join(dir, "hostile")
|
||||
if err := os.Symlink("/etc/shadow", hostileLink); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
if err := validateNotSymlink(hostileLink); err == nil {
|
||||
t.Fatal("validateNotSymlink(symlink-to-/etc/shadow) succeeded, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLaunchDrivePathAllowsManagedRootDMDevice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue