//go:build linux package cgroup import ( "bufio" "bytes" "context" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "sync" "syscall" "testing" "time" "golang.org/x/sys/unix" ) // The shim placement under carlos-edge.service's own sandbox lines (zdd // readiness 3, Task 4; amadan #272). The shipped unit's RestrictNamespaces= // yes has systemd's seccomp filter answer clone3 with ENOSYS, which broke // clone3 placement on the dev box. TestEdgeHardeningPlacement copies the // unit's [Service] sandbox lines onto a transient user unit and runs // TestEdgeHardeningInner there, after a control run in a unit without them. // Opt-in: CARLOS_EDGE_HARDENING_KERNEL=1, run outside any unit, unsandboxed, // with TMPDIR under /tmp (the brief's /tmp/k/zddr3). const ( edgeUnitPath = "../host/units/carlos-edge.service" edgeInnerEnv = "CARLOS_EDGE_HARDENING_INNER" edgeHostUTSEnv = "CARLOS_EDGE_HARDENING_HOST_UTS" edgeHomeEnv = "CARLOS_EDGE_HARDENING_HOME" edgeMarkerEnv = "CARLOS_EDGE_HARDENING_MARKER" edgeTasksMaxEnv = "CARLOS_EDGE_HARDENING_TASKS_MAX" edgeUnitLifetime = "RuntimeMaxSec=300" ) // edgeApplied are the [Service] keys passed to the hardened unit as // -p Key=value, exactly as the unit file has them. var edgeApplied = []string{ "NoNewPrivileges", "ProtectSystem", "ProtectHome", "PrivateTmp", "PrivateDevices", "ProtectClock", "ProtectHostname", "ProtectKernelTunables", "ProtectKernelModules", "ProtectKernelLogs", "ProtectProc", "RestrictAddressFamilies", "RestrictNamespaces", "RestrictRealtime", "LockPersonality", "MemoryDenyWriteExecute", "SystemCallFilter", "SystemCallErrorNumber", "SystemCallArchitectures", "TasksMax", } // edgeExcluded are the [Service] keys the hardened unit does not get, each // with its reason. A [Service] key in neither table fails the driver, so a // new line in the unit forces a decision here. var edgeExcluded = map[string]string{ "EnvironmentFile": "process control, not sandbox: names the box's /etc/carlos/host.env", "ExecStart": "process control, not sandbox: the unit runs the test binary instead", "ExecReload": "process control, not sandbox", "Restart": "process control, not sandbox", "RestartSec": "process control, not sandbox", "User": "a user manager cannot change identity", "Group": "a user manager cannot change identity", "AmbientCapabilities": "a user manager cannot grant capabilities", "CapabilityBoundingSet": "a user manager cannot change capabilities", "ReadWritePaths": "names box paths (/data, /run/carlos) absent here", "Slice": "carlos.slice is the box's system slice; a user unit stays in the user manager's tree", "OOMPolicy": "unit lifecycle on an OOM kill, not sandbox; the test reads memory.events itself", "Delegate": "the test passes its own Delegate=yes (a user manager delegates cpu memory pids, no io)", } // edgeMustApply are the lines the brief requires applied: each must be in // the unit file and in edgeApplied. var edgeMustApply = []string{ "RestrictNamespaces", "SystemCallFilter", "SystemCallErrorNumber", "SystemCallArchitectures", "NoNewPrivileges", "MemoryDenyWriteExecute", "LockPersonality", "RestrictRealtime", "RestrictAddressFamilies", "ProtectProc", "ProtectClock", "ProtectHostname", "ProtectKernelTunables", "ProtectKernelModules", "ProtectKernelLogs", "PrivateDevices", "ProtectSystem", "TasksMax", } type edgeLine struct{ key, value string } // readEdgeService returns every Key=value line of path's [Service] section, // in file order. func readEdgeService(t *testing.T, path string) []edgeLine { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } var lines []edgeLine section := "" for n, line := range strings.Split(string(raw), "\n") { line = strings.TrimSpace(line) switch { case line == "", strings.HasPrefix(line, "#"), strings.HasPrefix(line, ";"): continue case strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]"): section = line continue case section != "[Service]": continue case strings.HasSuffix(line, `\`): t.Fatalf("%s:%d: a continued line; the driver reads one line per key", path, n+1) } key, value, ok := strings.Cut(line, "=") if !ok { t.Fatalf("%s:%d: %q is no Key=value line", path, n+1, line) } lines = append(lines, edgeLine{strings.TrimSpace(key), strings.TrimSpace(value)}) } if len(lines) == 0 { t.Fatalf("%s has no [Service] lines", path) } return lines } // edgeLog is the driver's view of a unit's output: each line goes to the // test log as it arrives, and the whole is kept for the verdict. type edgeLog struct { t *testing.T label string mu sync.Mutex part []byte all bytes.Buffer } func (w *edgeLog) Write(p []byte) (int, error) { w.mu.Lock() defer w.mu.Unlock() w.all.Write(p) w.part = append(w.part, p...) for { i := bytes.IndexByte(w.part, '\n') if i < 0 { break } w.t.Logf("%s| %s", w.label, w.part[:i]) w.part = w.part[i+1:] } return len(p), nil } func (w *edgeLog) flush() { w.mu.Lock() defer w.mu.Unlock() if len(w.part) > 0 { w.t.Logf("%s| %s", w.label, w.part) w.part = nil } } // runEdgeUnit runs the inner test in a transient user unit with props and // fails unless it passed, without a skip. func runEdgeUnit(t *testing.T, label, bin string, props, env []string) { t.Helper() args := []string{"--user", "--wait", "--pipe", "--collect"} for _, p := range props { args = append(args, "-p", p) } for _, e := range env { args = append(args, "-E", e) } args = append(args, bin, "-test.run", "^TestEdgeHardeningInner$", "-test.v", "-test.count=1") t.Logf("%s: systemd-run %s", label, strings.Join(quoteArgs(args), " ")) out := &edgeLog{t: t, label: label} cmd := exec.Command("systemd-run", args...) cmd.Stdout = out cmd.Stderr = out err := cmd.Run() out.flush() all := out.all.String() if err != nil { t.Fatalf("%s: systemd-run: %v", label, err) } if !strings.Contains(all, "--- PASS: TestEdgeHardeningInner ") { t.Fatalf("%s: no --- PASS line for TestEdgeHardeningInner", label) } if strings.Contains(all, "--- SKIP") || strings.Contains(all, "--- FAIL") { t.Fatalf("%s: the inner run skipped or failed", label) } } func quoteArgs(args []string) []string { q := make([]string, len(args)) for i, a := range args { if strings.ContainsAny(a, " $'\"") { a = strconv.Quote(a) } q[i] = a } return q } // TestEdgeHardeningPlacement is the driver: an unrestricted control, where // clone3 placement works, then the inner test under the edge unit's // sandbox lines, where clone3 answers ENOSYS and the shim must place. func TestEdgeHardeningPlacement(t *testing.T) { if os.Getenv("CARLOS_EDGE_HARDENING_KERNEL") != "1" { t.Skip("set CARLOS_EDGE_HARDENING_KERNEL=1, outside any unit, unsandboxed (zdd readiness 3, Task 4)") } if os.Getenv(edgeInnerEnv) != "" { t.Fatalf("the driver runs outside the unit, but %s is set", edgeInnerEnv) } if _, err := exec.LookPath("systemd-run"); err != nil { t.Fatal(err) } // 1. The unit's [Service] lines, each in exactly one table. lines := readEdgeService(t, edgeUnitPath) applied := map[string]bool{} for _, k := range edgeApplied { if _, both := edgeExcluded[k]; both { t.Fatalf("%s is in both the applied and the excluded table", k) } applied[k] = true } inUnit := map[string]bool{} var props []string tasksMax := "" for _, l := range lines { inUnit[l.key] = true _, excluded := edgeExcluded[l.key] switch { case applied[l.key]: props = append(props, l.key+"="+l.value) if l.key == "TasksMax" { tasksMax = l.value } case excluded: default: t.Fatalf("%s: [Service] %s=%s is in neither the applied nor the excluded table: decide which, and why", edgeUnitPath, l.key, l.value) } } for _, k := range edgeApplied { if !inUnit[k] { t.Fatalf("applied table names %s, which %s no longer has", k, edgeUnitPath) } } for k := range edgeExcluded { if !inUnit[k] { t.Fatalf("excluded table names %s, which %s no longer has", k, edgeUnitPath) } } for _, k := range edgeMustApply { if !inUnit[k] || !applied[k] { t.Fatalf("%s must be in %s and applied (in unit %v, applied %v)", k, edgeUnitPath, inUnit[k], applied[k]) } } t.Logf("applied (%d), passed as -p Key=value:", len(props)) for _, p := range props { t.Logf(" %s", p) } t.Logf("excluded (%d):", len(edgeExcluded)) for _, l := range lines { if r, ok := edgeExcluded[l.key]; ok { t.Logf(" %-22s %s", l.key, r) } } // The probes' inputs: what the inner run compares against this side. bin, err := os.Executable() if err != nil { t.Fatal(err) } uts, err := os.Readlink("/proc/self/ns/uts") if err != nil { t.Fatal(err) } home, err := os.UserHomeDir() if err != nil { t.Fatal(err) } if _, err := os.ReadDir(home); err != nil { t.Fatalf("the ProtectHome probe needs a readable home here: %v", err) } marker := filepath.Join(t.TempDir(), "visible-outside") if !strings.HasPrefix(marker, "/tmp/") { t.Fatalf("the PrivateTmp probe needs TMPDIR under /tmp (the brief's /tmp/k/zddr3), got marker %s", marker) } if err := os.WriteFile(marker, []byte("x"), 0o600); err != nil { t.Fatal(err) } env := []string{ edgeHostUTSEnv + "=" + uts, edgeHomeEnv + "=" + home, edgeMarkerEnv + "=" + marker, edgeTasksMaxEnv + "=" + tasksMax, } // 2. The unrestricted control: clone3 placement works here, or the // hardened run below proves nothing. runEdgeUnit(t, "control", bin, []string{"Delegate=yes", edgeUnitLifetime}, append([]string{edgeInnerEnv + "=control"}, env...)) // 3. The hardened run. WorkingDirectory=/ because a user unit's default // is the home directory, which ProtectHome=yes hides; the binary is // bound read-only because PrivateTmp=yes (and ProtectHome=yes, for a // binary built under home) would hide it. hardened := append([]string{"Delegate=yes", edgeUnitLifetime, "WorkingDirectory=/", "BindReadOnlyPaths=" + bin}, props...) runEdgeUnit(t, "hardened", bin, hardened, append([]string{edgeInnerEnv + "=hardened", "CARLOS_SHIM_KERNEL=1", "CARLOS_GEN_KERNEL=1"}, env...)) } // edgeProbeEnv is what the probes compare against: the driver's side, and // this unit's own cgroup. type edgeProbeEnv struct { hostUTS, home, marker, tasksMax string unitCgroup string // below /sys/fs/cgroup } // An edgeProbe reports whether the line's effect is observed in this // process, and what it saw. The control run requires every probe to read // false: a probe that reads true without the hardening tells nothing. type edgeProbe func(t *testing.T, e edgeProbeEnv) (bool, string) // edgeProbes holds the observable effect of each applied line that has one // here. var edgeProbes = map[string]edgeProbe{ "NoNewPrivileges": func(t *testing.T, _ edgeProbeEnv) (bool, string) { v := procStatusField(t, "NoNewPrivs") return v == "1", "NoNewPrivs: " + v }, "ProtectSystem": func(*testing.T, edgeProbeEnv) (bool, string) { const p = "/usr/.carlos-edge-hardening-probe" f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err == nil { _ = f.Close() _ = os.Remove(p) return false, "created " + p } return errors.Is(err, syscall.EROFS), "create under /usr: " + errnoName(err) }, "ProtectHome": func(_ *testing.T, e edgeProbeEnv) (bool, string) { _, err := os.ReadDir(e.home) if err == nil { return false, "read " + e.home } return true, "read " + e.home + ": " + errnoName(err) }, "PrivateTmp": func(_ *testing.T, e edgeProbeEnv) (bool, string) { _, err := os.Stat(e.marker) if err == nil { return false, "the driver's /tmp marker is visible" } return errors.Is(err, os.ErrNotExist), "the driver's /tmp marker: " + errnoName(err) }, "PrivateDevices": func(t *testing.T, _ edgeProbeEnv) (bool, string) { m, ok := lastMount(t, "/dev") if !ok { return false, "no /dev mount" } return m.fstype == "tmpfs", "/dev is " + m.fstype }, "ProtectHostname": func(t *testing.T, e edgeProbeEnv) (bool, string) { uts, err := os.Readlink("/proc/self/ns/uts") if err != nil { t.Fatal(err) } return uts != e.hostUTS, fmt.Sprintf("%s here, %s in the driver", uts, e.hostUTS) }, "ProtectKernelTunables": func(t *testing.T, _ edgeProbeEnv) (bool, string) { m, ok := lastMount(t, "/proc/sys") if !ok { return false, "no /proc/sys mount" } return m.readOnly(), "/proc/sys mounted " + m.opts }, "ProtectKernelModules": func(*testing.T, edgeProbeEnv) (bool, string) { _, err := os.ReadDir("/usr/lib/modules") if err == nil { return false, "read /usr/lib/modules" } return true, "read /usr/lib/modules: " + errnoName(err) }, "ProtectKernelLogs": func(t *testing.T, _ edgeProbeEnv) (bool, string) { m, ok := lastMount(t, "/proc/kmsg") if !ok { return false, "no mount over /proc/kmsg" } return true, "/proc/kmsg mounted over (" + m.opts + ")" }, "ProtectProc": func(*testing.T, edgeProbeEnv) (bool, string) { b, err := os.ReadFile("/proc/1/cmdline") if err != nil { return true, "/proc/1/cmdline: " + errnoName(err) } if len(b) == 0 { return true, "/proc/1/cmdline is empty" } return false, fmt.Sprintf("/proc/1/cmdline readable (%q)", strings.SplitN(string(b), "\x00", 2)[0]) }, "RestrictAddressFamilies": func(*testing.T, edgeProbeEnv) (bool, string) { fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC, unix.NETLINK_ROUTE) if err == nil { _ = unix.Close(fd) return false, "socket(AF_NETLINK) succeeded" } return true, "socket(AF_NETLINK): " + errnoName(err) }, "RestrictNamespaces": func(*testing.T, edgeProbeEnv) (bool, string) { e := rawClone3() return e == unix.ENOSYS, "raw clone3: " + errnoName(e) }, "LockPersonality": func(*testing.T, edgeProbeEnv) (bool, string) { // personality is per thread: try PER_LINUX32, and if the change // went through, restore the previous persona (its return value) on // the same thread. The lock refuses any persona but the current // one, the 0xffffffff query included, so there is no query first. runtime.LockOSThread() defer runtime.UnlockOSThread() const perLinux32 = 0x0008 prev, _, e := unix.RawSyscall(unix.SYS_PERSONALITY, perLinux32, 0, 0) if e == 0 { _, _, _ = unix.RawSyscall(unix.SYS_PERSONALITY, prev, 0, 0) return false, "personality(PER_LINUX32) succeeded" } return e == unix.EPERM, "personality(PER_LINUX32): " + errnoName(e) }, "MemoryDenyWriteExecute": func(*testing.T, edgeProbeEnv) (bool, string) { b, err := unix.Mmap(-1, 0, os.Getpagesize(), unix.PROT_READ|unix.PROT_WRITE|unix.PROT_EXEC, unix.MAP_PRIVATE|unix.MAP_ANON) if err == nil { _ = unix.Munmap(b) return false, "mmap(W|X) succeeded" } return true, "mmap(W|X): " + errnoName(err) }, // ptrace is in @debug, not @system-service. PEEKDATA on a process that // is not our tracee answers ESRCH when the call is allowed. "SystemCallFilter": func(*testing.T, edgeProbeEnv) (bool, string) { e := ptracePeekSelf() return e != unix.ESRCH, "ptrace(PEEKDATA, self): " + errnoName(e) }, "SystemCallErrorNumber": func(*testing.T, edgeProbeEnv) (bool, string) { e := ptracePeekSelf() return e == unix.EPERM, "a filtered call answers " + errnoName(e) }, "TasksMax": func(t *testing.T, e edgeProbeEnv) (bool, string) { got := strings.TrimSpace(readFixture(t, filepath.Join("/sys/fs/cgroup", e.unitCgroup, "pids.max"))) return got == e.tasksMax, "unit pids.max " + got }, } // edgeNoProbe are the applied lines whose effect cannot be told apart here // from another line's or from what this user already lacks: untested, so // the dev re-check on the real unit carries them. var edgeNoProbe = map[string]string{ "ProtectClock": "the @clock calls are already refused by SystemCallFilter=@system-service, and /dev/rtc is already hidden by PrivateDevices", "RestrictRealtime": "RLIMIT_RTPRIO is 0 (hard) for this user, so SCHED_FIFO answers EPERM with or without the line", "SystemCallArchitectures": "a non-native call needs 32-bit code, which a Go test cannot issue", } // edgeMayGoUnobserved are probed lines a user unit is known not to // enforce on DOCS-T1. Observed, they are enforced; unobserved, untested. // Every other probed line must be observed in the hardened run. var edgeMayGoUnobserved = map[string]string{ "ProtectProc": "the user manager cannot mount a hidepid procfs (the unit's /proc is the host's)", } func procStatusField(t *testing.T, name string) string { t.Helper() for _, line := range strings.Split(readFixture(t, "/proc/self/status"), "\n") { if v, ok := strings.CutPrefix(line, name+":"); ok { return strings.TrimSpace(v) } } t.Fatalf("/proc/self/status has no %s line", name) return "" } type mountEntry struct{ point, opts, fstype string } func (m mountEntry) readOnly() bool { for _, o := range strings.Split(m.opts, ",") { if o == "ro" { return true } } return false } // lastMount returns the topmost mount at point, from /proc/self/mountinfo. func lastMount(t *testing.T, point string) (mountEntry, bool) { t.Helper() var found mountEntry ok := false for _, line := range strings.Split(readFixture(t, "/proc/self/mountinfo"), "\n") { pre, post, sep := strings.Cut(line, " - ") f := strings.Fields(pre) if !sep || len(f) < 6 || f[4] != point { continue } ff := strings.Fields(post) if len(ff) == 0 { continue } found, ok = mountEntry{point: f[4], opts: f[5], fstype: ff[0]}, true } return found, ok } func errnoName(err error) string { var e syscall.Errno if errors.As(err, &e) { if n := unix.ErrnoName(e); n != "" { return n } } if err == nil { return "ok" } return err.Error() } // rawClone3 calls clone3(NULL, 0): an allowed clone3 answers EINVAL (the // size is below CLONE_ARGS_SIZE_VER0) and forks nothing; a filtered one // answers what the filter says. func rawClone3() syscall.Errno { _, _, e := unix.RawSyscall(unix.SYS_CLONE3, 0, 0, 0) return e } func ptracePeekSelf() syscall.Errno { _, _, e := unix.RawSyscall6(unix.SYS_PTRACE, unix.PTRACE_PEEKDATA, uintptr(os.Getpid()), 0, 0, 0, 0) return e } // plainCgroupStart is Go's own clone3 placement, the one the shim replaced: // exec.Cmd with UseCgroupFD into dir. func plainCgroupStart(t *testing.T, dir string) (*exec.Cmd, error) { t.Helper() f, err := os.Open(dir) if err != nil { t.Fatal(err) } defer f.Close() cmd := exec.Command("/bin/sleep", "60") cmd.Env = []string{} cmd.SysProcAttr = &syscall.SysProcAttr{UseCgroupFD: true, CgroupFD: int(f.Fd())} return cmd, cmd.Start() } // TestEdgeHardeningInner runs only in the driver's units. With the // variable set nothing in it skips: a precondition it cannot establish // fails it. func TestEdgeHardeningInner(t *testing.T) { mode := os.Getenv(edgeInnerEnv) switch mode { case "": t.Skip("runs inside TestEdgeHardeningPlacement's units only") case "control", "hardened": default: t.Fatalf("%s=%q, want control or hardened", edgeInnerEnv, mode) } pe := edgeProbeEnv{ hostUTS: os.Getenv(edgeHostUTSEnv), home: os.Getenv(edgeHomeEnv), marker: os.Getenv(edgeMarkerEnv), tasksMax: os.Getenv(edgeTasksMaxEnv), } if pe.hostUTS == "" || pe.home == "" || pe.marker == "" || pe.tasksMax == "" { t.Fatalf("the driver's probe inputs are missing: %+v", pe) } rel, ok := ParseSelfCgroup(readFixture(t, "/proc/self/cgroup")) if !ok { t.Fatal("no unified cgroup path") } if filepath.Base(rel) == "supervise" { rel = filepath.Dir(rel) } pe.unitCgroup = rel // Step 0: what the unit enforces, observed from inside. seccomp := procStatusField(t, "Seccomp") t.Logf("[%s] Seccomp: %s, NoNewPrivs: %s", mode, seccomp, procStatusField(t, "NoNewPrivs")) if mode == "hardened" && seccomp != "2" { t.Fatalf("Seccomp: %s, want 2 (filter mode)", seccomp) } if mode == "control" && seccomp != "0" { t.Fatalf("Seccomp: %s in the control, want 0", seccomp) } t.Logf("[%s] %-24s %-9s %s", mode, "property", "status", "observation") var untested []string for _, k := range edgeApplied { probe, probed := edgeProbes[k] why, unprobed := edgeNoProbe[k] if probed == unprobed { t.Fatalf("%s needs exactly one of a probe or a no-probe reason", k) } if unprobed { t.Logf("[%s] %-24s %-9s %s", mode, k, "untested", "no probe: "+why) untested = append(untested, k) continue } seen, what := probe(t, pe) status := "enforced" switch { case mode == "control" && seen: t.Fatalf("control: the %s probe reads enforced without the hardening (%s), so it tells nothing", k, what) case mode == "control": status = "absent" case !seen && edgeMayGoUnobserved[k] != "": status = "untested" what += "; " + edgeMayGoUnobserved[k] untested = append(untested, k) case !seen: t.Fatalf("hardened: %s is applied but not observed: %s", k, what) } t.Logf("[%s] %-24s %-9s %s", mode, k, status, what) } if mode == "hardened" { t.Logf("[hardened] untested here, for the dev re-check on the real unit: %s", strings.Join(untested, ", ")) } // The delegation: this test is the gate, so it fails rather than skips. t.Setenv("CARLOS_SHIM_KERNEL", "1") m := shimKernelManager(t) scratch := filepath.Join(m.Root(), "edge-hardening-scratch") if err := os.Mkdir(scratch, 0o755); err != nil { t.Fatal(err) } t.Cleanup(func() { if err := os.Remove(scratch); err != nil { t.Errorf("remove %s: %v", scratch, err) } }) // Step 1 (and the control's whole point): clone3 and Go's UseCgroupFD // start work without the hardening and answer ENOSYS under it. e := rawClone3() cmd, err := plainCgroupStart(t, scratch) if mode == "control" { if e != unix.EINVAL { t.Fatalf("control: raw clone3 answered %s, want EINVAL (clone3 allowed)", errnoName(e)) } if err != nil { t.Fatalf("control: UseCgroupFD start: %v", err) } k := &kernelKid{cmd: cmd, done: make(chan struct{})} got, cgErr := cgroupOf(cmd.Process.Pid) _ = cmd.Process.Kill() <-k.reaped() if want := strings.TrimPrefix(scratch, "/sys/fs/cgroup/"); cgErr != nil || got != want { t.Fatalf("control: the UseCgroupFD child ran in %q (%v), want %s", got, cgErr, want) } t.Logf("[control] raw clone3: EINVAL; UseCgroupFD start landed in %s", got) return } if e != unix.ENOSYS { t.Fatalf("hardened: raw clone3 answered %s, want ENOSYS", errnoName(e)) } if err == nil { _ = cmd.Process.Kill() _ = cmd.Wait() t.Fatal("hardened: a UseCgroupFD start succeeded under the filter") } if !errors.Is(err, syscall.ENOSYS) { t.Fatalf("hardened: UseCgroupFD start: %v, want ENOSYS", err) } t.Logf("[hardened] raw clone3: ENOSYS; UseCgroupFD start: %v", err) // Step 2: placement through the shim succeeds. const host = "hardening.example" lim := Limit{MemoryMax: 64 << 20} parent := m.hostDir(host) base := filepath.Join(strings.TrimPrefix(m.Root(), "/sys/fs/cgroup/"), ExecName(host)) // Registered first, so it runs last: after every child is reaped. t.Cleanup(func() { m.RemoveChild(host, GenChild(1)) m.RemoveChild(host, GenChild(2)) m.CleanupHost(host) }) var tenantIn io.WriteCloser var tenantOut io.ReadCloser var pipeErr error tenantCmd, err := m.StartChildGated(host, GenChild(1), lim, lim, func() *exec.Cmd { c := exec.Command("/bin/sh", "-c", `echo "$$"; cat "/proc/$$/cgroup"; read -r _`) c.Env = []string{"PATH=/usr/bin:/bin"} c.Stderr = os.Stderr if tenantIn, pipeErr = c.StdinPipe(); pipeErr == nil { tenantOut, pipeErr = c.StdoutPipe() } return c }, nil) tenant := reapOnCleanup(t, m, host, GenChild(1), tenantCmd, tenantIn) if err != nil { t.Fatalf("hardened: StartChildGated g1: %v", err) } if pipeErr != nil { t.Fatal(pipeErr) } type said struct { pid, cg string err error } saidc := make(chan said, 1) go func() { r := bufio.NewReader(tenantOut) pid, err := r.ReadString('\n') if err != nil { saidc <- said{err: err} return } cg, err := r.ReadString('\n') saidc <- said{pid: strings.TrimSpace(pid), cg: cg, err: err} }() var s said select { case s = <-saidc: case <-time.After(10 * time.Second): t.Fatal("hardened: the tenant printed nothing within 10s") } if s.err != nil { t.Fatalf("hardened: reading the tenant: %v", s.err) } wantG1 := filepath.Join(base, GenChild(1)) gotCg, ok := ParseSelfCgroup(s.cg) if !ok || gotCg != wantG1 { t.Fatalf("hardened: the tenant says it runs in %q, want %s", s.cg, wantG1) } if s.pid != strconv.Itoa(tenantCmd.Process.Pid) { t.Fatalf("hardened: the tenant's pid is %s, cmd.Process.Pid is %d", s.pid, tenantCmd.Process.Pid) } if comm, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", tenantCmd.Process.Pid)); err != nil || strings.TrimSpace(string(comm)) != "sh" { t.Fatalf("hardened: pid %d is %q (%v), want the exec'd tenant, sh", tenantCmd.Process.Pid, comm, err) } t.Logf("[hardened] g1 tenant pid %s = cmd.Process.Pid, in /%s", s.pid, gotCg) // Step 3: the limit applies. The parent is raised as a swap raises it, // so g2's own memory.max is the one that bites. if err := m.SetParentLimit(host, OverlapLimit(lim, lim)); err != nil { t.Fatal(err) } var hogIn io.WriteCloser var hogPipeErr error hogCmd, err := m.StartChildGated(host, GenChild(2), lim, lim, func() *exec.Cmd { c := genKernelCmd("hog") hogIn, hogPipeErr = c.StdinPipe() return c }, nil) hog := reapOnCleanup(t, m, host, GenChild(2), hogCmd, hogIn) if err != nil { t.Fatalf("hardened: StartChildGated g2: %v", err) } if hogPipeErr != nil { t.Fatal(hogPipeErr) } g2 := filepath.Join(parent, GenChild(2)) expectFile(t, filepath.Join(g2, "memory.max"), "67108864") if got, err := cgroupOf(hogCmd.Process.Pid); err != nil || got != filepath.Join(base, GenChild(2)) { t.Fatalf("hardened: the hog runs in %s (%v), want %s", got, err, filepath.Join(base, GenChild(2))) } if _, err := fmt.Fprintln(hogIn, "go"); err != nil { t.Fatalf("hog go-ahead: %v", err) } select { case <-hog.reaped(): case <-time.After(60 * time.Second): t.Fatal("hardened: the hog was not OOM-killed within 60s") } var exitErr *exec.ExitError if !errors.As(hog.err, &exitErr) { t.Fatalf("hardened: hog exit: %v", hog.err) } if ws, ok := exitErr.Sys().(syscall.WaitStatus); !ok || !ws.Signaled() || ws.Signal() != syscall.SIGKILL { t.Fatalf("hardened: the hog ended with %v, want SIGKILL from the OOM killer", hog.err) } kills := oomKills(t, g2) if kills < 1 { t.Fatalf("hardened: g2 memory.events oom_kill = %d, want at least 1", kills) } if n := oomKills(t, filepath.Join(parent, GenChild(1))); n != 0 { t.Fatalf("hardened: g1 saw %d OOM kills", n) } select { case <-tenant.reaped(): t.Fatalf("hardened: g1's tenant died beside the hog: %v", tenant.err) default: } if err := syscall.Kill(tenantCmd.Process.Pid, 0); err != nil { t.Fatalf("hardened: g1's tenant is gone: %v", err) } t.Logf("[hardened] g2 hog: %v, g2 oom_kill %d; g1 tenant alive", hog.err, kills) // Step 4: arming would arm. ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() if err := m.VerifyPlacement(ctx); err != nil { t.Fatalf("hardened: VerifyPlacement: %v", err) } t.Logf("[hardened] VerifyPlacement: nil") }