Account Database Files or Their Backups Not Owned and Locked by Root
Checks the owner and permissions of /etc/passwd, /etc/group, /etc/shadow, /etc/gshadow and their dash-suffixed backup copies.
- Why It Matters
- The tools that edit accounts leave a backup copy (passwd-, shadow-) next to the live file. A readable shadow- backup leaks the same password hashes as shadow itself, and a file not owned by root can be rewritten by whoever owns it. HOST-FS-001 checks the mode of the live files; this check adds ownership and the backups.
- How It Detects
- Stats the eight files (metadata only). passwd, group and their backups must be owned by uid 0 and not writable by group or others; shadow, gshadow and their backups must be owned by uid 0 and have no permission bits for others. Files that do not exist are skipped; none existing is reported as incomplete.
- How to Fix
- chown root:root /etc/passwd /etc/passwd- /etc/group /etc/group- && chmod 644 /etc/passwd /etc/group && chmod 600 /etc/passwd- /etc/group- ; chown root:shadow /etc/shadow /etc/shadow- /etc/gshadow /etc/gshadow- && chmod 640 /etc/shadow /etc/gshadow && chmod 600 /etc/shadow- /etc/gshadow- (use root:root and mode 000 on RHEL family systems).
- How to Verify
stat -c '%a %U:%G %n' /etc/passwd* /etc/group* /etc/shadow* /etc/gshadow*- CVSS v3.1 Vector
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N- Control Category
- Access Control: CIS Controls v8 3, 6; NIST CSF 2.0 PR.AA, PR.DS
- Fix Effort
- Low
- CIS Controls v8 Safeguard
- 3.3
- NIST CSF 2.0 Subcategory
- PR.AA-05, PR.DS-01
- CWE
- CWE-732, CWE-522
- NIST SP 800-53 Rev. 5
- AC-3, AC-6, IA-5
- Benchmark Topic
- Account database file ownership and permissions (CIS Controls v8 3.3) (topic only, no benchmark text)
- References
- shadow(5), Linux man-pages
NIST SP 800-123, Guide to General Server Security
Source Code
The complete file that defines this check. Run it with openvapt scan --only HOST-FS-022, or read it offline with openvapt explain HOST-FS-022.
internal/checks/host/files_mounts.go · Lines 1-667View on GitHub at This Commit
| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "io/fs" |
| 8 | "path" |
| 9 | "regexp" |
| 10 | "sort" |
| 11 | "strconv" |
| 12 | "strings" |
| 13 | |
| 14 | "github.com/openvapt/openvapt/internal/check" |
| 15 | "github.com/openvapt/openvapt/internal/sys" |
| 16 | ) |
| 17 | |
| 18 | // Filesystem depth: mount layout and options, ownership, SUID inventory, |
| 19 | // boot files, cron access lists and shell umasks. Everything is metadata |
| 20 | // (/proc/mounts, stat) except the small text files named in each check. |
| 21 | // |
| 22 | // Sources: NIST SP 800-123, NIST SP 800-53 AC-6, CM-6 and CM-7, CIS |
| 23 | // Controls v8 safeguards 3.3 and 4.1 by id, Linux man-pages. |
| 24 | |
| 25 | var ( |
| 26 | refMount8 = check.Ref{Title: "mount(8), Linux man-pages (nodev, nosuid, noexec)", URL: "https://man7.org/linux/man-pages/man8/mount.8.html"} |
| 27 | refProcMnt = check.Ref{Title: "proc(5), Linux man-pages (/proc/[pid]/mounts)", URL: "https://man7.org/linux/man-pages/man5/proc.5.html"} |
| 28 | refATT1548 = check.Ref{Title: "MITRE ATT&CK T1548.001, Setuid and Setgid", URL: "https://attack.mitre.org/techniques/T1548/001/"} |
| 29 | refCrontab1 = check.Ref{Title: "crontab(1), Linux man-pages (cron.allow and cron.deny)", URL: "https://man7.org/linux/man-pages/man1/crontab.1.html"} |
| 30 | refAt1 = check.Ref{Title: "at(1), Linux man-pages (at.allow and at.deny)", URL: "https://man7.org/linux/man-pages/man1/at.1.html"} |
| 31 | refUmask = check.Ref{Title: "umask(2), Linux man-pages", URL: "https://man7.org/linux/man-pages/man2/umask.2.html"} |
| 32 | ) |
| 33 | |
| 34 | // mounts reads /proc/mounts into mount point -> options. ok is false when |
| 35 | // the file is missing, partial or unreadable. |
| 36 | func mounts(ctx context.Context, s sys.System) (map[string]string, bool) { |
| 37 | text, complete, ok := readText(ctx, s, "/proc/mounts") |
| 38 | if !ok || !complete { |
| 39 | return nil, false |
| 40 | } |
| 41 | m := map[string]string{} |
| 42 | for _, line := range strings.Split(text, "\n") { |
| 43 | f := strings.Fields(line) |
| 44 | if len(f) >= 4 { |
| 45 | m[f[1]] = f[3] |
| 46 | } |
| 47 | } |
| 48 | return m, true |
| 49 | } |
| 50 | |
| 51 | func hasOpt(opts, want string) bool { return strings.Contains(","+opts+",", ","+want+",") } |
| 52 | |
| 53 | // separateMountRule describes a path that should be its own filesystem. |
| 54 | type separateMountRule struct { |
| 55 | id, title, summary, rationale, fix, cvss string |
| 56 | sev check.Severity |
| 57 | paths []string // any one of these as a separate mount satisfies the rule |
| 58 | opts []string // options that must then be present |
| 59 | cwe string |
| 60 | } |
| 61 | |
| 62 | var separateMountRules = []separateMountRule{ |
| 63 | { |
| 64 | id: "HOST-FS-010", title: "/var/tmp Not a Separate Filesystem", sev: check.Low, |
| 65 | cvss: "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:L", |
| 66 | summary: "Checks that /var/tmp is its own mount (or a bind mount of /tmp) with nodev, nosuid and noexec.", |
| 67 | rationale: "/var/tmp is world-writable and survives reboots. On the root filesystem it can be filled to exhaust disk space, and it accepts planted device files and executables unless mounted with restricting options.", |
| 68 | fix: "Mount /var/tmp as a separate filesystem, or bind-mount it onto /tmp: /tmp /var/tmp none bind,nodev,nosuid,noexec 0 0 in /etc/fstab, then reboot or remount. Caution: /var/tmp is meant to survive reboots and some installers and databases run programs from it, so check what uses it before binding it to /tmp with noexec, and keep console access when editing /etc/fstab (a wrong line can stop the boot).", |
| 69 | paths: []string{"/var/tmp"}, opts: []string{"nodev", "nosuid", "noexec"}, cwe: "CWE-276", |
| 70 | }, |
| 71 | { |
| 72 | id: "HOST-FS-011", title: "/home Not a Separate Filesystem With nodev", sev: check.Low, |
| 73 | cvss: "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:L", |
| 74 | summary: "Checks that user home directories live on their own mount with nodev (and nosuid where practical).", |
| 75 | rationale: "Users control the contents of /home. A separate mount stops a user filling the root filesystem, and nodev stops device files being created in a home directory to reach raw disks or memory.", |
| 76 | fix: "Place /home on its own partition or logical volume and add nodev,nosuid to its options in /etc/fstab; remount with: mount -o remount /home . Caution: moving /home to a new volume needs a backup, a maintenance window and console access; a wrong /etc/fstab line can stop the boot.", |
| 77 | paths: []string{"/home"}, opts: []string{"nodev"}, cwe: "CWE-276", |
| 78 | }, |
| 79 | { |
| 80 | id: "HOST-FS-012", title: "Log Directories on the Root Filesystem", sev: check.Low, |
| 81 | cvss: "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:L", |
| 82 | summary: "Checks that /var/log (or /var) is a separate filesystem so runaway logging cannot fill the root disk.", |
| 83 | rationale: "Logs grow under attacker influence (failed logins, scanner noise). When they share the root filesystem, filling them stops the whole server, and when the disk is full new security events are lost.", |
| 84 | fix: "Place /var/log (and ideally /var/log/audit) on its own partition or logical volume, listed in /etc/fstab with nodev,nosuid,noexec. Caution: moving log directories needs a backup, a maintenance window and console access; stop the logging services while copying, and a wrong /etc/fstab line can stop the boot.", |
| 85 | paths: []string{"/var/log", "/var"}, opts: nil, cwe: "CWE-770", |
| 86 | }, |
| 87 | } |
| 88 | |
| 89 | func init() { |
| 90 | for _, r := range separateMountRules { |
| 91 | r := r |
| 92 | check.Register(&check.Check{ |
| 93 | ID: r.id, Title: r.title, Domain: check.DomainHost, Severity: r.sev, Category: check.CatConfig, Effort: check.EffortMedium, CVSS: r.cvss, |
| 94 | Summary: r.summary, Rationale: r.rationale, |
| 95 | Detection: "Reads /proc/mounts (no command is run) and looks for a mount entry for " + strings.Join(r.paths, " or ") + mountOptsText(r.opts) + ". Unreadable or partial /proc/mounts is reported as incomplete.", |
| 96 | Fix: r.fix, |
| 97 | Verify: "findmnt -no TARGET,OPTIONS " + strings.Join(r.paths, " "), |
| 98 | Refs: []check.Ref{refMount8, refProcMnt, refNIST800123}, |
| 99 | Standards: check.Standards{CWE: []string{r.cwe}, NIST: []string{"CM-6", "CM-7", "SC-5"}, CISTopic: "Separate partitions and mount options (CIS Controls v8 4.1)"}, |
| 100 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 101 | m, ok := mounts(ctx, s) |
| 102 | if !ok { |
| 103 | return []check.Result{check.IncompleteR("/proc/mounts", "Mount table could not be read")} |
| 104 | } |
| 105 | for _, p := range r.paths { |
| 106 | o, found := m[p] |
| 107 | if !found { |
| 108 | continue |
| 109 | } |
| 110 | var miss []string |
| 111 | for _, need := range r.opts { |
| 112 | if !hasOpt(o, need) { |
| 113 | miss = append(miss, need) |
| 114 | } |
| 115 | } |
| 116 | if len(miss) > 0 { |
| 117 | return []check.Result{check.FailR(p, p+" is a separate mount but lacks "+strings.Join(miss, ", "), "options "+o)} |
| 118 | } |
| 119 | return []check.Result{check.PassR(p, p+" is a separate mount", "options "+o)} |
| 120 | } |
| 121 | return []check.Result{check.FailR(r.paths[0], r.paths[0]+" is not a separate filesystem (it lives on the root filesystem)")} |
| 122 | }, |
| 123 | }) |
| 124 | } |
| 125 | |
| 126 | check.Register(&check.Check{ |
| 127 | ID: "HOST-FS-013", Title: "Temporary Filesystems Allow Executables", Domain: check.DomainHost, |
| 128 | Severity: check.Low, Category: check.CatConfig, Effort: check.EffortMedium, |
| 129 | CVSS: "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N", |
| 130 | Summary: "Checks that /tmp, /var/tmp and /dev/shm, where they are separate mounts, carry the noexec option.", |
| 131 | Rationale: "Attackers who gain a foothold download tools into a world-writable temporary directory and run them from there. noexec makes that step fail and forces noisier workarounds.", |
| 132 | Detection: "Reads /proc/mounts. Each of /tmp, /var/tmp and /dev/shm that appears as a mount must include noexec; paths that are not separate mounts are skipped (HOST-FS-008 covers nodev and nosuid, HOST-FS-010 covers /var/tmp as a mount).", |
| 133 | Fix: "Add noexec to the mount options of each temporary filesystem (systemd tmp.mount drop-in or /etc/fstab) and remount. Some package managers need exec on /tmp during installs; set TMPDIR for them rather than removing noexec.", |
| 134 | Verify: "findmnt -no TARGET,OPTIONS /tmp /var/tmp /dev/shm", |
| 135 | Refs: []check.Ref{refMount8, refNIST800123}, |
| 136 | Standards: check.Standards{CWE: []string{"CWE-276"}, NIST: []string{"CM-6", "CM-7"}, CISTopic: "noexec on temporary filesystems (CIS Controls v8 4.1)"}, |
| 137 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 138 | m, ok := mounts(ctx, s) |
| 139 | if !ok { |
| 140 | return []check.Result{check.IncompleteR("/proc/mounts", "Mount table could not be read")} |
| 141 | } |
| 142 | var out []check.Result |
| 143 | for _, mp := range []string{"/tmp", "/var/tmp", "/dev/shm"} { |
| 144 | o, found := m[mp] |
| 145 | if !found { |
| 146 | continue |
| 147 | } |
| 148 | if hasOpt(o, "noexec") { |
| 149 | out = append(out, check.PassR(mp, mp+" is mounted noexec", "options "+o)) |
| 150 | } else { |
| 151 | out = append(out, check.FailR(mp, mp+" allows executables", "options "+o)) |
| 152 | } |
| 153 | } |
| 154 | if len(out) == 0 { |
| 155 | return []check.Result{check.SkipR("/proc/mounts", "Temporary directories are not separate mounts")} |
| 156 | } |
| 157 | return out |
| 158 | }, |
| 159 | }) |
| 160 | |
| 161 | check.Register(&check.Check{ |
| 162 | ID: "HOST-FS-014", Title: "World-Writable Files Outside Temporary Directories", Domain: check.DomainHost, |
| 163 | Severity: check.Medium, Category: check.CatAccess, Effort: check.EffortLow, |
| 164 | CVSS: "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N", |
| 165 | Summary: "Looks for files that any user can modify in program, service and home locations other than /etc (which HOST-FS-003 covers).", |
| 166 | Rationale: "A world-writable script, binary or data file lets any local account, including a compromised web application, change what other users and services run or read.", |
| 167 | Detection: "Walks /usr/local, /opt, /srv, /var/www, /home, /root, /usr/bin and /usr/sbin (metadata only, up to 20000 files per tree, symlinks not followed) and lists regular files with the 'other write' bit set.", |
| 168 | Fix: "For each listed file: chmod o-w <path>. Then find the deployment step or chmod 777 habit that created it.", |
| 169 | Verify: "find /usr/local /opt /srv /var/www /home /usr/bin /usr/sbin -xdev -type f -perm -0002 should print nothing.", |
| 170 | Refs: []check.Ref{refNIST800123, refNIST80053}, |
| 171 | Standards: check.Standards{CWE: []string{"CWE-732"}, NIST: []string{"AC-3", "AC-6", "CM-6"}, CISTopic: "No world-writable files (CIS Controls v8 3.3)"}, |
| 172 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 173 | var bad []string |
| 174 | total, complete, seen := 0, true, false |
| 175 | for _, root := range []string{"/usr/local", "/opt", "/srv", "/var/www", "/home", "/root", "/usr/bin", "/usr/sbin"} { |
| 176 | w, err := s.Walk(ctx, root, 20000) |
| 177 | if err != nil { |
| 178 | if !errors.Is(err, fs.ErrNotExist) { |
| 179 | complete = false |
| 180 | } |
| 181 | continue |
| 182 | } |
| 183 | seen = true |
| 184 | if !w.Complete { |
| 185 | complete = false |
| 186 | } |
| 187 | total += len(w.Files) |
| 188 | for _, f := range w.Files { |
| 189 | if f.Mode.Perm()&0o002 != 0 { |
| 190 | bad = append(bad, perm(f.Mode)+" "+f.Path) |
| 191 | } |
| 192 | } |
| 193 | } |
| 194 | sort.Strings(bad) |
| 195 | if len(bad) > 0 { |
| 196 | if len(bad) > 25 { |
| 197 | bad = append(bad[:25], fmt.Sprintf("... and %d more", len(bad)-25)) |
| 198 | } |
| 199 | return []check.Result{check.FailR("filesystem", fmt.Sprintf("%d world-writable file(s) found", len(bad)), bad...)} |
| 200 | } |
| 201 | if !seen || !complete { |
| 202 | return []check.Result{check.IncompleteR("filesystem", fmt.Sprintf("Filesystem walk was partial after %d files", total))} |
| 203 | } |
| 204 | return []check.Result{check.PassR("filesystem", fmt.Sprintf("No world-writable files among %d checked", total))} |
| 205 | }, |
| 206 | }) |
| 207 | |
| 208 | check.Register(&check.Check{ |
| 209 | ID: "HOST-FS-015", Title: "Files With No Known Owner or Group", Domain: check.DomainHost, |
| 210 | Severity: check.Low, Category: check.CatAccess, Effort: check.EffortLow, |
| 211 | CVSS: "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:N", |
| 212 | Summary: "Looks for files whose owner or group id no longer appears in /etc/passwd or /etc/group.", |
| 213 | Rationale: "When an account is deleted, its files keep the numeric id. The next account created with that id silently inherits them, which can hand a new user old keys, scripts or cron jobs.", |
| 214 | Detection: "Reads the numeric ids from /etc/passwd and /etc/group, then walks /etc, /usr/local, /opt, /srv, /var/www, /home and /root (metadata only, up to 20000 files per tree, symlinks not followed) and lists files whose uid or gid is unknown. Unreadable account files make the check incomplete.", |
| 215 | Fix: "Decide who should own each listed file and apply chown <user>:<group> <path>, or remove files that belonged to a deleted account after checking their contents.", |
| 216 | Verify: "find /etc /usr/local /opt /srv /var/www /home -xdev \\( -nouser -o -nogroup \\) should print nothing.", |
| 217 | Refs: []check.Ref{refNIST800123, {Title: "passwd(5), Linux man-pages", URL: "https://man7.org/linux/man-pages/man5/passwd.5.html"}}, |
| 218 | Standards: check.Standards{CWE: []string{"CWE-282"}, NIST: []string{"AC-2", "AC-6", "CM-6"}, CISTopic: "No unowned or ungrouped files (CIS Controls v8 3.3)"}, |
| 219 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 220 | uids, ok1 := idSet(ctx, s, "/etc/passwd", 2) |
| 221 | gids, ok2 := idSet(ctx, s, "/etc/group", 2) |
| 222 | if !ok1 || !ok2 { |
| 223 | return []check.Result{check.IncompleteR("/etc/passwd", "Account or group database could not be read")} |
| 224 | } |
| 225 | var bad []string |
| 226 | total, complete, seen := 0, true, false |
| 227 | for _, root := range []string{"/etc", "/usr/local", "/opt", "/srv", "/var/www", "/home", "/root"} { |
| 228 | w, err := s.Walk(ctx, root, 20000) |
| 229 | if err != nil { |
| 230 | if !errors.Is(err, fs.ErrNotExist) { |
| 231 | complete = false |
| 232 | } |
| 233 | continue |
| 234 | } |
| 235 | seen = true |
| 236 | if !w.Complete { |
| 237 | complete = false |
| 238 | } |
| 239 | total += len(w.Files) |
| 240 | for _, f := range w.Files { |
| 241 | switch { |
| 242 | case !uids[f.UID] && !gids[f.GID]: |
| 243 | bad = append(bad, fmt.Sprintf("uid=%d gid=%d %s (no such user or group)", f.UID, f.GID, f.Path)) |
| 244 | case !uids[f.UID]: |
| 245 | bad = append(bad, fmt.Sprintf("uid=%d %s (no such user)", f.UID, f.Path)) |
| 246 | case !gids[f.GID]: |
| 247 | bad = append(bad, fmt.Sprintf("gid=%d %s (no such group)", f.GID, f.Path)) |
| 248 | } |
| 249 | } |
| 250 | } |
| 251 | sort.Strings(bad) |
| 252 | if len(bad) > 0 { |
| 253 | if len(bad) > 25 { |
| 254 | bad = append(bad[:25], fmt.Sprintf("... and %d more", len(bad)-25)) |
| 255 | } |
| 256 | return []check.Result{check.FailR("filesystem", fmt.Sprintf("%d file(s) have an unknown owner or group", len(bad)), bad...)} |
| 257 | } |
| 258 | if !seen || !complete { |
| 259 | return []check.Result{check.IncompleteR("filesystem", fmt.Sprintf("Filesystem walk was partial after %d files", total))} |
| 260 | } |
| 261 | return []check.Result{check.PassR("filesystem", fmt.Sprintf("Every one of %d files has a known owner and group", total))} |
| 262 | }, |
| 263 | }) |
| 264 | |
| 265 | check.Register(&check.Check{ |
| 266 | ID: "HOST-FS-016", Title: "SUID or SGID Programs Outside the Known Baseline", Domain: check.DomainHost, |
| 267 | Severity: check.Medium, Category: check.CatAccess, Effort: check.EffortMedium, |
| 268 | CVSS: "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N", |
| 269 | Summary: "Lists set-user-ID and set-group-ID programs in the system binary directories that are not on the documented baseline of what distributions ship.", |
| 270 | Rationale: "Every SUID program is a door into another account, usually root. Programs such as nmap, vim, find, python or a custom helper with the SUID bit are a known route to instant root; the fewer unexplained SUID programs, the smaller that door.", |
| 271 | Detection: "Walks /bin, /sbin, /usr/bin, /usr/sbin, /usr/libexec and /usr/lib (metadata only, up to 20000 files per tree, symlinks not followed), keeps files with the SUID or SGID bit, and reports those whose file name is not in the baseline list kept in this check's source (passwd, sudo, su, mount, ping, ssh-keysign and about forty others). An empty inventory is reported as incomplete.", |
| 272 | Fix: "For each listed program decide whether the bit is needed. Remove it with chmod u-s,g-s <path> or uninstall the package. Record the ones you keep so the next review is quick. Caution: programs such as passwd, sudo, su, mount and ping need their bit to work for normal users; removing it from those breaks password changes and administrator access.", |
| 273 | Verify: "find /bin /sbin /usr/bin /usr/sbin /usr/libexec /usr/lib -xdev -type f -perm /6000 and compare with your record.", |
| 274 | Refs: []check.Ref{refATT1548, refNIST800123}, |
| 275 | Standards: check.Standards{CWE: []string{"CWE-250"}, NIST: []string{"AC-6", "CM-7"}, CISTopic: "Audit SUID and SGID executables (CIS Controls v8 4.1)"}, |
| 276 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 277 | var unknown, known []string |
| 278 | complete := true |
| 279 | for _, root := range []string{"/bin", "/sbin", "/usr/bin", "/usr/sbin", "/usr/libexec", "/usr/lib"} { |
| 280 | w, err := s.Walk(ctx, root, 20000) |
| 281 | if err != nil { |
| 282 | if !errors.Is(err, fs.ErrNotExist) { |
| 283 | complete = false |
| 284 | } |
| 285 | continue |
| 286 | } |
| 287 | if !w.Complete { |
| 288 | complete = false |
| 289 | } |
| 290 | for _, f := range w.Files { |
| 291 | if f.Mode&(fs.ModeSetuid|fs.ModeSetgid) == 0 { |
| 292 | continue |
| 293 | } |
| 294 | if suidBaseline[path.Base(f.Path)] { |
| 295 | known = append(known, f.Path) |
| 296 | } else { |
| 297 | unknown = append(unknown, perm(f.Mode)+" "+f.Path) |
| 298 | } |
| 299 | } |
| 300 | } |
| 301 | sort.Strings(unknown) |
| 302 | if len(unknown) > 0 { |
| 303 | if len(unknown) > 25 { |
| 304 | unknown = append(unknown[:25], fmt.Sprintf("... and %d more", len(unknown)-25)) |
| 305 | } |
| 306 | return []check.Result{check.FailR("filesystem", fmt.Sprintf("%d SUID/SGID program(s) are not on the baseline", len(unknown)), unknown...)} |
| 307 | } |
| 308 | if !complete || len(known) == 0 { |
| 309 | return []check.Result{check.IncompleteR("filesystem", "SUID/SGID inventory was partial or empty")} |
| 310 | } |
| 311 | return []check.Result{check.PassR("filesystem", fmt.Sprintf("All %d SUID/SGID programs are on the baseline", len(known)))} |
| 312 | }, |
| 313 | }) |
| 314 | |
| 315 | check.Register(&check.Check{ |
| 316 | ID: "HOST-FS-017", Title: "World-Writable Directories Without the Sticky Bit", Domain: check.DomainHost, |
| 317 | Severity: check.Medium, Category: check.CatAccess, Effort: check.EffortLow, |
| 318 | CVSS: "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N", |
| 319 | Summary: "Looks for shared writable directories outside /tmp and /etc that let any user delete or replace other users' files.", |
| 320 | Rationale: "In a world-writable directory without the sticky bit, any user can remove or swap a file placed there by another user or by a service, which turns upload folders and spool directories into file-replacement attacks.", |
| 321 | Detection: "Lists directories up to three levels below /var, /home, /opt, /srv and /usr/local (metadata only, at most 5000 per tree, symlinks never treated as directories) and reports those writable by others without the sticky bit. HOST-FS-002 covers /tmp and /var/tmp; HOST-FS-003 covers /etc.", |
| 322 | Fix: "chmod +t <dir> for directories that must stay shared, or chmod o-w <dir> for those that should not be writable by everyone.", |
| 323 | Verify: "find /var /home /opt /srv /usr/local -xdev -type d -perm -0002 ! -perm -1000 should print nothing.", |
| 324 | Refs: []check.Ref{{Title: "chmod(1), Linux man-pages (restricted deletion flag)", URL: "https://man7.org/linux/man-pages/man1/chmod.1.html"}, refNIST800123}, |
| 325 | Standards: check.Standards{CWE: []string{"CWE-732", "CWE-377"}, NIST: []string{"AC-3", "CM-6"}, CISTopic: "Sticky bit on world-writable directories (CIS Controls v8 3.3)"}, |
| 326 | Run: func(_ context.Context, s sys.System) []check.Result { |
| 327 | var bad []string |
| 328 | seen := 0 |
| 329 | for _, root := range []string{"/var", "/home", "/opt", "/srv", "/usr/local"} { |
| 330 | b, n := worldWritableDirsCount(s, root, 3, 5000) |
| 331 | bad = append(bad, b...) |
| 332 | seen += n |
| 333 | } |
| 334 | sort.Strings(bad) |
| 335 | if len(bad) > 0 { |
| 336 | if len(bad) > 25 { |
| 337 | bad = append(bad[:25], fmt.Sprintf("... and %d more", len(bad)-25)) |
| 338 | } |
| 339 | return []check.Result{check.FailR("filesystem", fmt.Sprintf("%d shared directory(ies) lack the sticky bit", len(bad)), bad...)} |
| 340 | } |
| 341 | if seen == 0 { |
| 342 | return []check.Result{check.IncompleteR("filesystem", "No directories could be listed")} |
| 343 | } |
| 344 | return []check.Result{check.PassR("filesystem", fmt.Sprintf("No world-writable directories without the sticky bit among %d checked", seen))} |
| 345 | }, |
| 346 | }) |
| 347 | |
| 348 | check.Register(&check.Check{ |
| 349 | ID: "HOST-FS-018", Title: "Kernel Symbol Map or Initial RAM Disk Readable by Others", Domain: check.DomainHost, |
| 350 | Severity: check.Low, Category: check.CatExposure, Effort: check.EffortLow, |
| 351 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N", |
| 352 | Summary: "Checks that /boot/System.map-* and the initial RAM disk images are not readable by every user.", |
| 353 | Rationale: "System.map lists every kernel symbol address, which defeats kptr_restrict and helps kernel exploits. An initial RAM disk can embed network credentials, disk-unlock key files or custom scripts that should not be world-readable.", |
| 354 | Detection: "Stats /boot/System.map-*, /boot/initrd.img-*, /boot/initramfs-*.img and /boot/initrd-* (metadata only) and fails if any is readable by others. Skipped when none exist (for example inside a container).", |
| 355 | Fix: "chmod 600 /boot/System.map-* /boot/initrd* /boot/initramfs* and, on Debian family systems, set UMASK=0077 in /etc/initramfs-tools/initramfs.conf so new images are created private.", |
| 356 | Verify: "stat -c '%a %n' /boot/System.map-* /boot/initrd*", |
| 357 | Refs: []check.Ref{refKernelDocs, refNIST800123}, |
| 358 | Standards: check.Standards{CWE: []string{"CWE-497", "CWE-732"}, NIST: []string{"AC-3", "AC-6"}, CISTopic: "Boot file permissions (CIS Controls v8 3.3)"}, |
| 359 | Run: func(_ context.Context, s sys.System) []check.Result { |
| 360 | var bad []string |
| 361 | seen := 0 |
| 362 | for _, pat := range []string{"/boot/System.map-*", "/boot/initrd.img-*", "/boot/initramfs-*.img", "/boot/initrd-*"} { |
| 363 | paths, _ := s.Glob(pat) |
| 364 | sort.Strings(paths) |
| 365 | for _, p := range paths { |
| 366 | st, err := s.Stat(p) |
| 367 | if err != nil || st.IsDir { |
| 368 | continue |
| 369 | } |
| 370 | seen++ |
| 371 | if st.Mode.Perm()&0o004 != 0 { |
| 372 | bad = append(bad, perm(st.Mode)+" "+p) |
| 373 | } |
| 374 | } |
| 375 | } |
| 376 | if seen == 0 { |
| 377 | return []check.Result{check.SkipR("/boot", "No kernel symbol map or initial RAM disk found")} |
| 378 | } |
| 379 | if len(bad) > 0 { |
| 380 | return []check.Result{check.FailR("/boot", fmt.Sprintf("%d boot file(s) are readable by others", len(bad)), bad...)} |
| 381 | } |
| 382 | return []check.Result{check.PassR("/boot", fmt.Sprintf("%d boot file(s) are private to root", seen))} |
| 383 | }, |
| 384 | }) |
| 385 | |
| 386 | check.Register(&check.Check{ |
| 387 | ID: "HOST-FS-019", Title: "Cron and At Not Restricted to Allowed Users", Domain: check.DomainHost, |
| 388 | Severity: check.Low, Category: check.CatAccess, Effort: check.EffortLow, |
| 389 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N", |
| 390 | Summary: "Checks that cron.allow and at.allow exist, are private to root, and that the deny lists are not relied upon instead.", |
| 391 | Rationale: "By default every local account may schedule jobs. A deny list only names known accounts; an allow list says exactly who may schedule, which is what least privilege asks for and what makes persistence by a compromised service account harder.", |
| 392 | Detection: "Stats /etc/cron.allow and /etc/at.allow (metadata only). For each scheduler that is installed (/etc/crontab or /etc/cron.d for cron, /etc/at.deny, /etc/at.allow or /var/spool/at for at), a missing allow file, an allow file not owned by root, or one readable or writable by group or others fails. A deny file present without an allow file is reported.", |
| 393 | Fix: "Create the allow lists: touch /etc/cron.allow /etc/at.allow && chmod 600 /etc/cron.allow /etc/at.allow && chown root:root /etc/cron.allow /etc/at.allow then add one user name per line for the accounts that need scheduling, and remove /etc/cron.deny and /etc/at.deny. Caution: once cron.allow exists, crontabs of users not listed stop running, so list every service and user account that has a crontab (ls /var/spool/cron/crontabs or /var/spool/cron) before creating it.", |
| 394 | Verify: "stat -c '%a %U %n' /etc/cron.allow /etc/at.allow; ls /etc/cron.deny /etc/at.deny 2>&1", |
| 395 | Refs: []check.Ref{refCrontab1, refAt1, refNIST800123}, |
| 396 | Standards: check.Standards{CWE: []string{"CWE-862"}, NIST: []string{"AC-6", "CM-7"}, CISTopic: "Restrict cron and at to authorised users (CIS Controls v8 4.1)"}, |
| 397 | Run: func(_ context.Context, s sys.System) []check.Result { |
| 398 | var out []check.Result |
| 399 | for _, sch := range []struct { |
| 400 | name, allow, deny string |
| 401 | present []string |
| 402 | }{ |
| 403 | {"cron", "/etc/cron.allow", "/etc/cron.deny", []string{"/etc/crontab", "/etc/cron.d", "/etc/cron.daily", "/var/spool/cron"}}, |
| 404 | {"at", "/etc/at.allow", "/etc/at.deny", []string{"/etc/at.deny", "/etc/at.allow", "/var/spool/at", "/var/spool/cron/atjobs", "/usr/bin/at"}}, |
| 405 | } { |
| 406 | installed := false |
| 407 | for _, p := range sch.present { |
| 408 | if _, err := s.Stat(p); err == nil { |
| 409 | installed = true |
| 410 | break |
| 411 | } |
| 412 | } |
| 413 | if !installed { |
| 414 | continue |
| 415 | } |
| 416 | _, denyErr := s.Stat(sch.deny) |
| 417 | st, err := s.Stat(sch.allow) |
| 418 | switch { |
| 419 | case errors.Is(err, fs.ErrNotExist): |
| 420 | msg := sch.allow + " does not exist, so every user may schedule " + sch.name + " jobs" |
| 421 | if denyErr == nil { |
| 422 | msg = sch.allow + " does not exist; only " + sch.deny + " limits who may schedule " + sch.name + " jobs" |
| 423 | } |
| 424 | out = append(out, check.FailR(sch.allow, msg)) |
| 425 | case err != nil: |
| 426 | out = append(out, check.IncompleteR(sch.allow, "Could not stat "+sch.allow)) |
| 427 | case st.UID != 0 || st.Mode.Perm()&0o077 != 0: |
| 428 | out = append(out, check.FailR(sch.allow, sch.allow+" is not private to root", fmt.Sprintf("mode %s uid=%d", perm(st.Mode), st.UID))) |
| 429 | default: |
| 430 | ev := []string{"mode " + perm(st.Mode)} |
| 431 | if denyErr == nil { |
| 432 | ev = append(ev, sch.deny+" also exists (ignored when the allow file is present)") |
| 433 | } |
| 434 | out = append(out, check.PassR(sch.allow, sch.allow+" restricts "+sch.name+" to listed users", ev...)) |
| 435 | } |
| 436 | } |
| 437 | if len(out) == 0 { |
| 438 | return []check.Result{check.SkipR("cron", "Neither cron nor at is installed")} |
| 439 | } |
| 440 | return out |
| 441 | }, |
| 442 | }) |
| 443 | |
| 444 | check.Register(&check.Check{ |
| 445 | ID: "HOST-FS-020", Title: "Cron Configuration Readable by Other Users", Domain: check.DomainHost, |
| 446 | Severity: check.Low, Category: check.CatAccess, Effort: check.EffortLow, |
| 447 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N", |
| 448 | Summary: "Checks that /etc/crontab is mode 600 and the cron.d, cron.hourly, cron.daily, cron.weekly and cron.monthly directories are mode 700, all owned by root.", |
| 449 | Rationale: "Readable cron configuration tells a local attacker exactly which scripts root runs and when, which is the reconnaissance step before replacing one of them. HOST-FS-005 stops writing; this check stops reading.", |
| 450 | Detection: "Stats /etc/crontab and the five cron directories (metadata only). Any group or other permission bit, or a non-root owner, fails. Paths that do not exist are skipped.", |
| 451 | Fix: "chown root:root /etc/crontab /etc/cron.* && chmod 600 /etc/crontab && chmod 700 /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly", |
| 452 | Verify: "stat -c '%a %U %n' /etc/crontab /etc/cron.*", |
| 453 | Refs: []check.Ref{{Title: "crontab(5), Linux man-pages", URL: "https://man7.org/linux/man-pages/man5/crontab.5.html"}, refNIST800123}, |
| 454 | Standards: check.Standards{CWE: []string{"CWE-732"}, NIST: []string{"AC-3", "AC-6"}, CISTopic: "Cron file permissions (CIS Controls v8 3.3)"}, |
| 455 | Run: func(_ context.Context, s sys.System) []check.Result { |
| 456 | var bad []string |
| 457 | seen := 0 |
| 458 | for _, p := range []string{"/etc/crontab", "/etc/cron.d", "/etc/cron.hourly", "/etc/cron.daily", "/etc/cron.weekly", "/etc/cron.monthly"} { |
| 459 | st, err := s.Stat(p) |
| 460 | if err != nil { |
| 461 | continue |
| 462 | } |
| 463 | seen++ |
| 464 | if st.UID != 0 || st.Mode.Perm()&0o077 != 0 { |
| 465 | bad = append(bad, fmt.Sprintf("%s uid=%d %s", perm(st.Mode), st.UID, p)) |
| 466 | } |
| 467 | } |
| 468 | if seen == 0 { |
| 469 | return []check.Result{check.SkipR("cron", "No system cron files found")} |
| 470 | } |
| 471 | if len(bad) > 0 { |
| 472 | return []check.Result{check.FailR("cron", fmt.Sprintf("%d cron path(s) are readable by group or others", len(bad)), bad...)} |
| 473 | } |
| 474 | return []check.Result{check.PassR("cron", fmt.Sprintf("%d cron path(s) are private to root", seen))} |
| 475 | }, |
| 476 | }) |
| 477 | |
| 478 | check.Register(&check.Check{ |
| 479 | ID: "HOST-FS-021", Title: "Shell Profile Sets a Permissive File Creation Mask", Domain: check.DomainHost, |
| 480 | Severity: check.Low, Category: check.CatAccess, Effort: check.EffortLow, |
| 481 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N", |
| 482 | Summary: "Checks that system-wide shell start-up files do not set a umask that leaves new files readable by every user.", |
| 483 | Rationale: "A umask line in /etc/profile or /etc/bash.bashrc overrides the login.defs default for every interactive shell. Many distributions still ship umask 022 there, which makes every file an administrator creates readable by all local accounts.", |
| 484 | Detection: "Reads /etc/profile, /etc/bash.bashrc, /etc/bashrc, /etc/zsh/zshrc and /etc/profile.d/*.sh and finds lines of the form umask NNN. A value that leaves the 'other' read bit open (022, 002, 000) fails; 027 or stricter passes. Files that exist but could not be read fully make the check incomplete; when no shell file sets a umask the check is skipped (HOST-FS-009 covers login.defs).", |
| 485 | Fix: "Change every umask 022 line in the listed files to umask 027, or remove the line so the login.defs value applies. Users start a new login shell to pick it up.", |
| 486 | Verify: "grep -rn '^[[:space:]]*umask' /etc/profile /etc/bash.bashrc /etc/bashrc /etc/profile.d/ 2>/dev/null", |
| 487 | Refs: []check.Ref{refUmask, refLoginDefs}, |
| 488 | Standards: check.Standards{CWE: []string{"CWE-276"}, NIST: []string{"AC-6", "CM-6"}, CISTopic: "Default user umask in shell start-up files (CIS Controls v8 3.3)"}, |
| 489 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 490 | paths := []string{"/etc/profile", "/etc/bash.bashrc", "/etc/bashrc", "/etc/zsh/zshrc"} |
| 491 | more, _ := s.Glob("/etc/profile.d/*.sh") |
| 492 | sort.Strings(more) |
| 493 | paths = append(paths, more...) |
| 494 | var bad, good []string |
| 495 | partial := false |
| 496 | for _, p := range paths { |
| 497 | r, err := s.ReadFile(ctx, p) |
| 498 | if err != nil { |
| 499 | if !errors.Is(err, fs.ErrNotExist) { |
| 500 | partial = true |
| 501 | } |
| 502 | continue |
| 503 | } |
| 504 | if !r.Complete { |
| 505 | partial = true |
| 506 | } |
| 507 | for _, m := range umaskLine.FindAllStringSubmatch(string(r.Data), -1) { |
| 508 | n, err := strconv.ParseUint(m[1], 8, 32) |
| 509 | if err != nil { |
| 510 | continue |
| 511 | } |
| 512 | if n&0o004 == 0 { |
| 513 | bad = append(bad, p+": umask "+m[1]) |
| 514 | } else { |
| 515 | good = append(good, p+": umask "+m[1]) |
| 516 | } |
| 517 | } |
| 518 | } |
| 519 | if len(bad) > 0 { |
| 520 | return []check.Result{check.FailR("/etc/profile", fmt.Sprintf("%d shell file(s) set a umask that lets other users read new files", len(bad)), bad...)} |
| 521 | } |
| 522 | if partial { |
| 523 | return []check.Result{check.IncompleteR("/etc/profile", "One or more shell start-up files could not be read fully")} |
| 524 | } |
| 525 | if len(good) == 0 { |
| 526 | return []check.Result{check.SkipR("/etc/profile", "No umask is set in the shell start-up files; /etc/login.defs applies")} |
| 527 | } |
| 528 | return []check.Result{check.PassR("/etc/profile", "Shell start-up files set a restrictive umask", good...)} |
| 529 | }, |
| 530 | }) |
| 531 | |
| 532 | check.Register(&check.Check{ |
| 533 | ID: "HOST-FS-022", Title: "Account Database Files or Their Backups Not Owned and Locked by Root", Domain: check.DomainHost, |
| 534 | Severity: check.High, Category: check.CatAccess, Effort: check.EffortLow, |
| 535 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N", |
| 536 | Summary: "Checks the owner and permissions of /etc/passwd, /etc/group, /etc/shadow, /etc/gshadow and their dash-suffixed backup copies.", |
| 537 | Rationale: "The tools that edit accounts leave a backup copy (passwd-, shadow-) next to the live file. A readable shadow- backup leaks the same password hashes as shadow itself, and a file not owned by root can be rewritten by whoever owns it. HOST-FS-001 checks the mode of the live files; this check adds ownership and the backups.", |
| 538 | Detection: "Stats the eight files (metadata only). passwd, group and their backups must be owned by uid 0 and not writable by group or others; shadow, gshadow and their backups must be owned by uid 0 and have no permission bits for others. Files that do not exist are skipped; none existing is reported as incomplete.", |
| 539 | Fix: "chown root:root /etc/passwd /etc/passwd- /etc/group /etc/group- && chmod 644 /etc/passwd /etc/group && chmod 600 /etc/passwd- /etc/group- ; chown root:shadow /etc/shadow /etc/shadow- /etc/gshadow /etc/gshadow- && chmod 640 /etc/shadow /etc/gshadow && chmod 600 /etc/shadow- /etc/gshadow- (use root:root and mode 000 on RHEL family systems).", |
| 540 | Verify: "stat -c '%a %U:%G %n' /etc/passwd* /etc/group* /etc/shadow* /etc/gshadow*", |
| 541 | Refs: []check.Ref{{Title: "shadow(5), Linux man-pages", URL: "https://man7.org/linux/man-pages/man5/shadow.5.html"}, refNIST800123}, |
| 542 | Standards: check.Standards{CWE: []string{"CWE-732", "CWE-522"}, NIST: []string{"AC-3", "AC-6", "IA-5"}, CISTopic: "Account database file ownership and permissions (CIS Controls v8 3.3)"}, |
| 543 | Run: func(_ context.Context, s sys.System) []check.Result { |
| 544 | var bad []string |
| 545 | seen := 0 |
| 546 | for _, f := range []struct { |
| 547 | path string |
| 548 | bad fs.FileMode |
| 549 | why string |
| 550 | }{ |
| 551 | {"/etc/passwd", 0o022, "writable by group or others"}, {"/etc/passwd-", 0o022, "writable by group or others"}, |
| 552 | {"/etc/group", 0o022, "writable by group or others"}, {"/etc/group-", 0o022, "writable by group or others"}, |
| 553 | {"/etc/shadow", 0o007, "accessible by others"}, {"/etc/shadow-", 0o007, "accessible by others"}, |
| 554 | {"/etc/gshadow", 0o007, "accessible by others"}, {"/etc/gshadow-", 0o007, "accessible by others"}, |
| 555 | } { |
| 556 | st, err := s.Stat(f.path) |
| 557 | if err != nil { |
| 558 | continue |
| 559 | } |
| 560 | seen++ |
| 561 | switch { |
| 562 | case st.UID != 0: |
| 563 | bad = append(bad, fmt.Sprintf("%s uid=%d %s (not owned by root)", perm(st.Mode), st.UID, f.path)) |
| 564 | case st.Mode.Perm()&f.bad != 0: |
| 565 | bad = append(bad, fmt.Sprintf("%s %s (%s)", perm(st.Mode), f.path, f.why)) |
| 566 | } |
| 567 | } |
| 568 | if seen == 0 { |
| 569 | return []check.Result{check.IncompleteR("/etc/passwd", "No account database files could be stat'ed")} |
| 570 | } |
| 571 | if len(bad) > 0 { |
| 572 | return []check.Result{check.FailR("/etc/passwd", fmt.Sprintf("%d account file(s) have an unsafe owner or mode", len(bad)), bad...)} |
| 573 | } |
| 574 | return []check.Result{check.PassR("/etc/passwd", fmt.Sprintf("%d account file(s) are root-owned with the expected modes", seen))} |
| 575 | }, |
| 576 | }) |
| 577 | } |
| 578 | |
| 579 | var umaskLine = regexp.MustCompile(`(?m)^[ \t]*umask[ \t]+0?([0-7]{3})[ \t]*(?:#.*)?$`) |
| 580 | |
| 581 | // idSet reads the numeric ids in colon-separated field idx of an account |
| 582 | // database file. ok is false when the file is missing or partial. |
| 583 | func idSet(ctx context.Context, s sys.System, path string, idx int) (map[int]bool, bool) { |
| 584 | r, err := s.ReadFile(ctx, path) |
| 585 | if err != nil || !r.Complete { |
| 586 | return nil, false |
| 587 | } |
| 588 | ids := map[int]bool{} |
| 589 | for _, line := range strings.Split(string(r.Data), "\n") { |
| 590 | f := strings.Split(line, ":") |
| 591 | if len(f) <= idx { |
| 592 | continue |
| 593 | } |
| 594 | if n, err := strconv.Atoi(strings.TrimSpace(f[idx])); err == nil { |
| 595 | ids[n] = true |
| 596 | } |
| 597 | } |
| 598 | return ids, len(ids) > 0 |
| 599 | } |
| 600 | |
| 601 | // worldWritableDirsCount is worldWritableDirs plus the number of directories |
| 602 | // it looked at, so a caller can tell "nothing wrong" from "nothing seen". |
| 603 | func worldWritableDirsCount(s sys.System, root string, depth, limit int) ([]string, int) { |
| 604 | var bad []string |
| 605 | pat := root |
| 606 | seen := 0 |
| 607 | for d := 0; d < depth; d++ { |
| 608 | pat += "/*" |
| 609 | paths, _ := s.Glob(pat) |
| 610 | sort.Strings(paths) |
| 611 | for _, p := range paths { |
| 612 | if seen >= limit { |
| 613 | return bad, seen |
| 614 | } |
| 615 | st, err := s.Stat(p) |
| 616 | if err != nil || !st.IsDir { |
| 617 | continue |
| 618 | } |
| 619 | seen++ |
| 620 | if st.Mode.Perm()&0o002 != 0 && st.Mode&fs.ModeSticky == 0 { |
| 621 | bad = append(bad, perm(st.Mode)+" "+p+"/ (directory, no sticky bit)") |
| 622 | } |
| 623 | } |
| 624 | } |
| 625 | return bad, seen |
| 626 | } |
| 627 | |
| 628 | func mountOptsText(opts []string) string { |
| 629 | if len(opts) == 0 { |
| 630 | return "" |
| 631 | } |
| 632 | return " carrying the options " + strings.Join(opts, ", ") |
| 633 | } |
| 634 | |
| 635 | // suidBaseline lists program names that mainstream distributions ship with |
| 636 | // the SUID or SGID bit. Anything else with the bit in a system binary |
| 637 | // directory deserves a look. Names only; paths vary between distributions. |
| 638 | // |
| 639 | // Sources, checked 2026-10-14 against the distributions' own package file |
| 640 | // lists: Debian 12 and Ubuntu 24.04 (packages.debian.org and |
| 641 | // packages.ubuntu.com "list of files" pages for passwd, util-linux, |
| 642 | // openssh-client, dbus, libutempter0, snapd, polkitd, fuse3, cifs-utils, |
| 643 | // xserver-xorg-legacy, uidmap, lxc, evolution-data-server, sssd-common) and |
| 644 | // RHEL 9 / AlmaLinux 9 (rpm -ql file lists for shadow-utils, util-linux, |
| 645 | // openssh-clients, dbus-daemon, libutempter, polkit, fuse3, cockpit-ws, |
| 646 | // grub2-tools-minimal, initscripts, sssd-common and sssd-krb5-common). The |
| 647 | // sssd helpers krb5_child, ldap_child, proxy_child and selinux_child live in |
| 648 | // /usr/libexec/sssd and are installed setuid root on both families. |
| 649 | // abrt-action-install-debuginfo-to-abrt-cache (abrt), qemu-bridge-helper |
| 650 | // (qemu-kvm-common) and staprun (systemtap-runtime) are setuid root in |
| 651 | // their packages on the RHEL family and are common on server installs. |
| 652 | var suidBaseline = map[string]bool{ |
| 653 | "krb5_child": true, "ldap_child": true, "proxy_child": true, "selinux_child": true, |
| 654 | "abrt-action-install-debuginfo-to-abrt-cache": true, "qemu-bridge-helper": true, "staprun": true, "netreport": true, |
| 655 | "passwd": true, "chage": true, "chfn": true, "chsh": true, "gpasswd": true, "newgrp": true, "expiry": true, "sg": true, |
| 656 | "su": true, "sudo": true, "sudoedit": true, "pkexec": true, "polkit-agent-helper-1": true, "doas": true, |
| 657 | "mount": true, "umount": true, "fusermount": true, "fusermount3": true, "mount.nfs": true, "mount.cifs": true, "ntfs-3g": true, |
| 658 | "ping": true, "ping6": true, "traceroute6.iputils": true, "arping": true, "clockdiff": true, |
| 659 | "ssh-keysign": true, "ssh-agent": true, "unix_chkpwd": true, "unix2_chkpwd": true, "pam_timestamp_check": true, "pam_extrausers_chkpwd": true, |
| 660 | "dbus-daemon-launch-helper": true, "utempter": true, "pt_chown": true, "chrome-sandbox": true, "snap-confine": true, |
| 661 | "at": true, "crontab": true, "wall": true, "write": true, "bsd-write": true, "locate": true, "mlocate": true, "plocate": true, |
| 662 | "dotlockfile": true, "mail-dotlock": true, "lockfile": true, "procmail": true, "exim4": true, "sendmail": true, "sendmail.postfix": true, "postdrop": true, "postqueue": true, |
| 663 | "newuidmap": true, "newgidmap": true, "lxc-user-nic": true, "bwrap": true, "screen": true, "Xorg": true, "Xorg.wrap": true, |
| 664 | "cockpit-session": true, "grub2-set-bootflag": true, "usernetctl": true, "dmcrypt-get-device": true, "pppd": true, "vmware-user-suid-wrapper": true, |
| 665 | "su-l": true, "userhelper": true, "ksu": true, "pam_krb5_storetmp": true, "chromium-sandbox": true, "spice-client-glib-usb-acl-helper": true, |
| 666 | "xtrlock": true, "camel-lock-helper-1.2": true, |
| 667 | } |
Also available as plain Markdown for notes, scripts and AI tools. Same facts, same source, no styling.
Cite This Page
If this page helped your work, please cite it. The version and web address are filled in; the access date is today's date in your browser. Every other style, and when credit is required, is on How to Cite OpenVAPT.
OpenVAPT. (2026). HOST-FS-022 Account Database Files or Their Backups Not Owned and Locked by Root (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/HOST-FS-022.html
In text: (OpenVAPT, 2026). Accessed: the date shown is today's date from your own browser (the site's build date without JavaScript); change it to the day you read the page.
@misc{openvapt_host_fs_022,
author = {{OpenVAPT}},
title = {{HOST-FS-022 Account Database Files or Their Backups Not Owned and Locked by Root}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/HOST-FS-022.html}},
note = {OpenVAPT Check Catalogue, version 0.1.0-alpha.6. Accessed 16 October 2026}
}
Accessed: the date shown is today's date from your own browser (the site's build date without JavaScript); change it to the day you read the page.
Citing OpenVAPT does not mean OpenVAPT endorses, approves or has reviewed your work.