//go:build linux package host import ( "fmt" "os" "path/filepath" "strings" "testing" "github.com/carlosframework/platform/internal/instancectl" "github.com/carlosframework/platform/internal/release" ) const containedStatus = "Uid:\t65534\t65534\t65534\t65534\nCapInh:\t0000000000000000\nCapPrm:\t0000000000000000\nCapEff:\t0000000000000000\nCapBnd:\t0000000000000000\nCapAmb:\t0000000000000000\nNoNewPrivs:\t1\nSeccomp:\t2\n" func TestPlan14GenerationContainedTask(t *testing.T) { for _, attack := range []string{"none", "closed history", "old record", "retired", "group identity", "main lifetime", "launcher", "namespace", "capability", "privilege gain", "missing filter", "unmasked", "other bind", "wrong app", "task group", "foreign proc", "foreign proc descriptor bind", "unrecognized mount root"} { t.Run(attack, func(t *testing.T) { f := generationProc(t) group := instancectl.ClaimUnitControlGroup(f.reservation.Binding.Binding.Host) cgroups, root := t.TempDir(), t.TempDir() if err := os.MkdirAll(filepath.Join(cgroups, group), 0755); err != nil { t.Fatal(err) } gid, err := generationIdentity(filepath.Join(cgroups, group)) if err != nil { t.Fatal(err) } lid, err := generationIdentity(f.unrelated) if err != nil { t.Fatal(err) } r := f.reservation r.Binding.Target.BootID = "boot-fixture" r.Process = release.GenerationProcess{PID: 100, TenantPID: 200, Start: "123", TenantStart: "123", Group: group, Invocation: "invocation", GroupIdentity: gid, LauncherIdentity: lid, Containment: 1} for _, pid := range []string{"100", "200"} { p := filepath.Join(f.proc, pid) if err := os.MkdirAll(p+"/ns", 0755); err != nil { t.Fatal(err) } generationWrite(t, p+"/stat", pid+" (app) S 100 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 123 0\n") generationWrite(t, p+"/cgroup", "0::/"+group+"\n") for _, ns := range []string{"mnt", "pid"} { generationLink(t, "confined-"+ns, p+"/ns/"+ns) } } generationLink(t, f.unrelated, f.proc+"/100/exe") generationLink(t, r.Binding.Path, f.proc+"/200/exe") generationWrite(t, f.task+"/cgroup", "0::/"+group+"\n") generationWrite(t, f.task+"/status", containedStatus) generationRelink(t, root, f.task+"/root") for _, ns := range []string{"mnt", "pid"} { generationRelink(t, "confined-"+ns, f.task+"/ns/"+ns) } if err := os.MkdirAll(root+"/opt/carlos", 0755); err != nil { t.Fatal(err) } generationLink(t, r.Binding.Path, root+"/opt/carlos/app") generationWrite(t, f.task+"/mountinfo", fmt.Sprintf("1 0 %d:%d / / ro - ext4 disk ro\n2 1 %d:%d %s /opt/carlos/app ro - ext4 disk ro\n", generationMajor(gid.Device), generationMinor(gid.Device), generationMajor(r.Binding.Executable.Device), generationMinor(r.Binding.Executable.Device), r.Binding.Path)) old := r old.Closed = true old.Binding.Attempt += "-old" old.Process.Invocation = "old-invocation" old.Process.GroupIdentity.Inode++ status := []byte(containedStatus) switch attack { case "old record": r.Process.Containment = 0 case "retired": r.Retirement = "retired" case "group identity": r.Process.GroupIdentity.Inode++ case "main lifetime": r.Process.Start = "999" case "launcher": r.Process.LauncherIdentity.Inode++ case "namespace": generationRelink(t, "namespace-mnt", f.task+"/ns/mnt") case "capability": status = []byte(strings.Replace(containedStatus, "CapEff:\t0000000000000000", "CapEff:\t0000000000000001", 1)) case "privilege gain": status = []byte(strings.Replace(containedStatus, "NoNewPrivs:\t1", "NoNewPrivs:\t0", 1)) case "missing filter": status = []byte(strings.Replace(containedStatus, "Seccomp:\t2", "Seccomp:\t0", 1)) case "unmasked": if err := os.MkdirAll(filepath.Join(root, f.versions), 0755); err != nil { t.Fatal(err) } case "other bind": generationWrite(t, f.task+"/mountinfo", fmt.Sprintf("1 0 0:1 / / ro - tmpfs tmpfs ro\n2 1 %d:%d %s /alias ro - ext4 disk ro\n", generationMajor(r.Binding.Executable.Device), generationMinor(r.Binding.Executable.Device), filepath.Dir(r.Binding.Tree))) case "unrecognized mount root": generationWrite(t, f.task+"/mountinfo", fmt.Sprintf("1 0 %d:%d / / ro - ext4 disk ro\n2 1 %d:%d /versions/v1/shop /opt/carlos/app ro - ext4 disk ro\n", generationMajor(gid.Device), generationMinor(gid.Device), generationMajor(r.Binding.Executable.Device), generationMinor(r.Binding.Executable.Device))) case "wrong app": generationRelink(t, f.unrelated, root+"/opt/carlos/app") case "foreign proc", "foreign proc descriptor bind": source := "/" if attack == "foreign proc descriptor bind" { source = "/777/fd" } generationWrite(t, f.task+"/mountinfo", "1 0 0:1 "+source+" /hostproc ro - proc proc ro\n") case "task group": generationWrite(t, f.task+"/cgroup", "0::/other\n") } k := generationKernel{proc: f.proc, cgroups: cgroups, contained: []release.GenerationReservation{r}, owner: uint32(os.Geteuid())} if attack == "closed history" { old := r old.Closed = true old.Process.Start = "dead" k.contained = append([]release.GenerationReservation{old}, k.contained...) } ok, err := k.containedTask(t.Context(), f.task, status) if attack == "none" || attack == "closed history" { if !ok || err != nil { t.Fatalf("contained tenant refused: %v %v", ok, err) } } else if ok && err == nil { t.Fatal("unsafe containment accepted") } for _, pid := range []string{"100", "200"} { p := filepath.Join(f.proc, pid) generationWrite(t, p+"/status", string(status)) generationLink(t, root, p+"/root") mounts, err := os.ReadFile(f.task + "/mountinfo") if err != nil { t.Fatal(err) } generationWrite(t, p+"/mountinfo", string(mounts)) } for _, name := range []string{"status", "root", "mountinfo", "ns", "cgroup"} { generationLink(t, f.task+"/"+name, f.proc+"/41/"+name) } generationWrite(t, filepath.Join(cgroups, group, "cgroup.procs"), "100\n200\n41\n") err = k.replacedGroup(t.Context(), old) if attack == "none" || attack == "closed history" { if err != nil { t.Fatal("independently contained replacement refused", err) } for _, mutation := range []string{"missing reservation", "live old", "same group", "same invocation", "other host", "unrecorded child"} { t.Run(mutation, func(t *testing.T) { candidate, prior, check := r, old, k switch mutation { case "live old": prior.Closed = false case "same group": prior.Process.GroupIdentity = candidate.Process.GroupIdentity case "same invocation": prior.Process.Invocation = candidate.Process.Invocation case "other host": candidate.Binding.Binding.Host = "foreign.bab.example" case "unrecorded child": generationWrite(t, filepath.Join(cgroups, group, "cgroup.procs"), "100\n200\n999\n") t.Cleanup(func() { generationWrite(t, filepath.Join(cgroups, group, "cgroup.procs"), "100\n200\n41\n") }) } check.contained = []release.GenerationReservation{candidate} if mutation == "missing reservation" { check.contained = nil } if err := check.replacedGroup(t.Context(), prior); err == nil { t.Fatal("unverified replacement accepted") } }) } } else if err == nil { t.Fatal("unsafe replacement accepted") } }) } }