277 lines
9.5 KiB
Go
277 lines
9.5 KiB
Go
//go:build linux
|
|
|
|
package main
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// A REAL TREE, REALLY KILLED. Everything else here decides from stubs; this is
|
|
// the one test that sends SIGSTOP and SIGKILL, and only to processes it started
|
|
// itself: a shell that backgrounds two sleeps and waits. It proves the freeze
|
|
// and the kill reach real grandchildren through the real parent map.
|
|
//
|
|
// MUTATION: make osStopProcess a no-op and killProcess skip descendants.
|
|
// RESULT: red — a sleep survives.
|
|
func TestKillTreeKillsARealProcessTree(t *testing.T) {
|
|
prevStop, prevResume, prevKill, prevTree, prevID := stopProcess, resumeProcess, killProcess, processTree, procIdentity
|
|
t.Cleanup(func() {
|
|
stopProcess, resumeProcess, killProcess, processTree, procIdentity = prevStop, prevResume, prevKill, prevTree, prevID
|
|
})
|
|
// THE FENCE. Real signals, as root, on the host's own process table: a
|
|
// tree-kill with one guard removed can SIGKILL every child of pid 1 on the
|
|
// machine running the test. Here every signal goes through `fenced`, which
|
|
// refuses — and fails the test for — any pid that is not the shell this
|
|
// test started or one of its descendants at the moment of asking. A
|
|
// mutant of killTree/descendantsOf can then fail this test, never reach
|
|
// another process.
|
|
// `allowed` is fixed BEFORE killTree runs: the shell and the children it
|
|
// had then. Re-deriving it at signal time would refuse the sleeps once the
|
|
// shell is dead and they are re-parented.
|
|
var root int
|
|
allowed := map[int]bool{}
|
|
refused := []int{}
|
|
fenced := func(pid int, sig syscall.Signal) error {
|
|
if !allowed[pid] {
|
|
refused = append(refused, pid)
|
|
return syscall.EPERM
|
|
}
|
|
return syscall.Kill(pid, sig)
|
|
}
|
|
stopProcess = func(pid int) error { return fenced(pid, syscall.SIGSTOP) }
|
|
resumeProcess = func(pid int) error { return fenced(pid, syscall.SIGCONT) }
|
|
killProcess = func(pid int) error { return fenced(pid, syscall.SIGKILL) }
|
|
procIdentity = osProcIdentity
|
|
processTree = processParents
|
|
defer func() {
|
|
if len(refused) > 0 {
|
|
t.Errorf("killTree asked to signal pids outside the test's own tree: %v", refused)
|
|
}
|
|
}()
|
|
|
|
cmd := exec.Command("sh", "-c", "sleep 300 & sleep 300 & wait")
|
|
if err := cmd.Start(); err != nil {
|
|
t.Skipf("cannot start a shell here: %v", err)
|
|
}
|
|
root = cmd.Process.Pid
|
|
done := make(chan struct{})
|
|
go func() { _ = cmd.Wait(); close(done) }()
|
|
|
|
// Wait until both sleeps are the shell's children in the real parent map.
|
|
// The shell's children, read from /proc by THIS TEST, not through
|
|
// descendantsOf or processParents: the fence below is built from this list,
|
|
// and a fence built with the code under test falls when that code does (a
|
|
// mutant descendantsOf returning every pid put every pid in the fence).
|
|
var kids []int
|
|
for i := 0; i < 100; i++ {
|
|
kids = childrenFromProc(root)
|
|
if len(kids) >= 2 {
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
if len(kids) < 2 {
|
|
_ = syscall.Kill(root, syscall.SIGKILL)
|
|
t.Fatalf("the shell's two sleeps never appeared under pid %d (saw %v)", root, kids)
|
|
}
|
|
|
|
allowed[root] = true
|
|
for _, k := range kids {
|
|
allowed[k] = true
|
|
}
|
|
if n := killTree(root, spareFromContain); n < 3 {
|
|
t.Fatalf("killTree reached %d processes, want 3 (the shell and two sleeps)", n)
|
|
}
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("the shell did not die")
|
|
}
|
|
for _, k := range kids {
|
|
// A reaped or zombie child is dead. A SIGKILL lands when the task next
|
|
// runs, so on a loaded machine a killed sleep can still read 'R' (or
|
|
// be part-way out) for a moment after the shell is gone: it is given
|
|
// five seconds to finish dying, and only one still alive then
|
|
// survived.
|
|
var stat string
|
|
for i := 0; i < 250; i++ {
|
|
s, err := readProcStat(k)
|
|
if err != nil || strings.Contains(s, ") Z ") {
|
|
stat = ""
|
|
break
|
|
}
|
|
stat = s
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
if stat != "" {
|
|
t.Fatalf("sleep %d survived the tree kill: %s", k, stat)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A REAL RELAYING PARENT, REALLY THAWED. The parent here does what sudo does:
|
|
// it waits on its child with WUNTRACED, and when the child is reported stopped
|
|
// it stops itself, then goes on waiting. killTree freezes and kills the child;
|
|
// with the fix the parent is continued, collects the exit, and leaves — so the
|
|
// child's pid is GONE (not a zombie) and the parent has exited. Without it the
|
|
// parent stays `T` and the child stays `Z`, which is what a machine showed
|
|
// after a real containment of an agent started through sudo.
|
|
//
|
|
// Same fence as the test above: every signal goes through `fenced`, and the
|
|
// allowed set is the parent and the child, read from /proc by this test.
|
|
//
|
|
// MUTATION: drop the thawRelayingAncestors call. RESULT: red — the parent is
|
|
// still stopped and the child is a zombie after the kill.
|
|
func TestKillTreeThawsARealRelayingParent(t *testing.T) {
|
|
py, err := exec.LookPath("python3")
|
|
if err != nil {
|
|
t.Skip("python3 is needed to stand in for sudo here")
|
|
}
|
|
prevStop, prevResume, prevKill, prevTree, prevID := stopProcess, resumeProcess, killProcess, processTree, procIdentity
|
|
t.Cleanup(func() {
|
|
stopProcess, resumeProcess, killProcess, processTree, procIdentity = prevStop, prevResume, prevKill, prevTree, prevID
|
|
})
|
|
allowed := map[int]bool{}
|
|
refused := []int{}
|
|
fenced := func(pid int, sig syscall.Signal) error {
|
|
if !allowed[pid] {
|
|
refused = append(refused, pid)
|
|
return syscall.EPERM
|
|
}
|
|
return syscall.Kill(pid, sig)
|
|
}
|
|
stopProcess = func(pid int) error { return fenced(pid, syscall.SIGSTOP) }
|
|
resumeProcess = func(pid int) error { return fenced(pid, syscall.SIGCONT) }
|
|
var parent int
|
|
killProcess = func(pid int) error {
|
|
// The relay needs the stand-in to be scheduled between our SIGSTOP and
|
|
// SIGKILL: a kill that lands first hands it an exit instead of a stop and
|
|
// nothing relays anything. sudo has the same window. The test closes it
|
|
// by letting the kill wait until the parent has stopped, bounded.
|
|
for i := 0; i < 200 && parent != 0; i++ {
|
|
if _, stopped, ok := osProcIdentity(parent); ok && stopped {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
return fenced(pid, syscall.SIGKILL)
|
|
}
|
|
procIdentity = osProcIdentity
|
|
processTree = processParents
|
|
defer func() {
|
|
if len(refused) > 0 {
|
|
t.Errorf("killTree asked to signal pids outside the test's own tree: %v", refused)
|
|
}
|
|
}()
|
|
|
|
// The stand-in for sudo: start the child, wait for it to be reported
|
|
// STOPPED, stop ourselves the way sudo does, then wait for it to end and
|
|
// exit 0. If what it collects is an exit instead of a stop it leaves with
|
|
// status 3 — the relay never happened and the run below has to say so
|
|
// rather than pass on a parent that was never stopped.
|
|
prog := "import os,signal,subprocess,sys\n" +
|
|
"p=subprocess.Popen(['sleep','300'])\n" +
|
|
"_, st = os.waitpid(p.pid, os.WUNTRACED)\n" +
|
|
"if not os.WIFSTOPPED(st):\n" +
|
|
" sys.exit(3)\n" +
|
|
"os.kill(os.getpid(), signal.SIGSTOP)\n" +
|
|
"p.wait()\n"
|
|
cmd := exec.Command(py, "-c", prog)
|
|
if err := cmd.Start(); err != nil {
|
|
t.Skipf("cannot start python3 here: %v", err)
|
|
}
|
|
parent = cmd.Process.Pid
|
|
done := make(chan struct{})
|
|
var waitErr error
|
|
go func() { waitErr = cmd.Wait(); close(done) }()
|
|
t.Cleanup(func() {
|
|
// Only while the stand-in is still ours: once Wait has returned, the
|
|
// number may belong to someone else and is not signalled.
|
|
select {
|
|
case <-done:
|
|
default:
|
|
_ = syscall.Kill(parent, syscall.SIGCONT)
|
|
_ = syscall.Kill(parent, syscall.SIGKILL)
|
|
}
|
|
})
|
|
|
|
var kids []int
|
|
for i := 0; i < 100; i++ {
|
|
kids = childrenFromProc(parent)
|
|
if len(kids) == 1 {
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
if len(kids) != 1 {
|
|
t.Fatalf("the stand-in's child never appeared under pid %d (saw %v)", parent, kids)
|
|
}
|
|
child := kids[0]
|
|
allowed[parent] = true
|
|
allowed[child] = true
|
|
|
|
if n := killTree(child, spareFromContain); n != 1 {
|
|
t.Fatalf("killTree reached %d processes, want 1 (the child)", n)
|
|
}
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
stat, _ := readProcStat(parent)
|
|
t.Fatalf("the parent never left: it was not continued after its child was killed (%s)", strings.TrimSpace(stat))
|
|
}
|
|
// Continued, not killed: a thaw that SIGKILLed the parent would also make
|
|
// it leave, with the child reaped by init. Exit 0 is the stand-in reaching
|
|
// the end of its script; exit 3 is the relay never having happened, which
|
|
// is a broken test, not a passing one.
|
|
if waitErr != nil {
|
|
t.Fatalf("the parent did not exit cleanly after being continued (3 = it never saw the stop): %v", waitErr)
|
|
}
|
|
if stat, err := readProcStat(child); err == nil {
|
|
t.Fatalf("the child is still in the process table after its parent exited: %s", strings.TrimSpace(stat))
|
|
}
|
|
}
|
|
|
|
// childrenFromProc lists the direct children of parent by reading
|
|
// /proc/<pid>/stat's ppid field (after the last ')', so a name with spaces
|
|
// cannot shift it). Deliberately independent of the package's own walk.
|
|
func childrenFromProc(parent int) []int {
|
|
ents, err := os.ReadDir("/proc")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
out := []int{}
|
|
for _, e := range ents {
|
|
pid, err := strconv.Atoi(e.Name())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
raw, err := os.ReadFile("/proc/" + e.Name() + "/stat")
|
|
if err != nil {
|
|
continue
|
|
}
|
|
s := string(raw)
|
|
i := strings.LastIndex(s, ")")
|
|
if i < 0 || i+2 > len(s) {
|
|
continue
|
|
}
|
|
f := strings.Fields(s[i+2:])
|
|
if len(f) > 1 {
|
|
if pp, err := strconv.Atoi(f[1]); err == nil && pp == parent {
|
|
out = append(out, pid)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func readProcStat(pid int) (string, error) {
|
|
out, err := exec.Command("cat", "/proc/"+strconv.Itoa(pid)+"/stat").Output()
|
|
return string(out), err
|
|
}
|