//go:build linux package stopevidence import ( "context" "fmt" "os" "path/filepath" "strconv" "strings" "syscall" "golang.org/x/sys/unix" ) // Holders is root's complete descriptor scan over paths (each matched by // name, and by the device and inode it has now) and files (by device and // inode). It is FDHolders with each descriptor's identity: strict, so an // unreadable process, task or descriptor fails the whole scan. func Holders(ctx context.Context, procRoot string, paths []string, files []FileIdentity) ([]Descriptor, error) { return fdDescriptors(ctx, procRoot, paths, files, realFDOps()) } // HoldersWithPrefixes includes every descriptor beneath the validated directory // prefixes, even if its file was created after launch or has been unlinked. // Each match retains the descriptor identity needed by ReplicatorOnly. func HoldersWithPrefixes(ctx context.Context, procRoot string, paths, prefixes []string, files []FileIdentity) ([]Descriptor, error) { return fdDescriptorsCovered(ctx, procRoot, paths, prefixes, files, realFDOps()) } // OwnDescriptors is the agent's half: the descriptors of one process's own // leader table that hold one of paths. It reads only /proc//fd, so the // agent (uid carlos, no CAP_SYS_PTRACE) can take it for itself immediately // before and after it opens a replicator. An unreadable table is // ErrIncomplete, never an empty set. func OwnDescriptors(ctx context.Context, procRoot string, pid int, paths []string) ([]DescriptorHint, error) { if ctx == nil || pid <= 0 || !filepath.IsAbs(procRoot) || len(paths) == 0 { return nil, ErrInvalidInput } wanted := map[string]bool{} for _, path := range paths { if !filepath.IsAbs(path) { return nil, ErrInvalidInput } wanted[filepath.Clean(path)] = true } ops := realFDOps() inodes := map[fileID]bool{} for path := range wanted { fi, err := ops.stat(path) if os.IsNotExist(err) { continue } if err != nil { return nil, fmt.Errorf("%w: %s: %w", ErrIncomplete, path, err) } id, ok := idOf(fi) if !ok { return nil, fmt.Errorf("%w: %s: no inode", ErrIncomplete, path) } inodes[id] = true } taskDir := filepath.Join(procRoot, strconv.Itoa(pid), "task", strconv.Itoa(pid)) if _, err := ops.stat(taskDir); err != nil { return nil, fmt.Errorf("%w: %w", ErrIncomplete, err) } held, err := taskDescriptors(ctx, taskDir, pid, pid, wanted, inodes, ops) if err != nil { return nil, err } out := make([]DescriptorHint, 0, len(held)) for _, d := range held { if d.Device == 0 || d.Inode == 0 { return nil, fmt.Errorf("%w: fd %d: no identity", ErrIncomplete, d.FD) } out = append(out, DescriptorHint{FD: d.FD, Device: d.Device, Inode: d.Inode}) } return out, nil } // ReplicatorOnly passes when every descriptor in held is one of the agent's // replicator descriptors: in the agent's own process, in its leader's table // or a task sharing it, at a number the hints name, on the file that hint // names. Hints only narrow: one root did not observe is ignored, and none // can exempt another process, an unshared thread or a zero identity. The // agent's start time is read before and after, so a reused pid fails. It // returns the exempted descriptors. func ReplicatorOnly(ctx context.Context, procRoot string, held []Descriptor, agent Agent, hints []DescriptorHint) ([]Descriptor, error) { if ctx == nil || !filepath.IsAbs(procRoot) { return nil, ErrInvalidInput } if len(held) == 0 { return []Descriptor{}, nil } if agent.PID <= 0 || agent.Start == "" { return nil, ErrInvalidInput } if err := agentStarted(procRoot, agent); err != nil { return nil, err } allowed := map[DescriptorHint]bool{} for _, h := range hints { if h.FD >= 0 && h.Device != 0 && h.Inode != 0 { allowed[h] = true } } shared := map[int]bool{agent.PID: true} out := make([]Descriptor, 0, len(held)) for _, d := range held { if err := ctx.Err(); err != nil { return nil, err } if d.PID != agent.PID || d.Device == 0 || d.Inode == 0 || !allowed[DescriptorHint{FD: d.FD, Device: d.Device, Inode: d.Inode}] { return nil, fmt.Errorf("%w: pid %d tid %d fd %d", ErrForeignHolder, d.PID, d.TID, d.FD) } if _, ok := shared[d.TID]; !ok { same, err := sameFDTable(procRoot, d.PID, d.TID) if err != nil { return nil, fmt.Errorf("%w: pid %d tid %d: %w", ErrIncomplete, d.PID, d.TID, err) } shared[d.TID] = same } if !shared[d.TID] { return nil, fmt.Errorf("%w: pid %d tid %d has its own descriptor table", ErrForeignHolder, d.PID, d.TID) } out = append(out, d) } if err := agentStarted(procRoot, agent); err != nil { return nil, err } return out, nil } // agentStarted checks the agent's pid still names the process root // authenticated. func agentStarted(procRoot string, agent Agent) error { start, err := processStart(procRoot, agent.PID) if err != nil { return fmt.Errorf("%w: %w", ErrForeignHolder, err) } if start != agent.Start { return fmt.Errorf("%w: pid %d was restarted", ErrForeignHolder, agent.PID) } return nil } func processStart(procRoot string, pid int) (string, error) { raw, err := os.ReadFile(filepath.Join(procRoot, strconv.Itoa(pid), "stat")) if err != nil { return "", err } i := strings.LastIndexByte(string(raw), ')') if i < 0 { return "", ErrIncomplete } fields := strings.Fields(string(raw)[i+1:]) if len(fields) < 20 || fields[19] == "" { return "", ErrIncomplete } return fields[19], nil } // kcmpFiles is kcmp(2)'s KCMP_FILES: the same descriptor table. const kcmpFiles = 2 // sameFDTable reports whether task tid of pid shares pid's leader table. // kcmp(2) takes pids in the caller's namespace, so it is asked only for the // real /proc; any other root has no kernel to ask and fails closed. Tests // replace it. var sameFDTable = func(procRoot string, pid, tid int) (bool, error) { if tid == pid { return true, nil } if filepath.Clean(procRoot) != "/proc" { return false, syscall.ENOSYS } r, _, errno := unix.Syscall6(unix.SYS_KCMP, uintptr(pid), uintptr(tid), kcmpFiles, 0, 0, 0) if errno != 0 { return false, errno } return r == 0, nil }