diff --git a/internal/sandbox/loader_bind_test.go b/internal/sandbox/loader_bind_test.go new file mode 100644 index 00000000..dbac8b63 --- /dev/null +++ b/internal/sandbox/loader_bind_test.go @@ -0,0 +1,129 @@ +//go:build linux + +package sandbox + +import ( + "bytes" + "debug/elf" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// readInterp returns the ELF program interpreter of bin, or "" when the +// file is not an ELF or is statically linked. +func readInterp(t *testing.T, bin string) string { + t.Helper() + f, err := elf.Open(bin) + if err != nil { + return "" + } + defer func() { _ = f.Close() }() + sec := f.Section(".interp") + if sec == nil { + return "" + } + data, err := sec.Data() + if err != nil { + t.Fatalf("%s: read .interp: %v", bin, err) + } + return strings.TrimRight(string(data), "\x00") +} + +// buildLoaderHelper compiles the dynamically-linked loaderhelper test +// program and returns its path. +func buildLoaderHelper(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + bin := filepath.Join(t.TempDir(), "loaderhelper") + build := exec.Command("go", "build", "-o", bin, "./testdata/loaderhelper") + build.Dir = filepath.Dir(thisFile) + if out, berr := build.CombinedOutput(); berr != nil { + t.Skipf("go build unavailable: %v\n%s", berr, out) + } + return bin +} + +// uniqueSocketDir returns a fresh dir with a process-unique basename: +// stage1 names its staging root from the socket dir's basename and +// refuses to reuse a leftover root, so a plain t.TempDir() (".../001") +// collides across tests. +func uniqueSocketDir(t *testing.T) string { + t.Helper() + d, err := os.MkdirTemp("", "cp-sandboxtest-") + if err != nil { + t.Fatalf("mkdtemp: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(d) }) + return d +} + +// TestBindPlanIncludesLoader pins that bindPlan mirrors the directory +// of a dynamically-linked plugin binary's dynamic loader into the +// sandbox. The FHS bind list already covers /lib* loaders; on non-FHS +// distros (NixOS, Guix) the loader lives elsewhere and without this +// bind the exec of the plugin fails with ENOENT while the probe still +// reports the namespaces backend as available. +func TestBindPlanIncludesLoader(t *testing.T) { + exe := buildLoaderHelper(t) + interp := readInterp(t, exe) + if interp == "" { + t.Skip("helper built statically; loader discovery not exercisable") + } + if !filepath.IsAbs(interp) { + t.Fatalf("unexpected relative interpreter %q", interp) + } + spec := Spec{PluginName: "probe", BinaryPath: exe} + for _, b := range bindPlan(spec) { + if b.src == filepath.Dir(interp) { + return // the loader's directory is mirrored + } + } + t.Fatalf("bindPlan(%s) does not mirror the dynamic loader directory %q", exe, filepath.Dir(interp)) +} + +// TestDynamicBinaryRunsInNamespacesSandbox spawns a real +// dynamically-linked binary through the namespaces sandbox. On +// non-FHS distros this is the end-to-end failure the loader binds +// fix: exec of the plugin fails with ENOENT because the interpreter +// lives outside the FHS dirs. +func TestDynamicBinaryRunsInNamespacesSandbox(t *testing.T) { + av, err := Probe() + if err != nil { + t.Skipf("no sandbox backend: %v", err) + } + if av.Mode != ModeNamespaces { + t.Skipf("namespaces backend unavailable (%s); loader binds only matter there", av.Mode) + } + + // Build a small dynamically-linked helper (cgo on by default). A + // static build would exercise nothing. + bin := buildLoaderHelper(t) + if interp := readInterp(t, bin); interp == "" { + t.Skip("helper built statically; loader discovery not exercisable") + } + + cmd, err := Command(Spec{ + PluginName: "loaderhelper", + BinaryPath: bin, + SocketDir: uniqueSocketDir(t), + }, ModeNamespaces) + if err != nil { + t.Fatalf("sandbox command: %v", err) + } + var out, errb bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errb + if err := cmd.Run(); err != nil { + t.Fatalf("dynamically-linked binary failed in namespaces sandbox: %v\nstderr: %s", err, errb.String()) + } + if got := strings.TrimSpace(out.String()); got != "ok" { + t.Fatalf("helper stdout = %q, want %q", got, "ok") + } +} diff --git a/internal/sandbox/stage1_linux.go b/internal/sandbox/stage1_linux.go index bd53027f..2d5fd611 100644 --- a/internal/sandbox/stage1_linux.go +++ b/internal/sandbox/stage1_linux.go @@ -1,6 +1,7 @@ package sandbox import ( + "debug/elf" "encoding/json" "fmt" "os" @@ -261,6 +262,11 @@ func bindPlan(spec Spec) []bind { {src: "/etc/ld.so.conf", ro: true, optional: true}, {src: "/etc/ld.so.conf.d", ro: true, optional: true}, } + // Non-FHS distros (NixOS, Guix) keep the dynamic loader outside + // the FHS dirs above; without it exec of a dynamically-linked + // plugin fails with ENOENT. Mirror the loader from the binary's + // own ELF .interp section instead of assuming a location. + plan = append(plan, loaderBinds(spec.BinaryPath)...) if spec.Network == NetworkOutbound { plan = append(plan, bind{src: "/etc/resolv.conf", ro: true, optional: true}, @@ -276,7 +282,54 @@ func bindPlan(spec Spec) []bind { plan = append(plan, bind{src: p, ro: true}) } plan = append(plan, bind{src: spec.SocketDir, ro: false}) - return plan + return dedupeBinds(plan) +} + +// loaderBinds returns the bind needed to exec a dynamically-linked ELF +// binary: the directory holding its program interpreter (.interp). +// glibc keeps libc.so.6 and friends next to the loader, so mirroring +// the directory brings everything the binary needs at exec time. On +// FHS distros the loader's directory is already in bindPlan (the /lib* +// entries) and dedupeBinds drops this one; on non-FHS distros (NixOS, +// Guix) this is the bind that makes the plugin executable at all. +// Best-effort: static, non-ELF, or unreadable binaries yield no bind +// and keep the previous behavior. +func loaderBinds(binPath string) []bind { + f, err := elf.Open(binPath) + if err != nil { + return nil + } + defer func() { _ = f.Close() }() + sec := f.Section(".interp") + if sec == nil { + return nil + } + data, err := sec.Data() + if err != nil { + return nil + } + interp := strings.TrimRight(string(data), "\x00") + if interp == "" || !filepath.IsAbs(interp) { + return nil + } + return []bind{{src: filepath.Dir(interp), ro: true}} +} + +// dedupeBinds keeps the first bind for each source path. The loader +// binds can overlap the FHS list (on standard distros the loader +// lives under /lib64 or /usr/lib, which are already mirrored), so a +// duplicate would mount the same tree twice. +func dedupeBinds(plan []bind) []bind { + seen := make(map[string]bool, len(plan)) + out := plan[:0] + for _, b := range plan { + if seen[b.src] { + continue + } + seen[b.src] = true + out = append(out, b) + } + return out } // bindIntoRoot bind-mounts b.src at root+b.src, then remounts diff --git a/internal/sandbox/testdata/loaderhelper/main.go b/internal/sandbox/testdata/loaderhelper/main.go new file mode 100644 index 00000000..050fcac1 --- /dev/null +++ b/internal/sandbox/testdata/loaderhelper/main.go @@ -0,0 +1,20 @@ +// loaderhelper is a tiny dynamically-linked program the sandbox tests +// exec through the namespaces sandbox. It exists only to prove that +// the sandbox can run a binary whose dynamic loader lives outside the +// FHS /lib* dirs. +package main + +import ( + "fmt" + "net" +) + +func main() { + // net pulls the cgo resolver into the link, which links libc and + // gives the binary a dynamic loader (.interp) — the condition the + // loader binds address. + if net.ParseIP("127.0.0.1") == nil { + panic("unreachable") + } + fmt.Println("ok") +}