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:
| Directive | Meaning |
|---|---|
kernel.kptr_restrict = 2 | Hides kernel pointer addresses in /proc from everyone, including root without CAP_SYSLOG: prevents using them to defeat kernel ASLR |
kernel.dmesg_restrict = 1 | Restricts reading the dmesg buffer to processes with CAP_SYSLOG, keeps an unprivileged user from harvesting addresses or hardware details from it |
kernel.sysrq = 4 | A 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 = 2 | Full ASLR: stack, heap, mmap, and PIE all randomized (the weaker value 1 leaves the heap unrandomized) |
kernel.yama.ptrace_scope = 2 | Restricts ptrace to processes holding CAP_SYS_PTRACE (stricter than 1, which only lets a parent trace its own child) |
net.core.bpf_jit_harden = 2 | Hardens 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 = 2 | Refuses 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 = 0 | Prevents 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_scopestays inert on this specific kernel. The Yama security module isn’t compiled intolinux-image-rpi-v8-rt(CONFIG_SECURITY_YAMAabsent): 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 panicThis 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.monthlyThe legal banner (
/etc/issue.net) was already created in the previous chapter, along with the matchingBannerdirective insshd_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
EOFUMASK 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
doneBlacklisting 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
EOFauditd: 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 parametersauditd’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.logtologrotate.auditdmanages its own internal rotation viamax_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/logis atmpfs(Filesystem chapter), this directory must be recreated on every boot viatmpfiles.d, without this rule,auditdfails withCould not open dir /var/log/audit.- Watching (
-w) a file that doesn’t exist yet.auditdrefuses a-wrule 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 respectivelyThis 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.