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).
747 lines
25 KiB
Go
747 lines
25 KiB
Go
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",
|
|
"a polkit rule that grants every account: a rule of a .rules file that answers polkit.Result.YES and names " +
|
|
"no user and no group, or a .pkla section whose Identity is unix-user:* or unix-group:* with a Result of " +
|
|
"yes — each rule and section judged on its own, comments taken out",
|
|
"write access to a container runtime's socket (docker, podman, containerd), by owner, group, other or " +
|
|
"POSIX ACL",
|
|
"a container runtime's API listening on TCP port 2375 or 2376 (/proc/net/tcp, /proc/net/tcp6), which any " +
|
|
"local account reaches",
|
|
"read access to a secret the mesh placed for another account, by owner, group, other or POSIX ACL",
|
|
"a program that no installed package owns and is setuid with owner root, or setgid with group root, " +
|
|
"whoever owns it, on every local filesystem mounted without nosuid (container and image layers, " +
|
|
"network and pseudo filesystems excepted) — searched in the background at most hourly, its last result " +
|
|
"served with when it was found; until a search has finished, the verdict says not judged",
|
|
"hard links or symbolic links to other accounts' files left unprotected by the kernel " +
|
|
"(fs.protected_hardlinks or fs.protected_symlinks is 0)",
|
|
}
|
|
|
|
// 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)",
|
|
"a polkit rule that grants every account by logic the text does not show (a JavaScript condition " +
|
|
"other than a named user or group), or every account with an active local session",
|
|
"a container runtime's API on a TCP port other than 2375 and 2376, or on a unix socket not named here",
|
|
"a setuid program on a filesystem not judged: a container or image layer (overlay, squashfs), a network " +
|
|
"or FUSE filesystem",
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// PolkitGrantsEveryone says whether a polkit rule or authority file grants every account, naming none: a .rules
|
|
// file one of whose rules answers polkit.Result.YES with no condition on a user or a group, or a .pkla one of
|
|
// whose sections has an Identity of every user or every group with a Result of yes.
|
|
//
|
|
// **Each rule is judged on its own, comments taken out first** (the third review of 2026-10-08): a rule naming a
|
|
// user elsewhere in the file, or a user named only in a comment, must not hide another rule's unconditional
|
|
// yes. A rule's text is read, not run: a condition the text does not show is listed under NotJudged.
|
|
func PolkitGrantsEveryone(path, text string) bool {
|
|
if strings.HasSuffix(path, ".pkla") {
|
|
var kept []string
|
|
for _, line := range strings.Split(text, "\n") {
|
|
if t := strings.TrimSpace(line); strings.HasPrefix(t, "#") || strings.HasPrefix(t, ";") {
|
|
continue
|
|
}
|
|
kept = append(kept, line)
|
|
}
|
|
for _, section := range strings.Split(strings.Join(kept, "\n"), "[") {
|
|
var every, yes bool
|
|
for _, line := range strings.Split(section, "\n") {
|
|
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
v = strings.TrimSpace(v)
|
|
switch strings.TrimSpace(k) {
|
|
case "Identity":
|
|
for _, id := range strings.Split(v, ";") {
|
|
if id = strings.TrimSpace(id); id == "unix-user:*" || id == "unix-group:*" {
|
|
every = true
|
|
}
|
|
}
|
|
case "ResultAny", "ResultActive", "ResultInactive":
|
|
if v == "yes" {
|
|
yes = true
|
|
}
|
|
}
|
|
}
|
|
if every && yes {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
for _, rule := range polkitRules(stripJSComments(text)) {
|
|
if !strings.Contains(rule, "polkit.Result.YES") {
|
|
continue
|
|
}
|
|
// A condition on a user or a group names whom it grants; one on an active local session grants only an
|
|
// account with a seat, which an agent started through sudo has not (listed under NotJudged).
|
|
conditioned := false
|
|
for _, condition := range []string{".user", "isInGroup", "unix-user:", "unix-group:", ".active", ".local"} {
|
|
if strings.Contains(rule, condition) {
|
|
conditioned = true
|
|
break
|
|
}
|
|
}
|
|
if !conditioned {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// polkitRules is each rule of a .rules file: the text from one polkit.addRule to the next. Text before the first
|
|
// is no rule.
|
|
func polkitRules(text string) []string {
|
|
parts := strings.Split(text, "addRule(")
|
|
if len(parts) < 2 {
|
|
return nil
|
|
}
|
|
return parts[1:]
|
|
}
|
|
|
|
// stripJSComments takes /* … */ and // … comments out of a rule file, leaving what is in strings alone.
|
|
func stripJSComments(text string) string {
|
|
var b strings.Builder
|
|
var quote byte
|
|
for i := 0; i < len(text); i++ {
|
|
c := text[i]
|
|
switch {
|
|
case quote != 0:
|
|
b.WriteByte(c)
|
|
if c == '\\' && i+1 < len(text) {
|
|
i++
|
|
b.WriteByte(text[i])
|
|
} else if c == quote {
|
|
quote = 0
|
|
}
|
|
case c == '"' || c == '\'' || c == '`':
|
|
quote = c
|
|
b.WriteByte(c)
|
|
case c == '/' && i+1 < len(text) && text[i+1] == '*':
|
|
end := strings.Index(text[i+2:], "*/")
|
|
if end < 0 {
|
|
return b.String()
|
|
}
|
|
i += end + 3
|
|
case c == '/' && i+1 < len(text) && text[i+1] == '/':
|
|
end := strings.IndexByte(text[i:], '\n')
|
|
if end < 0 {
|
|
return b.String()
|
|
}
|
|
i += end - 1
|
|
default:
|
|
b.WriteByte(c)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// RuntimeAPIPorts are the ports a container runtime's API listens on by convention: 2375 plain, 2376 TLS.
|
|
var RuntimeAPIPorts = []int{2375, 2376}
|
|
|
|
// ListeningPorts is every local TCP port in the listening state, from /proc/net/tcp's or tcp6's text.
|
|
func ListeningPorts(text string) []int {
|
|
var out []int
|
|
for i, line := range strings.Split(text, "\n") {
|
|
f := strings.Fields(line)
|
|
if i == 0 || len(f) < 4 || f[3] != "0A" {
|
|
continue
|
|
}
|
|
_, port, ok := strings.Cut(f[1], ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
var n int
|
|
if _, err := fmt.Sscanf(port, "%X", &n); err == nil {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pseudoFS are filesystem types no setuid program is installed on, or that are a container's or an image's
|
|
// own layers: not searched.
|
|
var pseudoFS = map[string]bool{"proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true,
|
|
"cgroup": true, "cgroup2": true, "securityfs": true, "pstore": true, "bpf": true, "debugfs": true,
|
|
"tracefs": true, "mqueue": true, "hugetlbfs": true, "configfs": true, "fusectl": true, "autofs": true,
|
|
"binfmt_misc": true, "efivarfs": true, "overlay": true, "squashfs": true, "nsfs": true, "ramfs": true,
|
|
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "9p": true, "virtiofs": true}
|
|
|
|
// SuidMounts is every local filesystem a setuid program could run from: the mount points of /proc/mounts whose
|
|
// type is not pseudo, network or a container's layer, and that are not mounted nosuid. Root first.
|
|
func SuidMounts(text string) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, line := range strings.Split(text, "\n") {
|
|
f := strings.Fields(line)
|
|
if len(f) < 4 || pseudoFS[f[2]] || strings.HasPrefix(f[2], "fuse") {
|
|
continue
|
|
}
|
|
at := strings.ReplaceAll(strings.ReplaceAll(f[1], `\040`, " "), `\011`, "\t")
|
|
if strings.HasPrefix(at, "/var/lib/docker") || strings.HasPrefix(at, "/var/lib/containers") ||
|
|
strings.HasPrefix(at, "/proc") || strings.HasPrefix(at, "/sys") {
|
|
continue
|
|
}
|
|
if contains(strings.Split(f[3], ","), "nosuid") || seen[at] {
|
|
continue
|
|
}
|
|
seen[at] = true
|
|
out = append(out, at)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i] == "/" || (out[j] != "/" && out[i] < out[j]) })
|
|
return out
|
|
}
|
|
|
|
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 every local filesystem, and runs it in the
|
|
// background (novox/hq ADR 0266, the third review): a disk too large to search in time must never stall a
|
|
// look, the health statement or the apply report. A look reads the last result the search left, and starts a
|
|
// new search when the last one is older than Every; a search that failed or did not finish is tried again
|
|
// after a back-off that doubles, from Every up to MaxBackoff.
|
|
type SetuidCache struct {
|
|
Every time.Duration
|
|
|
|
mu sync.Mutex
|
|
wg sync.WaitGroup
|
|
at time.Time // when the last search that finished finished
|
|
found []string
|
|
running bool
|
|
startedAt time.Time
|
|
failedAt time.Time
|
|
failed error
|
|
backoff time.Duration
|
|
}
|
|
|
|
// SearchBound is how long one search may run in the background; MaxBackoff the longest wait after failures.
|
|
const (
|
|
SearchBound = 15 * time.Minute
|
|
MaxBackoff = 6 * time.Hour
|
|
)
|
|
|
|
// SetuidNotJudged is the search's answer when it has no result to give: never run to its end yet, or failed
|
|
// every time so far. The judge says the way is not judged, never that there is none.
|
|
type SetuidNotJudged struct {
|
|
// Pending is true while the first search runs; false after one failed or did not finish.
|
|
Pending bool
|
|
Since time.Time
|
|
Err error
|
|
}
|
|
|
|
func (e *SetuidNotJudged) Error() string {
|
|
if e.Pending {
|
|
return fmt.Sprintf("%s: the search for setuid programs, started at %s, has not finished yet", ReasonPending,
|
|
e.Since.Local().Format("15:04"))
|
|
}
|
|
return fmt.Sprintf("%s: the search for setuid programs did not finish (last tried at %s: %v); it is tried "+
|
|
"again later", ReasonIncomplete, e.Since.Local().Format("15:04"), e.Err)
|
|
}
|
|
|
|
// The reasons of a root verdict the setuid search could not answer.
|
|
const (
|
|
ReasonPending = "not judged yet (search running)"
|
|
ReasonIncomplete = "not judged (search incomplete)"
|
|
)
|
|
|
|
// Look answers the last result and when its search finished, starting a search in the background when one is
|
|
// due; it never waits for one. Nil c searches in place, for a caller that wants to.
|
|
func (c *SetuidCache) Look(now time.Time, search func(ctx context.Context) ([]string, error)) ([]string, time.Time, error) {
|
|
if c == nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), SearchBound)
|
|
defer cancel()
|
|
found, err := search(ctx)
|
|
return found, now, err
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
due := !c.running && (c.at.IsZero() || now.Sub(c.at) >= c.Every) &&
|
|
(c.failed == nil || now.Sub(c.failedAt) >= c.backoff)
|
|
if due {
|
|
c.running, c.startedAt = true, now
|
|
c.wg.Add(1)
|
|
go func() {
|
|
defer c.wg.Done()
|
|
ctx, cancel := context.WithTimeout(context.Background(), SearchBound)
|
|
found, err := search(ctx)
|
|
cancel()
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.running = false
|
|
done := time.Now()
|
|
if err != nil {
|
|
c.failed, c.failedAt = err, done
|
|
c.backoff = min(max(2*c.backoff, c.Every), MaxBackoff)
|
|
return
|
|
}
|
|
c.found, c.at, c.failed, c.backoff = found, done, nil, 0
|
|
}()
|
|
}
|
|
switch {
|
|
case !c.at.IsZero():
|
|
return c.found, c.at, nil
|
|
case c.failed != nil:
|
|
return nil, time.Time{}, &SetuidNotJudged{Since: c.failedAt, Err: c.failed}
|
|
default:
|
|
return nil, time.Time{}, &SetuidNotJudged{Pending: true, Since: c.startedAt}
|
|
}
|
|
}
|
|
|
|
// Wait is for a test: until the search running, if any, has finished.
|
|
func (c *SetuidCache) Wait() { c.wg.Wait() }
|
|
|
|
// 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, mounts []string) ([]string, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
|
defer cancel()
|
|
// Each mount point a starting point of its own, -xdev keeping each to its own filesystem: every local
|
|
// filesystem mounted without nosuid is searched once (the re-review of 2026-10-08), and a program setgid to
|
|
// root's group counts whoever owns it.
|
|
args := append(append([]string{}, mounts...), "-xdev", "(", "-path", "/proc", "-o", "-path", "/sys", "-o",
|
|
"-path", "/var/lib/docker", "-o", "-path", "/var/lib/containers", ")", "-prune", "-o",
|
|
"-type", "f", "(", "(", "-user", "root", "-perm", "-4000", ")", "-o", "(", "-group", "root", "-perm",
|
|
"-2000", ")", ")", "-print")
|
|
out, err := e.Run(ctx, "find", args...)
|
|
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, everyone []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)
|
|
} else if PolkitGrantsEveryone(path, string(raw)) {
|
|
everyone = append(everyone, 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)
|
|
}
|
|
for _, p := range everyone {
|
|
ways = append(ways, "a polkit rule grants every account: "+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
|
|
}
|
|
mountsText, err := read("/proc/mounts")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the mounted filesystems could not be read: %w", err)
|
|
}
|
|
mounts := SuidMounts(string(mountsText))
|
|
if len(mounts) == 0 {
|
|
return nil, errors.New("no filesystem a setuid program could run from was found in /proc/mounts")
|
|
}
|
|
// In the background, its last result served (SetuidCache): never a stall. No result yet is said beside
|
|
// the other ways, which are judged all the same.
|
|
unowned, searchedAt, searchErr := e.Cache.Look(now(), func(sctx context.Context) ([]string, error) {
|
|
return e.setuidSearch(sctx, mounts)
|
|
})
|
|
var notJudged *SetuidNotJudged
|
|
if searchErr != nil && !errors.As(searchErr, ¬Judged) {
|
|
return nil, searchErr
|
|
}
|
|
for _, p := range unowned {
|
|
ways = append(ways, "a setuid-root program no package owns: "+p+" (searched at "+
|
|
searchedAt.Local().Format("15:04")+")")
|
|
}
|
|
|
|
// The kernel's protection of links: off, any account may link another's file where root then acts on it.
|
|
for _, sysctl := range []string{"protected_hardlinks", "protected_symlinks"} {
|
|
raw, err := read("/proc/sys/fs/" + sysctl)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fs.%s could not be read: %w", sysctl, err)
|
|
}
|
|
if strings.TrimSpace(string(raw)) == "0" {
|
|
what := map[string]string{"protected_hardlinks": "hard links", "protected_symlinks": "symbolic links"}[sysctl]
|
|
ways = append(ways, fmt.Sprintf("%s to other accounts' files are not protected (fs.%s=0)", what, sysctl))
|
|
}
|
|
}
|
|
|
|
// A container runtime's API on TCP, which any account on the machine reaches.
|
|
for _, table := range []string{"/proc/net/tcp", "/proc/net/tcp6"} {
|
|
raw, err := read(table)
|
|
if errors.Is(err, fs.ErrNotExist) && table == "/proc/net/tcp6" {
|
|
continue // no IPv6 on this machine
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the listening ports in %s could not be read: %w", table, err)
|
|
}
|
|
for _, port := range ListeningPorts(string(raw)) {
|
|
for _, api := range RuntimeAPIPorts {
|
|
if port == api {
|
|
ways = append(ways, fmt.Sprintf("a container runtime's API listens on TCP port %d, which any "+
|
|
"local account reaches", port))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if notJudged != nil {
|
|
// The other ways are judged; this one is said as not judged, never as none.
|
|
return ways, notJudged
|
|
}
|
|
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
|
|
}
|