Skip to Content
OpenVAPTAlpha
Pre-release. Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. Free for your own use, 100% at your own risk. This website collects no data. Read MoreRead Less
Pre-release. OpenVAPT has no Stable release yet. Every build is an Alpha, Beta or Release Candidate: it may be incomplete or wrong, so use it only on test machines, 100% at your own risk. See Release Channels
Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. These are AI systems, not people or registered engineers. Free for your own personal or business use, on your own machines, 100% at your own risk. Not for sale. Not for providing services. This website collects no data. Read the Acknowledgements
Pre-release. OpenVAPT has no Stable release yet. Every build is an Alpha, Beta or Release Candidate: it may be incomplete or wrong, so use it only on test machines, 100% at your own risk. See Release Channels
Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. These are AI systems, not people or registered engineers. Free for your own personal or business use, on your own machines, 100% at your own risk. Not for sale. Not for providing services. This website collects no data. Read the Acknowledgements
LowHOST-FS-015Access ControlServer

Files With No Known Owner or Group

Looks for files whose owner or group id no longer appears in /etc/passwd or /etc/group.

Why It Matters
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.
How It Detects
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.
How to 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.
How to Verify
find /etc /usr/local /opt /srv /var/www /home -xdev \( -nouser -o -nogroup \) should print nothing.
CVSS v3.1 Vector
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/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-282
NIST SP 800-53 Rev. 5
AC-2, AC-6, CM-6
Benchmark Topic
No unowned or ungrouped files (CIS Controls v8 3.3) (topic only, no benchmark text)
References
NIST SP 800-123, Guide to General Server Security
passwd(5), Linux man-pages
Learn the theory behind this check: P1 Linux Server Hardening, P17 Kernel, Filesystem and Boot Hardening.

Source Code

The complete file that defines this check. Run it with openvapt scan --only HOST-FS-015, or read it offline with openvapt explain HOST-FS-015.

internal/checks/host/files_mounts.go · Lines 1-667View on GitHub at This Commit
1package host
2
3import (
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
25var (
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.
36func 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
51func hasOpt(opts, want string) bool { return strings.Contains(","+opts+",", ","+want+",") }
52
53// separateMountRule describes a path that should be its own filesystem.
54type 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
62var 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
89func 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
579var 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.
583func 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".
603func 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
628func 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.
652var 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.

APA 7
OpenVAPT. (2026). HOST-FS-015 Files With No Known Owner or Group (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/HOST-FS-015.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.

BibTeX
@misc{openvapt_host_fs_015,
  author       = {{OpenVAPT}},
  title        = {{HOST-FS-015 Files With No Known Owner or Group}},
  year         = {2026},
  howpublished = {\url{https://openvapt.com/checks/HOST-FS-015.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.