| 1 | //go:build linux |
| 2 | |
| 3 | package stopevidence |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | "syscall" |
| 13 | |
| 14 | "golang.org/x/sys/unix" |
| 15 | ) |
| 16 | |
| 17 | // Holders is root's complete descriptor scan over paths (each matched by |
| 18 | // name, and by the device and inode it has now) and files (by device and |
| 19 | // inode). It is FDHolders with each descriptor's identity: strict, so an |
| 20 | // unreadable process, task or descriptor fails the whole scan. |
| 21 | func Holders(ctx context.Context, procRoot string, paths []string, files []FileIdentity) ([]Descriptor, error) { |
| 22 | return fdDescriptors(ctx, procRoot, paths, files, realFDOps()) |
| 23 | } |
| 24 | |
| 25 | // HoldersWithPrefixes includes every descriptor beneath the validated directory |
| 26 | // prefixes, even if its file was created after launch or has been unlinked. |
| 27 | // Each match retains the descriptor identity needed by ReplicatorOnly. |
| 28 | func HoldersWithPrefixes(ctx context.Context, procRoot string, paths, prefixes []string, files []FileIdentity) ([]Descriptor, error) { |
| 29 | return fdDescriptorsCovered(ctx, procRoot, paths, prefixes, files, realFDOps()) |
| 30 | } |
| 31 | |
| 32 | // OwnDescriptors is the agent's half: the descriptors of one process's own |
| 33 | // leader table that hold one of paths. It reads only /proc/<pid>/fd, so the |
| 34 | // agent (uid carlos, no CAP_SYS_PTRACE) can take it for itself immediately |
| 35 | // before and after it opens a replicator. An unreadable table is |
| 36 | // ErrIncomplete, never an empty set. |
| 37 | func OwnDescriptors(ctx context.Context, procRoot string, pid int, paths []string) ([]DescriptorHint, error) { |
| 38 | if ctx == nil || pid <= 0 || !filepath.IsAbs(procRoot) || len(paths) == 0 { |
| 39 | return nil, ErrInvalidInput |
| 40 | } |
| 41 | wanted := map[string]bool{} |
| 42 | for _, path := range paths { |
| 43 | if !filepath.IsAbs(path) { |
| 44 | return nil, ErrInvalidInput |
| 45 | } |
| 46 | wanted[filepath.Clean(path)] = true |
| 47 | } |
| 48 | ops := realFDOps() |
| 49 | inodes := map[fileID]bool{} |
| 50 | for path := range wanted { |
| 51 | fi, err := ops.stat(path) |
| 52 | if os.IsNotExist(err) { |
| 53 | continue |
| 54 | } |
| 55 | if err != nil { |
| 56 | return nil, fmt.Errorf("%w: %s: %w", ErrIncomplete, path, err) |
| 57 | } |
| 58 | id, ok := idOf(fi) |
| 59 | if !ok { |
| 60 | return nil, fmt.Errorf("%w: %s: no inode", ErrIncomplete, path) |
| 61 | } |
| 62 | inodes[id] = true |
| 63 | } |
| 64 | taskDir := filepath.Join(procRoot, strconv.Itoa(pid), "task", strconv.Itoa(pid)) |
| 65 | if _, err := ops.stat(taskDir); err != nil { |
| 66 | return nil, fmt.Errorf("%w: %w", ErrIncomplete, err) |
| 67 | } |
| 68 | held, err := taskDescriptors(ctx, taskDir, pid, pid, wanted, inodes, ops) |
| 69 | if err != nil { |
| 70 | return nil, err |
| 71 | } |
| 72 | out := make([]DescriptorHint, 0, len(held)) |
| 73 | for _, d := range held { |
| 74 | if d.Device == 0 || d.Inode == 0 { |
| 75 | return nil, fmt.Errorf("%w: fd %d: no identity", ErrIncomplete, d.FD) |
| 76 | } |
| 77 | out = append(out, DescriptorHint{FD: d.FD, Device: d.Device, Inode: d.Inode}) |
| 78 | } |
| 79 | return out, nil |
| 80 | } |
| 81 | |
| 82 | // ReplicatorOnly passes when every descriptor in held is one of the agent's |
| 83 | // replicator descriptors: in the agent's own process, in its leader's table |
| 84 | // or a task sharing it, at a number the hints name, on the file that hint |
| 85 | // names. Hints only narrow: one root did not observe is ignored, and none |
| 86 | // can exempt another process, an unshared thread or a zero identity. The |
| 87 | // agent's start time is read before and after, so a reused pid fails. It |
| 88 | // returns the exempted descriptors. |
| 89 | func ReplicatorOnly(ctx context.Context, procRoot string, held []Descriptor, agent Agent, hints []DescriptorHint) ([]Descriptor, error) { |
| 90 | if ctx == nil || !filepath.IsAbs(procRoot) { |
| 91 | return nil, ErrInvalidInput |
| 92 | } |
| 93 | if len(held) == 0 { |
| 94 | return []Descriptor{}, nil |
| 95 | } |
| 96 | if agent.PID <= 0 || agent.Start == "" { |
| 97 | return nil, ErrInvalidInput |
| 98 | } |
| 99 | if err := agentStarted(procRoot, agent); err != nil { |
| 100 | return nil, err |
| 101 | } |
| 102 | allowed := map[DescriptorHint]bool{} |
| 103 | for _, h := range hints { |
| 104 | if h.FD >= 0 && h.Device != 0 && h.Inode != 0 { |
| 105 | allowed[h] = true |
| 106 | } |
| 107 | } |
| 108 | shared := map[int]bool{agent.PID: true} |
| 109 | out := make([]Descriptor, 0, len(held)) |
| 110 | for _, d := range held { |
| 111 | if err := ctx.Err(); err != nil { |
| 112 | return nil, err |
| 113 | } |
| 114 | if d.PID != agent.PID || d.Device == 0 || d.Inode == 0 || !allowed[DescriptorHint{FD: d.FD, Device: d.Device, Inode: d.Inode}] { |
| 115 | return nil, fmt.Errorf("%w: pid %d tid %d fd %d", ErrForeignHolder, d.PID, d.TID, d.FD) |
| 116 | } |
| 117 | if _, ok := shared[d.TID]; !ok { |
| 118 | same, err := sameFDTable(procRoot, d.PID, d.TID) |
| 119 | if err != nil { |
| 120 | return nil, fmt.Errorf("%w: pid %d tid %d: %w", ErrIncomplete, d.PID, d.TID, err) |
| 121 | } |
| 122 | shared[d.TID] = same |
| 123 | } |
| 124 | if !shared[d.TID] { |
| 125 | return nil, fmt.Errorf("%w: pid %d tid %d has its own descriptor table", ErrForeignHolder, d.PID, d.TID) |
| 126 | } |
| 127 | out = append(out, d) |
| 128 | } |
| 129 | if err := agentStarted(procRoot, agent); err != nil { |
| 130 | return nil, err |
| 131 | } |
| 132 | return out, nil |
| 133 | } |
| 134 | |
| 135 | // agentStarted checks the agent's pid still names the process root |
| 136 | // authenticated. |
| 137 | func agentStarted(procRoot string, agent Agent) error { |
| 138 | start, err := processStart(procRoot, agent.PID) |
| 139 | if err != nil { |
| 140 | return fmt.Errorf("%w: %w", ErrForeignHolder, err) |
| 141 | } |
| 142 | if start != agent.Start { |
| 143 | return fmt.Errorf("%w: pid %d was restarted", ErrForeignHolder, agent.PID) |
| 144 | } |
| 145 | return nil |
| 146 | } |
| 147 | |
| 148 | func processStart(procRoot string, pid int) (string, error) { |
| 149 | raw, err := os.ReadFile(filepath.Join(procRoot, strconv.Itoa(pid), "stat")) |
| 150 | if err != nil { |
| 151 | return "", err |
| 152 | } |
| 153 | i := strings.LastIndexByte(string(raw), ')') |
| 154 | if i < 0 { |
| 155 | return "", ErrIncomplete |
| 156 | } |
| 157 | fields := strings.Fields(string(raw)[i+1:]) |
| 158 | if len(fields) < 20 || fields[19] == "" { |
| 159 | return "", ErrIncomplete |
| 160 | } |
| 161 | return fields[19], nil |
| 162 | } |
| 163 | |
| 164 | // kcmpFiles is kcmp(2)'s KCMP_FILES: the same descriptor table. |
| 165 | const kcmpFiles = 2 |
| 166 | |
| 167 | // sameFDTable reports whether task tid of pid shares pid's leader table. |
| 168 | // kcmp(2) takes pids in the caller's namespace, so it is asked only for the |
| 169 | // real /proc; any other root has no kernel to ask and fails closed. Tests |
| 170 | // replace it. |
| 171 | var sameFDTable = func(procRoot string, pid, tid int) (bool, error) { |
| 172 | if tid == pid { |
| 173 | return true, nil |
| 174 | } |
| 175 | if filepath.Clean(procRoot) != "/proc" { |
| 176 | return false, syscall.ENOSYS |
| 177 | } |
| 178 | r, _, errno := unix.Syscall6(unix.SYS_KCMP, uintptr(pid), uintptr(tid), kcmpFiles, 0, 0, 0) |
| 179 | if errno != 0 { |
| 180 | return false, errno |
| 181 | } |
| 182 | return r == 0, nil |
| 183 | } |
| 184 | |