mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group fix/361-the-setuid-search-runs-to-completion ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
The control-node's walk takes far longer than the engine runs between updates, so a search that starts over at every restart never completes. The walk is the engine's own, in lexical order, so its progress is one cursor per filesystem, kept every minute beside the engine's state and carried on after a restart while younger than rootsearch.FreshFor. It runs on a thread of its own at nice 19 and the idle I/O class.
680 lines
22 KiB
Go
680 lines
22 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"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"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
|
|
}
|
|
|
|
// setuidSearch is every setuid- or setgid-root regular file on the filesystems of p that no installed package
|
|
// owns: walked by the engine (setuid_walk.go, resumable from p), then asked of the package manager through the
|
|
// search's runner (Exec.SearchRun). **No bound of its own** (novox/hq issue 361): it runs until it has walked
|
|
// everything. A walk that could not complete — a directory it could not read — is an unanswered question, never
|
|
// "none"; a file that vanished while it walked is not.
|
|
func (e Exec) setuidSearch(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error) {
|
|
walk := e.Walk
|
|
if walk == nil {
|
|
walk = walker{every: CheckpointEvery}.walk
|
|
}
|
|
if err := walk(ctx, p, checkpoint); err != nil {
|
|
return nil, fmt.Errorf("the walk for setuid programs did not complete: %w", err)
|
|
}
|
|
if e.SearchRun != nil {
|
|
e.Run = e.SearchRun
|
|
}
|
|
found := slices.Clone(p.Found)
|
|
slices.Sort(found)
|
|
return e.unpackaged(ctx, slices.Compact(found))
|
|
}
|
|
|
|
// errorLines are the lines a command said on its error stream, as the runner words a failure: "<name> exited N:
|
|
// <its words>", the prefix taken off the first line. Empty lines are dropped.
|
|
func errorLines(err error, name string) []string {
|
|
words := err.Error()
|
|
if strings.HasPrefix(words, name+" exited") {
|
|
if i := strings.Index(words, ": "); i >= 0 {
|
|
words = words[i+2:]
|
|
}
|
|
}
|
|
var out []string
|
|
for _, l := range strings.Split(words, "\n") {
|
|
if l = strings.TrimSpace(l); l != "" {
|
|
out = append(out, l)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// unpackaged is every path no installed package owns: pacman asked about all of them in one call, else apk
|
|
// about each. Never a guess: a path neither said owned nor said unowned is an unanswered question.
|
|
func (e Exec) unpackaged(ctx context.Context, paths []string) ([]string, error) {
|
|
if len(paths) == 0 {
|
|
return nil, nil
|
|
}
|
|
out, err := e.Run(ctx, "pacman", append([]string{"-Qo"}, paths...)...)
|
|
if err == nil || !errors.Is(err, exec.ErrNotFound) && !errors.Is(err, fs.ErrNotExist) {
|
|
owned := map[string]bool{}
|
|
for _, l := range strings.Split(out, "\n") {
|
|
if p, _, ok := strings.Cut(strings.TrimSpace(l), " is owned by "); ok {
|
|
owned[p] = true
|
|
}
|
|
}
|
|
unowned := map[string]bool{}
|
|
if err != nil {
|
|
// pacman exits 1 when a path is owned by nothing, and says which on its error stream.
|
|
for _, l := range errorLines(err, "pacman") {
|
|
if _, p, ok := strings.Cut(l, "No package owns "); ok {
|
|
unowned[strings.TrimSpace(p)] = true
|
|
} else {
|
|
return nil, fmt.Errorf("pacman could not say who owns the setuid programs found: %s", l)
|
|
}
|
|
}
|
|
}
|
|
var out []string
|
|
for _, p := range paths {
|
|
switch {
|
|
case owned[p]:
|
|
case unowned[p]:
|
|
out = append(out, p)
|
|
default:
|
|
return nil, fmt.Errorf("pacman said nothing of who owns %s", p)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
var none []string
|
|
for _, p := range paths {
|
|
said, err := e.Run(ctx, "apk", "info", "-W", p)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("no package manager could say who owns %s: %w", p, err)
|
|
}
|
|
if !strings.Contains(said, " is owned by ") {
|
|
none = append(none, p)
|
|
}
|
|
}
|
|
return none, 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, the last complete result served while it is fresh (SetuidCache): never a stall. No
|
|
// fresh complete result is said beside the other ways, which are judged all the same.
|
|
unowned, searchedAt, searchErr := e.Cache.Look(now(), mounts, e.setuidSearch)
|
|
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+" (search started "+
|
|
searchedAt.Local().Format("2006-01-02 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
|
|
}
|