diff --git a/.changes/unreleased/+mapped-startup-authority.yaml b/.changes/unreleased/+mapped-startup-authority.yaml new file mode 100644 index 00000000..47aa6fc0 --- /dev/null +++ b/.changes/unreleased/+mapped-startup-authority.yaml @@ -0,0 +1,2 @@ +kind: Added +body: Add an internal mapped startup transition with explicit capability ceilings and exact credentials. Public mapped execution remains gated on the remaining admission and runtime proofs. diff --git a/internal/probe/mapped_exec.go b/internal/probe/mapped_exec.go new file mode 100644 index 00000000..6c835349 --- /dev/null +++ b/internal/probe/mapped_exec.go @@ -0,0 +1,109 @@ +package probe + +import ( + "fmt" + "slices" + "strconv" + "strings" +) + +// This bitmap is a private trusted-launch argument, not blueprint syntax. +// Its presence selects the mapped transition; omission keeps the old waiter +// inhibited. Ordinary sandbox/restricted-exec parsing does not accept it. +const mappedCapabilityArgumentV1 = "--mapped-capabilities=" + +func parseMappedSandboxExecPlanV1(args []string) (sandboxExecPlanV1, error) { + var ceiling *uint64 + if len(args) > 0 && strings.HasPrefix(args[0], mappedCapabilityArgumentV1) { + text := strings.TrimPrefix(args[0], mappedCapabilityArgumentV1) + value, err := strconv.ParseUint(text, 16, 64) + if err != nil || fmt.Sprintf("%016x", value) != text { + return sandboxExecPlanV1{}, fmt.Errorf("mapped capability ceiling must be 16 lowercase hexadecimal digits") + } + ceiling = &value + args = args[1:] + } + plan, err := parseSandboxExecPlanV1(args, true) + if err != nil { + return plan, err + } + plan.MappedCapabilities = ceiling + if ceiling != nil { + if plan.UID != 0 && *ceiling != 0 { + return sandboxExecPlanV1{}, fmt.Errorf("nonzero UID with mapped capabilities is not qualified") + } + if plan.PrivateEnvironment || plan.RecordExitStatus { + return sandboxExecPlanV1{}, fmt.Errorf("mapped private environment and observed-exit transitions are not qualified") + } + } + return plan, nil +} + +func verifyMappedKernelStatusV1(content []byte, plan sandboxExecPlanV1) error { + if plan.MappedCapabilities == nil { + return fmt.Errorf("mapped authority requires an explicit capability ceiling") + } + expected := map[string][]string{ + "Uid": {"", "", "", ""}, "Gid": {"", "", "", ""}, + "Groups": {}, "CapPrm": {}, "CapEff": {}, "CapBnd": {}, + "CapInh": {}, "CapAmb": {}, "NoNewPrivs": {"1"}, "Seccomp": {"2"}, + } + for i := range 4 { + expected["Uid"][i] = strconv.FormatUint(uint64(plan.UID), 10) + expected["Gid"][i] = strconv.FormatUint(uint64(plan.GID), 10) + } + for _, group := range plan.Groups { + expected["Groups"] = append(expected["Groups"], strconv.FormatUint(uint64(group), 10)) + } + seen := make(map[string]bool, len(expected)) + for _, line := range strings.Split(string(content), "\n") { + name, raw, found := strings.Cut(line, ":") + want, required := expected[name] + if !found || !required { + continue + } + if seen[name] { + return fmt.Errorf("mapped %s appears more than once", name) + } + seen[name] = true + got := strings.Fields(raw) + if name == "Groups" { + // Linux orders kernel GIDs, which need not have the same order + // after translation through an exact container-to-host map. + groups := make([]uint32, len(got)) + for index, text := range got { + group, err := parseCredentialV1(text) + if err != nil { + return fmt.Errorf("mapped Groups is malformed") + } + groups[index] = group + } + slices.Sort(groups) + if !slices.Equal(groups, plan.Groups) { + return fmt.Errorf("mapped Groups differs from accepted authority") + } + continue + } + if strings.HasPrefix(name, "Cap") { + mask := uint64(0) + if name == "CapPrm" || name == "CapEff" || name == "CapBnd" { + mask = *plan.MappedCapabilities + } + if len(got) != 1 { + return fmt.Errorf("mapped %s is malformed", name) + } + value, err := strconv.ParseUint(got[0], 16, 64) + if err != nil || value != mask { + return fmt.Errorf("mapped %s differs from the accepted ceiling", name) + } + continue + } + if len(got) != len(want) || strings.Join(got, " ") != strings.Join(want, " ") { + return fmt.Errorf("mapped %s differs from accepted authority", name) + } + } + if len(seen) != len(expected) { + return fmt.Errorf("mapped kernel authority is incomplete") + } + return nil +} diff --git a/internal/probe/mapped_exec_linux.go b/internal/probe/mapped_exec_linux.go new file mode 100644 index 00000000..a3d39a78 --- /dev/null +++ b/internal/probe/mapped_exec_linux.go @@ -0,0 +1,155 @@ +//go:build linux && (amd64 || arm64) + +package probe + +import ( + "fmt" + "math" + "os" + "path" + "runtime" + "strconv" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/unix" +) + +func validateMappedStartupV1(plan sandboxExecPlanV1) error { + return validateMappedStartupWithV1(plan, os.ReadFile) +} + +func validateMappedStartupWithV1(plan sandboxExecPlanV1, readFile func(string) ([]byte, error)) error { + if plan.MappedCapabilities == nil || plan.UID == math.MaxUint32 || plan.GID == math.MaxUint32 || + plan.UID != 0 && *plan.MappedCapabilities != 0 || plan.PrivateEnvironment || plan.RecordExitStatus { + return fmt.Errorf("mapped credential tuple is not qualified") + } + for i, group := range plan.Groups { + if group == math.MaxUint32 || i > 0 && plan.Groups[i-1] >= group { + return fmt.Errorf("mapped groups must be exact, sorted and unique") + } + } + if len(plan.Argv) == 0 || !path.IsAbs(plan.Argv[0]) || path.Clean(plan.Argv[0]) != plan.Argv[0] { + return fmt.Errorf("mapped executable must be a normalized absolute path") + } + content, err := readFile("/proc/sys/kernel/cap_last_cap") + if err != nil { + return fmt.Errorf("read mapped capability limit: %w", err) + } + last, err := strconv.Atoi(strings.TrimSpace(string(content))) + if err != nil || last < 0 || last >= 64 || last < 63 && *plan.MappedCapabilities>>(last+1) != 0 { + return fmt.Errorf("mapped capability ceiling is outside the kernel range") + } + for fd := 0; fd < 3; fd++ { + flags, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0) + if err != nil || flags&unix.FD_CLOEXEC != 0 { + return fmt.Errorf("mapped standard descriptor %d is unavailable at exec", fd) + } + } + return nil +} + +func execMappedApplicationV1(plan sandboxExecPlanV1, environment []string) error { + // Prepare every pointer while the shared Go descriptor table is intact. + filename, err := syscall.BytePtrFromString(plan.Argv[0]) + if err != nil { + return err + } + argv, err := syscall.SlicePtrFromStrings(plan.Argv) + if err != nil { + return err + } + envp, err := syscall.SlicePtrFromStrings(environment) + if err != nil { + return err + } + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if unix.Gettid() == os.Getpid() { + // Keep the leader pinned and trusted while a different locked thread + // performs the transition. Exec must destroy this stronger peer too. + result := make(chan error, 1) + go func() { result <- execMappedApplicationV1(plan, environment) }() + return <-result + } + mask := *plan.MappedCapabilities + // Kernel support was checked before sandbox preparation. Dropping all + // unwanted bits, including future ones, cannot grant authority. + for capability := 0; capability < 64; capability++ { + if mask&(uint64(1)< 0 { + groups = unsafe.Pointer(&plan.Groups[0]) + } + mappedRawCallV1(unix.SYS_SETGROUPS, uintptr(len(plan.Groups)), uintptr(groups), 0) + mappedRawCallV1(unix.SYS_SETRESGID, uintptr(plan.GID), uintptr(plan.GID), uintptr(plan.GID)) + mappedRawCallV1(unix.SYS_SETRESUID, uintptr(plan.UID), uintptr(plan.UID), uintptr(plan.UID)) + header := unix.CapUserHeader{Version: unix.LINUX_CAPABILITY_VERSION_3} + data := [2]unix.CapUserData{ + {Effective: uint32(mask), Permitted: uint32(mask)}, + {Effective: uint32(mask >> 32), Permitted: uint32(mask >> 32)}, + } + mappedRawCallV1(unix.SYS_CAPSET, uintptr(unsafe.Pointer(&header)), uintptr(unsafe.Pointer(&data[0])), 0) + actualBits, _, errno := syscall.RawSyscall6(unix.SYS_PRCTL, unix.PR_GET_SECUREBITS, 0, 0, 0, 0, 0) + if errno != 0 || actualBits != bits { + mappedTransitionFailureV1(122) + } + content, err := readApplicationKernelStatus() + if err != nil || verifyMappedKernelStatusV1(content, plan) != nil { + mappedTransitionFailureV1(122) + } + mappedExecTailV1(filename, unsafe.Pointer(&argv[0]), unsafe.Pointer(&envp[0])) + runtime.KeepAlive(argv) + runtime.KeepAlive(envp) + runtime.KeepAlive(plan.Groups) + return fmt.Errorf("mapped exec tail returned unexpectedly") +} + +// Pointer conversions must stay in this call's argument list. The directive +// keeps their objects on the heap and alive across any wrapper stack growth. +// +//go:uintptrescapes +func mappedRawCallV1(number, a, b, c uintptr) { + if _, _, errno := syscall.RawSyscall6(number, a, b, c, 0, 0, 0); errno != 0 { + mappedTransitionFailureV1(121) + } +} + +//go:nosplit +//go:norace +func mappedTransitionFailureV1(code uintptr) { + syscall.RawSyscall(unix.SYS_EXIT_GROUP, code, 0, 0) + for { + } +} + +// No Go work, argument conversion, logging or returning error path is allowed +// after detaching and closing the descriptor table. Constants come from the +// target architecture's UAPI, never another architecture's fixture numbers. +// +//go:nosplit +//go:norace +func mappedExecTailV1(filename *byte, argv, envp unsafe.Pointer) { + _, _, errno := syscall.RawSyscall6(unix.SYS_CLOSE_RANGE, 3, uintptr(^uint32(0)), unix.CLOSE_RANGE_UNSHARE, 0, 0, 0) + if errno == 0 { + syscall.RawSyscall(unix.SYS_EXECVE, uintptr(unsafe.Pointer(filename)), uintptr(argv), uintptr(envp)) + } + mappedTransitionFailureV1(125) +} diff --git a/internal/probe/mapped_exec_linux_test.go b/internal/probe/mapped_exec_linux_test.go new file mode 100644 index 00000000..3b32bb2f --- /dev/null +++ b/internal/probe/mapped_exec_linux_test.go @@ -0,0 +1,480 @@ +//go:build linux && (amd64 || arm64) + +package probe + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + "unsafe" + + "github.com/omry/reploy/internal/canonical" + "github.com/omry/reploy/internal/mappedlaunch" + "golang.org/x/sys/unix" +) + +//go:noinline +func mappedReadAtDepthV1(depth, fd int) byte { + var buffer [256]byte + buffer[0] = 77 + if depth > 0 { + value := mappedReadAtDepthV1(depth-1, fd) + runtime.KeepAlive(&buffer) + return value + } + mappedRawCallV1(unix.SYS_READ, uintptr(fd), uintptr(unsafe.Pointer(&buffer[0])), 1) + return buffer[0] +} + +func TestMappedRawPointerStackRelocationV1(t *testing.T) { + var pipe [2]int + if err := unix.Pipe(pipe[:]); err != nil { + t.Fatal(err) + } + defer unix.Close(pipe[0]) + defer unix.Close(pipe[1]) + for depth := range 2048 { + if _, err := unix.Write(pipe[1], []byte{42}); err != nil { + t.Fatal(err) + } + result := make(chan byte, 1) + go func() { result <- mappedReadAtDepthV1(depth, pipe[0]) }() + if got := <-result; got != 42 { + t.Errorf("stack depth%d: pointer destination=%d, want42", depth, got) + } + } +} + +func TestMappedStartupPreflightRejectsUnsupportedAuthorityV1(t *testing.T) { + mask := uint64(0) + good := sandboxExecPlanV1{UID: 200, GID: 201, Groups: []uint32{100, 201}, MappedCapabilities: &mask, Argv: []string{"/bin/true"}} + if err := validateMappedStartupV1(good); err != nil { + t.Fatal(err) + } + for _, mode := range []string{"nil", "UID", "GID", "groups sentinel", "groups unsorted", "groups duplicate", "nonzero capabilities", "private", "observed", "no argv", "relative", "unclean", "kernel ceiling"} { + t.Run(mode, func(t *testing.T) { + plan := good + plan.Groups = append([]uint32(nil), good.Groups...) + value := mask + plan.MappedCapabilities = &value + switch mode { + case "nil": + plan.MappedCapabilities = nil + case "UID": + plan.UID = math.MaxUint32 + case "GID": + plan.GID = math.MaxUint32 + case "groups sentinel": + plan.Groups = []uint32{math.MaxUint32} + case "groups unsorted": + plan.Groups = []uint32{201, 100} + case "groups duplicate": + plan.Groups = []uint32{100, 100} + case "nonzero capabilities": + value = 1 + case "private": + plan.PrivateEnvironment = true + case "observed": + plan.RecordExitStatus = true + case "no argv": + plan.Argv = nil + case "relative": + plan.Argv = []string{"bin/true"} + case "unclean": + plan.Argv = []string{"/bin/../bin/true"} + case "kernel ceiling": + plan.UID, value = 0, uint64(1)<<63 + } + if err := validateMappedStartupV1(plan); err == nil { + t.Fatal("unsupported authority accepted") + } + }) + } + // All conversion errors precede credential changes or thread creation. + for _, mode := range []string{"filename", "argument", "environment"} { + t.Run(mode, func(t *testing.T) { + plan, environment := good, []string{"PATH=/usr/bin:/bin"} + switch mode { + case "filename": + plan.Argv = []string{"/bad\x00path"} + case "argument": + plan.Argv = []string{"/bin/true", "bad\x00argument"} + case "environment": + environment = []string{"bad\x00environment"} + } + if err := execMappedApplicationV1(plan, environment); err == nil { + t.Fatal("invalid exec pointer accepted") + } + }) + } +} + +func TestMappedStandardDescriptorsV1(t *testing.T) { + if text := os.Getenv("REPLOY_TEST_MAPPED_FD_V1"); text != "" { + fd, err := strconv.Atoi(text) + if err != nil { + os.Exit(3) + } + if _, err := unix.FcntlInt(uintptr(fd), unix.F_SETFD, unix.FD_CLOEXEC); err != nil { + os.Exit(4) + } + mask := uint64(0) + plan := sandboxExecPlanV1{UID: 200, GID: 200, MappedCapabilities: &mask, Argv: []string{"/bin/true"}} + if err := validateMappedStartupV1(plan); err == nil || !strings.Contains(err.Error(), "standard descriptor") { + os.Exit(5) + } + os.Exit(0) + } + for fd := range 3 { + command := exec.Command(os.Args[0], "-test.run=^TestMappedStandardDescriptorsV1$") + command.Env = append(os.Environ(), fmt.Sprintf("REPLOY_TEST_MAPPED_FD_V1=%d", fd)) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("reject close-on-exec descriptor%d: %v: %s", fd, err, output) + } + } +} + +func TestMappedKernelCapabilityLimitV1(t *testing.T) { + for _, test := range []struct { + text string + mask uint64 + err error + pass bool + }{ + {"", 0, errors.New("fixture read failure"), false}, + {"broken", 0, nil, false}, {"-1", 0, nil, false}, {"64", 0, nil, false}, + {"0", 2, nil, false}, {"0", 1, nil, true}, {"63", uint64(1) << 63, nil, true}, + } { + plan := sandboxExecPlanV1{MappedCapabilities: &test.mask, Argv: []string{"/bin/true"}} + called := false + err := validateMappedStartupWithV1(plan, func(path string) ([]byte, error) { + called = true + if path != "/proc/sys/kernel/cap_last_cap" { + t.Fatal("untrusted capability-limit path", path) + } + return []byte(test.text), test.err + }) + if !called || (err == nil) != test.pass { + t.Fatalf("limit %q mask%x pass=%v: %v", test.text, test.mask, test.pass, err) + } + } +} + +func mappedFixtureSubIDV1(t *testing.T, kind string) uint64 { + t.Helper() + content, err := os.ReadFile("/etc/sub" + kind) + if err != nil { + t.Skipf("production mapped proof needs subordinate %ss: %v", kind, err) + } + uid := strconv.Itoa(os.Getuid()) + identity, err := exec.Command("id", "-un").Output() + if err != nil { + t.Fatal(err) + } + for _, line := range strings.Split(string(content), "\n") { + fields := strings.Split(line, ":") + if len(fields) != 3 || fields[0] != uid && fields[0] != strings.TrimSpace(string(identity)) { + continue + } + base, e1 := strconv.ParseUint(fields[1], 10, 32) + count, e2 := strconv.ParseUint(fields[2], 10, 32) + if e1 == nil && e2 == nil && count >= 512 && base+511 < math.MaxUint32 { + return base + } + } + t.Skipf("production mapped proof needs an authorized512-ID subordinate %s range", kind) + return 0 +} + +func mappedProductionBinaryV1(t *testing.T, directory string) string { + t.Helper() + probe := filepath.Join(directory, "reploy-probe") + command := exec.Command("go", "build", "-buildvcs=false", "-trimpath", "-o", probe, "../../cmd/reploy-probe") + command.Env = append(os.Environ(), "CGO_ENABLED=0") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("build exact production probe: %v: %s", err, output) + } + disassembly, err := exec.Command("go", "tool", "objdump", "-s", "github.com/omry/reploy/internal/probe.mappedExecTailV1", probe).CombinedOutput() + if err != nil || !bytes.Contains(disassembly, []byte("TEXT ")) { + t.Fatalf("inspect exact production tail: %v: %s", err, disassembly) + } + calls := 0 + for _, line := range strings.Split(string(disassembly), "\n") { + if strings.Contains(line, "CALL") { + calls++ + if !strings.Contains(line, "syscall.RawSyscall") { + t.Fatalf("non-raw call after descriptor closure: %s", line) + } + } + } + if calls != 3 || strings.Contains(string(disassembly), "runtime.") { + t.Fatalf("unexpected raw exec-or-exit tail: %s", disassembly) + } + return probe +} + +func TestMappedProductionExecTailV1(t *testing.T) { + // Compiler/tail qualification runs even without namespace/map helpers. + _ = mappedProductionBinaryV1(t, t.TempDir()) +} + +func TestMappedProductionStartupV1(t *testing.T) { + for _, name := range []string{"cc", "go", "unshare", "newuidmap", "newgidmap"} { + if _, err := exec.LookPath(name); err != nil { + t.Skipf("production mapped proof prerequisite %s unavailable: %v", name, err) + } + } + if output, err := exec.Command("unshare", "--user", "--map-root-user", "--net", "true").CombinedOutput(); err != nil { + t.Skipf("production mapped proof needs disposable user/network namespaces: %v: %s", err, output) + } + uidBase, gidBase := mappedFixtureSubIDV1(t, "uid"), mappedFixtureSubIDV1(t, "gid") + directory, err := os.MkdirTemp("", "reploy-mapped-startup-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(directory) }) + // These are public fixture executables. Namespace-root must traverse the + // directory after losing the caller's host identity. No secrets are stored. + if err := os.Chmod(directory, 0o755); err != nil { + t.Fatal(err) + } + probe := mappedProductionBinaryV1(t, directory) + payload, bootstrap := filepath.Join(directory, "payload"), filepath.Join(directory, "bootstrap") + commands := [][]string{ + {"go", "build", "-buildvcs=false", "-trimpath", "-o", payload, "testdata/mapped_startup_payload.go"}, + {"cc", "-O2", "-Wall", "-Wextra", "-Werror", "-o", bootstrap, "testdata/mapped_startup_bootstrap.c"}, + } + for _, argv := range commands { + command := exec.Command(argv[0], argv[1:]...) + command.Env = append(os.Environ(), "CGO_ENABLED=0") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("build exact production/fixture binary %v: %v: %s", argv, err, output) + } + } + t.Logf("qualified production source with %s %s/%s; fixture mode2 is not effective-policy qualification", runtime.Version(), runtime.GOOS, runtime.GOARCH) + for _, profile := range []string{"exact", "exact-permuted-gids", "bounded"} { + for _, tuple := range []struct { + uid, gid uint32 + groups []uint32 + mask uint64 + }{ + {0, 0, nil, 0}, {200, 201, []uint32{100, 201}, 0}, + {0, 0, nil, 0xc4}, {0, 200, []uint32{100, 201}, 0x400c1}, + {0, 0, []uint32{100}, uint64(1) << 40}, + } { + for repetition := range 3 { + t.Run(fmt.Sprintf("%s/uid%d/gid%d/mask%x/%d", profile, tuple.uid, tuple.gid, tuple.mask, repetition), func(t *testing.T) { + mappedProductionCaseV1(t, bootstrap, probe, payload, directory, profile, uidBase, gidBase, tuple.uid, tuple.gid, tuple.groups, tuple.mask, "normal", 0) + }) + } + } + for _, negative := range []struct { + mode string + uid uint32 + gid uint32 + code int + }{{"deny-close", 0, 0, 125}, {"bad-exec", 0, 0, 125}, {"deny-setuid", 0, 0, 121}, {"deny-setgid", 0, 0, 121}, {"deny-groups", 0, 0, 121}, {"deny-capset", 0, 0, 121}, {"deny-prctl", 0, 0, 121}, {"deny-get-securebits", 0, 0, 122}, {"unmapped-uid", 999, 0, 121}, {"unmapped-gid", 0, 999, 121}} { + t.Run(profile+"/"+negative.mode, func(t *testing.T) { + mappedProductionCaseV1(t, bootstrap, probe, payload, directory, profile, uidBase, gidBase, negative.uid, negative.gid, nil, 0, negative.mode, negative.code) + }) + } + t.Run(profile+"/unmapped-groups", func(t *testing.T) { + mappedProductionCaseV1(t, bootstrap, probe, payload, directory, profile, uidBase, gidBase, 0, 0, []uint32{999}, 0, "normal", 121) + }) + t.Run(profile+"/clear-ambient", func(t *testing.T) { + mappedProductionCaseV1(t, bootstrap, probe, payload, directory, profile, uidBase, gidBase, 0, 0, nil, 0, "ambient", 0) + }) + t.Run(profile+"/set-nnp", func(t *testing.T) { + status, err := os.ReadFile("/proc/self/status") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(status), "NoNewPrivs:\t0\n") { + t.Skip("cannot remove inherited host no-new-privileges for this control") + } + mappedProductionCaseV1(t, bootstrap, probe, payload, directory, profile, uidBase, gidBase, 0, 0, nil, 0, "unset-nnp", 0) + }) + t.Run(profile+"/no-filter", func(t *testing.T) { + status, err := os.ReadFile("/proc/self/status") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(status), "Seccomp:\t0\n") { + t.Skip("cannot remove an inherited host seccomp filter for this negative control") + } + mappedProductionCaseV1(t, bootstrap, probe, payload, directory, profile, uidBase, gidBase, 0, 0, nil, 0, "no-filter", 122) + }) + } +} + +func mappedProductionCaseV1(t *testing.T, bootstrap, probe, payload, directory, profile string, uidBase, gidBase uint64, uid, gid uint32, groups []uint32, mask uint64, mode string, expectedExit int) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + binding := mappedWaitBindingV1() + encoded, err := canonical.Marshal(binding) + if err != nil { + t.Fatal(err) + } + frame, err := mappedlaunch.Frame(mappedlaunch.Request{Binding: binding, Container: strings.Repeat("b", 64)}) + if err != nil { + t.Fatal(err) + } + groupText := []string{} + for _, g := range groups { + groupText = append(groupText, strconv.FormatUint(uint64(g), 10)) + } + canary, err := os.CreateTemp(directory, "mapped-private-canary-") + if err != nil { + t.Fatal(err) + } + defer canary.Close() + executable := payload + if mode == "bad-exec" { + executable = filepath.Join(directory, "missing-executable") + } + args := []string{probe, fmt.Sprint(mask), mode, "/reploy-probe", mappedlaunch.Command, string(encoded), time.Now().Add(15 * time.Second).UTC().Format(time.RFC3339Nano), "sandbox-exec", fmt.Sprintf("%s%016x", mappedCapabilityArgumentV1, mask), "--uid", fmt.Sprint(uid), "--gid", fmt.Sprint(gid), "--groups", strings.Join(groupText, ","), "--public", "deny", "--local", "deny", "--ambiguous", "require-both", "--", executable, "3"} + command := exec.CommandContext(ctx, bootstrap, args...) + command.ExtraFiles = []*os.File{canary} + input, err := command.StdinPipe() + if err != nil { + t.Fatal(err) + } + output, err := command.StdoutPipe() + if err != nil { + t.Fatal(err) + } + var stderr bytes.Buffer + command.Stderr = &stderr + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = command.Process.Kill(); _ = command.Wait() }() + reader := bufio.NewReader(output) + ready, err := reader.ReadString('\n') + if err != nil || ready != "ready\n" { + t.Fatalf("namespace readiness: %q %v", ready, err) + } + for _, domain := range []struct { + kind string + base uint64 + ids []uint64 + }{{"uid", uidBase, []uint64{0, 100, 200}}, {"gid", gidBase, []uint64{0, 100, 200, 201}}} { + mapArgs := []string{strconv.Itoa(command.Process.Pid)} + if profile == "bounded" { + mapArgs = append(mapArgs, "0", strconv.FormatUint(domain.base, 10), "512") + } else { + for _, id := range domain.ids { + hostOffset := id + if profile == "exact-permuted-gids" && domain.kind == "gid" { + if id == 100 { + hostOffset = 201 + } else if id == 201 { + hostOffset = 100 + } + } + mapArgs = append(mapArgs, fmt.Sprint(id), fmt.Sprint(domain.base+hostOffset), "1") + } + } + if out, err := exec.CommandContext(ctx, "new"+domain.kind+"map", mapArgs...).CombinedOutput(); err != nil { + t.Fatalf("install exact %s maps: %v: %s", domain.kind, err, out) + } + } + if _, err := input.Write([]byte{'G'}); err != nil { + t.Fatal(err) + } + // Observe stronger peer threads in the actual production Go process while + // it waits for admission. They must not survive the later winning exec. + stronger, seeded := false, mode != "ambient" && mode != "unset-nnp" + for !stronger && ctx.Err() == nil { + processStatus, _ := os.ReadFile(fmt.Sprintf("/proc/%d/status", command.Process.Pid)) + if strings.Contains(string(processStatus), "State:\tZ") { + _ = command.Wait() + t.Fatalf("fixture exited before production readiness: %s", stderr.String()) + } + entries, _ := os.ReadDir(fmt.Sprintf("/proc/%d/task", command.Process.Pid)) + if len(entries) >= 2 { + for _, entry := range entries { + content, _ := os.ReadFile(fmt.Sprintf("/proc/%d/task/%s/status", command.Process.Pid, entry.Name())) + for _, line := range strings.Split(string(content), "\n") { + if strings.HasPrefix(line, "CapEff:") { + bits, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "CapEff:")), 16, 64) + stronger = stronger || err == nil && bits & ^mask != 0 + } + if mode == "ambient" && strings.HasPrefix(line, "CapAmb:") { + bits, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "CapAmb:")), 16, 64) + seeded = seeded || err == nil && bits != 0 + } + if mode == "unset-nnp" && line == "NoNewPrivs:\t0" { + seeded = true + } + } + } + } + if !stronger { + time.Sleep(time.Millisecond) + } + } + if !stronger || !seeded { + t.Fatal("missing stronger production peer-thread control") + } + _, _ = input.Write(append(frame, []byte("mapped-app-stdin\n")...)) + _ = input.Close() + stdout, readErr := io.ReadAll(reader) + waitErr := command.Wait() + if readErr != nil || command.ProcessState.ExitCode() != expectedExit { + t.Fatalf("production transition exit=%d want%d, read=%v wait=%v stderr=%s stdout=%s", command.ProcessState.ExitCode(), expectedExit, readErr, waitErr, stderr.String(), stdout) + } + if expectedExit != 0 { + if len(stdout) != 0 { + t.Fatal("failed transition reached payload", string(stdout)) + } + return + } + var observed struct { + PID int `json:"pid"` + Threads []map[string]string `json:"threads"` + Securebits uintptr `json:"securebits"` + PrivateLeaks []string `json:"private_leaks"` + Stdin string `json:"stdin"` + NativeDrop map[string]string `json:"native_uid_drop"` + } + if err := json.Unmarshal(stdout, &observed); err != nil { + t.Fatal(err, string(stdout), stderr.String()) + } + plan := sandboxExecPlanV1{UID: uid, GID: gid, Groups: groups, MappedCapabilities: &mask} + for _, thread := range observed.Threads { + var status strings.Builder + for key, value := range thread { + fmt.Fprintf(&status, "%s: %s\n", key, value) + } + if err := verifyMappedKernelStatusV1([]byte(status.String()), plan); err != nil { + t.Fatal("post-exec thread authority", thread, err) + } + } + wantBits := uintptr(2 | 8 | 32 | 64 | 128) + if mask == 0 { + wantBits |= 1 | 4 + } + if len(observed.Threads) == 0 || observed.PID != command.Process.Pid || observed.Securebits != wantBits || len(observed.PrivateLeaks) != 0 || observed.Stdin != "mapped-app-stdin\n" { + t.Fatal("post-exec authority, descriptor or stdin mismatch", observed) + } + if uid == 0 && mask&(1<<7) != 0 { + if observed.NativeDrop["Uid"] != "200 200 200 200" || observed.NativeDrop["CapPrm"] != "0000000000000000" || observed.NativeDrop["CapEff"] != "0000000000000000" { + t.Fatal("native UID change retained capability authority", observed.NativeDrop) + } + } +} diff --git a/internal/probe/mapped_exec_other.go b/internal/probe/mapped_exec_other.go new file mode 100644 index 00000000..0f377a2a --- /dev/null +++ b/internal/probe/mapped_exec_other.go @@ -0,0 +1,13 @@ +//go:build !linux || (!amd64 && !arm64) + +package probe + +import "fmt" + +func validateMappedStartupV1(sandboxExecPlanV1) error { + return fmt.Errorf("mapped startup is not qualified for this architecture") +} + +func execMappedApplicationV1(sandboxExecPlanV1, []string) error { + return fmt.Errorf("mapped startup is not qualified for this architecture") +} diff --git a/internal/probe/mapped_exec_test.go b/internal/probe/mapped_exec_test.go new file mode 100644 index 00000000..948a9061 --- /dev/null +++ b/internal/probe/mapped_exec_test.go @@ -0,0 +1,149 @@ +package probe + +import ( + "fmt" + "reflect" + "strings" + "testing" +) + +func mappedSandboxArgumentsV1() []string { + return []string{"--uid", "0", "--gid", "0", "--groups", "100,201", "--public", "deny", "--local", "deny", "--ambiguous", "require-both", "--", "/bin/true"} +} + +func TestMappedCeilingParsingV1(t *testing.T) { + args := mappedSandboxArgumentsV1() + legacy, err := parseMappedSandboxExecPlanV1(args) + if err != nil || legacy.MappedCapabilities != nil { + t.Fatal("legacy waiter must remain inhibited", legacy, err) + } + for _, text := range []string{"0000000000000000", "00000000000400c1", "ffffffffffffffff"} { + t.Run(text, func(t *testing.T) { + mapped := append([]string{mappedCapabilityArgumentV1 + text}, args...) + plan, err := parseMappedSandboxExecPlanV1(mapped) + if err != nil || plan.MappedCapabilities == nil || fmt.Sprintf("%016x", *plan.MappedCapabilities) != text || !reflect.DeepEqual(plan.Groups, []uint32{100, 201}) { + t.Fatal(plan, err) + } + if _, err := parseSandboxExecPlanV1(mapped, true); err == nil { + t.Fatal("ordinary sandbox accepted mapped-only authority") + } + }) + } + for _, text := range []string{"", "0", "c4", "00000000000000C4", "00000000000000000", "+000000000000000", "0x000000000000c4", "00000000000000cg", "00000000000000c4\x00"} { + t.Run("invalid "+text, func(t *testing.T) { + if _, err := parseMappedSandboxExecPlanV1(append([]string{mappedCapabilityArgumentV1 + text}, args...)); err == nil { + t.Fatal("accepted noncanonical ceiling") + } + }) + } + for _, mode := range []string{"duplicate", "wrong position", "nonzero UID", "private environment", "bad ordinary args"} { + t.Run(mode, func(t *testing.T) { + a := append([]string{mappedCapabilityArgumentV1 + "00000000000000c4"}, args...) + switch mode { + case "duplicate": + a = append(a[:1], a...) + case "wrong position": + a = append(append([]string{}, args[:2]...), append(a[:1], args[2:]...)...) + case "nonzero UID": + a[2] = "200" + case "private environment": + a = append(a[:1], append([]string{"--private-environment"}, args...)...) + case "bad ordinary args": + a = a[:len(a)-2] + } + if _, err := parseMappedSandboxExecPlanV1(a); err == nil { + t.Fatal("accepted unsupported input", a) + } + }) + } + args[1] = "200" + if plan, err := parseMappedSandboxExecPlanV1(append([]string{mappedCapabilityArgumentV1 + "0000000000000000"}, args...)); err != nil || plan.UID != 200 { + t.Fatal("empty ceiling at nonzero UID", plan, err) + } +} + +func mappedStatusFixtureV1(plan sandboxExecPlanV1) string { + groups := []string{} + for _, group := range plan.Groups { + groups = append(groups, fmt.Sprint(group)) + } + return fmt.Sprintf("Name:\tfixture\nUid:\t%d %d %d %d\nGid:\t%d %d %d %d\nGroups:\t%s\nCapPrm:\t%016x\nCapEff:\t%016x\nCapBnd:\t%016x\nCapInh:\t0000000000000000\nCapAmb:\t0000000000000000\nNoNewPrivs:\t1\nSeccomp:\t2\n", + plan.UID, plan.UID, plan.UID, plan.UID, plan.GID, plan.GID, plan.GID, plan.GID, + strings.Join(groups, " "), *plan.MappedCapabilities, *plan.MappedCapabilities, *plan.MappedCapabilities) +} + +func TestMappedKernelStatusRequiresExactAuthorityV1(t *testing.T) { + for _, mask := range []uint64{0, 0xc4, 0x400c1} { + plan := sandboxExecPlanV1{UID: 0, GID: 200, Groups: []uint32{100, 201}, MappedCapabilities: &mask} + good := mappedStatusFixtureV1(plan) + if err := verifyMappedKernelStatusV1([]byte(good), plan); err != nil { + t.Fatal(err) + } + for _, name := range []string{"Uid", "Gid", "Groups", "CapPrm", "CapEff", "CapBnd", "CapInh", "CapAmb", "NoNewPrivs", "Seccomp"} { + start := strings.Index(good, name+":") + end := start + strings.Index(good[start:], "\n") + for _, defect := range []string{"missing", "duplicate", "empty", "different", "malformed"} { + t.Run(fmt.Sprintf("%x/%s/%s", mask, name, defect), func(t *testing.T) { + line := good[start:end] + var bad string + switch defect { + case "missing": + bad = good[:start] + good[end+1:] + case "duplicate": + bad = good + line + "\n" + case "empty": + bad = good[:start] + name + ":\n" + good[end+1:] + case "different": + bad = good[:start] + name + ": 999\n" + good[end+1:] + case "malformed": + bad = good[:start] + name + ": nope nope\n" + good[end+1:] + } + if err := verifyMappedKernelStatusV1([]byte(bad), plan); err == nil { + t.Fatal("accepted altered authority", bad) + } + }) + } + } + for _, kind := range []string{"Uid", "Gid"} { + for field := range 4 { + values := strings.Fields(strings.SplitN(strings.SplitN(good, kind+":", 2)[1], "\n", 2)[0]) + values[field] = "201" + start := strings.Index(good, kind+":") + end := start + strings.Index(good[start:], "\n") + bad := good[:start] + kind + ": " + strings.Join(values, " ") + good[end:] + if err := verifyMappedKernelStatusV1([]byte(bad), plan); err == nil { + t.Fatalf("accepted changed %s field%d", kind, field) + } + } + } + } + mask := uint64(0) + plan := sandboxExecPlanV1{UID: 200, GID: 200, MappedCapabilities: &mask} + if err := verifyMappedKernelStatusV1([]byte(mappedStatusFixtureV1(plan)), plan); err != nil { + t.Fatal("empty groups are valid", err) + } + plan.MappedCapabilities = nil + if err := verifyMappedKernelStatusV1(nil, plan); err == nil { + t.Fatal("implicit capability ceiling") + } +} + +func TestMappedKernelGroupOrderV1(t *testing.T) { + mask := uint64(0) + plan := sandboxExecPlanV1{UID: 200, GID: 201, Groups: []uint32{100, 201}, MappedCapabilities: &mask} + good := mappedStatusFixtureV1(plan) + for _, test := range []struct { + groups string + pass bool + }{ + {"201 100", true}, {"100 201", true}, + {"100 100", false}, {"201 201", false}, {"100", false}, + {"100 201 300", false}, {"100 nope", false}, + {"100 0201", false}, {"100 4294967295", false}, + } { + content := strings.Replace(good, "Groups:\t100 201\n", "Groups:\t"+test.groups+"\n", 1) + if err := verifyMappedKernelStatusV1([]byte(content), plan); (err == nil) != test.pass { + t.Errorf("groups %q: pass=%v, err=%v", test.groups, test.pass, err) + } + } +} diff --git a/internal/probe/mapped_wait.go b/internal/probe/mapped_wait.go index 411f0bf2..cd179194 100644 --- a/internal/probe/mapped_wait.go +++ b/internal/probe/mapped_wait.go @@ -90,7 +90,8 @@ func mappedWaitV1(args []string, input *os.File) error { if len(args) < 3 || args[2] != "sandbox-exec" { return fmt.Errorf("mapped waiter requires fixed binding and admission deadline") } - if _, err := parseSandboxExecPlanV1(args[3:], true); err != nil { + plan, err := parseMappedSandboxExecPlanV1(args[3:]) + if err != nil { return err } binding, err := mappedlaunch.DecodeBinding([]byte(args[0])) @@ -106,7 +107,8 @@ func mappedWaitV1(args []string, input *os.File) error { if err := waitMappedReleaseV1(ctx, input, binding); err != nil { return err } - // E2 owns the qualified credential/descriptor/direct-exec tail. An accepted - // frame alone cannot substitute for that proof or execute an ordinary path. - return fmt.Errorf("mapped startup requires a qualified final exec transition") + if plan.MappedCapabilities == nil { + return fmt.Errorf("mapped startup requires a qualified final exec transition") + } + return sandboxAndExecApplicationV1(plan) } diff --git a/internal/probe/sandbox_exec.go b/internal/probe/sandbox_exec.go index c48d6cca..503cb8b1 100644 --- a/internal/probe/sandbox_exec.go +++ b/internal/probe/sandbox_exec.go @@ -10,6 +10,7 @@ import ( ) type sandboxExecPlanV1 struct { + MappedCapabilities *uint64 UID uint32 GID uint32 Groups []uint32 diff --git a/internal/probe/sandbox_exec_linux.go b/internal/probe/sandbox_exec_linux.go index e9cdac35..6df05960 100644 --- a/internal/probe/sandbox_exec_linux.go +++ b/internal/probe/sandbox_exec_linux.go @@ -74,6 +74,11 @@ func sandboxAndExecApplicationV1(plan sandboxExecPlanV1) error { if os.Geteuid() != 0 { return fmt.Errorf("trusted sandbox setup must begin as container root") } + if plan.MappedCapabilities != nil { + if err := validateMappedStartupV1(plan); err != nil { + return err + } + } if plan.PrepareTemporaryHome { if err := prepareApplicationTemporaryHomeV1(plan.UID, plan.GID); err != nil { return fmt.Errorf("prepare private temporary home: %w", err) @@ -124,6 +129,9 @@ func sandboxAndExecApplicationV1(plan sandboxExecPlanV1) error { environment, ) } + if plan.MappedCapabilities != nil { + return execMappedApplicationV1(plan, environment) + } if err := dropApplicationAuthorityV1(plan.UID, plan.GID, plan.Groups); err != nil { return err } diff --git a/internal/probe/testdata/mapped_startup_bootstrap.c b/internal/probe/testdata/mapped_startup_bootstrap.c new file mode 100644 index 00000000..4559d876 --- /dev/null +++ b/internal/probe/testdata/mapped_startup_bootstrap.c @@ -0,0 +1,88 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void die(const char *s) { perror(s); _exit(110); } + +/* A disposable namespace harness, never linked into the product. The parent + * installs its exact uid/gid maps before releasing the child. */ +int main(int argc, char **argv) { + if (argc < 6) return 111; + int executable = open(argv[1], O_RDONLY | O_CLOEXEC); + if (executable < 0 || unshare(CLONE_NEWUSER)) die("prepare namespace"); + puts("ready"); fflush(stdout); + char signal; + if (read(0, &signal, 1) != 1 || signal != 'G') return 112; + if (setgroups(0, NULL) || setresgid(0, 0, 0) || setresuid(0, 0, 0) || + unshare(CLONE_NEWNET)) die("prepare namespace authority"); + gid_t initial_groups[] = {100, 201}; + if (setgroups(2, initial_groups)) die("initial supplementary groups"); + unsigned long long ceiling = strtoull(argv[2], NULL, 0) | 0x11c0ULL; + if (!strcmp(argv[3], "ambient")) ceiling |= 1; + FILE *limit = fopen("/proc/sys/kernel/cap_last_cap", "r"); + int last = -1; + if (!limit || fscanf(limit, "%d", &last) != 1 || last < 0 || last >= 64) + die("capability range"); + fclose(limit); + for (int cap = 0; cap <= last; cap++) + if (!(ceiling & (1ULL << cap)) && prctl(PR_CAPBSET_DROP, cap, 0, 0, 0)) + die("bounding set"); + if (!strcmp(argv[3], "ambient")) { + struct __user_cap_header_struct header = {_LINUX_CAPABILITY_VERSION_3, 0}; + struct __user_cap_data_struct data[2]; + if (syscall(SYS_capget, &header, data)) die("initial capget"); + data[0].inheritable |= 1; + if (syscall(SYS_capset, &header, data) || + prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, 0, 0, 0)) + die("initial ambient authority"); + } + if (strcmp(argv[3], "unset-nnp") && prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) + die("no new privileges"); + int denied = -1; + if (!strcmp(argv[3], "deny-close")) denied = SYS_close_range; + if (!strcmp(argv[3], "deny-setuid")) denied = SYS_setresuid; + if (!strcmp(argv[3], "deny-setgid")) denied = SYS_setresgid; + if (!strcmp(argv[3], "deny-groups")) denied = SYS_setgroups; + if (!strcmp(argv[3], "deny-capset")) denied = SYS_capset; + if (!strcmp(argv[3], "deny-prctl")) denied = SYS_prctl; + /* Mode2 is a fixture precondition, not qualification of production policy. + * The mapped effective default-deny policy remains an E2.3 responsibility. */ + if (strcmp(argv[3], "no-filter")) { + struct sock_filter filter[] = { + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, (unsigned)denied, 0, 1), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), + }; + struct sock_filter getter[] = { + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_prctl, 0, 3), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[0])), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, PR_GET_SECUREBITS, 0, 1), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), + }; + struct sock_fprog program = {4, filter}; + if (!strcmp(argv[3], "deny-get-securebits")) { + program.len = 6; + program.filter = getter; + } + if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program)) die("fixture seccomp"); + } + char *environment[] = {"PATH=/usr/bin:/bin", "GODEBUG=asyncpreemptoff=0", + "GOMAXPROCS=4", "GOGC=20", NULL}; + fexecve(executable, argv + 4, environment); + die("exec production probe"); +} diff --git a/internal/probe/testdata/mapped_startup_payload.go b/internal/probe/testdata/mapped_startup_payload.go new file mode 100644 index 00000000..9a148948 --- /dev/null +++ b/internal/probe/testdata/mapped_startup_payload.go @@ -0,0 +1,94 @@ +// A trusted disposable observer, never part of the shipped launcher. +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "runtime" + "strconv" + "strings" + "syscall" +) + +type observation struct { + PID int `json:"pid"` + Threads []map[string]string `json:"threads"` + Securebits uintptr `json:"securebits"` + PrivateLeaks []string `json:"private_leaks"` + Stdin string `json:"stdin"` + NativeDrop map[string]string `json:"native_uid_drop,omitempty"` +} + +func main() { + runtime.LockOSThread() + result := observation{PID: os.Getpid(), Threads: []map[string]string{}, PrivateLeaks: []string{}} + entries, err := os.ReadDir("/proc/self/task") + if err != nil { + panic(err) + } + for _, entry := range entries { + content, err := os.ReadFile("/proc/self/task/" + entry.Name() + "/status") + if os.IsNotExist(err) { + continue // A vanished thread cannot survive the transition. + } + if err != nil { + panic(err) + } + fields := map[string]string{"tid": entry.Name()} + for _, line := range strings.Split(string(content), "\n") { + key, value, ok := strings.Cut(line, ":") + if ok && (key == "Uid" || key == "Gid" || key == "Groups" || strings.HasPrefix(key, "Cap") || key == "NoNewPrivs" || key == "Seccomp") { + fields[key] = strings.Join(strings.Fields(value), " ") + } + } + result.Threads = append(result.Threads, fields) + } + result.Securebits, _, _ = syscall.RawSyscall6(syscall.SYS_PRCTL, 27, 0, 0, 0, 0, 0) + for _, fd := range os.Args[1:] { + value, err := strconv.Atoi(fd) + if err != nil { + panic(err) + } + // The fixture gives each inherited private handle a unique inode. + // Runtime-created descriptors can reuse its number after exec. + link, err := os.Readlink(fmt.Sprintf("/proc/self/fd/%d", value)) + if err == nil && strings.Contains(link, "mapped-private-canary") { + result.PrivateLeaks = append(result.PrivateLeaks, link) + } + } + input, err := io.ReadAll(os.Stdin) + if err != nil { + panic(err) + } + result.Stdin = string(input) + // Native masters must retain Linux's normal root-to-user capability + // fixup. This checks behavior after the accepted initial-state snapshot. + mask, err := strconv.ParseUint(result.Threads[0]["CapPrm"], 16, 64) + if err != nil { + panic(err) + } + if result.Threads[0]["Uid"] == "0 0 0 0" && mask&(1<<7) != 0 { + if err := syscall.Setresuid(200, 200, 200); err != nil { + panic(err) + } + content, err := os.ReadFile("/proc/thread-self/status") + if err != nil { + panic(err) + } + result.NativeDrop = map[string]string{} + for _, line := range strings.Split(string(content), "\n") { + key, value, ok := strings.Cut(line, ":") + if ok && (key == "Uid" || key == "CapPrm" || key == "CapEff") { + result.NativeDrop[key] = strings.Join(strings.Fields(value), " ") + } + } + } + if len(result.Threads) == 0 { + panic("no surviving threads observed") + } + if err := json.NewEncoder(os.Stdout).Encode(result); err != nil { + panic(err) + } +}