CIS Benchmark Hardening for Embedded Linux: What's Worth It and What Isn't
The CIS Distribution Independent Linux Benchmark v2.0 defines over 200 security controls for Linux systems 1. Applying all of them to a server is reasonable. Applying all of them to an embedded appliance with 4 GB of storage and no interactive users is not.
This article is what I learned going through the CIS controls for an embedded Linux device, organized by effort and impact. Each section covers what I tried, what was worth the effort, and what I would skip next time. It also covers CVE triage — how I ran vulnerability scans against a Yocto-built image and figured out which CVEs actually needed a fix.
These notes come from hardening a headless x86-64 embedded Linux device I was working on. If you are doing something similar, I hope they help you decide where to spend your time.
Tier 1: The non-negotiable (cheap, high impact)
These controls require configuration changes only — no new packages, no runtime overhead, no ongoing maintenance burden. Implement them first.
The device I was hardening is a headless x86-64 appliance that runs Docker for application containers, uses a VPN mesh for remote access, and has no interactive users beyond the administrator.
Kernel hardening via sysctl
The Linux kernel exposes dozens of security-relevant parameters through
/proc/sys/. A handful deliver outsized protection:
# /etc/sysctl.d/50-security-hardening.conf
# Full Address Space Layout Randomization (CIS 1.5.1)
kernel.randomize_va_space = 2
# Restrict dmesg to root only (CIS 1.5.4)
kernel.dmesg_restrict = 1
# Hide kernel pointers from non-root (CIS 1.5.2)
kernel.kptr_restrict = 2
# Reverse path filtering — prevent IP spoofing (CIS 3.3.7)
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# TCP syncookies — prevent SYN flood DoS (CIS 3.3.8)
net.ipv4.tcp_syncookies = 1
# Disable ICMP redirects (CIS 3.3.2)
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
# Disable source routing (CIS 3.3.1)
net.ipv4.conf.all.accept_source_route = 0
# Log martian packets (CIS 3.3.4)
net.ipv4.conf.all.log_martians = 1
# Disable IPv6 router advertisements (if IPv6 is not needed)
net.ipv6.conf.all.accept_ra = 0
# Harden BPF JIT (kernel hardening, not in CIS DIL benchmark)
net.core.bpf_jit_harden = 2These are applied by systemd-sysctl at boot with no additional daemon 2.
The security benefit is immediate and the performance cost is negligible.
SSH hardening
The default sshd_config on most distributions is permissive. On an embedded device, SSH should allow only key-based authentication for a single user:
# /etc/ssh/sshd_config (CIS 5.2)
PermitRootLogin prohibit-password
PermitEmptyPasswords no
PasswordAuthentication no
X11Forwarding no
AllowTcpForwarding no
AllowAgentForwarding no
MaxAuthTries 4
LoginGraceTime 60
ClientAliveInterval 300
ClientAliveCountMax 3
HostbasedAuthentication no
PermitUserEnvironment noThe file permissions on sshd_config must also be restricted:
chmod 0600 /etc/ssh/sshd_configOn an embedded device with a serial console, you can go further and disable SSH entirely if remote access is provided through a VPN mesh like Tailscale. But key-only SSH with a restricted user is the baseline 3.
Disable unused kernel modules
Every kernel module that is loaded is a potential attack surface. Disable filesystems, network protocols, and device drivers that the device will never use:
# /etc/modprobe.d/hardening.conf
# Unused filesystems (CIS 1.1.1)
install cramfs /bin/true
install freevxfs /bin/true
install hfs /bin/true
install hfsplus /bin/true
install jffs2 /bin/true
install udf /bin/true
# Unused network protocols (CIS 3.4.3)
install dccp /bin/true
install sctp /bin/true
install rds /bin/true
install tipc /bin/true
These are install directives, not blacklist directives. install /bin/true means: if something tries to load this module, run /bin/true
instead 4. The module cannot be loaded even by a privileged process.
Tier 2: The worthwhile (moderate effort, moderate impact)
These controls require new packages or services but deliver real protection.
Default-deny firewall
An embedded device that only needs SSH, DNS, HTTPS outbound, and a VPN mesh should have a firewall that reflects that. iptables with a default DROP policy:
#!/bin/sh
# /etc/iptables/rules.v4
# Default policy: DROP
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT # Outbound is permissive
# Allow loopback
iptables -A INPUT -i lo -j ACCEPT
# Allow established connections
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Allow SSH on the VPN interface only (not on WAN)
iptables -A INPUT -i tailscale0 -p tcp --dport 22 -j ACCEPT
# Allow Tailscale UDP
iptables -A INPUT -i eth0 -p udp --dport 41641 -j ACCEPT
# Allow ICMP
iptables -A INPUT -p icmp -j ACCEPT
# Log dropped packets (rate-limited)
iptables -A INPUT -m limit --limit 5/min -j LOG \
--log-prefix "iptables-dropped: " --log-level 4For an appliance behind a NAT or on a private network, the firewall is defense in depth — the network boundary should already restrict inbound traffic. But the firewall protects against misconfigured upstream routers and lateral movement from compromised devices on the same network 5.
sshguard: brute-force protection
Even with key-only SSH, failed authentication attempts consume CPU and log space. sshguard blocks IPs that exceed a configurable attack threshold 6:
# /etc/sshguard.conf
THRESHOLD=30
BLOCK_TIME=420
DETECTION_TIME=1800
WHITELIST_FILE=/etc/sshguard/whitelistsshguard parses journalctl output and inserts iptables rules to block
offending IPs. It adds negligible overhead and prevents brute-force attacks
from filling logs.
PAM hardening
PAM (Pluggable Authentication Modules) controls authentication policy:
# /etc/pam.d/common-auth
auth required pam_faillock.so preauth audit silent deny=5 unlock_time=900
auth required pam_faillock.so authfail audit deny=5 unlock_time=900
auth required pam_unix.so
# /etc/pam.d/common-session
session required pam_umask.so umask=027The pam_faillock.so module locks an account after 5 failed attempts for
15 minutes (CIS 5.3.2). The umask ensures new files are created with
restricted permissions (CIS 5.4.4) 7.
On a device with only key-based SSH and no password authentication, PAM lockout is a backstop against misconfiguration — if password authentication is accidentally enabled, brute-force attacks are still mitigated.
Tier 3: The heavy lift (higher effort, strong impact)
These controls require significant engineering but are worthwhile for devices that process sensitive data or face a hostile network.
The time estimates below are for a typical embedded appliance with 5-15 services. A simpler device with fewer services will take less time.
AppArmor mandatory access control
AppArmor confines processes to a predefined set of capabilities, filesystem paths, and network operations. A process that breaks out of its intended behavior is contained by its profile 8.
Integrating AppArmor into a Yocto build requires the meta-security layer:
# In kas-config.yml
repos:
meta-security:
url: git://git.yoctoproject.org/meta-security
branch: scarthgap
commit: <pinned-sha>
layers:
meta-security:
local_conf_header:
apparmor: |
DISTRO_FEATURES:append = " apparmor"
IMAGE_INSTALL:append = " apparmor apparmor-profiles"The kernel also needs AppArmor support:
CONFIG_SECURITY_APPARMOR=y
CONFIG_SECURITY_APPARMOR_BOOTPARAM_VALUE=1
CONFIG_DEFAULT_SECURITY_APPARMOR=y
CONFIG_AUDIT=yStart in complain mode (logs violations but does not enforce). After validating that no critical paths are blocked, switch to enforce mode 9:
# Check current mode
aa-status
# Switch all profiles to enforce
aa-enforce /etc/apparmor.d/*AppArmor profiles for system services (sshd, tailscaled, dockerd) are
available from the apparmor-profiles package. Custom profiles for
application services must be written from scratch. The effort scales with
the number of services on the device.
For an embedded appliance with 10-15 services, allocating 2-3 weeks for profile development and testing in complain mode is realistic. For devices with interactive users and arbitrary third-party software, AppArmor is a continuous maintenance burden.
Kernel module lockdown
After all required modules are loaded, disable further module loading:
# After boot is complete, lock down kernel modules
echo 1 > /proc/sys/kernel/modules_disabledThis is a one-way door — once set, modules cannot be loaded or unloaded
until the next reboot 10. A systemd oneshot service that runs after
multi-user.target applies this after all services have started:
[Unit]
Description=Lock down kernel module loading
After=multi-user.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'echo 1 > /proc/sys/kernel/modules_disabled'
RemainAfterExit=yes
[Install]
WantedBy=multi-user.targetFilesystem mount hardening
Restrict what can happen on writable mount points:
# /etc/fstab
tmpfs /tmp tmpfs defaults,nodev,nosuid,noexec 0 0
tmpfs /var/tmp tmpfs defaults,nodev,nosuid,noexec 0 0
tmpfs /dev/shm tmpfs defaults,nodev,nosuid,noexec 0 0nodev: block/character devices cannot be creatednosuid: setuid/setgid bits are ignorednoexec: binaries cannot be executed from this mount point
For an embedded device with Docker containers, noexec on /tmp and
/var/tmp may break container builds that execute scripts from those
directories. Test thoroughly before enabling in production 11.
Tier 4: The ones to skip (for embedded)
Some CIS controls are designed for multi-user servers and do not apply to headless embedded appliances:
| Control | Why skip |
|---|---|
| auditd (CIS 4.1) | Adds 5-15% CPU overhead and constant disk writes on devices with flash storage. systemd-journald provides enough audit data for embedded use cases. |
| AIDE file integrity monitoring (CIS 1.4) | Resources (CPU, disk I/O) are better spent on signed OTA updates and dm-verity. A rootfs that is verified at boot by dm-verity does not need runtime integrity checks. |
| GNOME / graphical login hardening (CIS 1.7-1.8) | Not present on headless devices. |
| cron / at restrictions (CIS 5.1) | Not present on a device without cron. |
| USB storage disabling (CIS 1.1.20) | May interfere with factory provisioning via USB flasher. |
| Wireless interface disabling (CIS 3.1.2) | The device has no wireless interfaces. |
The rule: map every CIS control against the actual services and interfaces present on the device. If the control targets a component that does not exist, skip it.
CVE management for embedded Linux
A Yocto build produces a Software Bill of Materials (SPDX by default) that lists every package and its version. CVE scanners compare this SBOM against the NVD database and flag known vulnerabilities 12.
Running a CVE scan
Yocto includes a built-in CVE check via the cve-check class. Enable it with
a KAS overlay:
# kas-config-cve.yml
header:
version: 14
local_conf_header:
cve_check: |
INHERIT += "cve-check"Note: cve-check scans for known vulnerabilities. SPDX SBOM generation is a
separate feature (INHERIT += "create-spdx") and is enabled by default in
recent Yocto releases.
The scan runs at build time and produces cve-summary.json in the deploy
directory. A typical embedded Linux image with 200 packages will have:
- 15,000-20,000 CVEs patched by upstream (no action needed)
- 50-150 unpatched CVEs
- 3-10 critical unpatched CVEs (CVSS >= 9.0)
Triaging CVEs: not all criticals are critical
A CVE with a CVSS 9.8 score on the NVD may be irrelevant to your device. The CVE triage process answers two questions 13:
-
Is the vulnerable component present and used in our deployment? A glibc
scanfvulnerability is relevant. A GnuTLS RSA-PSK authentication bypass is not, if the device does not use RSA-PSK ciphersuites. -
Is the attack surface reachable? A vulnerability in Python's XML parser is irrelevant on a device that never parses untrusted XML. A vulnerability in sshd's key exchange is relevant because SSH is exposed.
CVEs fall into three exclusion categories:
| Status | Meaning | Action |
|---|---|---|
not-applicable-config | The vulnerable feature is not used | Document the rationale and exclude |
mitigated | The vulnerability exists but cannot be exploited in our deployment context | Monitor for upstream fix |
fixed-version | Our version is newer than the affected range (false positive) | Exclude |
Excluded CVEs are recorded in a cve-extra-exclusions.inc file with the
rationale:
# CVE-2024-41110: docker-moby AuthZ bypass (CVSS 9.9)
# We do not use Docker AuthZ plugins. Docker API is restricted to root only.
# This CVE requires AuthZ plugins to be configured, which they are not.
CVE_STATUS[CVE-2024-41110] = "not-applicable-config \
reason: no Docker AuthZ plugins used, Docker API root-only via Unix socket"
# CVE-2026-XXXX: glibc scanf heap buffer overflow (CVSS 9.8, illustrative)
# Requires scanf with %mc format specifier and attacker-controlled width >1024.
# No service on this headless appliance uses scanf with attacker input.
CVE_STATUS[CVE-2026-XXXX] = "mitigated \
reason: no scanf with attacker-controlled format strings; \
monitoring upstream glibc stable branch for fix"Each exclusion includes the CVE ID, CVSS score, vulnerability description, and a specific justification for why it does not apply. This is a compliance requirement — "we decided it's fine" is not a defensible rationale.
Grype for runtime scanning
The Yocto CVE check scans packages at build time. For runtime-level detection of CVEs in container images and interpreted language dependencies, Anchore Grype provides a second layer 14:
```bash
# Mount the EXT4 image to a local directory first
sudo mount -o loop build/tmp/deploy/images/genericx86-64/core-image.wic /mnt/ext4-rootfs
# Scan the mounted filesystem with Grype
grype dir:/mnt/ext4-rootfs -o json > grype-report.json
# Unmount when done
sudo umount /mnt/ext4-rootfsGrype detects CVEs that the build-time scan misses: Python packages installed at runtime, container images pulled by Docker, and dynamically linked libraries 15.
Remediation cadence
- Weekly: run
check-kas-updates.shto detect new commits in upstream layers. New commits may include CVE fixes. - Per release: run the full CVE scan before tagging. Review all new CVEs and update exclusions.
- Quarterly: review all entries in
cve-extra-exclusions.incfor continued applicability. A CVE that was "not applicable" six months ago may be applicable now if the device configuration has changed.
The hardening checklist for a new embedded Linux device
- sysctl hardening — apply the kernel parameters. Takes 10 minutes.
- SSH hardening — key-only auth, restricted ciphers. Takes 10 minutes.
- Unused kernel modules — disable filesystems and protocols the device does not use. Takes 5 minutes.
- Default-deny firewall — restrict inbound traffic to known ports. Takes 30 minutes.
- sshguard — install and configure. Takes 15 minutes.
- PAM hardening — faillock and umask. Takes 15 minutes.
- AppArmor — enable in complain mode, develop and test profiles over the next few weeks, then switch to enforce. Time varies with service count: a 3-service device takes a few days; a 15-service device takes 2-3 weeks of part-time work.
- Kernel module lockdown — enable after all services are verified. Takes 5 minutes.
- Filesystem mount hardening — add
nodev,nosuid,noexecwhere safe. Takes 30 minutes to apply; test for 1-2 weeks. - CVE scan — run the build-time scan, triage the results, create exclusions with rationale. Takes 2-4 hours initially, then 30 minutes per release.
Summary
Hardening an embedded Linux device against the CIS Benchmark turned out to be a triage exercise, not a checklist. The tiers above reflect what I found worthwhile on a headless appliance with limited storage: do the cheap, high-impact controls first, add moderate-effort ones as time allowed, and skip anything targeting a component my device did not even have.
CVE management taught me the same lesson. Most CVEs in a Yocto-built image were already patched upstream. Of the ones that were not, most were irrelevant to my device — the vulnerable code path was not reachable. The ones that actually mattered got a fix. The rest got a documented exclusion with a specific reason why.
References
[1] Center for Internet Security, "CIS Distribution Independent Linux Benchmark v2.0," https://www.cisecurity.org/benchmark/distribution_independent_linux, accessed June 2026.
[2] Freedesktop.org, "systemd-sysctl.service Manual Page," https://www.freedesktop.org/software/systemd/man/systemd-sysctl.service.html, accessed June 2026.
[3] OpenSSH, "sshd_config Manual Page," https://man.openbsd.org/sshd_config, accessed June 2026.
[4] Linux Man Pages, "modprobe.d(5) — Configuration directory for modprobe," https://man7.org/linux/man-pages/man5/modprobe.d.5.html, accessed June 2026.
[5] Netfilter, "iptables Manual Page," https://www.netfilter.org/, accessed June 2026.
[6] sshguard, "sshguard — Monitor and Block Brute-Force Attacks," https://www.sshguard.net/, accessed June 2026.
[7] Linux-PAM, "The Linux-PAM System Administrators' Guide," https://linux-pam.org/, accessed June 2026.
[8] AppArmor, "AppArmor Documentation," https://apparmor.net/, accessed June 2026.
[9] Yocto Project, "meta-security layer," https://git.yoctoproject.org/meta-security/, accessed June 2026.
[10] Linux Kernel Documentation, "Kernel Module Lockdown," https://docs.kernel.org/admin-guide/sysctl/kernel.html, accessed June 2026.
[11] Linux Kernel Documentation, "Filesystem Mount Options," https://docs.kernel.org/admin-guide/filesystem-mounting.html, accessed June 2026.
[12] Yocto Project, "Checking for Vulnerabilities," https://docs.yoctoproject.org/dev/dev-manual/vulnerabilities.html, accessed June 2026.
[13] NIST, "National Vulnerability Database," https://nvd.nist.gov/, accessed June 2026.
[14] Anchore, "Grype — Vulnerability Scanner for Container Images and Filesystems," https://github.com/anchore/grype, accessed June 2026.
[15] Yocto Project, "Creating a Software Bill of Materials," https://docs.yoctoproject.org/dev/dev-manual/sbom.html, accessed June 2026.