← Back to Blog

CIS Benchmark Hardening for Embedded Linux: What's Worth It and What Isn't

·14 min read
Embedded LinuxSecurityCIS BenchmarksLinux HardeningAppArmorYocto

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 = 2

These 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 no

The file permissions on sshd_config must also be restricted:

chmod 0600 /etc/ssh/sshd_config

On 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 4

For 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/whitelist

sshguard 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=027

The 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=y

Start 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_disabled

This 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.target

Filesystem 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 0
  • nodev: block/character devices cannot be created
  • nosuid: setuid/setgid bits are ignored
  • noexec: 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:

ControlWhy 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]:

  1. Is the vulnerable component present and used in our deployment? A glibc scanf vulnerability is relevant. A GnuTLS RSA-PSK authentication bypass is not, if the device does not use RSA-PSK ciphersuites.

  2. 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:

StatusMeaningAction
not-applicable-configThe vulnerable feature is not usedDocument the rationale and exclude
mitigatedThe vulnerability exists but cannot be exploited in our deployment contextMonitor for upstream fix
fixed-versionOur 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-rootfs

Grype 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.sh to 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.inc for 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

  1. sysctl hardening — apply the kernel parameters. Takes 10 minutes.
  2. SSH hardening — key-only auth, restricted ciphers. Takes 10 minutes.
  3. Unused kernel modules — disable filesystems and protocols the device does not use. Takes 5 minutes.
  4. Default-deny firewall — restrict inbound traffic to known ports. Takes 30 minutes.
  5. sshguard — install and configure. Takes 15 minutes.
  6. PAM hardening — faillock and umask. Takes 15 minutes.
  7. 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.
  8. Kernel module lockdown — enable after all services are verified. Takes 5 minutes.
  9. Filesystem mount hardening — add nodev, nosuid, noexec where safe. Takes 30 minutes to apply; test for 1-2 weeks.
  10. 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.