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