Custom Services Lack Sandboxing
Checks whether custom long-running services use systemd's basic sandboxing options.
- Why It Matters
- systemd can confine a service with a few lines: block privilege gain through setuid programs, mount the operating system read-only, give it a private /tmp and hide home directories. These limit what an attacker can do after exploiting the service, at almost no cost.
- How It Detects
- For each long-running service unit in /etc/systemd/system, merges drop-ins and checks for NoNewPrivileges=yes, ProtectSystem=strict (or full/yes), PrivateTmp=yes and ProtectHome=yes (or read-only/tmpfs). Lists the missing settings per unit.
- How to Fix
- Add the missing lines with systemctl edit <unit> (a drop-in), for example NoNewPrivileges=yes, ProtectSystem=strict, ReadWritePaths=/var/lib/<app>, PrivateTmp=yes, ProtectHome=yes. Restart the service and test it; add ReadWritePaths= for every folder the app must write. systemd-analyze security <unit> suggests further options.
- How to Verify
systemd-analyze security <unit> shows a lower exposure score, and the service still works.- CVSS v3.1 Vector
CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:L/I:L/A:N- Control Category
- Secure Configuration: CIS Controls v8 4; NIST CSF 2.0 PR.PS
- Fix Effort
- Medium
- CIS Controls v8 Safeguard
- 4.1
- NIST CSF 2.0 Subcategory
- PR.PS-01
- CWE
- CWE-250, CWE-653
- NIST SP 800-53 Rev. 5
- CM-6, SC-39, AC-6
- Benchmark Topic
- Service isolation (topic only, no benchmark text)
- References
- systemd.exec(5), execution environment configuration
systemd-analyze(1), security verb
Source Code
The complete file that defines this check. Run it with openvapt scan --only HOST-SYSD-002, or read it offline with openvapt explain HOST-SYSD-002.
internal/checks/host/systemd.go · Lines 1-247View on GitHub at This Commit
| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "regexp" |
| 7 | "sort" |
| 8 | "strings" |
| 9 | |
| 10 | "github.com/openvapt/openvapt/internal/check" |
| 11 | "github.com/openvapt/openvapt/internal/checks/collect" |
| 12 | "github.com/openvapt/openvapt/internal/sys" |
| 13 | ) |
| 14 | |
| 15 | // Checks on systemd service units: who services run as, how well they are |
| 16 | // sandboxed, whether their unit files and programs can be changed by other |
| 17 | // users, and whether secrets are written into them. Only unit files are |
| 18 | // read; no systemctl or systemd-analyze command is run. |
| 19 | |
| 20 | var refSystemdExec = check.Ref{Title: "systemd.exec(5), execution environment configuration", URL: "https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html"} |
| 21 | var refSystemdUnit = check.Ref{Title: "systemd.unit(5), unit configuration", URL: "https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html"} |
| 22 | |
| 23 | // unitsOrSkip returns the service units, or a Skip result when there are none. |
| 24 | func unitsOrSkip(ctx context.Context, s sys.System, filter func(collect.Unit) bool) ([]collect.Unit, []check.Result) { |
| 25 | var out []collect.Unit |
| 26 | for _, u := range collect.Units(ctx, s) { |
| 27 | if filter(u) { |
| 28 | out = append(out, u) |
| 29 | } |
| 30 | } |
| 31 | if len(out) == 0 { |
| 32 | return nil, []check.Result{check.SkipR("systemd units", "No matching systemd service units were found")} |
| 33 | } |
| 34 | return out, nil |
| 35 | } |
| 36 | |
| 37 | func customLongRunning(u collect.Unit) bool { return u.Custom && u.LongRunning() } |
| 38 | |
| 39 | // sandbox lists the settings HOST-SYSD-002 expects and how to judge each. |
| 40 | var sandbox = []struct { |
| 41 | key string |
| 42 | ok func(string) bool |
| 43 | hint string |
| 44 | }{ |
| 45 | {"service.nonewprivileges", collect.Truthy, "NoNewPrivileges=yes"}, |
| 46 | {"service.protectsystem", func(v string) bool { v = strings.ToLower(v); return v == "strict" || v == "full" || collect.Truthy(v) }, "ProtectSystem=strict"}, |
| 47 | {"service.privatetmp", collect.Truthy, "PrivateTmp=yes"}, |
| 48 | {"service.protecthome", func(v string) bool { |
| 49 | v = strings.ToLower(v) |
| 50 | return v == "read-only" || v == "tmpfs" || collect.Truthy(v) |
| 51 | }, "ProtectHome=yes"}, |
| 52 | } |
| 53 | |
| 54 | // secretVar matches environment variable names that usually hold secrets. |
| 55 | var secretVar = regexp.MustCompile(`(?i)(pass(word|wd)?|secret|token|api[_-]?key|private[_-]?key|credential|auth)`) |
| 56 | |
| 57 | // unsafeDirs hold files any local user can create or replace. |
| 58 | var unsafeDirs = []string{"/tmp/", "/var/tmp/", "/dev/shm/"} |
| 59 | |
| 60 | func init() { |
| 61 | check.Register(&check.Check{ |
| 62 | ID: "HOST-SYSD-001", Title: "Custom Services Run as Root", Domain: check.DomainHost, |
| 63 | Severity: check.Medium, Category: check.CatIdentity, Effort: check.EffortMedium, |
| 64 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L", |
| 65 | Summary: "Checks whether long-running services written by the administrator run as root instead of a dedicated user.", |
| 66 | Rationale: "Application services (Node.js, Python, Java, custom daemons) are the code most exposed to attackers. If one runs as root, any bug in it gives the attacker full control of the machine instead of one unprivileged account.", |
| 67 | Detection: "Reads every service unit in /etc/systemd/system (symlinks skipped) together with its drop-in files, keeps units that start a long-running process (Type is not oneshot), and flags those with no User= (or User=root) and no DynamicUser=yes.", |
| 68 | Fix: "Create a dedicated account (useradd --system --no-create-home --shell /usr/sbin/nologin <app>), give it ownership of only the folders the app writes to, then add User=<app> and Group=<app> to the [Service] section (or DynamicUser=yes). Run systemctl daemon-reload and restart the service. Ports below 1024 can be allowed with AmbientCapabilities=CAP_NET_BIND_SERVICE instead of root.", |
| 69 | Verify: "systemctl show -p User,DynamicUser <unit> and ps -o user= -p $(systemctl show -p MainPID --value <unit>)", |
| 70 | Refs: []check.Ref{refSystemdExec, {Title: "NIST SP 800-123, Guide to General Server Security (least privilege for services)", URL: "https://csrc.nist.gov/pubs/sp/800/123/final"}}, |
| 71 | Standards: check.Standards{CWE: []string{"CWE-250"}, NIST: []string{"AC-6", "CM-7"}, CISTopic: "Run services with least privilege"}, |
| 72 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 73 | units, skip := unitsOrSkip(ctx, s, customLongRunning) |
| 74 | if skip != nil { |
| 75 | return skip |
| 76 | } |
| 77 | var bad []string |
| 78 | for _, u := range units { |
| 79 | if u.RunsAsRoot() { |
| 80 | bad = append(bad, u.Name+": no User= or DynamicUser=, runs as root ("+u.Path+")") |
| 81 | } |
| 82 | } |
| 83 | if len(bad) > 0 { |
| 84 | return []check.Result{check.FailR("systemd units", fmt.Sprintf("%d custom service(s) run as root", len(bad)), bad...)} |
| 85 | } |
| 86 | return []check.Result{check.PassR("systemd units", fmt.Sprintf("All %d custom long-running services run as a non-root user", len(units)))} |
| 87 | }, |
| 88 | }) |
| 89 | |
| 90 | check.Register(&check.Check{ |
| 91 | ID: "HOST-SYSD-002", Title: "Custom Services Lack Sandboxing", Domain: check.DomainHost, |
| 92 | Severity: check.Low, Category: check.CatConfig, Effort: check.EffortMedium, |
| 93 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:L/I:L/A:N", |
| 94 | Summary: "Checks whether custom long-running services use systemd's basic sandboxing options.", |
| 95 | Rationale: "systemd can confine a service with a few lines: block privilege gain through setuid programs, mount the operating system read-only, give it a private /tmp and hide home directories. These limit what an attacker can do after exploiting the service, at almost no cost.", |
| 96 | Detection: "For each long-running service unit in /etc/systemd/system, merges drop-ins and checks for NoNewPrivileges=yes, ProtectSystem=strict (or full/yes), PrivateTmp=yes and ProtectHome=yes (or read-only/tmpfs). Lists the missing settings per unit.", |
| 97 | Fix: "Add the missing lines with systemctl edit <unit> (a drop-in), for example NoNewPrivileges=yes, ProtectSystem=strict, ReadWritePaths=/var/lib/<app>, PrivateTmp=yes, ProtectHome=yes. Restart the service and test it; add ReadWritePaths= for every folder the app must write. systemd-analyze security <unit> suggests further options.", |
| 98 | Verify: "systemd-analyze security <unit> shows a lower exposure score, and the service still works.", |
| 99 | Refs: []check.Ref{refSystemdExec, {Title: "systemd-analyze(1), security verb", URL: "https://www.freedesktop.org/software/systemd/man/latest/systemd-analyze.html"}}, |
| 100 | Standards: check.Standards{CWE: []string{"CWE-250", "CWE-653"}, NIST: []string{"CM-6", "SC-39", "AC-6"}, CISTopic: "Service isolation"}, |
| 101 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 102 | units, skip := unitsOrSkip(ctx, s, customLongRunning) |
| 103 | if skip != nil { |
| 104 | return skip |
| 105 | } |
| 106 | var bad []string |
| 107 | for _, u := range units { |
| 108 | var missing []string |
| 109 | for _, sb := range sandbox { |
| 110 | if !sb.ok(u.Get(sb.key)) { |
| 111 | missing = append(missing, sb.hint) |
| 112 | } |
| 113 | } |
| 114 | if len(missing) > 0 { |
| 115 | bad = append(bad, u.Name+" is missing: "+strings.Join(missing, ", ")) |
| 116 | } |
| 117 | } |
| 118 | if len(bad) > 0 { |
| 119 | return []check.Result{check.FailR("systemd units", fmt.Sprintf("%d custom service(s) lack basic sandboxing", len(bad)), bad...)} |
| 120 | } |
| 121 | return []check.Result{check.PassR("systemd units", "Custom services use the basic sandboxing options")} |
| 122 | }, |
| 123 | }) |
| 124 | |
| 125 | check.Register(&check.Check{ |
| 126 | ID: "HOST-SYSD-003", Title: "Service Unit Files Writable by Non-Root Users", Domain: check.DomainHost, |
| 127 | Severity: check.High, Category: check.CatAccess, Effort: check.EffortLow, |
| 128 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", |
| 129 | Summary: "Checks whether any systemd service unit or drop-in file can be changed by a user other than root.", |
| 130 | Rationale: "systemd starts services as root by default. Whoever can edit a unit file can change ExecStart= or User= and have their own program run as root at the next restart or reboot, a well-known privilege escalation and persistence technique.", |
| 131 | Detection: "Reads the metadata of every service unit and drop-in in /etc/systemd/system, /lib/systemd/system and /usr/lib/systemd/system (symlinks skipped) and flags files that are group- or world-writable or not owned by root.", |
| 132 | Fix: "chown root:root <file> && chmod 644 <file> for each listed file, then review its contents for changes you did not make (ExecStart=, User=, ExecStartPre=) and run systemctl daemon-reload.", |
| 133 | Verify: "stat -c '%U %a %n' /etc/systemd/system/*.service shows root and 644 (or stricter).", |
| 134 | Refs: []check.Ref{refSystemdUnit, {Title: "MITRE ATT&CK T1543.002, Create or Modify System Process: Systemd Service", URL: "https://attack.mitre.org/techniques/T1543/002/"}}, |
| 135 | Standards: check.Standards{CWE: []string{"CWE-732"}, NIST: []string{"AC-3", "CM-5"}, CISTopic: "Protect service definitions"}, |
| 136 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 137 | units, skip := unitsOrSkip(ctx, s, func(collect.Unit) bool { return true }) |
| 138 | if skip != nil { |
| 139 | return skip |
| 140 | } |
| 141 | var bad []string |
| 142 | for _, u := range units { |
| 143 | for _, p := range append([]string{u.Path}, u.DropIns...) { |
| 144 | st, err := s.Stat(p) |
| 145 | if err != nil { |
| 146 | continue |
| 147 | } |
| 148 | if st.Mode.Perm()&0o022 != 0 || st.UID != 0 { |
| 149 | bad = append(bad, fmt.Sprintf("%s mode %04o uid %d", p, st.Mode.Perm(), st.UID)) |
| 150 | } |
| 151 | } |
| 152 | } |
| 153 | if len(bad) > 0 { |
| 154 | return []check.Result{check.FailR("systemd units", "Service unit files can be changed by non-root users", bad...)} |
| 155 | } |
| 156 | return []check.Result{check.PassR("systemd units", fmt.Sprintf("All %d service units are owned by root and not writable by others", len(units)))} |
| 157 | }, |
| 158 | }) |
| 159 | |
| 160 | check.Register(&check.Check{ |
| 161 | ID: "HOST-SYSD-004", Title: "Service Programs in Writable Locations", Domain: check.DomainHost, |
| 162 | Severity: check.High, Category: check.CatAccess, Effort: check.EffortLow, |
| 163 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", |
| 164 | Summary: "Checks whether a service starts a program from a temporary folder, or a program file that other users can replace.", |
| 165 | Rationale: "If the program a root service starts lives in /tmp or is writable by other users, any local user (or a compromised web application) can replace it and gain root the next time the service restarts.", |
| 166 | Detection: "Takes the program path of each long-running service's ExecStart= line and flags paths under /tmp, /var/tmp or /dev/shm, program files that are world-writable, and, for services running as root, program files not owned by root.", |
| 167 | Fix: "Install the program in a root-owned location (/usr/local/bin or /opt/<app>), chown root:root and chmod 755 it, point ExecStart= at it, then systemctl daemon-reload and restart the service.", |
| 168 | Verify: "stat -c '%U %a %n' $(systemctl show -p ExecStart --value <unit> | awk '{print $2}' | sed 's/path=//;s/;//')", |
| 169 | Refs: []check.Ref{refSystemdExec, {Title: "MITRE ATT&CK T1574.010, Hijack Execution Flow: Services File Permissions Weakness", URL: "https://attack.mitre.org/techniques/T1574/010/"}}, |
| 170 | Standards: check.Standards{CWE: []string{"CWE-732", "CWE-427"}, NIST: []string{"AC-3", "CM-5"}, CISTopic: "Protect service executables"}, |
| 171 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 172 | units, skip := unitsOrSkip(ctx, s, collect.Unit.LongRunning) |
| 173 | if skip != nil { |
| 174 | return skip |
| 175 | } |
| 176 | var bad []string |
| 177 | for _, u := range units { |
| 178 | prog := u.Program() |
| 179 | if !strings.HasPrefix(prog, "/") { |
| 180 | continue |
| 181 | } |
| 182 | unsafe := false |
| 183 | for _, d := range unsafeDirs { |
| 184 | if strings.HasPrefix(prog, d) { |
| 185 | bad = append(bad, u.Name+": starts "+prog+" from a temporary folder") |
| 186 | unsafe = true |
| 187 | break |
| 188 | } |
| 189 | } |
| 190 | if unsafe { |
| 191 | continue |
| 192 | } |
| 193 | st, err := s.Stat(prog) |
| 194 | if err != nil { |
| 195 | continue |
| 196 | } |
| 197 | switch { |
| 198 | case st.Mode.Perm()&0o002 != 0: |
| 199 | bad = append(bad, fmt.Sprintf("%s: %s is world-writable (mode %04o)", u.Name, prog, st.Mode.Perm())) |
| 200 | case u.RunsAsRoot() && st.UID != 0: |
| 201 | bad = append(bad, fmt.Sprintf("%s: runs as root but %s is owned by uid %d", u.Name, prog, st.UID)) |
| 202 | } |
| 203 | } |
| 204 | sort.Strings(bad) |
| 205 | if len(bad) > 0 { |
| 206 | return []check.Result{check.FailR("systemd units", "Services start programs that other users can replace", bad...)} |
| 207 | } |
| 208 | return []check.Result{check.PassR("systemd units", "Service programs are in protected locations")} |
| 209 | }, |
| 210 | }) |
| 211 | |
| 212 | check.Register(&check.Check{ |
| 213 | ID: "HOST-SYSD-005", Title: "Secrets Written Into Service Unit Files", Domain: check.DomainHost, |
| 214 | Severity: check.Medium, Category: check.CatExposure, Effort: check.EffortLow, |
| 215 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N", |
| 216 | Summary: "Checks whether passwords, tokens or keys are set with Environment= in systemd unit files.", |
| 217 | Rationale: "Unit files are normally readable by every user, and systemctl show prints a service's Environment= values to any local user. A database password or API token placed there is effectively public on the machine.", |
| 218 | Detection: "Reads Environment= lines of every service unit and its drop-ins and flags variables whose names suggest a secret (PASSWORD, SECRET, TOKEN, API_KEY, PRIVATE_KEY, CREDENTIAL, AUTH). Only the variable names are reported; values are never copied.", |
| 219 | Fix: "Move secrets to a root-owned file readable only by root (chmod 600) and load it with EnvironmentFile=, or better use LoadCredential=/SetCredentialEncrypted= so the secret is only visible to the service. Then rotate every secret that was exposed.", |
| 220 | Verify: "systemctl show -p Environment <unit> should show no secret values.", |
| 221 | Refs: []check.Ref{refSystemdExec, {Title: "systemd, System and Service Credentials", URL: "https://systemd.io/CREDENTIALS/"}}, |
| 222 | Standards: check.Standards{CWE: []string{"CWE-526", "CWE-312"}, OWASP: []string{"A05:2021"}, NIST: []string{"IA-5", "SC-28"}, CISTopic: "Protect stored credentials"}, |
| 223 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 224 | units, skip := unitsOrSkip(ctx, s, func(collect.Unit) bool { return true }) |
| 225 | if skip != nil { |
| 226 | return skip |
| 227 | } |
| 228 | var bad []string |
| 229 | for _, u := range units { |
| 230 | var names []string |
| 231 | for k := range u.Env() { |
| 232 | if secretVar.MatchString(k) { |
| 233 | names = append(names, k) |
| 234 | } |
| 235 | } |
| 236 | sort.Strings(names) |
| 237 | if len(names) > 0 { |
| 238 | bad = append(bad, u.Name+" (values not shown): Environment= sets "+strings.Join(names, ", ")) |
| 239 | } |
| 240 | } |
| 241 | if len(bad) > 0 { |
| 242 | return []check.Result{check.FailR("systemd units", "Service unit files store secrets", bad...)} |
| 243 | } |
| 244 | return []check.Result{check.PassR("systemd units", "No Environment= setting names look like secrets")} |
| 245 | }, |
| 246 | }) |
| 247 | } |
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-SYSD-002 Custom Services Lack Sandboxing (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/HOST-SYSD-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_sysd_002,
author = {{OpenVAPT}},
title = {{HOST-SYSD-002 Custom Services Lack Sandboxing}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/HOST-SYSD-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.