System hardening: the basics#

Why#

A hardened SSH (previous chapter) protects the entry point, but a system compromised through another route (a future application vulnerability, a local privilege escalation) still needs to remain hard to exploit and traceable. This chapter lays the general hardening groundwork for the operating system, independent of the application services installed in later chapters. As with SSH, these foundations are laid early, before any services are installed, so that no service ever runs on an unhardened base, even temporarily.

This guide does not aim for exhaustiveness on security. The measures shown here are those judged relevant for this project; they don’t replace a complete security policy tailored to each deployment’s own context. The Lynis chapter, later on, offers a method to objectively audit the system beyond this list.

How#

Kernel hardening via sysctl#

cat > /etc/sysctl.d/99-hardening.conf <<'EOF'
# === Kernel ===
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.sysrq = 4
kernel.randomize_va_space = 2
kernel.yama.ptrace_scope = 2
net.core.bpf_jit_harden = 2

# === Filesystem ===
fs.protected_fifos = 2
dev.tty.ldisc_autoload = 0

# === IPv4 network ===
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv4.conf.default.secure_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_rfc1337 = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1

# === IPv6 network ===
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv6.conf.default.accept_source_route = 0
EOF

sysctl --system 2>&1 | grep -E "99-hardening|error"

These settings partly follow the ANSSI BP-028 guide (Recommandations de configuration d’un système GNU/Linux, the French cybersecurity agency’s Linux hardening guide), in particular kptr_restrict and dmesg_restrict. Breaking down the non-obvious values:

DirectiveMeaning
kernel.kptr_restrict = 2Hides kernel pointer addresses in /proc from everyone, including root without CAP_SYSLOG: prevents using them to defeat kernel ASLR
kernel.dmesg_restrict = 1Restricts reading the dmesg buffer to processes with CAP_SYSLOG, keeps an unprivileged user from harvesting addresses or hardware details from it
kernel.sysrq = 4A restricted Magic SysRq bitmask (not 0, fully disabled, nor 1, fully enabled): keeps a minimal emergency-recovery capability rather than removing it outright, a documented doctrine choice that deliberately departs from Lynis’s default suggestion
kernel.randomize_va_space = 2Full ASLR: stack, heap, mmap, and PIE all randomized (the weaker value 1 leaves the heap unrandomized)
kernel.yama.ptrace_scope = 2Restricts ptrace to processes holding CAP_SYS_PTRACE (stricter than 1, which only lets a parent trace its own child)
net.core.bpf_jit_harden = 2Hardens the eBPF JIT compiler against JIT-spraying attacks for all users, including privileged ones (1 would only harden it for unprivileged users)
fs.protected_fifos = 2Refuses to open a FIFO not owned by the current user inside a world-writable sticky directory, even for a privileged process (1 would make an exception for root)
dev.tty.ldisc_autoload = 0Prevents an unprivileged user from arbitrarily loading a tty line discipline (each one is a kernel module), closing a known local privilege-escalation vector

kernel.yama.ptrace_scope stays inert on this specific kernel. The Yama security module isn’t compiled into linux-image-rpi-v8-rt (CONFIG_SECURITY_YAMA absent): the directive above is silently ignored, with no error or warning. No boot parameter would change that; only a recompiled kernel with that option would activate it, an effort not justified for this single gain. The directive is kept as-is: harmless today, it would take effect on its own if a future kernel compiled it in.

On the network side, rp_filter = 1 enables reverse-path filtering: a packet is dropped if the return route to its source address wouldn’t go back out the arriving interface, a classic anti-spoofing measure. The accept_redirects, secure_redirects, send_redirects, and accept_source_route directives set to 0 respectively disable accepting ICMP redirects (including from trusted gateways, stricter than the default behavior), sending redirects (this server isn’t a router), and source-routed packets (a legacy IP option abused for spoofing): these are classic routing-table tampering vectors from an attacker on the same local network, with no legitimate use on this server. log_martians = 1 logs packets with impossible addresses (useful alongside auditd and fail2ban, already in place). tcp_syncookies = 1 protects against SYN flooding. tcp_rfc1337 = 1 applies RFC 1337 (ignores RST packets received in the TIME-WAIT state, against so-called TIME-WAIT assassination). icmp_echo_ignore_broadcasts = 1 keeps this server from unwittingly participating in a Smurf-style ICMP broadcast amplification attack. icmp_ignore_bogus_error_responses = 1 silences a well-known category of false positives generated by misconfigured routers (avoids needless log noise on a system whose logs are already actively monitored). The .all./.default. duplication on every IPv4 directive isn’t redundant: .all. applies to interfaces already present, .default. supplies the value for any interface created afterwards. The IPv6 directives follow the same anti-redirect/anti-source-route logic as their IPv4 counterparts.

Automatic reboot after a kernel panic#

Without a specific setting, a kernel panic leaves the machine stuck indefinitely until someone physically intervenes. On a server without continuous monitoring, with logs on tmpfs (the panic trace is lost at the next reboot anyway), auto-reboot is preferable to a silent lockup:

cat > /etc/sysctl.d/90-kernelpanic-reboot.conf <<'EOF'
# Automatic reboot after a kernel panic.
# Value: number of seconds to wait before rebooting after a panic.
kernel.panic = 10
EOF

sysctl --system 2>&1 | grep -i panic

This file is deliberately kept separate from 99-hardening.conf above: it serves a resilience goal, not a hardening one, and the two concerns are better kept in distinct files.

Strict permissions#

chmod 600 /etc/crontab /etc/ssh/sshd_config
chmod 700 /etc/cron.d /etc/cron.daily /etc/cron.hourly /etc/cron.weekly /etc/cron.monthly

The legal banner (/etc/issue.net) was already created in the previous chapter, along with the matching Banner directive in sshd_config.

login.defs: password policy#

sed -i 's|^UMASK\s.*|UMASK           027|' /etc/login.defs
grep -q "^UMASK" /etc/login.defs || echo "UMASK           027" >> /etc/login.defs

grep -q "^SHA_CRYPT_MIN_ROUNDS" /etc/login.defs || cat >> /etc/login.defs <<'EOF'

SHA_CRYPT_MIN_ROUNDS 50000
SHA_CRYPT_MAX_ROUNDS 100000
EOF

UMASK 027 removes all “other” permissions and group write access by default from files a user creates (Debian’s default, 022, leaves new files readable by any local account). SHA_CRYPT_MIN_ROUNDS/MAX_ROUNDS raise the number of iterations of the SHA-512 hash used for /etc/shadow: glibc’s default (around 5000 rounds) hasn’t changed in a long time, while the compute power available for an offline attack has grown considerably. Raising it to 50,000-100,000 rounds multiplies the cost per password guess, with no perceptible impact on a normal interactive login.

Restricting compilers#

# Prevents an attacker who compromised a non-root account from compiling
# a local exploit directly on the machine
for c in /usr/bin/gcc /usr/bin/g++ /usr/bin/cc /usr/bin/cpp /usr/bin/as /usr/bin/ld; do
    if [ -x "$c" ]; then
        chmod 750 "$c"
        chown root:root "$c"
    fi
done

Blacklisting exotic network protocols#

cat > /etc/modprobe.d/99-blacklist-exotic-net.conf <<'EOF'
install dccp /bin/true
install sctp /bin/true
install rds /bin/true
install tipc /bin/true
EOF

auditd: tracing sensitive files#

mkdir -p /var/log/audit
chown root:root /var/log/audit
chmod 0750 /var/log/audit

cat > /etc/audit/rules.d/99-stratum1.rules <<'EOF'
# Local accounts and privileges
-w /etc/passwd       -p wa -k identity
-w /etc/shadow       -p wa -k identity
-w /etc/group        -p wa -k identity
-w /etc/gshadow      -p wa -k identity
-w /etc/sudoers      -p wa -k scope
-w /etc/sudoers.d/   -p wa -k scope

# SSH
-w /etc/ssh/sshd_config        -p wa -k sshd_config
-w /etc/ssh/sshd_config.d/     -p wa -k sshd_config

# Privilege escalation by a non-root user
# auid = the original login uid (immutable, distinct from the current uid);
# auid >= 1000 excludes system accounts, euid = 0 flags an execution that
# is effectively running as root, and auid != 4294967295 (-1 unsigned, the
# kernel's "no session" value) excludes system processes with no auid
# assigned (services, cron), keeping only genuine user sessions that
# gained root privilege
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=4294967295 -k root_execve

# Logins
-w /var/log/lastlog -p wa -k logins
-w /var/log/faillog -p wa -k logins

-e 2
EOF

systemctl enable --now auditd
augenrules --load

# Sizing for a tmpfs /var/log (Filesystem chapter)
sed -i 's|^max_log_file = .*|max_log_file = 8|'                       /etc/audit/auditd.conf
sed -i 's|^num_logs = .*|num_logs = 4|'                               /etc/audit/auditd.conf
sed -i 's|^space_left_action = .*|space_left_action = SYSLOG|'        /etc/audit/auditd.conf
sed -i 's|^admin_space_left_action = .*|admin_space_left_action = SUSPEND|' /etc/audit/auditd.conf
systemctl restart auditd   # auditd doesn't hot-reload these parameters

auditd’s defaults (8 MB x 5 files = 40 MB, alert thresholds at 75/50 MB) are sized for a multi-GB disk. Here, /var/log is a 256 MB tmpfs shared with nginx, certbot, and journald (Filesystem chapter): 8 x 4 = 32 MB max for audit logs leaves a comfortable margin for the rest of the tmpfs. space_left_action = SYSLOG just alerts while some space remains, while admin_space_left_action = SUSPEND stops auditing (not the system) as a last resort if space gets critically low, so that /var/log filling up with audit logs themselves doesn’t block the other services writing to it.

Pitfalls to avoid#

  • Don’t add audit.log to logrotate. auditd manages its own internal rotation via max_log_file/num_logs. Double rotation can corrupt the audit daemon’s state, see also the Logrotate chapter.
  • /var/log/audit/ missing at boot. Since /var/log is a tmpfs (Filesystem chapter), this directory must be recreated on every boot via tmpfiles.d, without this rule, auditd fails with Could not open dir /var/log/audit.
  • Watching (-w) a file that doesn’t exist yet. auditd refuses a -w rule pointing to a path missing at load time (Error sending add rule: No such file or directory). That’s precisely why the rules above only cover what already exists at this stage of the guide (system accounts, SSH), not yet /etc/chrony/, /etc/nftables.conf, or /etc/fail2ban/, which won’t exist until the corresponding chapters. The Lynis chapter, later on, revisits these rules to complete them once every service is installed.
  • Restricting compilers before finishing the install. Some packages or install scripts may need to compile something (DKMS modules, for instance). Make sure no later step in the install depends on a compiler being accessible to a non-root user before applying this restriction.

Verify#

sysctl net.ipv4.tcp_syncookies kernel.kptr_restrict
# Should reflect the values from 99-hardening.conf

auditctl -l | head -10
auditctl -s | grep -i enabled
# "enabled 2" = immutable configuration

stat -c "%a" /etc/crontab /etc/cron.d
# 600 and 700 respectively

This foundation gets completed later in this guide by the firewall (nftables chapter), fail2ban, and a global audit via Lynis once all services are installed, see the dedicated Lynis chapter, which revisits these settings as part of an overall hardening score.