The two finds on the control-node sit in the operator's home of mode 0700, which the agent account cannot search. The search keeps every find; each judgement reads the modes on the way again, since an account can open its own home without root. A find in a home is said by count, its path in the journal alone. Also from review: a checkpoint racing SearchNow forgets the walk it just kept, and a walk kept by an older engine says its counts as lower bounds.
801 lines
26 KiB
Go
801 lines
26 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 := unescapeMount(f[1])
|
|
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
|
|
}
|
|
|
|
// MountPoints is every mount point /proc/mounts lists, of any type: the walk leaves each it meets inside
|
|
// another filesystem without reading it.
|
|
func MountPoints(text string) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, line := range strings.Split(text, "\n") {
|
|
if f := strings.Fields(line); len(f) >= 2 {
|
|
out[unescapeMount(f[1])] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// unescapeMount is a mount point as /proc/mounts escapes it — a space, a tab, a newline and a backslash as
|
|
// three octal digits after a backslash — made a path again.
|
|
func unescapeMount(s string) string {
|
|
var b strings.Builder
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\\' && i+3 < len(s) && isOctal(s[i+1:i+4]) {
|
|
b.WriteByte((s[i+1]-'0')<<6 | (s[i+2]-'0')<<3 | (s[i+3] - '0'))
|
|
i += 3
|
|
continue
|
|
}
|
|
b.WriteByte(s[i])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func isOctal(s string) bool {
|
|
if len(s) != 3 {
|
|
return false
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
if s[i] < '0' || s[i] > '7' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
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(), mountPoints map[string]bool) ([]string, error) {
|
|
walk := e.Walk
|
|
if walk == nil {
|
|
walk = walker{every: CheckpointEvery, mountPoints: mountPoints}.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.
|
|
mountPoints := MountPoints(string(mountsText))
|
|
unowned, searchedAt, searchErr := e.Cache.Look(now(), mounts,
|
|
func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error) {
|
|
return e.setuidSearch(ctx, p, checkpoint, mountPoints)
|
|
})
|
|
var notJudged *SetuidNotJudged
|
|
if searchErr != nil && !errors.As(searchErr, ¬Judged) {
|
|
return nil, searchErr
|
|
}
|
|
// Counted only where the account can execute it, read now from the paths' modes, never kept with the
|
|
// search: an account may open its own home to others later without root, and no search would see it.
|
|
lstat := e.Lstat
|
|
if lstat == nil {
|
|
lstat = lstatOf
|
|
}
|
|
started := " (search started " + searchedAt.Local().Format("2006-01-02 15:04") + ")"
|
|
inHomes := 0
|
|
for _, p := range unowned {
|
|
if !Executable(p, uid, gids, lstat, acl) {
|
|
continue
|
|
}
|
|
if inAHome(p) {
|
|
inHomes++ // its path stays in the engine's journal: a home's names are its owner's
|
|
continue
|
|
}
|
|
ways = append(ways, "a setuid-root program no package owns, which "+account+" can execute: "+p+started)
|
|
}
|
|
switch {
|
|
case inHomes == 1:
|
|
ways = append(ways, "a setuid-root program no package owns in an account's home, which "+account+
|
|
" can execute"+started)
|
|
case inHomes > 1:
|
|
ways = append(ways, fmt.Sprintf("%d setuid-root programs no package owns in accounts' homes, which %s can "+
|
|
"execute%s", inHomes, account, started))
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Executable says whether an account of uid, in the groups gids, can execute path as the kernel decides it:
|
|
// every directory from the root down searchable for it, and the file executable for it, each by the owner's
|
|
// bits for its owner, the group's for a member, the others' for anybody else (novox/hq issue 361). It errs
|
|
// towards a way: a link on the way, an ACL on any of them, or a mode that could not be read is executable.
|
|
// A path gone is not: there is nothing to execute.
|
|
func Executable(path string, uid int, gids map[int]bool, lstat func(string) (FileMode, error),
|
|
acl func(string) ([]ACLEntry, error)) bool {
|
|
if uid == 0 {
|
|
return true
|
|
}
|
|
at := "/"
|
|
steps := []string{at}
|
|
for _, name := range components(path) {
|
|
at = filepath.Join(at, name)
|
|
steps = append(steps, at)
|
|
}
|
|
for i, p := range steps {
|
|
m, err := lstat(p)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return false
|
|
}
|
|
if err != nil || m.Perm&fs.ModeSymlink != 0 {
|
|
return true
|
|
}
|
|
if a, err := acl(p); (err != nil && !errors.Is(err, fs.ErrNotExist)) || len(a) > 0 {
|
|
return true
|
|
}
|
|
bit := fs.FileMode(0o001)
|
|
switch {
|
|
case m.UID == uid:
|
|
bit = 0o100
|
|
case gids[m.GID]:
|
|
bit = 0o010
|
|
}
|
|
if m.Perm&bit == 0 {
|
|
return false
|
|
}
|
|
if i < len(steps)-1 && !m.Perm.IsDir() {
|
|
return false // a file on the way: the path names nothing
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// homes are where accounts' homes are: a path under one is said by count alone, its name its owner's.
|
|
var homes = []string{"/home/", "/root/", "/var/home/"}
|
|
|
|
func inAHome(path string) bool {
|
|
for _, h := range homes {
|
|
if strings.HasPrefix(path, h) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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
|
|
}
|