Core Dumps Not Restricted
Checks that process core dumps are turned off through PAM limits, and through systemd-coredump where the kernel hands dumps to it.
- Why It Matters
- A core dump is a copy of a program's memory, including passwords, keys and session tokens it held. On a server nobody debugs, dumps are only a leak waiting in /var/lib/systemd/coredump or the working directory. HOST-KRN-004 covers setuid programs; this check covers every process.
- How It Detects
- Reads /etc/security/limits.conf and /etc/security/limits.d/*.conf for a * hard core 0 line, and /proc/sys/kernel/core_pattern. When the pattern pipes dumps to systemd-coredump, /etc/systemd/coredump.conf and /etc/systemd/coredump.conf.d/*.conf must set Storage=none (ProcessSizeMax=0 also counts). Either the limits line or the systemd setting passes. Missing files are observed as missing; a file that exists but cannot be read makes the check incomplete.
- How to Fix
- Add * hard core 0 to /etc/security/limits.conf (or a file in limits.d), set Storage=none and ProcessSizeMax=0 in the [Coredump] section of /etc/systemd/coredump.conf, and keep fs.suid_dumpable = 0.
- How to Verify
grep -E '^\*\s+hard\s+core\s+0' /etc/security/limits.conf /etc/security/limits.d/*; grep -E '^(Storage|ProcessSizeMax)' /etc/systemd/coredump.conf; cat /proc/sys/kernel/core_pattern- CVSS v3.1 Vector
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N- Control Category
- Information Exposure: CIS Controls v8 3, 16; NIST CSF 2.0 PR.DS
- Fix Effort
- Low
- CIS Controls v8 Safeguard
- 4.1
- NIST CSF 2.0 Subcategory
- PR.PS-01
- CWE
- CWE-528
- NIST SP 800-53 Rev. 5
- SI-11, CM-6, AC-6
- Benchmark Topic
- Restrict core dumps (CIS Controls v8 4.1) (topic only, no benchmark text)
- References
- limits.conf(5), Linux-PAM
coredump.conf(5), systemd
Linux kernel documentation, /proc/sys/kernel
Source Code
The complete file that defines this check. Run it with openvapt scan --only HOST-BOOT-008, or read it offline with openvapt explain HOST-BOOT-008.
internal/checks/host/boot.go · Lines 1-450View on GitHub at This Commit
| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "io/fs" |
| 8 | "regexp" |
| 9 | "sort" |
| 10 | "strings" |
| 11 | |
| 12 | "github.com/openvapt/openvapt/internal/check" |
| 13 | "github.com/openvapt/openvapt/internal/checks/collect" |
| 14 | "github.com/openvapt/openvapt/internal/sys" |
| 15 | ) |
| 16 | |
| 17 | // Boot and integrity: boot loader password, Secure Boot state, kernel module |
| 18 | // restrictions, file integrity monitoring, prelink and core dumps. Every |
| 19 | // check reads files only; no command is run. |
| 20 | // |
| 21 | // Sources: GNU GRUB manual, UEFI Specification, Linux kernel documentation, |
| 22 | // NIST SP 800-123, NIST SP 800-53 CM-6, CM-7, SI-7 and AC-6, CIS Controls |
| 23 | // v8 safeguards 4.1 and 4.8 by id. |
| 24 | |
| 25 | var ( |
| 26 | refGrubSec = check.Ref{Title: "GNU GRUB Manual, Security", URL: "https://www.gnu.org/software/grub/manual/grub/html_node/Security.html"} |
| 27 | refUEFI = check.Ref{Title: "UEFI Specification, Secure Boot and Driver Signing", URL: "https://uefi.org/specs/UEFI/2.10/32_Secure_Boot_and_Driver_Signing.html"} |
| 28 | refModprobeD = check.Ref{Title: "modprobe.d(5), Linux man-pages", URL: "https://man7.org/linux/man-pages/man5/modprobe.d.5.html"} |
| 29 | refModSign = check.Ref{Title: "Linux kernel documentation, Kernel module signing facility", URL: "https://docs.kernel.org/admin-guide/module-signing.html"} |
| 30 | refAIDE = check.Ref{Title: "AIDE, Advanced Intrusion Detection Environment", URL: "https://aide.github.io/"} |
| 31 | refLimits = check.Ref{Title: "limits.conf(5), Linux-PAM", URL: "https://man7.org/linux/man-pages/man5/limits.conf.5.html"} |
| 32 | refCoredump = check.Ref{Title: "coredump.conf(5), systemd", URL: "https://www.freedesktop.org/software/systemd/man/latest/coredump.conf.html"} |
| 33 | ) |
| 34 | |
| 35 | // readSome reads path and reports (text, exists, readable). A file that is |
| 36 | // absent is a normal observation; one that exists but cannot be read fully |
| 37 | // must make a check incomplete rather than pass. |
| 38 | func readSome(ctx context.Context, s sys.System, path string) (string, bool, bool) { |
| 39 | r, err := s.ReadFile(ctx, path) |
| 40 | if err != nil { |
| 41 | if errors.Is(err, fs.ErrNotExist) { |
| 42 | return "", false, true |
| 43 | } |
| 44 | return "", true, false |
| 45 | } |
| 46 | return string(r.Data), true, r.Complete |
| 47 | } |
| 48 | |
| 49 | // fsModules are filesystem drivers a server almost never needs. Each one |
| 50 | // has carried kernel bugs reachable by mounting a crafted image. |
| 51 | var fsModules = []string{"cramfs", "freevxfs", "jffs2", "hfs", "hfsplus", "udf", "squashfs"} |
| 52 | |
| 53 | // modprobeState reports, for each module, whether /etc/modprobe.d disables |
| 54 | // it (install <mod> /bin/false or /bin/true, or blacklist <mod>). Both |
| 55 | // forms count; the strictest hardening uses both. unreadable is true when |
| 56 | // a modprobe.d file exists but could not be read. |
| 57 | func modprobeState(ctx context.Context, s sys.System, mods []string) (map[string][]string, bool) { |
| 58 | state := map[string][]string{} |
| 59 | files, _ := s.Glob("/etc/modprobe.d/*.conf") |
| 60 | more, _ := s.Glob("/usr/lib/modprobe.d/*.conf") |
| 61 | files = append(files, more...) |
| 62 | sort.Strings(files) |
| 63 | unreadable := false |
| 64 | for _, f := range files { |
| 65 | text, _, ok := readSome(ctx, s, f) |
| 66 | if !ok { |
| 67 | unreadable = true |
| 68 | continue |
| 69 | } |
| 70 | for _, line := range strings.Split(text, "\n") { |
| 71 | fields := strings.Fields(strings.TrimSpace(line)) |
| 72 | if len(fields) < 2 || strings.HasPrefix(fields[0], "#") { |
| 73 | continue |
| 74 | } |
| 75 | name := strings.ReplaceAll(fields[1], "-", "_") |
| 76 | for _, m := range mods { |
| 77 | if name != strings.ReplaceAll(m, "-", "_") { |
| 78 | continue |
| 79 | } |
| 80 | switch { |
| 81 | case fields[0] == "install" && len(fields) >= 3 && (fields[2] == "/bin/false" || fields[2] == "/bin/true" || fields[2] == "/usr/bin/false" || fields[2] == "/usr/bin/true"): |
| 82 | state[m] = append(state[m], "install "+fields[1]+" "+fields[2]+" ("+f+")") |
| 83 | case fields[0] == "blacklist": |
| 84 | state[m] = append(state[m], "blacklist "+fields[1]+" ("+f+")") |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | } |
| 89 | return state, unreadable |
| 90 | } |
| 91 | |
| 92 | func init() { |
| 93 | check.Register(&check.Check{ |
| 94 | ID: "HOST-BOOT-001", Title: "Boot Loader Has No Password", Domain: check.DomainHost, |
| 95 | Severity: check.Medium, Category: check.CatAccess, Effort: check.EffortMedium, |
| 96 | CVSS: "CVSS:3.1/AV:P/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", |
| 97 | Summary: "Checks that GRUB is configured with a superuser and a hashed password so the boot entries cannot be edited at the console.", |
| 98 | Rationale: "Without a boot loader password anyone at the console or virtual console can edit the kernel command line (for example add init=/bin/bash) and get a root shell without any credentials, bypassing every control on the running system.", |
| 99 | Detection: "Reads /boot/grub/grub.cfg, /boot/grub2/grub.cfg, /boot/grub2/user.cfg, /boot/efi/EFI/*/grub.cfg, /boot/efi/EFI/*/user.cfg and /etc/grub.d/* and looks for set superusers= together with password_pbkdf2 (or password). Only the presence of these directives is reported; hashes are never shown. Skipped when no GRUB configuration exists; incomplete when a file exists but cannot be read.", |
| 100 | Fix: "Run grub-mkpasswd-pbkdf2 (grub2-setpassword on RHEL family), add set superusers=\"admin\" and password_pbkdf2 admin <hash> to /etc/grub.d/40_custom (RHEL family writes /boot/grub2/user.cfg), then regenerate with update-grub or grub2-mkconfig -o /boot/grub2/grub.cfg. Add --unrestricted to the normal menu entry so unattended reboots still work.", |
| 101 | Verify: "grep -E 'set superusers|password_pbkdf2' /boot/grub/grub.cfg /boot/grub2/grub.cfg /boot/grub2/user.cfg /etc/grub.d/* 2>/dev/null | sed 's/grub.pbkdf2.*/<hash>/'", |
| 102 | Refs: []check.Ref{refGrubSec, refNIST800123}, |
| 103 | Standards: check.Standards{CWE: []string{"CWE-306"}, NIST: []string{"AC-3", "AC-6", "CM-6"}, CISTopic: "Boot loader password (CIS Controls v8 4.1)"}, |
| 104 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 105 | paths := []string{"/boot/grub/grub.cfg", "/boot/grub2/grub.cfg", "/boot/grub2/user.cfg", "/boot/grub/user.cfg"} |
| 106 | for _, pat := range []string{"/boot/efi/EFI/*/grub.cfg", "/boot/efi/EFI/*/user.cfg", "/etc/grub.d/*"} { |
| 107 | more, _ := s.Glob(pat) |
| 108 | sort.Strings(more) |
| 109 | paths = append(paths, more...) |
| 110 | } |
| 111 | var super, pass []string |
| 112 | seen, unreadable := 0, 0 |
| 113 | for _, p := range paths { |
| 114 | text, exists, ok := readSome(ctx, s, p) |
| 115 | if !exists { |
| 116 | continue |
| 117 | } |
| 118 | seen++ |
| 119 | if !ok { |
| 120 | unreadable++ |
| 121 | continue |
| 122 | } |
| 123 | if grubSuperusers.MatchString(text) { |
| 124 | super = append(super, p) |
| 125 | } |
| 126 | if grubPassword.MatchString(text) { |
| 127 | pass = append(pass, p) |
| 128 | } |
| 129 | } |
| 130 | switch { |
| 131 | case seen == 0: |
| 132 | return []check.Result{check.SkipR("/boot/grub", "No GRUB configuration found")} |
| 133 | case len(super) > 0 && len(pass) > 0: |
| 134 | return []check.Result{check.PassR("/boot/grub", "GRUB has a superuser and a hashed password", "superusers in "+strings.Join(super, ", "), "password directive in "+strings.Join(pass, ", "))} |
| 135 | case unreadable > 0: |
| 136 | return []check.Result{check.IncompleteR("/boot/grub", fmt.Sprintf("%d GRUB file(s) could not be read; no password directive found in the readable ones", unreadable))} |
| 137 | case len(super) > 0: |
| 138 | return []check.Result{check.FailR("/boot/grub", "GRUB names a superuser but sets no password", "superusers in "+strings.Join(super, ", "))} |
| 139 | default: |
| 140 | return []check.Result{check.FailR("/boot/grub", fmt.Sprintf("No superuser or password directive in %d GRUB file(s)", seen))} |
| 141 | } |
| 142 | }, |
| 143 | }) |
| 144 | |
| 145 | check.Register(&check.Check{ |
| 146 | ID: "HOST-BOOT-002", Title: "UEFI Secure Boot Disabled", Domain: check.DomainHost, |
| 147 | Severity: check.Medium, Category: check.CatConfig, Effort: check.EffortHigh, |
| 148 | CVSS: "CVSS:3.1/AV:P/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", |
| 149 | Summary: "Checks the firmware's SecureBoot variable to see whether the boot chain is verified.", |
| 150 | Rationale: "Secure Boot makes the firmware refuse unsigned boot loaders and kernels, which blocks boot kits and unsigned kernels planted by an attacker with disk or console access. Without it, anything written to the boot partition runs at the next reboot.", |
| 151 | Detection: "Reads /sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c (the four-byte attribute header followed by one data byte; 1 means enabled). Machines without /sys/firmware/efi boot in legacy BIOS mode and are skipped, as are UEFI machines that expose no SecureBoot variable; an unreadable variable is incomplete. No command such as mokutil is run.", |
| 152 | Fix: "Enable Secure Boot in the firmware set-up and boot a signed shim and kernel (every mainstream distribution ships them). Custom kernels and out-of-tree modules then need signing with a Machine Owner Key. Where the platform cannot do Secure Boot, record the exception. Caution: unsigned kernels and modules (for example DKMS drivers such as NVIDIA or ZFS) stop loading once it is on, so sign or replace them first and keep console access for the first boot.", |
| 153 | Verify: "mokutil --sb-state or od -An -t u1 /sys/firmware/efi/efivars/SecureBoot-* (the fifth byte is 1 when enabled).", |
| 154 | Refs: []check.Ref{refUEFI, refNIST800123}, |
| 155 | Standards: check.Standards{CWE: []string{"CWE-1326"}, NIST: []string{"SI-7", "CM-6"}, CISTopic: "Boot integrity (CIS Controls v8 4.1)"}, |
| 156 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 157 | vars, _ := s.Glob("/sys/firmware/efi/efivars/SecureBoot-*") |
| 158 | if len(vars) == 0 { |
| 159 | if _, err := s.Stat("/sys/firmware/efi"); errors.Is(err, fs.ErrNotExist) { |
| 160 | return []check.Result{check.SkipR("/sys/firmware/efi", "Legacy BIOS boot; Secure Boot does not apply")} |
| 161 | } |
| 162 | return []check.Result{check.SkipR("/sys/firmware/efi/efivars", "UEFI firmware exposes no SecureBoot variable")} |
| 163 | } |
| 164 | sort.Strings(vars) |
| 165 | r, err := s.ReadFile(ctx, vars[0]) |
| 166 | if err != nil || !r.Complete || len(r.Data) < 5 { |
| 167 | return []check.Result{check.IncompleteR(vars[0], "SecureBoot variable could not be read")} |
| 168 | } |
| 169 | if r.Data[4] == 1 { |
| 170 | return []check.Result{check.PassR(vars[0], "Secure Boot is enabled", "SecureBoot = 1")} |
| 171 | } |
| 172 | return []check.Result{check.FailR(vars[0], "Secure Boot is disabled", fmt.Sprintf("SecureBoot = %d", r.Data[4]))} |
| 173 | }, |
| 174 | }) |
| 175 | |
| 176 | check.Register(&check.Check{ |
| 177 | ID: "HOST-BOOT-003", Title: "Uncommon Filesystem Modules Not Disabled", Domain: check.DomainHost, |
| 178 | Severity: check.Medium, Category: check.CatConfig, Effort: check.EffortLow, |
| 179 | CVSS: "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L", |
| 180 | Summary: "Checks that the cramfs, freevxfs, jffs2, hfs, hfsplus, udf and squashfs kernel modules are disabled in modprobe.d.", |
| 181 | Rationale: "Each filesystem driver is kernel code that parses untrusted data. A local user who can mount removable media or a loop device gets the kernel to parse a crafted image with one of these rarely maintained drivers, and several have had memory-corruption bugs. Removing drivers nobody uses removes that attack surface.", |
| 182 | Detection: "Reads /etc/modprobe.d/*.conf and /usr/lib/modprobe.d/*.conf and, for each module, accepts install <module> /bin/false (or /bin/true) or blacklist <module>. squashfs is left out when /snap exists because snaps need it. Modules with neither line fail; an unreadable modprobe.d file makes the check incomplete.", |
| 183 | Fix: "Create /etc/modprobe.d/openvapt-filesystems.conf with, for each module: install <module> /bin/false and blacklist <module> then unload any loaded one with modprobe -r <module>. Caution: first confirm that no listed module is in use (lsmod, findmnt); blocking a filesystem a mount or package format needs (for example squashfs for snap packages, or vfat for the EFI system partition) breaks those mounts at the next boot.", |
| 184 | Verify: "for m in cramfs freevxfs jffs2 hfs hfsplus udf squashfs; do modprobe -n -v $m; lsmod | grep -w $m; done", |
| 185 | Refs: []check.Ref{refModprobeD, refKSPP}, |
| 186 | Standards: check.Standards{CWE: []string{"CWE-1188"}, NIST: []string{"CM-7", "CM-6"}, CISTopic: "Disable unused filesystem kernel modules (CIS Controls v8 4.8)"}, |
| 187 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 188 | mods := fsModules |
| 189 | if _, err := s.Stat("/snap"); err == nil { |
| 190 | mods = mods[:len(mods)-1] |
| 191 | } |
| 192 | state, unreadable := modprobeState(ctx, s, mods) |
| 193 | var missing, ev []string |
| 194 | for _, m := range mods { |
| 195 | if len(state[m]) == 0 { |
| 196 | missing = append(missing, m) |
| 197 | } else { |
| 198 | ev = append(ev, state[m]...) |
| 199 | } |
| 200 | } |
| 201 | if len(missing) > 0 { |
| 202 | if unreadable { |
| 203 | return []check.Result{check.IncompleteR("/etc/modprobe.d", "Some modprobe.d files could not be read; not disabled in the readable ones: "+strings.Join(missing, ", "))} |
| 204 | } |
| 205 | return []check.Result{check.FailR("/etc/modprobe.d", "Filesystem modules not disabled: "+strings.Join(missing, ", "), ev...)} |
| 206 | } |
| 207 | return []check.Result{check.PassR("/etc/modprobe.d", fmt.Sprintf("All %d uncommon filesystem modules are disabled", len(mods)), ev...)} |
| 208 | }, |
| 209 | }) |
| 210 | |
| 211 | check.Register(&check.Check{ |
| 212 | ID: "HOST-BOOT-004", Title: "USB Storage Driver Not Disabled", Domain: check.DomainHost, |
| 213 | Severity: check.Medium, Category: check.CatConfig, Effort: check.EffortLow, |
| 214 | CVSS: "CVSS:3.1/AV:P/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N", |
| 215 | Summary: "Checks that the usb-storage kernel module is disabled in modprobe.d on servers that do not need removable media.", |
| 216 | Rationale: "A USB stick plugged into a server is the simplest way to copy data out or bring malware in without touching the network. Disabling the driver makes the device invisible to the kernel.", |
| 217 | Detection: "Reads /etc/modprobe.d/*.conf and /usr/lib/modprobe.d/*.conf for install usb-storage /bin/false (or /bin/true) or blacklist usb-storage. Neither present fails; an unreadable file makes the check incomplete.", |
| 218 | Fix: "Create /etc/modprobe.d/openvapt-usb.conf containing install usb-storage /bin/false and blacklist usb-storage then modprobe -r usb-storage. Servers that need USB backups should record the exception instead.", |
| 219 | Verify: "modprobe -n -v usb-storage should print install /bin/false; lsmod | grep usb_storage should print nothing.", |
| 220 | Refs: []check.Ref{refModprobeD, {Title: "MITRE ATT&CK T1091, Replication Through Removable Media", URL: "https://attack.mitre.org/techniques/T1091/"}}, |
| 221 | Standards: check.Standards{CWE: []string{"CWE-1188"}, NIST: []string{"MP-7", "CM-7"}, CISTopic: "Disable USB storage (CIS Controls v8 4.8)"}, |
| 222 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 223 | state, unreadable := modprobeState(ctx, s, []string{"usb-storage"}) |
| 224 | if len(state["usb-storage"]) > 0 { |
| 225 | return []check.Result{check.PassR("/etc/modprobe.d", "usb-storage is disabled", state["usb-storage"]...)} |
| 226 | } |
| 227 | if unreadable { |
| 228 | return []check.Result{check.IncompleteR("/etc/modprobe.d", "Some modprobe.d files could not be read; usb-storage is not disabled in the readable ones")} |
| 229 | } |
| 230 | return []check.Result{check.FailR("/etc/modprobe.d", "usb-storage is not disabled in modprobe.d")} |
| 231 | }, |
| 232 | }) |
| 233 | |
| 234 | check.Register(&check.Check{ |
| 235 | ID: "HOST-BOOT-005", Title: "Kernel Module Loading Unrestricted", Domain: check.DomainHost, |
| 236 | Severity: check.Low, Category: check.CatConfig, Effort: check.EffortMedium, |
| 237 | CVSS: "CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:L/I:L/A:L", |
| 238 | Summary: "Checks that the running kernel refuses unsigned modules or has module loading switched off entirely.", |
| 239 | Rationale: "A root-level attacker who can load any module owns the kernel: rootkits hide processes and files from every tool on the box. Signature enforcement or kernel.modules_disabled = 1 after boot makes that persistence step fail.", |
| 240 | Detection: "Reads /proc/sys/kernel/modules_disabled (1 passes), otherwise /sys/module/module/parameters/sig_enforce (Y passes) and /sys/kernel/security/lockdown ([integrity] or [confidentiality] passes). Kernels built without loadable module support are skipped; a value that exists but cannot be read is incomplete.", |
| 241 | Fix: "Boot with module.sig_enforce=1 (or lockdown=integrity) on the kernel command line and sign any out-of-tree modules, or set kernel.modules_disabled = 1 from a late boot service once every needed module is loaded (it cannot be turned back on without a reboot).", |
| 242 | Verify: "sysctl kernel.modules_disabled; cat /sys/module/module/parameters/sig_enforce /sys/kernel/security/lockdown", |
| 243 | Refs: []check.Ref{refModSign, refKSPP}, |
| 244 | Standards: check.Standards{CWE: []string{"CWE-1188"}, NIST: []string{"SI-7", "CM-7"}, CISTopic: "Restrict kernel module loading (CIS Controls v8 4.8)"}, |
| 245 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 246 | v, err := collect.Sysctl(s, "kernel.modules_disabled") |
| 247 | if err != nil { |
| 248 | if errors.Is(err, fs.ErrNotExist) { |
| 249 | return []check.Result{check.SkipR("kernel.modules_disabled", "Kernel has no loadable module support")} |
| 250 | } |
| 251 | return []check.Result{check.IncompleteR("kernel.modules_disabled", "Parameter could not be read")} |
| 252 | } |
| 253 | if v == "1" { |
| 254 | return []check.Result{check.PassR("kernel.modules_disabled", "Module loading is disabled for this boot", "kernel.modules_disabled = 1")} |
| 255 | } |
| 256 | var ev []string |
| 257 | for _, p := range []string{"/sys/module/module/parameters/sig_enforce", "/sys/kernel/security/lockdown"} { |
| 258 | text, exists, ok := readSome(ctx, s, p) |
| 259 | if !exists { |
| 260 | continue |
| 261 | } |
| 262 | if !ok { |
| 263 | return []check.Result{check.IncompleteR(p, "Value could not be read")} |
| 264 | } |
| 265 | text = strings.TrimSpace(text) |
| 266 | ev = append(ev, p+" = "+text) |
| 267 | if (strings.HasSuffix(p, "sig_enforce") && text == "Y") || strings.Contains(text, "[integrity]") || strings.Contains(text, "[confidentiality]") { |
| 268 | return []check.Result{check.PassR("kernel modules", "Unsigned modules are refused", ev...)} |
| 269 | } |
| 270 | } |
| 271 | return []check.Result{check.FailR("kernel.modules_disabled", "Any module can be loaded: modules_disabled = "+v+" and no signature enforcement or lockdown", ev...)} |
| 272 | }, |
| 273 | }) |
| 274 | |
| 275 | check.Register(&check.Check{ |
| 276 | ID: "HOST-BOOT-006", Title: "No File Integrity Monitoring", Domain: check.DomainHost, |
| 277 | Severity: check.Medium, Category: check.CatLogging, Effort: check.EffortMedium, |
| 278 | CVSS: "CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:N/I:H/A:N", |
| 279 | Summary: "Checks that a file integrity tool (AIDE, Tripwire, Samhain or equivalent) is installed and scheduled to run.", |
| 280 | Rationale: "After a compromise the first question is which files changed. Without a baseline and a scheduled comparison, a replaced binary, an added SUID file or an edited PAM stack stays invisible for months.", |
| 281 | Detection: "Looks for the tools' programs and configuration (/usr/bin/aide, /usr/sbin/aide, /etc/aide/aide.conf, /etc/aide.conf, /usr/sbin/tripwire, /etc/tripwire, /usr/sbin/samhain, /etc/samhainrc, /usr/sbin/osqueryd and /opt/ossec/bin/ossec-syscheckd) and then for a schedule: a file in /etc/cron.d, /etc/cron.daily or /etc/cron.weekly, a systemd timer under /etc/systemd/system or /usr/lib/systemd/system, or a line in /etc/crontab that names the tool. Tool present with no schedule still fails. Stat and glob only; configuration contents are not read except /etc/crontab.", |
| 282 | Fix: "Install AIDE (apt install aide / dnf install aide), initialise the database with aideinit or aide --init, move the new database into place, and enable the daily check (dailyaidecheck.timer on Debian family, or a cron.daily entry) that mails or logs the differences.", |
| 283 | Verify: "aide --check (or your tool's check command) runs and reports; systemctl list-timers | grep -i aide or ls /etc/cron.daily | grep -i aide", |
| 284 | Refs: []check.Ref{refAIDE, refNIST80053, refNIST800123}, |
| 285 | Standards: check.Standards{CWE: []string{"CWE-778"}, NIST: []string{"SI-7", "CM-3"}, CISTopic: "File integrity checking (CIS Controls v8 4.1)"}, |
| 286 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 287 | tools := []struct { |
| 288 | name string |
| 289 | paths []string |
| 290 | }{ |
| 291 | {"aide", []string{"/usr/bin/aide", "/usr/sbin/aide", "/etc/aide/aide.conf", "/etc/aide.conf"}}, |
| 292 | {"tripwire", []string{"/usr/sbin/tripwire", "/etc/tripwire"}}, |
| 293 | {"samhain", []string{"/usr/sbin/samhain", "/etc/samhainrc"}}, |
| 294 | {"osquery", []string{"/usr/sbin/osqueryd", "/usr/bin/osqueryd"}}, |
| 295 | {"ossec", []string{"/opt/ossec/bin/ossec-syscheckd", "/var/ossec/bin/ossec-syscheckd", "/var/ossec/bin/wazuh-syscheckd"}}, |
| 296 | } |
| 297 | var installed []string |
| 298 | for _, t := range tools { |
| 299 | for _, p := range t.paths { |
| 300 | if _, err := s.Stat(p); err == nil { |
| 301 | installed = append(installed, t.name) |
| 302 | break |
| 303 | } |
| 304 | } |
| 305 | } |
| 306 | if len(installed) == 0 { |
| 307 | return []check.Result{check.FailR("integrity", "No file integrity tool found (AIDE, Tripwire, Samhain, osquery or OSSEC)")} |
| 308 | } |
| 309 | var sched []string |
| 310 | for _, name := range installed { |
| 311 | if name == "osquery" || name == "ossec" { |
| 312 | sched = append(sched, name+" runs as a daemon") |
| 313 | continue |
| 314 | } |
| 315 | for _, pat := range []string{"/etc/cron.d/*", "/etc/cron.daily/*", "/etc/cron.weekly/*", "/etc/systemd/system/*.timer", "/etc/systemd/system/timers.target.wants/*.timer", "/usr/lib/systemd/system/*.timer", "/lib/systemd/system/*.timer"} { |
| 316 | paths, _ := s.Glob(pat) |
| 317 | sort.Strings(paths) |
| 318 | for _, p := range paths { |
| 319 | if strings.Contains(strings.ToLower(p), name) { |
| 320 | sched = append(sched, p) |
| 321 | } |
| 322 | } |
| 323 | } |
| 324 | if text, _, ok := readSome(ctx, s, "/etc/crontab"); ok && strings.Contains(text, name) { |
| 325 | sched = append(sched, "/etc/crontab names "+name) |
| 326 | } |
| 327 | } |
| 328 | if len(sched) == 0 { |
| 329 | return []check.Result{check.FailR("integrity", strings.Join(installed, ", ")+" installed but no scheduled check was found")} |
| 330 | } |
| 331 | return []check.Result{check.PassR("integrity", strings.Join(installed, ", ")+" installed and scheduled", sched...)} |
| 332 | }, |
| 333 | }) |
| 334 | |
| 335 | check.Register(&check.Check{ |
| 336 | ID: "HOST-BOOT-007", Title: "Prelink Installed", Domain: check.DomainHost, |
| 337 | Severity: check.Low, Category: check.CatConfig, Effort: check.EffortLow, |
| 338 | CVSS: "CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:N/I:L/A:N", |
| 339 | Summary: "Checks that the prelink tool is absent, or disabled where the package is still installed.", |
| 340 | Rationale: "Prelink rewrites binaries and libraries in place, which changes their hashes and defeats integrity monitoring and package verification, and it weakens address randomisation by fixing load addresses. Modern systems gain nothing from it.", |
| 341 | Detection: "Stats /usr/sbin/prelink, /usr/bin/prelink and /etc/prelink.conf. If any exists, reads /etc/sysconfig/prelink and /etc/default/prelink for PRELINKING=no, which passes with a note. Absent files pass; a stat error other than not-found is incomplete.", |
| 342 | Fix: "Undo and remove it: prelink -ua && apt purge prelink (or dnf remove prelink). Then re-initialise the integrity database (aide --init).", |
| 343 | Verify: "command -v prelink should print nothing.", |
| 344 | Refs: []check.Ref{{Title: "prelink(8), Linux man-pages", URL: "https://man7.org/linux/man-pages/man8/prelink.8.html"}, refNIST80053}, |
| 345 | Standards: check.Standards{CWE: []string{"CWE-344"}, NIST: []string{"SI-7", "CM-7"}, CISTopic: "Prelink not installed (CIS Controls v8 4.8)"}, |
| 346 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 347 | var found []string |
| 348 | for _, p := range []string{"/usr/sbin/prelink", "/usr/bin/prelink", "/etc/prelink.conf"} { |
| 349 | _, err := s.Stat(p) |
| 350 | switch { |
| 351 | case err == nil: |
| 352 | found = append(found, p) |
| 353 | case !errors.Is(err, fs.ErrNotExist): |
| 354 | return []check.Result{check.IncompleteR(p, "Could not stat "+p)} |
| 355 | } |
| 356 | } |
| 357 | if len(found) == 0 { |
| 358 | return []check.Result{check.PassR("prelink", "Prelink is not installed")} |
| 359 | } |
| 360 | for _, cfg := range []string{"/etc/sysconfig/prelink", "/etc/default/prelink"} { |
| 361 | text, exists, ok := readSome(ctx, s, cfg) |
| 362 | if exists && !ok { |
| 363 | return []check.Result{check.IncompleteR(cfg, "Prelink configuration could not be read")} |
| 364 | } |
| 365 | if prelinkOff.MatchString(text) { |
| 366 | return []check.Result{check.PassR("prelink", "Prelink is installed but disabled (PRELINKING=no); removing the package is cleaner", found...)} |
| 367 | } |
| 368 | } |
| 369 | return []check.Result{check.FailR("prelink", "Prelink is installed", found...)} |
| 370 | }, |
| 371 | }) |
| 372 | |
| 373 | check.Register(&check.Check{ |
| 374 | ID: "HOST-BOOT-008", Title: "Core Dumps Not Restricted", Domain: check.DomainHost, |
| 375 | Severity: check.Low, Category: check.CatExposure, Effort: check.EffortLow, |
| 376 | CVSS: "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N", |
| 377 | Summary: "Checks that process core dumps are turned off through PAM limits, and through systemd-coredump where the kernel hands dumps to it.", |
| 378 | Rationale: "A core dump is a copy of a program's memory, including passwords, keys and session tokens it held. On a server nobody debugs, dumps are only a leak waiting in /var/lib/systemd/coredump or the working directory. HOST-KRN-004 covers setuid programs; this check covers every process.", |
| 379 | Detection: "Reads /etc/security/limits.conf and /etc/security/limits.d/*.conf for a * hard core 0 line, and /proc/sys/kernel/core_pattern. When the pattern pipes dumps to systemd-coredump, /etc/systemd/coredump.conf and /etc/systemd/coredump.conf.d/*.conf must set Storage=none (ProcessSizeMax=0 also counts). Either the limits line or the systemd setting passes. Missing files are observed as missing; a file that exists but cannot be read makes the check incomplete.", |
| 380 | Fix: "Add * hard core 0 to /etc/security/limits.conf (or a file in limits.d), set Storage=none and ProcessSizeMax=0 in the [Coredump] section of /etc/systemd/coredump.conf, and keep fs.suid_dumpable = 0.", |
| 381 | Verify: "grep -E '^\\*\\s+hard\\s+core\\s+0' /etc/security/limits.conf /etc/security/limits.d/*; grep -E '^(Storage|ProcessSizeMax)' /etc/systemd/coredump.conf; cat /proc/sys/kernel/core_pattern", |
| 382 | Refs: []check.Ref{refLimits, refCoredump, refKernelDocs}, |
| 383 | Standards: check.Standards{CWE: []string{"CWE-528"}, NIST: []string{"SI-11", "CM-6", "AC-6"}, CISTopic: "Restrict core dumps (CIS Controls v8 4.1)"}, |
| 384 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 385 | var ev []string |
| 386 | partial := false |
| 387 | files := []string{"/etc/security/limits.conf"} |
| 388 | more, _ := s.Glob("/etc/security/limits.d/*.conf") |
| 389 | sort.Strings(more) |
| 390 | files = append(files, more...) |
| 391 | limited := false |
| 392 | for _, p := range files { |
| 393 | text, exists, ok := readSome(ctx, s, p) |
| 394 | if !exists { |
| 395 | continue |
| 396 | } |
| 397 | if !ok { |
| 398 | partial = true |
| 399 | continue |
| 400 | } |
| 401 | if hardCore0.MatchString(text) { |
| 402 | limited = true |
| 403 | ev = append(ev, p+": * hard core 0") |
| 404 | } |
| 405 | } |
| 406 | pattern, _, _ := readSome(ctx, s, "/proc/sys/kernel/core_pattern") |
| 407 | pattern = strings.TrimSpace(pattern) |
| 408 | if pattern != "" { |
| 409 | ev = append(ev, "kernel.core_pattern = "+pattern) |
| 410 | } |
| 411 | systemdOff := false |
| 412 | if strings.Contains(pattern, "systemd-coredump") { |
| 413 | cfgs := []string{"/etc/systemd/coredump.conf"} |
| 414 | more, _ := s.Glob("/etc/systemd/coredump.conf.d/*.conf") |
| 415 | sort.Strings(more) |
| 416 | cfgs = append(cfgs, more...) |
| 417 | for _, p := range cfgs { |
| 418 | text, exists, ok := readSome(ctx, s, p) |
| 419 | if !exists { |
| 420 | continue |
| 421 | } |
| 422 | if !ok { |
| 423 | partial = true |
| 424 | continue |
| 425 | } |
| 426 | if storageNone.MatchString(text) { |
| 427 | systemdOff = true |
| 428 | ev = append(ev, p+": Storage=none or ProcessSizeMax=0") |
| 429 | } |
| 430 | } |
| 431 | } |
| 432 | switch { |
| 433 | case limited || systemdOff: |
| 434 | return []check.Result{check.PassR("core dumps", "Core dumps are restricted", ev...)} |
| 435 | case partial: |
| 436 | return []check.Result{check.IncompleteR("core dumps", "A limits or coredump configuration file could not be read", ev...)} |
| 437 | default: |
| 438 | return []check.Result{check.FailR("core dumps", "No hard core 0 limit and no systemd-coredump Storage=none", ev...)} |
| 439 | } |
| 440 | }, |
| 441 | }) |
| 442 | } |
| 443 | |
| 444 | var ( |
| 445 | grubSuperusers = regexp.MustCompile(`(?m)^\s*set\s+superusers\s*=`) |
| 446 | grubPassword = regexp.MustCompile(`(?m)^\s*password(_pbkdf2)?\s+\S+\s+\S+`) |
| 447 | prelinkOff = regexp.MustCompile(`(?mi)^\s*PRELINKING\s*=\s*"?no"?\s*$`) |
| 448 | hardCore0 = regexp.MustCompile(`(?m)^\s*\*\s+(hard|-)\s+core\s+0\s*$`) |
| 449 | storageNone = regexp.MustCompile(`(?mi)^\s*(Storage\s*=\s*none|ProcessSizeMax\s*=\s*0)\s*$`) |
| 450 | ) |
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-BOOT-008 Core Dumps Not Restricted (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/HOST-BOOT-008.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_boot_008,
author = {{OpenVAPT}},
title = {{HOST-BOOT-008 Core Dumps Not Restricted}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/HOST-BOOT-008.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.