Skip to content

Commit d38a8db

Browse files
authored
fix: isolate /run with tmpfs and recreate /var/run symlinks in sandbox (#54)
* fix: isolate /run with tmpfs to prevent sandbox escape via host sockets Previously, buildDenyByDefaultMounts bind-mounted the entire host /run read-only, then overlaid /run/user with a tmpfs for D-Bus isolation. This left host sockets (Docker, Podman, containerd, libvirt, etc.) accessible inside the sandbox. Unix socket connections bypass filesystem read-only restrictions, so any exposed socket with matching permissions allowed full sandbox escape (e.g., docker run with host filesystem mounts). Replace the blanket --ro-bind /run /run with --tmpfs /run (allowlist approach), selectively mounting only: - /run/systemd/resolve/* when /etc/resolv.conf is a symlink into /run - /run/user/<uid>/bus for the filtered D-Bus proxy socket This matches the existing SSH key protection pattern (--ro-bind /dev/null over sensitive files) but is more robust: unknown sockets are excluded by default rather than requiring explicit deny rules. * fix: recreate /var/run and /var/lock symlinks in sandbox On modern Linux distros, /var/run is a symlink to /run and /var/lock is a symlink to /run/lock. Many programs (virsh, systemctl, etc.) hardcode /var/run paths. The sandbox already recreates symlinks for /bin, /sbin, /lib, /lib64 but was missing /var, causing programs to fail with "No such file or directory" even when /run paths were correctly configured. Fixes #53
1 parent 55a5b75 commit d38a8db

File tree

1 file changed

+63
-7
lines changed

1 file changed

+63
-7
lines changed

internal/sandbox/linux.go

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,47 @@ func dbusIsolationArgs(dbusBridge *DbusBridge, debug bool) []string {
516516
return args
517517
}
518518

519+
// runIsolationArgs returns bwrap arguments for /run in defaultDenyRead mode.
520+
// Instead of bind-mounting the host's /run (which exposes dangerous sockets like
521+
// Docker, Podman, containerd, libvirt), we start with an empty tmpfs and
522+
// selectively mount only what's needed.
523+
func runIsolationArgs(dbusBridge *DbusBridge, debug bool) []string {
524+
args := []string{"--tmpfs", "/run"}
525+
526+
// If /etc/resolv.conf is a symlink into /run (e.g., systemd-resolved
527+
// points to /run/systemd/resolve/stub-resolv.conf), we need to make
528+
// the target reachable inside the sandbox.
529+
if extra := resolveSymlinkForBind("/etc/resolv.conf", debug); len(extra) > 0 {
530+
// resolveSymlinkForBind may emit --tmpfs /run again (it detects /run as
531+
// a separate mount). Since we already have --tmpfs /run, filter those out
532+
// and only keep --dir and --ro-bind entries.
533+
for i := 0; i < len(extra); i++ {
534+
if extra[i] == "--tmpfs" && i+1 < len(extra) && extra[i+1] == "/run" {
535+
i++ // skip both --tmpfs and /run
536+
continue
537+
}
538+
args = append(args, extra[i])
539+
}
540+
}
541+
542+
// D-Bus session bus isolation: create /run/user/<uid> and optionally
543+
// bind-mount the filtered D-Bus proxy socket.
544+
uid := os.Getuid()
545+
userRunDir := fmt.Sprintf("/run/user/%d", uid)
546+
547+
if dbusBridge != nil {
548+
args = append(args, "--dir", userRunDir)
549+
args = append(args, "--bind", dbusBridge.SocketPath, filepath.Join(userRunDir, "bus"))
550+
if debug {
551+
fmt.Fprintf(os.Stderr, "[greywall:linux] /run isolated (tmpfs); D-Bus session bus filtered (only org.freedesktop.Notifications allowed)\n")
552+
}
553+
} else if debug {
554+
fmt.Fprintf(os.Stderr, "[greywall:linux] /run isolated (tmpfs); D-Bus session bus blocked\n")
555+
}
556+
557+
return args
558+
}
559+
519560
func fileExists(path string) bool {
520561
_, err := os.Stat(path) //nolint:gosec // internal paths only
521562
return err == nil
@@ -686,7 +727,7 @@ func buildDenyByDefaultMounts(cfg *config.Config, cwd string, dbusBridge *DbusBr
686727
// /bin, /sbin, /lib, /lib64 are often symlinks to /usr/*. We must
687728
// recreate these as symlinks via --symlink so the dynamic linker
688729
// and shell can be found. Real directories get bind-mounted.
689-
systemPaths := []string{"/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt", "/run"}
730+
systemPaths := []string{"/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt"}
690731
for _, p := range systemPaths {
691732
if !fileExists(p) {
692733
continue
@@ -702,11 +743,26 @@ func buildDenyByDefaultMounts(cfg *config.Config, cwd string, dbusBridge *DbusBr
702743
}
703744
}
704745

705-
// Block D-Bus session bus to prevent sandbox escape via GVFS/gnome-keyring.
706-
// /run/user/<uid>/bus exposes all host session services (file read via GVFS,
707-
// password read via gnome-keyring, process launch via Flatpak portal).
708-
// --tmpfs /run/user overlays the bind-mounted /run, hiding the D-Bus socket.
709-
args = append(args, dbusIsolationArgs(dbusBridge, debug)...)
746+
// /var: on modern distros, /var/run -> /run and /var/lock -> /run/lock.
747+
// Many programs (e.g., virsh, systemctl) use /var/run paths.
748+
// We recreate these symlinks so they resolve correctly inside the sandbox.
749+
if fileExists("/var") {
750+
args = append(args, "--dir", "/var")
751+
for _, sub := range []string{"/var/run", "/var/lock"} {
752+
if isSymlink(sub) {
753+
target, err := os.Readlink(sub)
754+
if err == nil {
755+
args = append(args, "--symlink", target, sub)
756+
}
757+
}
758+
}
759+
}
760+
761+
// /run: use an empty tmpfs and selectively mount only what's needed.
762+
// Mounting all of /run exposes dangerous host sockets (Docker, Podman,
763+
// containerd, libvirt, etc.) that allow sandbox escape even when
764+
// read-only, since Unix socket connections bypass filesystem write checks.
765+
args = append(args, runIsolationArgs(dbusBridge, debug)...)
710766

711767
// /sys needs to be accessible for system info
712768
if fileExists("/sys") && canMountOver("/sys") {
@@ -1009,7 +1065,7 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
10091065
// mounts like /run are empty, so the symlink target is unreachable and
10101066
// bwrap fails with "Can't create file at /etc/resolv.conf".
10111067
if !defaultDenyRead {
1012-
// In defaultDenyRead mode, /run is already explicitly mounted.
1068+
// In defaultDenyRead mode, resolv.conf symlink resolution is handled by runIsolationArgs.
10131069
if extra := resolveSymlinkForBind("/etc/resolv.conf", opts.Debug); len(extra) > 0 {
10141070
bwrapArgs = append(bwrapArgs, extra...)
10151071
}

0 commit comments

Comments
 (0)