sysctl Hardening: The Kernel Knobs That Matter

The handful of sysctl flags actually worth setting, and why the rest are noise

Contents

Search for “sysctl hardening” and you’ll find blog posts copying the same eighty-line config from each other, half of it cargo-culted, some of it actively wrong for a modern kernel, and none of it explaining why any individual line is there. I’ve been guilty of running exactly that kind of file myself, on a box where I couldn’t have told you what half the settings did if you’d asked. This is the opposite of that — a shorter list, each entry with the actual reasoning, so you can decide which apply to your setup instead of pasting a wall of .conf and hoping.

sysctl reads and writes kernel parameters at runtime, covering everything from TCP behaviour to how much information gets leaked through /proc. Most of the several hundred available knobs are performance tuning, utterly irrelevant to security, or defaults that are already correct on any kernel from the last decade. The ones that actually matter for hardening cluster into three groups: network stack behaviour, kernel information disclosure, and memory/process protections.

Why kernel-level settings instead of just a firewall

Advertisement

A firewall decides what traffic reaches a host in the first place. Sysctl hardening operates a layer beneath that — it changes how the kernel behaves once traffic does arrive, or how much information the kernel exposes to processes running locally, regardless of network access at all. The two aren’t substitutes; a well-configured firewall (see the nftables post if you haven’t set one up) stops the wrong things from getting in, and sysctl hardening reduces what an attacker can do or learn once something is already in — a compromised container, a malicious process, spoofed packets that got past the firewall’s rules because they matched an allowed port.

That second category matters more than it sounds. Local information disclosure through /proc — kernel pointer addresses, dmesg contents, other users’ process details — is a classic stepping stone in privilege escalation chains. None of it is stopped by a firewall, because none of it touches the network.

What I deliberately left off this list

The eighty-line configs floating around the internet earn their length by including a lot of settings that either do nothing meaningful on a current kernel or actively cost you performance for a security benefit that stopped mattering years ago. net.ipv4.tcp_timestamps = 0 shows up constantly, on the theory that timestamps leak uptime information useful for fingerprinting — true in the abstract, irrelevant in practice, and disabling it breaks PAWS (protection against wrapped sequence numbers) and hurts throughput estimation on anything with real latency. net.ipv4.ip_forward = 0 gets cargo-culted onto boxes that aren’t routers, which is harmless but pointless — it’s already the default on a non-router, so setting it explicitly signals nothing except that whoever wrote the config didn’t check first. Old advice around disabling TCP SACK to mitigate a specific 2019 kernel vulnerability (CVE-2019-11477 and friends) still circulates in some hardening guides years after the actual kernel fix shipped upstream — patch the kernel, don’t cripple TCP performance forever for a bug that’s been closed since. The pattern across all three: a setting that made sense once, in a specific context, that got copied forward without anyone checking whether the original reasoning still applied. I’d rather run a dozen settings I can each justify in one sentence than eighty I’d have to research from scratch if someone asked why they’re there.

The network stack settings

Advertisement

Source route validation (rp_filter) rejects packets that arrive on an interface they couldn’t plausibly have come from, based on the routing table — the classic use is stopping IP spoofing, where an attacker crafts packets claiming to be from an address that shouldn’t be reachable via the interface they arrived on.

1
2
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1

Strict mode (1) is safe for the overwhelming majority of single-homed or simply-routed homelab boxes. It becomes a problem specifically with asymmetric routing — multiple uplinks or unusual multi-homed setups where legitimate return traffic doesn’t retrace the same path — so if you’re running something like a multi-WAN router config, use loose mode (2) there instead of strict.

SYN cookies protect against SYN flood exhaustion of the connection queue by encoding connection state into the SYN-ACK sequence number instead of holding it in a table that can be filled up:

1
net.ipv4.tcp_syncookies = 1

This ships enabled by default on basically every modern distro, which is exactly why it’s worth stating explicitly in your own config — a default you’re relying on but haven’t pinned is one bad base image away from silently not being there.

ICMP redirect handling — a host shouldn’t be accepting or sending ICMP redirects unless it’s actually doing dynamic routing, which almost nothing in a homelab is:

1
2
3
4
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv6.conf.all.accept_redirects = 0

Accepting redirects lets something on your local network alter your routing table by suggestion, which is a plausible man-in-the-middle vector on a network you don’t fully trust (guest Wi-Fi, a shared VLAN). Disabling it costs nothing on a normal host.

Source-routed packets, similarly, let the sender dictate the path a packet takes through the network rather than letting routing decide — a feature with essentially no legitimate homelab use and a long history as an attack primitive:

1
2
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0

Martian packet logging — logging packets with impossible source addresses (private ranges arriving on a public interface, for instance) is cheap and occasionally catches a misconfiguration or a spoofing attempt you’d otherwise never notice:

1
net.ipv4.conf.all.log_martians = 1

Kernel information disclosure

Restricting kernel pointer exposure through /proc matters because raw kernel addresses leaking to userspace make certain classes of exploit (particularly ones defeating KASLR, kernel address space layout randomisation) meaningfully easier:

1
kernel.kptr_restrict = 2

1 hides pointers from unprivileged users; 2 hides them from everyone, including root, unless the CAP_SYSLOG capability is explicitly held. 2 is the right default for a hardened box — nothing you’re routinely doing as root needs to read raw kernel pointers out of /proc.

dmesg restriction stops unprivileged users reading the kernel ring buffer, which can contain everything from driver debug output to, historically, occasional leaked addresses or hardware details useful for fingerprinting:

1
kernel.dmesg_restrict = 1

ptrace scope limits which processes can attach a debugger (ptrace) to which other processes — unrestricted, any process running as your user can attach to and read the memory of any other process running as that same user, which is a real problem the moment one of those processes is a browser holding session tokens or a password manager unlocked:

1
kernel.yama.ptrace_scope = 1

1 restricts ptrace to a process’s own children (the normal case for actual debugging work) unless explicitly permitted otherwise. 2 is stricter still, admin-only; 1 is the sane general-purpose default that doesn’t break normal debugging workflows.

Unprivileged BPF — the extended Berkeley Packet Filter subsystem is powerful and has been a recurring source of privilege escalation CVEs specifically through its unprivileged-user code path:

1
kernel.unprivileged_bpf_disabled = 1

If nothing you run needs unprivileged users loading BPF programs — and on a typical homelab box, nothing does — there’s no reason to leave that surface open.

Memory and process protections

ASLR (address space layout randomisation) should already be at its strongest setting on any current distro, but confirm rather than assume:

1
kernel.randomize_va_space = 2

2 is full randomisation of stack, heap, and shared library placement — the setting that makes memory-corruption exploits meaningfully harder to write reliably, because the attacker can’t assume a fixed address for anything.

Restricted symlink and hardlink following in world-writable directories closes off a category of classic TOCTOU (time-of-check-to-time-of-use) attacks in shared temp directories, where a malicious symlink planted in /tmp gets followed by a privileged process:

1
2
3
4
fs.protected_symlinks = 1
fs.protected_hardlinks = 1
fs.protected_fifos = 2
fs.protected_regular = 2

These are default-on for most modern distros, and again, worth pinning explicitly rather than trusting an assumption about the base image.

Core dump restriction stops setuid/privileged processes from writing a core dump that might contain sensitive memory contents to a location a lower-privileged user could read:

1
fs.suid_dumpable = 0

Checking what a distro already sets before you override anything

Advertisement

Before pasting the file below onto a box, it’s worth running sysctl --system once just to see what’s already being applied and from where — most current distros ship at least a partial hardening baseline of their own in /usr/lib/sysctl.d/, and blindly layering a fresh 99-numbered file on top without checking can mean you’re either duplicating something that’s already correct or, worse, silently loosening something the distro maintainers deliberately tightened. Debian and Ubuntu, for instance, already ship reasonable defaults for several of the fs.protected_* settings above; Fedora’s defaults differ again. None of that makes the explicit file pointless — pinning a value explicitly protects you against a future base-image change quietly reverting it — but it does mean the honest way to build this list is to start from sysctl --system’s current output, not from a blank file, so you know which lines are reinforcing an existing default versus actually changing behaviour on your specific distro.

Applying it

Drop all of it into a dedicated file rather than editing /etc/sysctl.conf directly — a separate file in sysctl.d is easier to diff, version, and roll back independently of anything the distro ships:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# /etc/sysctl.d/99-hardening.conf
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv6.conf.all.accept_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
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.yama.ptrace_scope = 1
kernel.unprivileged_bpf_disabled = 1
kernel.randomize_va_space = 2
fs.protected_symlinks = 1
fs.protected_hardlinks = 1
fs.protected_fifos = 2
fs.protected_regular = 2
fs.suid_dumpable = 0

Apply without a reboot:

1
$ sudo sysctl -p /etc/sysctl.d/99-hardening.conf

And verify any individual value at any time:

1
2
$ sysctl kernel.kptr_restrict
kernel.kptr_restrict = 2

Troubleshooting

sysctl -p reports “No such file or directory” for one of the keys. That parameter isn’t available on this kernel — usually because it’s gated behind a kernel config option (Yama, for instance, is a Linux Security Module that needs to be compiled in and enabled) that’s missing on a minimal or custom kernel build. Check zcat /proc/config.gz | grep CONFIG_SECURITY_YAMA (if /proc/config.gz is available) or just accept the setting doesn’t apply on that particular kernel and remove the line rather than leaving a permanent error in your apply step.

Debugging tool (strace, gdb) stops working on unrelated processes after setting ptrace_scope. That’s the setting doing exactly what it’s meant to — 1 only permits a process to trace its own descendants. If you genuinely need to attach a debugger to an arbitrary running process regularly, beyond its own children, you either accept ptrace_scope = 0 on that specific box with the trade-off understood, or use CAP_SYS_PTRACE scoped to the specific debugging tool rather than opening it globally.

Container workloads misbehave after unprivileged_bpf_disabled. Some container runtimes and their networking plugins (certain CNI implementations, some eBPF-based service meshes) genuinely need BPF access. Check what your specific container stack requires before blanket-disabling — this is one of the settings most likely to need a documented exception rather than blind application everywhere.

Setting reverts after a reboot despite being in sysctl.d. Check for a numerically later file overriding it — sysctl.d files apply in lexical order, and a distro-shipped file with a higher-sorting name than 99-hardening.conf (unlikely, since 99 sorts late, but check /usr/lib/sysctl.d/ and /run/sysctl.d/ as well as /etc/sysctl.d/) can silently win. sysctl --system shows the full merged order and which file set each final value, which is the fastest way to find the actual conflict.

rp_filter breaks a legitimate multi-interface or VPN setup. This is the one most likely to bite in a real homelab — anything running Tailscale, WireGuard, or multiple NICs with asymmetric return paths can see strict rp_filter drop legitimate traffic. Switch the affected interface (not necessarily the global default) to loose mode (2) rather than disabling it everywhere.

Is it worth it

Yes, and unlike most hardening work, it’s essentially free — a text file and one command, no service restarts, no behavioural change for anything running normally on a standard setup. None of these settings individually stops a determined attacker who’s already got a foothold and a kernel exploit in hand, but that’s not really the point: they close off the cheap, generic techniques (spoofed packets, pointer leaks, TOCTOU symlink races, ptrace snooping between processes) that make everything downstream of an initial compromise easier. Pair this with the network-level protections you’d already have from a proper firewall setup and run it through Lynis afterwards — it checks several of these same sysctl values and will tell you plainly if any of them silently drifted back to a default.

The real payoff sits above any single line in that file: once it’s in sysctl.d and checked into whatever configuration management you use for your fleet, hardening stops being a thing you remember to redo after every reimage and becomes a thing that just happens, the same way a docker-compose.yml makes a service’s configuration something you can read rather than something you half-remember clicking through a year ago. That’s the actual argument for writing any of this down at all — a documented, versioned baseline is the only kind of hardening that survives contact with the next reinstall, regardless of how well any individual knob holds up against a determined attacker.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.