Accounts Without a Password
Checks /etc/shadow for accounts whose password field is empty.
- Why It Matters
- An empty password field means the account can log in with no password at all on any service that allows password logins. The score uses a local attack vector: any local user can switch to the account (su) without a password, while the SSH daemon refuses empty passwords unless PermitEmptyPasswords is yes. If HOST-SSH-003 also fails, the same account is open from the network and should be treated as Critical.
- How It Detects
- Reads /etc/shadow (needs root) and lists entries whose second field is empty. Locked ('!' or '*') entries pass. Hashes are never copied into the report.
- How to Fix
- For each listed user either set a password (passwd <user>) or lock the account (passwd -l <user>).
- How to Verify
awk -F: '$2==""' /etc/shadow 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
- Identity and Authentication: CIS Controls v8 5, 6; NIST CSF 2.0 PR.AA
- Fix Effort
- Low
- CIS Controls v8 Safeguard
- 5.2
- NIST CSF 2.0 Subcategory
- PR.AA-01
- CWE
- CWE-258, CWE-521
- NIST SP 800-53 Rev. 5
- IA-5
- Benchmark Topic
- User accounts: password fields are not empty (topic only, no benchmark text)
- References
- shadow(5), Linux man-pages
Source Code
The complete file that defines this check. Run it with openvapt scan --only HOST-USR-002, or read it offline with openvapt explain HOST-USR-002.
internal/checks/host/accounts.go · Lines 1-128View on GitHub at This Commit
| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "strings" |
| 6 | |
| 7 | "github.com/openvapt/openvapt/internal/check" |
| 8 | "github.com/openvapt/openvapt/internal/sys" |
| 9 | ) |
| 10 | |
| 11 | func init() { |
| 12 | check.Register(&check.Check{ |
| 13 | ID: "HOST-USR-001", |
| 14 | Title: "Extra Accounts With Root Privileges (UID 0)", |
| 15 | Domain: check.DomainHost, |
| 16 | Severity: check.High, |
| 17 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", |
| 18 | Summary: "Checks /etc/passwd for accounts other than root that have user id 0.", |
| 19 | Rationale: "Any account with UID 0 is root, whatever its name. Extra UID 0 accounts are a classic backdoor and bypass sudo logging.", |
| 20 | Detection: "Reads /etc/passwd and lists every entry whose third field (UID) is 0, other than 'root'.", |
| 21 | Fix: "Investigate who created each account. Remove it (userdel <name>) or give it a normal UID (usermod -u <new-uid> <name>). Grant admin rights through sudo instead.", |
| 22 | Verify: "awk -F: '$3==0' /etc/passwd should print only the root line.", |
| 23 | Refs: []check.Ref{{Title: "passwd(5), Linux man-pages", URL: "https://man7.org/linux/man-pages/man5/passwd.5.html"}, refNIST80053}, |
| 24 | Standards: check.Standards{CWE: []string{"CWE-250", "CWE-266"}, NIST: []string{"AC-2", "AC-6"}, CISTopic: "User accounts: root is the only UID 0 account"}, |
| 25 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 26 | r, err := s.ReadFile(ctx, "/etc/passwd") |
| 27 | if err != nil { |
| 28 | return []check.Result{check.ErrR("/etc/passwd", err)} |
| 29 | } |
| 30 | var extra []string |
| 31 | if !r.Complete { |
| 32 | return []check.Result{check.IncompleteR("/etc/passwd", "File was truncated while reading")} |
| 33 | } |
| 34 | for _, line := range strings.Split(string(r.Data), "\n") { |
| 35 | f := strings.Split(line, ":") |
| 36 | if len(f) >= 4 && f[2] == "0" && f[0] != "root" { |
| 37 | extra = append(extra, f[0]+":x:0:"+f[3]) |
| 38 | } |
| 39 | } |
| 40 | if len(extra) > 0 { |
| 41 | return []check.Result{check.FailR("/etc/passwd", "Accounts other than root have UID 0", extra...)} |
| 42 | } |
| 43 | return []check.Result{check.PassR("/etc/passwd", "Only root has UID 0")} |
| 44 | }, |
| 45 | }) |
| 46 | |
| 47 | check.Register(&check.Check{ |
| 48 | ID: "HOST-USR-002", |
| 49 | Title: "Accounts Without a Password", |
| 50 | Domain: check.DomainHost, |
| 51 | Severity: check.High, |
| 52 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", |
| 53 | Summary: "Checks /etc/shadow for accounts whose password field is empty.", |
| 54 | Rationale: "An empty password field means the account can log in with no password at all on any service that allows password logins. The score uses a local attack vector: any local user can switch to the account (su) without a password, while the SSH daemon refuses empty passwords unless PermitEmptyPasswords is yes. If HOST-SSH-003 also fails, the same account is open from the network and should be treated as Critical.", |
| 55 | Detection: "Reads /etc/shadow (needs root) and lists entries whose second field is empty. Locked ('!' or '*') entries pass. Hashes are never copied into the report.", |
| 56 | Fix: "For each listed user either set a password (passwd <user>) or lock the account (passwd -l <user>).", |
| 57 | Verify: "awk -F: '$2==\"\"' /etc/shadow should print nothing.", |
| 58 | Refs: []check.Ref{{Title: "shadow(5), Linux man-pages", URL: "https://man7.org/linux/man-pages/man5/shadow.5.html"}}, |
| 59 | Standards: check.Standards{CWE: []string{"CWE-258", "CWE-521"}, NIST: []string{"IA-5"}, CISTopic: "User accounts: password fields are not empty"}, |
| 60 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 61 | r, err := s.ReadFile(ctx, "/etc/shadow") |
| 62 | if err != nil { |
| 63 | return []check.Result{check.SkipR("/etc/shadow", "Not readable (run as root for this check)")} |
| 64 | } |
| 65 | var empty []string |
| 66 | if !r.Complete { |
| 67 | return []check.Result{check.IncompleteR("/etc/shadow", "File was truncated while reading")} |
| 68 | } |
| 69 | for _, line := range strings.Split(string(r.Data), "\n") { |
| 70 | f := strings.Split(line, ":") |
| 71 | if len(f) >= 2 && f[0] != "" && f[1] == "" { |
| 72 | empty = append(empty, f[0]) |
| 73 | } |
| 74 | } |
| 75 | if len(empty) > 0 { |
| 76 | return []check.Result{check.FailR("/etc/shadow", "Accounts have an empty password", empty...)} |
| 77 | } |
| 78 | return []check.Result{check.PassR("/etc/shadow", "Every account has a password or is locked")} |
| 79 | }, |
| 80 | }) |
| 81 | |
| 82 | check.Register(&check.Check{ |
| 83 | ID: "HOST-USR-003", |
| 84 | Title: "Sudo Rules Without Password (NOPASSWD)", |
| 85 | Domain: check.DomainHost, |
| 86 | Severity: check.High, |
| 87 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", |
| 88 | Summary: "Checks sudoers files for rules that grant root without asking for a password.", |
| 89 | Rationale: "NOPASSWD means anyone who takes over that user (a stolen SSH key, a hijacked session, a web shell running as that user) becomes root instantly.", |
| 90 | Detection: "Reads /etc/sudoers and /etc/sudoers.d/* and lists active lines containing NOPASSWD.", |
| 91 | Fix: "Edit with visudo and remove NOPASSWD, or restrict it to one specific, harmless command for automation accounts.", |
| 92 | Verify: "grep -r NOPASSWD /etc/sudoers /etc/sudoers.d should print nothing, or only narrowly scoped commands.", |
| 93 | Refs: []check.Ref{{Title: "sudoers(5) manual", URL: "https://www.sudo.ws/docs/man/sudoers.man/"}}, |
| 94 | Standards: check.Standards{CWE: []string{"CWE-306"}, NIST: []string{"AC-6", "IA-11"}, CISTopic: "Sudo: re-authentication for privilege escalation"}, |
| 95 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 96 | files := []string{"/etc/sudoers"} |
| 97 | more, _ := s.Glob("/etc/sudoers.d/*") |
| 98 | files = append(files, more...) |
| 99 | var hits []string |
| 100 | readable := false |
| 101 | complete := true |
| 102 | for _, f := range files { |
| 103 | r, err := s.ReadFile(ctx, f) |
| 104 | if err != nil { |
| 105 | continue |
| 106 | } |
| 107 | readable = true |
| 108 | complete = complete && r.Complete |
| 109 | for _, line := range strings.Split(string(r.Data), "\n") { |
| 110 | t := strings.TrimSpace(line) |
| 111 | if !strings.HasPrefix(t, "#") && strings.Contains(t, "NOPASSWD") { |
| 112 | hits = append(hits, f+": "+t) |
| 113 | } |
| 114 | } |
| 115 | } |
| 116 | if !readable { |
| 117 | return []check.Result{check.SkipR("/etc/sudoers", "Not readable (run as root for this check)")} |
| 118 | } |
| 119 | if len(hits) > 0 { |
| 120 | return []check.Result{check.FailR("/etc/sudoers", "Sudo rules grant root without a password", hits...)} |
| 121 | } |
| 122 | if !complete { |
| 123 | return []check.Result{check.IncompleteR("/etc/sudoers", "One or more sudoers files were truncated")} |
| 124 | } |
| 125 | return []check.Result{check.PassR("/etc/sudoers", "No NOPASSWD sudo rules")} |
| 126 | }, |
| 127 | }) |
| 128 | } |
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-USR-002 Accounts Without a Password (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/HOST-USR-002.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_usr_002,
author = {{OpenVAPT}},
title = {{HOST-USR-002 Accounts Without a Password}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/HOST-USR-002.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.