package accounts // The ways to root the judge looks for beyond uid, groups and sudo (novox/hq ADR 0266, the review of // 2026-10-08), and the ones it does not. Judged and NotJudged are the one written list: the record quotes it, // and a way added here is a line added there. import ( "bytes" "context" "encoding/binary" "errors" "fmt" "io/fs" "os" "os/exec" "path/filepath" "sort" "strings" "sync" "time" "golang.org/x/sys/unix" ) // Judged is every way to root the judge looks for, in the words its reasons use. var Judged = []string{ "its uid is 0", "membership of a group that grants root by membership: " + strings.Join(RootGroups, ", "), "any sudo rule for it or one of its groups, as `sudo -l -U` lists it (with or without a password)", "any doas rule permitting it or one of its groups (/etc/doas.conf, /etc/opendoas.conf)", "any polkit rule naming it or one of its groups (/etc/polkit-1/rules.d, /usr/share/polkit-1/rules.d, " + "/etc/polkit-1/localauthority), which is how pkexec and systemd's own actions are granted", "write access to a container runtime's socket (docker, podman, containerd), by owner, group, other or " + "POSIX ACL", "read access to a secret the mesh placed for another account, by owner, group, other or POSIX ACL", "a setuid- or setgid-root program that no installed package owns, anywhere on the root filesystem", } // NotJudged is what the judge does not look for: each is a way to root it would miss. var NotJudged = []string{ "a root-run unit, timer, cron entry or script the account can write", "a directory on root's PATH, or in /etc/profile.d, the account can write", "root's or the operator's ssh keys or authorized_keys readable or writable by it", "a file root writes or reads, in a directory the account owns, other than below its home (the node-engine " + "refuses links there)", "file capabilities (setcap) on a program", "a setuid program a package installed that has a flaw of its own", "a terminal the account shares with a root process (TIOCSTI, sudo without use_pty)", } // RuntimeSockets are the container runtimes' sockets: write access to one runs a container as root. var RuntimeSockets = []string{"/run/docker.sock", "/var/run/docker.sock", "/run/podman/podman.sock", "/run/containerd/containerd.sock"} // DoasConfigs and PolkitDirs are where those grants live. var ( DoasConfigs = []string{"/etc/doas.conf", "/etc/opendoas.conf"} PolkitDirs = []string{"/etc/polkit-1/rules.d", "/usr/share/polkit-1/rules.d", "/etc/polkit-1/localauthority"} ) // ACLEntry is one named entry of a POSIX ACL: a user's or a group's, and whether it grants read and write // once the ACL's mask is applied. type ACLEntry struct { User bool ID int Read, Write bool } // The tags and bits of the kernel's ACL xattr. const ( aclUser = 0x02 aclGroup = 0x08 aclMask = 0x10 ) // ParseACL reads a system.posix_acl_access value: a version, then entries of tag, permission and id; the // named users' and groups' entries are answered with the mask applied. func ParseACL(raw []byte) ([]ACLEntry, error) { if len(raw) < 4 || (len(raw)-4)%8 != 0 { return nil, fmt.Errorf("an ACL of %d bytes is not one", len(raw)) } type entry struct { tag, perm uint16 id uint32 } var es []entry mask := uint16(7) for at := 4; at < len(raw); at += 8 { e := entry{binary.LittleEndian.Uint16(raw[at:]), binary.LittleEndian.Uint16(raw[at+2:]), binary.LittleEndian.Uint32(raw[at+4:])} if e.tag == aclMask { mask = e.perm } es = append(es, e) } var out []ACLEntry for _, e := range es { if e.tag != aclUser && e.tag != aclGroup { continue } p := e.perm & mask out = append(out, ACLEntry{User: e.tag == aclUser, ID: int(e.id), Read: p&4 != 0, Write: p&2 != 0}) } return out, nil } // aclOf is a file's ACL from the machine: none when it has none. func aclOf(path string) ([]ACLEntry, error) { buf := make([]byte, 1024) n, err := unix.Getxattr(path, "system.posix_acl_access", buf) if errors.Is(err, unix.ENODATA) || errors.Is(err, unix.EOPNOTSUPP) { return nil, nil } if err != nil { return nil, &os.PathError{Op: "getxattr", Path: path, Err: err} } return ParseACL(buf[:n]) } // grantsByACL says whether an ACL entry gives the account read (or write). func grantsByACL(acl []ACLEntry, uid int, gids map[int]bool, write bool) bool { for _, e := range acl { if (e.User && e.ID == uid) || (!e.User && gids[e.ID]) { if (write && e.Write) || (!write && e.Read) { return true } } } return false } // Writable is Readable's twin for the write bits. func Writable(m FileMode, uid int, gids map[int]bool) bool { switch { case uid == 0: return true case m.UID == uid: return m.Perm&0o200 != 0 case gids[m.GID]: return m.Perm&0o020 != 0 default: return m.Perm&0o002 != 0 } } // DoasRules is every line of a doas configuration that permits the account or one of its groups. func DoasRules(conf string, account string, groups []string) []string { var out []string for _, line := range strings.Split(conf, "\n") { if i := strings.IndexByte(line, '#'); i >= 0 { line = line[:i] } f := strings.Fields(line) if len(f) < 2 || f[0] != "permit" { continue } i := 1 for i < len(f) && (f[i] == "nopass" || f[i] == "persist" || f[i] == "keepenv" || f[i] == "nolog" || f[i] == "setenv" || strings.HasPrefix(f[i], "{")) { if strings.HasPrefix(f[i], "{") { for i < len(f) && !strings.HasSuffix(f[i], "}") { i++ } } i++ } if i >= len(f) { continue } who := f[i] if who == account || (strings.HasPrefix(who, ":") && contains(groups, who[1:])) { out = append(out, strings.TrimSpace(line)) } } return out } // PolkitNames says whether a polkit rule or authority file names the account or one of its groups. func PolkitNames(text, account string, groups []string) bool { needles := []string{"unix-user:" + account, `"` + account + `"`, `'` + account + `'`} for _, g := range groups { needles = append(needles, "unix-group:"+g, `isInGroup("`+g+`")`, `isInGroup('`+g+`')`) } for _, n := range needles { if strings.Contains(text, n) { return true } } return false } func contains(xs []string, s string) bool { for _, x := range xs { if x == s { return true } } return false } // SetuidCache keeps the search for setuid programs, which walks the root filesystem, for Every: the judge // looks every minute, and a setuid program appears only by root's act. type SetuidCache struct { Every time.Duration mu sync.Mutex at time.Time found []string err error } func (c *SetuidCache) get(now time.Time, search func() ([]string, error)) ([]string, error) { if c == nil { return search() } c.mu.Lock() defer c.mu.Unlock() if c.at.IsZero() || now.Sub(c.at) >= c.Every || c.err != nil { c.found, c.err = search() c.at = now } return c.found, c.err } // setuidSearch is every setuid- or setgid-root regular file on the root filesystem that no installed package // owns, found with find and asked of the package manager. Bounded: a search that does not finish is an // unanswered question, never "none". func (e Exec) setuidSearch(ctx context.Context) ([]string, error) { ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() out, err := e.Run(ctx, "find", "/", "-xdev", "(", "-path", "/proc", "-o", "-path", "/sys", "-o", "-path", "/var/lib/docker", "-o", "-path", "/var/lib/containers", ")", "-prune", "-o", "-type", "f", "-user", "root", "-perm", "/6000", "-print") if ctx.Err() != nil { return nil, fmt.Errorf("the search for setuid programs did not finish within two minutes") } if err != nil && strings.TrimSpace(out) == "" { return nil, fmt.Errorf("the search for setuid programs did not finish: %w", err) } var paths []string for _, l := range strings.Split(out, "\n") { if l = strings.TrimSpace(l); l != "" { paths = append(paths, l) } } sort.Strings(paths) var unowned []string for _, p := range paths { owned, err := e.packaged(ctx, p) if err != nil { return nil, err } if !owned { unowned = append(unowned, p) } } return unowned, nil } // packaged says whether an installed package owns a path: pacman's or apk's answer, never a guess. func (e Exec) packaged(ctx context.Context, path string) (bool, error) { out, err := e.Run(ctx, "pacman", "-Qqo", path) if err == nil { return strings.TrimSpace(out) != "", nil } if strings.Contains(out+err.Error(), "No package owns") || strings.Contains(err.Error(), "exited 1") { return false, nil } if !errors.Is(err, exec.ErrNotFound) && !errors.Is(err, fs.ErrNotExist) { return false, fmt.Errorf("pacman could not say who owns %s: %w", path, err) } out, err = e.Run(ctx, "apk", "info", "-W", path) if err != nil { return false, fmt.Errorf("no package manager could say who owns %s: %w", path, err) } return strings.Contains(out, " is owned by "), nil } // moreWays is every way beyond uid, groups and sudo; an unanswered question is an error, never none. func (e Exec) moreWays(ctx context.Context, account string, uid int, groups []string, gids map[int]bool, secrets []string) ([]string, error) { read := e.ReadFile if read == nil { read = os.ReadFile } readDir := e.ReadDir if readDir == nil { readDir = os.ReadDir } acl := e.ACL if acl == nil { acl = aclOf } stat := e.Stat if stat == nil { stat = statOf } var ways []string // doas. for _, conf := range DoasConfigs { raw, err := read(conf) if errors.Is(err, fs.ErrNotExist) { continue } if err != nil { return nil, fmt.Errorf("doas's rules in %s could not be read: %w", conf, err) } for _, r := range DoasRules(string(raw), account, groups) { ways = append(ways, "doas permits it: "+r) } } // polkit. for _, dir := range PolkitDirs { var named []string err := walkFiles(readDir, dir, func(path string) error { raw, err := read(path) if err != nil { return err } if PolkitNames(string(raw), account, groups) { named = append(named, path) } return nil }) if err != nil && !errors.Is(err, fs.ErrNotExist) { return nil, fmt.Errorf("polkit's rules in %s could not be read: %w", dir, err) } for _, p := range named { ways = append(ways, "a polkit rule names it or a group of it: "+p) } } // The container runtimes' sockets. seen := map[string]bool{} for _, s := range RuntimeSockets { // Two names of one socket are one socket. A test that gives its own files names them as they are. real, err := filepath.EvalSymlinks(s) if e.Stat != nil { real, err = s, nil } if errors.Is(err, fs.ErrNotExist) { continue } if err != nil { return nil, fmt.Errorf("the socket %s could not be read: %w", s, err) } if seen[real] { continue } seen[real] = true m, err := stat(real) if errors.Is(err, fs.ErrNotExist) { continue } if err != nil { return nil, fmt.Errorf("the socket %s could not be read for its owner and mode: %w", s, err) } a, err := acl(real) if err != nil { return nil, err } if Writable(m, uid, gids) || grantsByACL(a, uid, gids, true) { ways = append(ways, "it can write the container runtime's socket "+s+", which runs a container as root") } } // The secrets' ACLs: the bits were judged already. for _, path := range secrets { a, err := acl(path) if errors.Is(err, fs.ErrNotExist) { continue } if err != nil { return nil, fmt.Errorf("the secret %s's ACL could not be read: %w", path, err) } if grantsByACL(a, uid, gids, false) { ways = append(ways, "an ACL lets it read the secret "+path) } } // setuid programs no package owns. now := time.Now if e.Now != nil { now = e.Now } unowned, err := e.Cache.get(now(), func() ([]string, error) { return e.setuidSearch(ctx) }) if err != nil { return nil, err } for _, p := range unowned { ways = append(ways, "a setuid-root program no package owns: "+p) } return ways, nil } // walkFiles calls fn for every regular file below dir, through readDir. func walkFiles(readDir func(string) ([]fs.DirEntry, error), dir string, fn func(string) error) error { entries, err := readDir(dir) if err != nil { return err } for _, en := range entries { p := filepath.Join(dir, en.Name()) if en.IsDir() { if err := walkFiles(readDir, p, fn); err != nil && !errors.Is(err, fs.ErrNotExist) { return err } continue } if err := fn(p); err != nil { return err } } return nil } // CLocale runs a command in the C locale, so what the judge parses — sudo's listing above all — is one // language whatever the machine's is; stdin closed, output captured. func CLocale(ctx context.Context, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C", "LANGUAGE=C") var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { var exit *exec.ExitError if errors.As(err, &exit) { return string(out), fmt.Errorf("%s exited %d: %s", name, exit.ExitCode(), strings.TrimSpace(stderr.String())) } return string(out), fmt.Errorf("%s: %w", name, err) } return string(out), nil }