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-SYSD-002Secure ConfigurationServer

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
Learn the theory behind this check: P11 Service Hardening With systemd.

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
1package host
2
3import (
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
20var refSystemdExec = check.Ref{Title: "systemd.exec(5), execution environment configuration", URL: "https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html"}
21var 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.
24func 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
37func 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.
40var 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.
55var 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.
58var unsafeDirs = []string{"/tmp/", "/var/tmp/", "/dev/shm/"}
59
60func 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.

APA 7
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.

BibTeX
@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.