Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions internal/sandbox/loader_bind_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
55 changes: 54 additions & 1 deletion internal/sandbox/stage1_linux.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package sandbox

import (
"debug/elf"
"encoding/json"
"fmt"
"os"
Expand Down Expand Up @@ -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},
Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions internal/sandbox/testdata/loaderhelper/main.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading