package accounts import ( "context" "errors" "fmt" "io/fs" "os" "os/exec" "path/filepath" "slices" "strconv" "strings" "syscall" "time" ) // Runner runs a command and answers what it printed — the apply's own (apply.ExecRunner). type Runner func(ctx context.Context, name string, args ...string) (string, error) // Exec reads the machine through its command lines and the process table. **Reads only**: `id`, `getent`, // the machine's own manager's `systemctl show`, `sudo -l -U` (which lists, as root, what sudo would let an // account run, and runs nothing), a status file under /proc and a secret's owner and mode (a test holds // both). type Exec struct { Run Runner // Proc is where the process table is; empty is /proc. A test points it at a directory of its own. Proc string // Stat is a file's owner and mode; nil is the machine's own (os.Stat). A test gives files of its own. Stat func(path string) (FileMode, error) // ReadFile, ReadDir and ACL read doas's and polkit's rules and a file's POSIX ACL; nil is the machine's. ReadFile func(path string) ([]byte, error) ReadDir func(path string) ([]fs.DirEntry, error) ACL func(path string) ([]ACLEntry, error) // Cache keeps the search for setuid programs between looks; nil searches on every look. Cache *SetuidCache // Now is the clock the cache is kept by; nil is the machine's. Now func() time.Time } // FileMode is what decides whether an account reads a file: its owner, its group and its permission bits. type FileMode struct { UID, GID int Perm fs.FileMode } // Escalation is every way account can become root without a person (novox/hq ADR 0266) that the judge // looks for — Judged lists them, NotJudged what it does not: its uid, a group of RootGroups the user database // lists it in, any sudo rule naming it or a group of it, any of secrets it can read by owner, group or other // bits, and the ways of ways.go: doas, polkit, a runtime's socket, an ACL, a setuid program no package owns. // sudo absent is no sudo rule; doas and polkit (pkexec's grants) are judged from their own rules. A secret not there yet is skipped: there is nothing to read. // The parent directories are not walked, so a file the bits allow and a directory hides is still said: // the judge errs toward saying a way that is not, never toward missing one that is. func (e Exec) Escalation(ctx context.Context, account string, secrets []string) ([]string, error) { var ways []string uidOut, err := e.Run(ctx, "id", "-u", account) if err != nil { return nil, fmt.Errorf("the user database did not answer about %q: %w", account, err) } uid, err := strconv.Atoi(strings.TrimSpace(uidOut)) if err != nil { return nil, fmt.Errorf("the user database gave %q as %q's number", strings.TrimSpace(uidOut), account) } if uid == 0 { ways = append(ways, "its uid is 0") } names, err := e.InDatabase(ctx, account) if err != nil { return nil, err } for _, g := range names { if slices.Contains(RootGroups, g) { ways = append(ways, "in the group "+g+", which grants root") } } listed, err := e.Run(ctx, "sudo", "-l", "-U", account) rules, err := SudoRules(listed, err) if err != nil { return nil, err } if len(rules) > 0 { ways = append(ways, "sudo grants it: "+strings.Join(rules, ", ")) } gidsOut, err := e.Run(ctx, "id", "-G", account) if err != nil { return nil, fmt.Errorf("the user database did not answer about %q's groups: %w", account, err) } gids := map[int]bool{} for _, f := range strings.Fields(gidsOut) { if n, err := strconv.Atoi(f); err == nil { gids[n] = true } } if len(secrets) > 0 { stat := e.Stat if stat == nil { stat = statOf } for _, path := range secrets { m, err := stat(path) if errors.Is(err, fs.ErrNotExist) { continue } if err != nil { return nil, fmt.Errorf("the secret %s could not be read for its owner and mode: %w", path, err) } if Readable(m, uid, gids) { ways = append(ways, "it can read the secret "+path) } } } more, err := e.moreWays(ctx, account, uid, names, gids, secrets) if err != nil { return nil, err } return append(ways, more...), nil } // Readable is whether an account of uid, in the groups gids, reads a file of m by its permission bits, as // the kernel decides it: the owner's bits for the owner (root reads everything), the group's for a member, // the others' for anybody else. func Readable(m FileMode, uid int, gids map[int]bool) bool { switch { case uid == 0: return true case m.UID == uid: return m.Perm&0o400 != 0 case gids[m.GID]: return m.Perm&0o040 != 0 default: return m.Perm&0o004 != 0 } } // SudoRules is the rules `sudo -l -U ` lists, from what it printed and how it ended: none when // the account "is not allowed to run sudo" or sudo is not on the machine; every indented line after "may // run the following commands" otherwise. Any rule counts — the decision is no sudo for the account at // all, so a rule that asks for a password the account was never given is still a rule somebody can give // it one for. Output that says neither is an error: unread is never none. func SudoRules(out string, err error) ([]string, error) { if err != nil && (errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist)) { return nil, nil } text := out if err != nil { text += "\n" + err.Error() } if strings.Contains(text, "is not allowed to run sudo") { return nil, nil } if err != nil { return nil, fmt.Errorf("sudo did not list the account's rules: %w", err) } var rules []string listing := false for _, line := range strings.Split(out, "\n") { if strings.Contains(line, "may run the following commands") { listing = true continue } if listing && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && strings.TrimSpace(line) != "" { rules = append(rules, strings.TrimSpace(line)) } } if !listing { return nil, fmt.Errorf("sudo listed neither rules nor a refusal: %q", firstLine(out)) } return rules, nil } func statOf(path string) (FileMode, error) { info, err := os.Stat(path) if err != nil { return FileMode{}, err } st, ok := info.Sys().(*syscall.Stat_t) if !ok { return FileMode{}, fmt.Errorf("%s has no owner this platform reports", path) } return FileMode{UID: int(st.Uid), GID: int(st.Gid), Perm: info.Mode().Perm()}, nil } // InDatabase is `id -nG`: every group the user database lists the account in. func (e Exec) InDatabase(ctx context.Context, account string) ([]string, error) { out, err := e.Run(ctx, "id", "-nG", account) if err != nil { return nil, err } return strings.Fields(out), nil } // Session reads the account's own manager, user@.service, from the machine's manager — never from // the account's, which asking would start — and the groups its process holds, from its status file. func (e Exec) Session(ctx context.Context, account string, groups []string) (Session, error) { passwd, err := e.Run(ctx, "getent", "passwd", account) if err != nil { return Session{}, fmt.Errorf("the user database did not answer about %q: %w", account, err) } fields := strings.Split(strings.TrimSpace(passwd), ":") if len(fields) < 7 || fields[2] == "" { return Session{}, fmt.Errorf("the user database gave no number for %q", account) } shown, err := e.Run(ctx, "systemctl", "show", "--property=MainPID", "--value", "user@"+fields[2]+".service") if err != nil { return Session{}, fmt.Errorf("the machine's service manager did not say whether %q's own runs: %w", account, err) } pid := strings.TrimSpace(shown) if pid == "" || pid == "0" { return Session{}, nil } held, err := e.heldBy(pid) if err != nil { return Session{}, err } s := Session{Running: true, Has: map[string]bool{}} for _, g := range groups { entry, err := e.Run(ctx, "getent", "group", g) if err != nil { return Session{}, fmt.Errorf("the group database did not answer about %q: %w", g, err) } parts := strings.Split(strings.TrimSpace(entry), ":") if len(parts) < 3 { return Session{}, fmt.Errorf("the group database gave %q for %q, which is not a group entry", entry, g) } s.Has[g] = held[parts[2]] } return s, nil } // heldBy is every group id a process holds, from the Groups line of its status file. func (e Exec) heldBy(pid string) (map[string]bool, error) { proc := e.Proc if proc == "" { proc = "/proc" } raw, err := os.ReadFile(filepath.Join(proc, pid, "status")) if errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("the account's manager, process %s, ended while it was read", pid) } if err != nil { return nil, err } // Its supplementary groups, and its own group, which the supplementary list need not repeat. held := map[string]bool{} named := false for _, line := range strings.Split(string(raw), "\n") { if rest, ok := strings.CutPrefix(line, "Groups:"); ok { named = true for _, gid := range strings.Fields(rest) { held[gid] = true } } if rest, ok := strings.CutPrefix(line, "Gid:"); ok { if f := strings.Fields(rest); len(f) > 0 { held[f[0]] = true } } } if !named { return nil, fmt.Errorf("process %s's status names no groups", pid) } return held, nil }