World-Writable Files in System Configuration
Looks for files under /etc and in cron directories that any user can modify.
- Why It Matters
- A world-writable config or cron file lets any local user change how a root-owned service behaves, often leading straight to root.
- How It Detects
- Walks /etc (metadata only, up to 20000 files, symlinks not followed) and lists regular files with the 'other write' bit set, plus directories up to three levels deep that are world-writable without the sticky bit (anyone could replace files inside them).
- How to Fix
- For each listed file or directory: chmod o-w <path>. Then find out how it became writable (a deployment script or a careless chmod 777).
- How to Verify
find /etc -xdev \( -type f -perm -0002 \) -o \( -type d -perm -0002 ! -perm -1000 \) should print nothing.- CVSS v3.1 Vector
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H- Control Category
- Access Control: CIS Controls v8 3, 6; NIST CSF 2.0 PR.AA, PR.DS
- Fix Effort
- Medium
- CIS Controls v8 Safeguard
- 3.3
- NIST CSF 2.0 Subcategory
- PR.AA-05, PR.DS-01
- CWE
- CWE-732
- NIST SP 800-53 Rev. 5
- AC-3, CM-6
- Benchmark Topic
- File permissions: no world-writable files (topic only, no benchmark text)
- References
- 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-003, or read it offline with openvapt explain HOST-FS-003.
internal/checks/host/files.go · Lines 1-244View on GitHub at This Commit
| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "io/fs" |
| 7 | "sort" |
| 8 | |
| 9 | "github.com/openvapt/openvapt/internal/check" |
| 10 | "github.com/openvapt/openvapt/internal/sys" |
| 11 | ) |
| 12 | |
| 13 | func perm(m fs.FileMode) string { return fmt.Sprintf("%04o", uint32(m.Perm())|specialBits(m)) } |
| 14 | |
| 15 | func specialBits(m fs.FileMode) uint32 { |
| 16 | var b uint32 |
| 17 | if m&fs.ModeSetuid != 0 { |
| 18 | b |= 0o4000 |
| 19 | } |
| 20 | if m&fs.ModeSetgid != 0 { |
| 21 | b |= 0o2000 |
| 22 | } |
| 23 | if m&fs.ModeSticky != 0 { |
| 24 | b |= 0o1000 |
| 25 | } |
| 26 | return b |
| 27 | } |
| 28 | |
| 29 | func init() { |
| 30 | check.Register(&check.Check{ |
| 31 | ID: "HOST-FS-001", |
| 32 | Title: "Sensitive Account Files Have Unsafe Permissions", |
| 33 | Domain: check.DomainHost, |
| 34 | Severity: check.High, |
| 35 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N", |
| 36 | Summary: "Checks that /etc/shadow and /etc/gshadow are not readable by others, and /etc/passwd and /etc/group are not writable by others.", |
| 37 | Rationale: "Readable shadow files let any local user (or a compromised web app) copy password hashes and crack them offline. Writable passwd/group files let them create a root account.", |
| 38 | Detection: "Stats each file without reading its contents and inspects the permission bits for 'other' and 'group' access.", |
| 39 | Fix: "chown root:root /etc/passwd /etc/group && chmod 644 /etc/passwd /etc/group ; chown root:shadow /etc/shadow /etc/gshadow && chmod 640 /etc/shadow /etc/gshadow (use root:root and 000 on RHEL-family systems).", |
| 40 | Verify: "stat -c '%a %U:%G %n' /etc/passwd /etc/group /etc/shadow /etc/gshadow", |
| 41 | Refs: []check.Ref{{Title: "shadow(5), Linux man-pages", URL: "https://man7.org/linux/man-pages/man5/shadow.5.html"}, refNIST800123}, |
| 42 | Standards: check.Standards{CWE: []string{"CWE-732"}, NIST: []string{"AC-3", "AC-6"}, CISTopic: "File permissions: account database files"}, |
| 43 | Run: func(_ context.Context, s sys.System) []check.Result { |
| 44 | var out []check.Result |
| 45 | for _, f := range []struct { |
| 46 | path string |
| 47 | bad fs.FileMode |
| 48 | why string |
| 49 | }{ |
| 50 | {"/etc/shadow", 0o004 | 0o002, "readable or writable by others"}, |
| 51 | {"/etc/gshadow", 0o004 | 0o002, "readable or writable by others"}, |
| 52 | {"/etc/passwd", 0o022, "writable by group or others"}, |
| 53 | {"/etc/group", 0o022, "writable by group or others"}, |
| 54 | } { |
| 55 | st, err := s.Stat(f.path) |
| 56 | if err != nil { |
| 57 | continue |
| 58 | } |
| 59 | if st.Mode.Perm()&f.bad != 0 { |
| 60 | out = append(out, check.FailR(f.path, f.path+" is "+f.why, "mode "+perm(st.Mode))) |
| 61 | } else { |
| 62 | out = append(out, check.PassR(f.path, f.path+" permissions are safe", "mode "+perm(st.Mode))) |
| 63 | } |
| 64 | } |
| 65 | if len(out) == 0 { |
| 66 | return []check.Result{check.SkipR("/etc", "Account files not found")} |
| 67 | } |
| 68 | return out |
| 69 | }, |
| 70 | }) |
| 71 | |
| 72 | check.Register(&check.Check{ |
| 73 | ID: "HOST-FS-002", |
| 74 | Title: "Temporary Directory Missing the Sticky Bit", |
| 75 | Domain: check.DomainHost, |
| 76 | Severity: check.High, |
| 77 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H", |
| 78 | Summary: "Checks that world-writable temp directories have the sticky bit set.", |
| 79 | Rationale: "Without the sticky bit any user can delete or replace other users' files in /tmp, enabling symlink attacks against root-run scripts.", |
| 80 | Detection: "Stats /tmp and /var/tmp; world-writable without the sticky bit (mode 1777 expected) fails.", |
| 81 | Fix: "chmod 1777 /tmp /var/tmp", |
| 82 | Verify: "stat -c '%a %n' /tmp /var/tmp should print 1777 for both.", |
| 83 | Refs: []check.Ref{{Title: "chmod(1), Linux man-pages (restricted deletion flag)", URL: "https://man7.org/linux/man-pages/man1/chmod.1.html"}}, |
| 84 | Standards: check.Standards{CWE: []string{"CWE-377", "CWE-732"}, NIST: []string{"AC-3"}, CISTopic: "Filesystem: sticky bit on world-writable directories"}, |
| 85 | Run: func(_ context.Context, s sys.System) []check.Result { |
| 86 | var out []check.Result |
| 87 | for _, d := range []string{"/tmp", "/var/tmp"} { |
| 88 | st, err := s.Stat(d) |
| 89 | if err != nil { |
| 90 | continue |
| 91 | } |
| 92 | if st.Mode.Perm()&0o002 != 0 && st.Mode&fs.ModeSticky == 0 { |
| 93 | out = append(out, check.FailR(d, d+" is world-writable without the sticky bit", "mode "+perm(st.Mode))) |
| 94 | } else { |
| 95 | out = append(out, check.PassR(d, d+" is protected", "mode "+perm(st.Mode))) |
| 96 | } |
| 97 | } |
| 98 | return out |
| 99 | }, |
| 100 | }) |
| 101 | |
| 102 | check.Register(&check.Check{ |
| 103 | ID: "HOST-FS-003", |
| 104 | Title: "World-Writable Files in System Configuration", |
| 105 | Domain: check.DomainHost, |
| 106 | Severity: check.High, |
| 107 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", |
| 108 | Summary: "Looks for files under /etc and in cron directories that any user can modify.", |
| 109 | Rationale: "A world-writable config or cron file lets any local user change how a root-owned service behaves, often leading straight to root.", |
| 110 | Detection: "Walks /etc (metadata only, up to 20000 files, symlinks not followed) and lists regular files with the 'other write' bit set, plus directories up to three levels deep that are world-writable without the sticky bit (anyone could replace files inside them).", |
| 111 | Fix: "For each listed file or directory: chmod o-w <path>. Then find out how it became writable (a deployment script or a careless chmod 777).", |
| 112 | Verify: "find /etc -xdev \\( -type f -perm -0002 \\) -o \\( -type d -perm -0002 ! -perm -1000 \\) should print nothing.", |
| 113 | Refs: []check.Ref{refNIST800123}, |
| 114 | Standards: check.Standards{CWE: []string{"CWE-732"}, NIST: []string{"AC-3", "CM-6"}, CISTopic: "File permissions: no world-writable files"}, |
| 115 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 116 | walk, err := s.Walk(ctx, "/etc", 20000) |
| 117 | files := walk.Files |
| 118 | if err != nil && len(files) == 0 { |
| 119 | return []check.Result{check.ErrR("/etc", err)} |
| 120 | } |
| 121 | var bad []string |
| 122 | for _, f := range files { |
| 123 | if f.Mode.Perm()&0o002 != 0 { |
| 124 | bad = append(bad, perm(f.Mode)+" "+f.Path) |
| 125 | } |
| 126 | } |
| 127 | bad = append(bad, worldWritableDirs(s, "/etc", 3, 5000)...) |
| 128 | if len(bad) > 0 { |
| 129 | if len(bad) > 25 { |
| 130 | bad = append(bad[:25], fmt.Sprintf("... and %d more", len(bad)-25)) |
| 131 | } |
| 132 | return []check.Result{check.FailR("/etc", "World-writable files found", bad...)} |
| 133 | } |
| 134 | if !walk.Complete { |
| 135 | return []check.Result{check.IncompleteR("/etc", fmt.Sprintf("Filesystem walk was partial after %d files", len(files)))} |
| 136 | } |
| 137 | return []check.Result{check.PassR("/etc", fmt.Sprintf("No world-writable files among %d checked", len(files)))} |
| 138 | }, |
| 139 | }) |
| 140 | |
| 141 | check.Register(&check.Check{ |
| 142 | ID: "HOST-FS-004", |
| 143 | Title: "Unexpected SUID or SGID Programs", |
| 144 | Domain: check.DomainHost, |
| 145 | Severity: check.High, |
| 146 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", |
| 147 | Summary: "Looks for set-user-ID or set-group-ID programs in places where packages do not normally install them.", |
| 148 | Rationale: "A SUID program runs with its owner's rights (usually root). One placed in /tmp, /home, /var/www or /opt is a common persistence and privilege-escalation trick.", |
| 149 | Detection: "Walks /tmp, /var/tmp, /dev/shm, /home, /var/www, /srv, /opt and /usr/local (metadata only) and lists files with the SUID or SGID bit.", |
| 150 | Fix: "Confirm whether each program is needed. Remove the bit with chmod u-s,g-s <file>, or delete the file if you did not put it there and investigate how it arrived.", |
| 151 | Verify: "find /tmp /home /var/www /opt /usr/local -xdev -perm /6000 -type f should print nothing unexpected.", |
| 152 | Refs: []check.Ref{{Title: "MITRE ATT&CK T1548.001, Setuid and Setgid", URL: "https://attack.mitre.org/techniques/T1548/001/"}}, |
| 153 | Standards: check.Standards{CWE: []string{"CWE-250"}, NIST: []string{"CM-7", "AC-6"}, CISTopic: "Filesystem: audit SUID and SGID executables"}, |
| 154 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 155 | var bad []string |
| 156 | complete := true |
| 157 | for _, root := range []string{"/tmp", "/var/tmp", "/dev/shm", "/home", "/var/www", "/srv", "/opt", "/usr/local"} { |
| 158 | walk, err := s.Walk(ctx, root, 20000) |
| 159 | if err != nil || !walk.Complete { |
| 160 | complete = false |
| 161 | } |
| 162 | for _, f := range walk.Files { |
| 163 | if f.Mode&(fs.ModeSetuid|fs.ModeSetgid) != 0 { |
| 164 | bad = append(bad, perm(f.Mode)+" "+f.Path) |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | if len(bad) > 0 { |
| 169 | return []check.Result{check.FailR("filesystem", "SUID/SGID programs outside standard system paths", bad...)} |
| 170 | } |
| 171 | if !complete { |
| 172 | return []check.Result{check.IncompleteR("filesystem", "One or more filesystem walks were partial or unreadable")} |
| 173 | } |
| 174 | return []check.Result{check.PassR("filesystem", "No SUID/SGID programs in unusual locations")} |
| 175 | }, |
| 176 | }) |
| 177 | |
| 178 | check.Register(&check.Check{ |
| 179 | ID: "HOST-FS-005", |
| 180 | Title: "Cron Files Writable by Non-Root Users", |
| 181 | Domain: check.DomainHost, |
| 182 | Severity: check.High, |
| 183 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", |
| 184 | Summary: "Checks that system cron files and directories are owned by root and not writable by others.", |
| 185 | Rationale: "Cron runs these jobs as root. Anyone who can edit them can run any command as root at the next scheduled minute.", |
| 186 | Detection: "Stats /etc/crontab, /etc/cron.d and its files, and the cron.hourly/daily/weekly/monthly directories, flagging non-root owners or group/other write bits.", |
| 187 | Fix: "chown root:root <path> && chmod go-w <path> for each listed path (600 for /etc/crontab is a common hardening choice).", |
| 188 | Verify: "stat -c '%a %U %n' /etc/crontab /etc/cron.*", |
| 189 | Refs: []check.Ref{{Title: "crontab(5), Linux man-pages", URL: "https://man7.org/linux/man-pages/man5/crontab.5.html"}, {Title: "MITRE ATT&CK T1053.003, Cron", URL: "https://attack.mitre.org/techniques/T1053/003/"}}, |
| 190 | Standards: check.Standards{CWE: []string{"CWE-732"}, NIST: []string{"AC-3", "CM-6"}, CISTopic: "Job schedulers: cron permissions"}, |
| 191 | Run: func(_ context.Context, s sys.System) []check.Result { |
| 192 | paths := []string{"/etc/crontab", "/etc/cron.d", "/etc/cron.hourly", "/etc/cron.daily", "/etc/cron.weekly", "/etc/cron.monthly"} |
| 193 | more, _ := s.Glob("/etc/cron.d/*") |
| 194 | paths = append(paths, more...) |
| 195 | var bad []string |
| 196 | seen := 0 |
| 197 | for _, p := range paths { |
| 198 | st, err := s.Stat(p) |
| 199 | if err != nil { |
| 200 | continue |
| 201 | } |
| 202 | seen++ |
| 203 | if (st.UID > 0) || st.Mode.Perm()&0o022 != 0 { |
| 204 | bad = append(bad, fmt.Sprintf("%s uid=%d %s", perm(st.Mode), st.UID, p)) |
| 205 | } |
| 206 | } |
| 207 | if seen == 0 { |
| 208 | return []check.Result{check.SkipR("cron", "No system cron files found")} |
| 209 | } |
| 210 | if len(bad) > 0 { |
| 211 | return []check.Result{check.FailR("cron", "Cron paths can be modified by non-root users", bad...)} |
| 212 | } |
| 213 | return []check.Result{check.PassR("cron", "Cron files are root-owned and not group/other writable")} |
| 214 | }, |
| 215 | }) |
| 216 | } |
| 217 | |
| 218 | // worldWritableDirs lists directories under root, up to depth levels, that |
| 219 | // are writable by others and lack the sticky bit. Stat does not follow |
| 220 | // symlinks, so a symlink is never reported as a directory. |
| 221 | func worldWritableDirs(s sys.System, root string, depth, limit int) []string { |
| 222 | var bad []string |
| 223 | pat := root |
| 224 | seen := 0 |
| 225 | for d := 0; d < depth; d++ { |
| 226 | pat += "/*" |
| 227 | paths, _ := s.Glob(pat) |
| 228 | sort.Strings(paths) |
| 229 | for _, p := range paths { |
| 230 | if seen >= limit { |
| 231 | return bad |
| 232 | } |
| 233 | st, err := s.Stat(p) |
| 234 | if err != nil || !st.IsDir { |
| 235 | continue |
| 236 | } |
| 237 | seen++ |
| 238 | if st.Mode.Perm()&0o002 != 0 && st.Mode&fs.ModeSticky == 0 { |
| 239 | bad = append(bad, perm(st.Mode)+" "+p+"/ (directory, no sticky bit)") |
| 240 | } |
| 241 | } |
| 242 | } |
| 243 | return bad |
| 244 | } |
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-003 World-Writable Files in System Configuration (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/HOST-FS-003.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_003,
author = {{OpenVAPT}},
title = {{HOST-FS-003 World-Writable Files in System Configuration}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/HOST-FS-003.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.