A search over a large disk ran inside the look, holding the judge's lock and stalling the health statement; it now runs apart, serving its last result, backing off after a failure, and saying not judged until it has one. And a rule naming a user, or a comment, no longer hides another rule's unconditional yes (hq ADR 0266).
266 lines
9.2 KiB
Go
266 lines
9.2 KiB
Go
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)
|
|
var notJudged *SetuidNotJudged
|
|
if err != nil && !errors.As(err, ¬Judged) {
|
|
return nil, err
|
|
}
|
|
// Every way found, and — when the setuid search has no result yet — that it is not judged.
|
|
return append(ways, more...), err
|
|
}
|
|
|
|
// 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 <account>` 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@<uid>.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
|
|
}
|