309 lines
11 KiB
Go
309 lines
11 KiB
Go
//go:build linux
|
|
|
|
package main
|
|
|
|
// The connection watch against the real kernel: nftables, the command's
|
|
// cgroup, a real /bin/sh through the handler. It needs root, nft and ip, and
|
|
// it never runs in this machine's own network namespace: the test re-runs
|
|
// itself in a new one (CLONE_NEWNET), with a dummy interface as its way out,
|
|
// so the tables it makes and the packets it sends are seen by no firewall but
|
|
// its own. A machine without those skips it; CI's container is one.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const watchNetnsEnv = "PRIVATAE_TEST_ROOTEXEC_WATCH_NETNS"
|
|
|
|
func TestRootExecWatchKernel(t *testing.T) {
|
|
if os.Getenv(watchNetnsEnv) != "1" {
|
|
if os.Geteuid() != 0 {
|
|
t.Skip("needs root: nftables and a cgroup of its own")
|
|
}
|
|
for _, b := range []string{"nft", "ip"} {
|
|
if _, err := exec.LookPath(b); err != nil {
|
|
if _, err := os.Stat("/usr/sbin/" + b); err != nil {
|
|
t.Skipf("needs %s", b)
|
|
}
|
|
}
|
|
}
|
|
own, err := rootExecOwnCgroup()
|
|
if err != nil || syscall.Access(filepath.Join(cgroupRoot, own), 2 /* W_OK */) != nil {
|
|
t.Skip("needs a writable cgroup v2 folder of its own")
|
|
}
|
|
cmd := exec.Command(os.Args[0], "-test.run=^TestRootExecWatchKernel$", "-test.v", "-test.count=1")
|
|
cmd.Env = append(os.Environ(), watchNetnsEnv+"=1")
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Cloneflags: syscall.CLONE_NEWNET}
|
|
out, err := cmd.CombinedOutput()
|
|
t.Logf("in a private network namespace:\n%s", out)
|
|
if err != nil {
|
|
t.Fatalf("the namespaced run failed: %v", err)
|
|
}
|
|
if strings.Contains(string(out), "--- SKIP") || !strings.Contains(string(out), "--- PASS: TestRootExecWatchKernel/") {
|
|
t.Fatal("the namespaced run did not run its cases")
|
|
}
|
|
return
|
|
}
|
|
watchNetnsSetup(t)
|
|
rootExecWatchStart, rootExecWatchStop = rootExecWatchStartKernel, rootExecWatchStopKernel
|
|
bin, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connect := func(args ...string) string {
|
|
return "'" + bin + "' " + rootExecConnectSubcommand + " " + strings.Join(args, " ")
|
|
}
|
|
|
|
// MUTATION: return no connections from rootExecWatchStopKernel.
|
|
// RESULT: red — the outside destinations are not reported.
|
|
t.Run("outside", func(t *testing.T) {
|
|
env := rootExecBox(t, true)
|
|
j := runKernelCommand(t, env, "exec-k-out", connect("tcp:192.0.2.7:443", "udp:[fd99::7]:53"), 30)
|
|
u := untrustedOf(t, j)
|
|
if !u["192.0.2.7 tcp 443"] || !u["fd99::7 udp 53"] || len(u) != 2 {
|
|
t.Fatalf("both outside destinations, nothing else: %v (J=%v)", u, j)
|
|
}
|
|
row := auditRow(t, env, "exec-k-out")
|
|
if n, _ := row["untrusted_connections"].(float64); n != 2 {
|
|
t.Fatalf("audit stop row: %v", row)
|
|
}
|
|
watchNothingLeft(t)
|
|
})
|
|
|
|
// MUTATION: drop the loopback test from rootExecIsOwn, or the list lookup.
|
|
// RESULT: red — 127.0.0.1 or the platform's address is reported.
|
|
t.Run("loopback and own only", func(t *testing.T) {
|
|
env := rootExecBox(t, true)
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer ln.Close()
|
|
writeOwnHosts(t, `{"hosts": ["192.0.2.0/24", "fd99::/64"]}`)
|
|
j := runKernelCommand(t, env, "exec-k-own",
|
|
connect("tcp:"+ln.Addr().String(), "tcp:192.0.2.8:443", "udp:[fd99::8]:53", "udp:10.9.9.1:53"), 30)
|
|
if _, ok := j["untrusted_connections"]; ok {
|
|
t.Fatalf("loopback, this machine's own address and the platform's hosts are not outside: %v", j["untrusted_connections"])
|
|
}
|
|
cs, _ := auditRow(t, env, "exec-k-own")["connections"].([]any)
|
|
seen := map[string]bool{}
|
|
for _, c := range cs {
|
|
m, _ := c.(map[string]any)
|
|
seen[fmt.Sprintf("%v %v", m["addr"], m["own"])] = true
|
|
}
|
|
if !seen["192.0.2.8 true"] || !seen["fd99::8 true"] {
|
|
t.Fatalf("the platform connections are still recorded, marked own: %v", cs)
|
|
}
|
|
watchNothingLeft(t)
|
|
})
|
|
|
|
// MUTATION: keep the plain `oifname "lo" return` rule (no relay ports).
|
|
// RESULT: red — the connection to the loopback relay is never recorded.
|
|
t.Run("a loopback relay", func(t *testing.T) {
|
|
env := rootExecBox(t, true)
|
|
relay, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer relay.Close()
|
|
plain, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer plain.Close()
|
|
port := relay.Addr().(*net.TCPAddr).Port
|
|
writeOwnHosts(t, fmt.Sprintf(`{"hosts": [], "loopback_relays": [%d]}`, port))
|
|
j := runKernelCommand(t, env, "exec-k-relay",
|
|
connect("tcp:"+relay.Addr().String(), "tcp:"+plain.Addr().String(), "udp:127.0.0.53:53"), 30)
|
|
u := untrustedOf(t, j)
|
|
if !u[fmt.Sprintf("127.0.0.1 tcp %d", port)] || len(u) != 1 {
|
|
t.Fatalf("only the relay is outside, not other loopback or the resolver: %v (J=%v)", u, j)
|
|
}
|
|
watchNothingLeft(t)
|
|
})
|
|
|
|
// MUTATION: start the shell without the watch's cgroup (cgroupFD -1).
|
|
// RESULT: red — nothing the command does is seen.
|
|
t.Run("a child process", func(t *testing.T) {
|
|
env := rootExecBox(t, true)
|
|
j := runKernelCommand(t, env, "exec-k-child",
|
|
"("+connect("fork", "tcp:203.0.113.5:80")+") & wait; "+connect("fork", "udp:198.51.100.6:123"), 30)
|
|
u := untrustedOf(t, j)
|
|
if !u["203.0.113.5 tcp 80"] || !u["198.51.100.6 udp 123"] {
|
|
t.Fatalf("a grandchild's connections are the command's: %v", u)
|
|
}
|
|
watchNothingLeft(t)
|
|
})
|
|
|
|
// MUTATION: skip rootExecWatchCleanup's cgroup removal on the deadline path.
|
|
// RESULT: red — the cgroup outlives the command.
|
|
t.Run("forced kill", func(t *testing.T) {
|
|
env := rootExecBox(t, true)
|
|
j := runKernelCommand(t, env, "exec-k-kill",
|
|
connect("tcp:192.0.2.9:443")+"; (trap '' TERM; exec sleep 37) & sleep 37", 1)
|
|
if j["timed_out"] != true {
|
|
t.Fatalf("the deadline must have stopped it: %v", j)
|
|
}
|
|
if u := untrustedOf(t, j); !u["192.0.2.9 tcp 443"] {
|
|
t.Fatalf("what it reached before the kill is reported: %v", u)
|
|
}
|
|
watchNothingLeft(t)
|
|
})
|
|
|
|
// A bastion that died mid-command leaves its table (systemd removes the
|
|
// cgroup with the unit; here the test leaves both). The next sweep
|
|
// removes them, and a process still in the cgroup moves to its parent.
|
|
t.Run("left by a crash", func(t *testing.T) {
|
|
w := rootExecWatchStartKernel("exec-k-crash")
|
|
if w.Err != "" {
|
|
t.Fatalf("watch: %s", w.Err)
|
|
}
|
|
sl := exec.Command("/bin/sleep", "30")
|
|
sl.SysProcAttr = &syscall.SysProcAttr{}
|
|
rootExecIntoCgroup(sl.SysProcAttr, w.CgroupFD())
|
|
if err := sl.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = sl.Process.Kill(); _ = sl.Wait() }()
|
|
_ = syscall.Close(w.fd)
|
|
// the process that made it is gone: it holds nothing any more
|
|
rootExecWatching.Lock()
|
|
delete(rootExecWatching.ids, w.ID)
|
|
rootExecWatching.Unlock()
|
|
if left := watchLeftovers(t); len(left) != 2 {
|
|
t.Fatalf("the abandoned watch must still be there before the sweep: %v", left)
|
|
}
|
|
rootExecWatchSweep()
|
|
watchNothingLeft(t)
|
|
cg, _ := os.ReadFile("/proc/" + strconv.Itoa(sl.Process.Pid) + "/cgroup")
|
|
if strings.Contains(string(cg), rootExecWatchCgroupPrefix) {
|
|
t.Fatalf("the process left in it must have moved out: %s", cg)
|
|
}
|
|
})
|
|
}
|
|
|
|
// watchNetnsSetup gives the namespace loopback and one dummy interface that
|
|
// every other address routes to, so a connection leaves as a packet and goes
|
|
// nowhere.
|
|
func watchNetnsSetup(t *testing.T) {
|
|
t.Helper()
|
|
for _, args := range [][]string{
|
|
{"link", "set", "lo", "up"},
|
|
{"link", "add", "d0", "type", "dummy"},
|
|
{"addr", "add", "10.9.9.1/24", "dev", "d0"},
|
|
{"-6", "addr", "add", "fd99::1/64", "dev", "d0", "nodad"},
|
|
{"link", "set", "d0", "up"},
|
|
{"route", "add", "default", "dev", "d0"},
|
|
{"-6", "route", "add", "default", "dev", "d0"},
|
|
} {
|
|
if out, err := exec.Command("ip", args...).CombinedOutput(); err != nil {
|
|
t.Fatalf("ip %v: %v %s", args, err, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func runKernelCommand(t *testing.T, env sessionEnv, sid, command string, deadline int) map[string]any {
|
|
t.Helper()
|
|
ws := sessionDialAs(t, env, tAnchor)
|
|
sendExec(t, ws, sid, command, deadline, 0)
|
|
got := readUntil(t, ws, 'X', 20*time.Second)
|
|
var j map[string]any
|
|
if err := json.Unmarshal(got['J'], &j); err != nil {
|
|
t.Fatalf("J: %v (X=%q E=%q)", err, got['X'], got['E'])
|
|
}
|
|
return j
|
|
}
|
|
|
|
func untrustedOf(t *testing.T, j map[string]any) map[string]bool {
|
|
t.Helper()
|
|
out := map[string]bool{}
|
|
cs, _ := j["untrusted_connections"].([]any)
|
|
for _, c := range cs {
|
|
m, _ := c.(map[string]any)
|
|
if m["addr"] == "*" {
|
|
t.Fatalf("the watch did not run: %v", m["note"])
|
|
}
|
|
out[fmt.Sprintf("%v %v %v", m["addr"], m["proto"], m["port"])] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
// watchLeftovers lists every watch table and command cgroup that exists.
|
|
func watchLeftovers(t *testing.T) []string {
|
|
t.Helper()
|
|
var left []string
|
|
out, err := exec.Command("nft", "list", "tables").CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("nft list tables: %v %s", err, out)
|
|
}
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
if strings.Contains(line, rootExecWatchTablePrefix) {
|
|
left = append(left, line)
|
|
}
|
|
}
|
|
own, _ := rootExecOwnCgroup()
|
|
ents, _ := os.ReadDir(filepath.Join(cgroupRoot, own))
|
|
for _, e := range ents {
|
|
if strings.HasPrefix(e.Name(), rootExecWatchCgroupPrefix) {
|
|
left = append(left, e.Name())
|
|
}
|
|
}
|
|
return left
|
|
}
|
|
|
|
func watchNothingLeft(t *testing.T) {
|
|
t.Helper()
|
|
if left := watchLeftovers(t); len(left) > 0 {
|
|
t.Fatalf("the watch left kernel objects behind: %v", left)
|
|
}
|
|
}
|
|
|
|
// The cgroup kill without a kernel: cgroup.kill gets "1" when the kernel has
|
|
// it; on an older kernel each pid cgroup.procs lists gets SIGKILL, never pid 0
|
|
// or 1, and the list is read again until it is empty.
|
|
func TestRootExecWatchKillKernelCgroup(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "cgroup.kill"), nil, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rootExecWatchKillKernel(&rootExecWatch{fd: -1, dir: dir})
|
|
if b, _ := os.ReadFile(filepath.Join(dir, "cgroup.kill")); string(b) != "1" {
|
|
t.Fatalf("cgroup.kill must get 1, got %q", b)
|
|
}
|
|
|
|
dir = t.TempDir()
|
|
procs := filepath.Join(dir, "cgroup.procs")
|
|
if err := os.WriteFile(procs, []byte("4242\n1\n0\n4343\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var sent []int
|
|
oldKill := rootExecKillPid
|
|
rootExecKillPid = func(pid int) error {
|
|
sent = append(sent, pid)
|
|
if len(sent) >= 2 {
|
|
_ = os.WriteFile(procs, nil, 0o600) // the kernel empties the cgroup
|
|
}
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { rootExecKillPid = oldKill })
|
|
rootExecWatchKillKernel(&rootExecWatch{fd: -1, dir: dir})
|
|
if fmt.Sprint(sent) != "[4242 4343]" {
|
|
t.Fatalf("SIGKILL to each listed pid but 0 and 1, until the list is empty: %v", sent)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "cgroup.kill")); !os.IsNotExist(err) {
|
|
t.Fatal("cgroup.kill must never be created where the kernel has none")
|
|
}
|
|
rootExecWatchKillKernel(&rootExecWatch{fd: -1})
|
|
rootExecWatchKillKernel(nil)
|
|
}
|