CoursesLinux hardeningKernel & memory hardening (sysctl)

Kernel & memory hardening (sysctl)

ptrace, dmesg, kptr, and friends.

Advanced12 min · lesson 9 of 16

A hardened host is not a host nobody can break into. That bar is impossible. A useful bar sits lower and meaner: when someone does get a toehold in one process, they find almost nothing worth having and no easy path to the rest of the machine. An office building after hours works the same way. A burglar who jimmies a ground-floor window still runs into locked stairwell doors, a floor plan that has been shredded, and a visitor log they cannot open. The Linux kernel (the core of the operating system that manages memory, hardware, and every running process) ships with a row of these interior locks either switched off or left half-closed. This lesson switches them on, and shows you how to prove they caught.

What these knobs actually control

Every one of these settings is a switch on the kernel's wall, and sysctl (system control, the interface for reading and writing kernel settings while the machine is running) is how you reach it. There are thousands of them. Each lives as a plain file under /proc/sys, so writing kernel.kptr_restrict is the exact same act as writing the file /proc/sys/kernel/kptr_restrict. You have two ways to flip a switch. sysctl -w flips it right now and forgets by the next reboot. A file dropped in /etc/sysctl.d/ flips it and flips it again on every boot, which is what you want for anything that matters. Start by reading where you stand.

~/secopslog — bash
$ sysctl kernel.yama.ptrace_scope kernel.kptr_restrict kernel.dmesg_restrict
kernel.yama.ptrace_scope = 1 kernel.kptr_restrict = 1 kernel.dmesg_restrict = 1

On a current Ubuntu those three already read 1, because Ubuntu turns the first two on for you in its own /etc/sysctl.d files and builds the kernel to restrict the log by default. Debian is looser out of the box: it leaves kernel.yama.ptrace_scope at 0 and does not tighten kptr_restrict, so on Debian you are not confirming these so much as enabling them. Either way the plan is the same. Confirm what is already set, ratchet kptr_restrict up one more notch, add the two or three knobs the distribution leaves open, and write the whole policy into a single file you own so a package update cannot quietly walk it back.

Stop one process from reading another's memory

Every running program keeps live secrets in memory: decrypted config, session tokens, the private key it is using this second. ptrace (process trace, the system call a debugger uses to reach inside another running process and control it) is the master key to all of that. A debugger uses the key for good reasons. An attacker who has already landed on your box uses the same key to step sideways into a neighbouring process and read its secrets straight out of memory.

Most services run more than one process under a single account. A web app might run eight worker processes, every one of them as the user www-data. Break into one worker, and if ptrace is wide open you can attach to the other seven and lift whatever they are holding. Yama (a Linux Security Module, an optional kernel component that bolts on extra access checks) decides how far ptrace is allowed to reach, through the setting kernel.yama.ptrace_scope. Watch it turn an attach down.

~/secopslog — bash
$ # Session A: start a long-running process (stands in for a service worker) sleep 900 & echo "victim pid: $!"
[1] 4242 victim pid: 4242
$ # Session B: a separate shell as the SAME user tries to attach strace -p 4242
strace: attach: ptrace(PTRACE_SEIZE, 4242): Operation not permitted

With the scope set to 1, a process may trace only its own descendants: the children it started, their children, and on down. The shell in Session B never started that sleep, so the kernel says no. Flip the value to 0 and the same strace would have sailed through, printing every system call the victim makes and laying its memory open to a debugger. There are four settings, from loosest to tightest. 0 is the classic Unix behaviour, where any process can trace another running under the same user. 1 narrows tracing to descendants. 2 allows it only for a process carrying the CAP_SYS_PTRACE capability (one of the fine-grained slices that root's total power is chopped into). 3 shuts ptrace off completely until the next reboot.

Hide the map and lock the logbook

A memory-corruption exploit is a burglar working blind. To turn a crash into a root shell, it needs real addresses: where a specific kernel function or structure sits in memory this boot. Two files hand that map over. /proc/kallsyms lists kernel symbols next to their addresses. The kernel log carries them too, sprinkled through crash traces and driver chatter.

kernel.kptr_restrict controls the map. At 0, kernel pointers print in full to anybody who looks. At 1, they come out as zeros for any user without the CAP_SYSLOG capability. At 2, they are zeros for everyone, root included. kernel.dmesg_restrict controls the logbook: set to 1, only a process holding CAP_SYSLOG may read the kernel ring buffer (a fixed-size in-memory log that keeps overwriting its own oldest lines). Look at both from an ordinary, unprivileged shell.

~/secopslog — bash
$ head -n 3 /proc/kallsyms dmesg | tail -n 1
0000000000000000 A fixed_percpu_data 0000000000000000 A __per_cpu_start 0000000000000000 D __start___ex_table dmesg: read kernel buffer failed: Operation not permitted

Zeros where the addresses should be, and a flat refusal on the log. The exploit now has to find its targets the hard way, and for a lot of off-the-shelf attack code the hard way means not at all. Pushing kptr_restrict up to 2 is the aggressive setting and it is safe on servers. About the only thing that trips over it is the odd monitoring tool that genuinely needs live kernel addresses.

Shrink the surface: BPF and perf

BPF and perf are two power tools the kernel lends out to ordinary programs. Both are useful. Both are sharp enough to cut you. BPF (Berkeley Packet Filter, a small programming sandbox that runs your code inside the kernel itself) drives modern tracing and packet filtering. perf (the kernel's performance-monitoring subsystem) reads deep hardware and kernel counters. Each has a long history of bugs that a local user has bent into a root shell. If the unprivileged programs on this host have no need for them, take them away.

kernel.unprivileged_bpf_disabled set to 1 blocks the bpf() system call for anyone without privilege, and closes a whole family of local-escalation bugs in one move. kernel.perf_event_paranoid set to 3 refuses unprivileged programs any access to perf. One honest footnote on that 3: it is a Debian and Ubuntu extension. On a stock upstream kernel the strictest value the code defines is 2, and writing 3 there behaves like 2 rather than adding anything more.

The config file

/etc/sysctl.d/90-kernel-hardening.conf
# Process isolation
kernel.yama.ptrace_scope = 1 # trace only your own descendants
# Information hiding
kernel.kptr_restrict = 2 # zero out kernel pointers for everyone
kernel.dmesg_restrict = 1 # only CAP_SYSLOG reads the kernel ring buffer
# Attack-surface reduction
kernel.perf_event_paranoid = 3 # no unprivileged perf (Debian/Ubuntu value)
kernel.unprivileged_bpf_disabled = 1 # no unprivileged BPF program loading

The filename is doing real work here. When sysctl --system runs, it gathers every file from /etc/sysctl.d/ and the system directories, sorts them by name, and applies them in that order, and for any single key the last file that sets it wins. The 90- prefix lands your file well after the distribution's own 50-default.conf, so your stricter values overwrite its gentler baseline. A file numbered higher than yours would beat you in turn, which is the reason to keep all your hardening keys together in this one file: a reviewer, or you in six months, can read the whole policy at a glance instead of hunting for a stray override buried in the 99s.

Apply and verify

~/secopslog — bash
$ sudo sysctl --system
* Applying /usr/lib/sysctl.d/50-default.conf ... * Applying /etc/sysctl.d/90-kernel-hardening.conf ... kernel.yama.ptrace_scope = 1 kernel.kptr_restrict = 2 kernel.dmesg_restrict = 1 kernel.perf_event_paranoid = 3 kernel.unprivileged_bpf_disabled = 1 * Applying /etc/sysctl.d/99-sysctl.conf ... * Applying /etc/sysctl.conf ...

sysctl --system rereads every file and echoes each key as it sets it, so that stream of key = value lines is your receipt that the file parsed and took. Notice the order: the 90- file is applied after 50-default.conf but before the 99- files, exactly as the name-sort predicts. Read any value straight back out of /proc/sys whenever you want to check the live state. Hold on to the difference between the two ways in: this file is the part that survives a reboot, while sysctl -w only ever touches the kernel running right now.

Some of these are one-way doors
Two of these settings latch shut until you reboot. Once you write kernel.unprivileged_bpf_disabled = 1, the kernel will not let you set it back to 0 while the machine is up. kernel.yama.ptrace_scope = 3 is the same kind of trap. That is the whole point, since an attacker cannot undo them either, but it does mean you cannot re-enable the tool on a live box without a reboot. If you need room to change your mind, kernel.unprivileged_bpf_disabled = 2 switches unprivileged BPF off right now yet leaves the knob free to move later.

Randomize the layout, close the link races

ASLR (Address Space Layout Randomization) reshuffles where a program's stack, libraries, and heap land in memory on every single launch, so an exploit that leans on a fixed address is guessing in the dark. kernel.randomize_va_space = 2 is the full-strength setting, and it is the modern default. Confirm it anyway. A stray tuning guide or an ancient container image will sometimes dial it down to shave a few cycles off something.

The link protections shut down a classic bait-and-switch. A privileged program sets out to create a file in a shared, world-writable directory like /tmp. An attacker slips a symbolic link into that directory pointing at /etc/passwd, gets the timing right, and the privileged program follows the link and writes straight into a file it never meant to touch. fs.protected_symlinks and fs.protected_hardlinks tell the kernel to refuse attacker-owned links in sticky directories. fs.protected_fifos and fs.protected_regular stretch the same rule over named pipes and ordinary files. fs.suid_dumpable = 0 stops a SUID program (Set User ID, a program that runs with its owner's privileges instead of yours) from writing a core dump (the snapshot of memory saved when a program crashes), because that snapshot would spill exactly the secrets the program was holding.

/etc/sysctl.d/90-kernel-hardening.conf
# ...appended to the same file
# Address-space randomization
kernel.randomize_va_space = 2 # full ASLR (stack, mmap, heap/brk)
# Filesystem race protections
fs.protected_symlinks = 1 # don't follow planted symlinks in sticky dirs
fs.protected_hardlinks = 1 # don't hardlink to files you can't read
fs.protected_fifos = 2 # don't open others' FIFOs in sticky dirs
fs.protected_regular = 2 # don't write others' regular files in sticky dirs
fs.suid_dumpable = 0 # never core-dump a SUID program
Test tracing changes against your real tools
These are safe on most servers, with two spots worth a real check. ptrace_scope can break a debugger, an APM (Application Performance Monitoring) agent, or a crash reporter that legitimately attaches to running processes. Turning off unprivileged BPF can blunt some observability agents. Apply the file, then confirm your actual services, profilers, and monitors still work, and write down any exception you had to carve out. A tidier exception than loosening the whole global policy is prctl(PR_SET_PTRACER), a call that lets one named debugger attach to one specific process. Hardening that silently breaks the on-call team's tools gets torn out root and branch; hardening that was applied and then verified is the kind that stays.
Where each knob stops the climb from foothold to root
1Foothold as a service account
attacker runs code as www-data
2Read a sibling process's memory
blocked by ptrace_scope = 1
3Look up kernel addresses
blocked by kptr_restrict = 2
4Mine the kernel log for leaks
blocked by dmesg_restrict = 1
5Load a BPF or perf exploit
blocked by unprivileged_bpf_disabled = 1 and perf_event_paranoid = 3
6Climb stalls
no cheap path from foothold to root
Quick check
01You change kernel.yama.ptrace_scope from 0 to 1, and a developer complains their debugger 'stopped working.' What actually changed?
Incorrect — That describes scope = 3, which disables ptrace entirely; scope = 1 does not go that far.
Correct — scope = 1 permits tracing descendants only, so launch-under works while attach-to-a-stranger is denied.
Incorrect — That is closer to scope = 2, which requires CAP_SYS_PTRACE; scope = 1 still lets an unprivileged process trace its own children.
Incorrect — ptrace_scope governs process-to-process tracing, not kernel memory access, so this option is unrelated.
02kernel.kptr_restrict can be 0, 1, or 2. What does raising it from 1 to 2 change?
Incorrect — that is the behavior of 1 (hidden from callers without CAP_SYSLOG); level 2 goes further.
Incorrect — kptr_restrict only masks pointer values in output; it has nothing to do with loading modules.
Correct — level 1 zeros pointers only for callers without CAP_SYSLOG, while level 2 zeros them for all, which is safe on servers apart from the rare tool needing live kernel addresses.
Incorrect — that conflates kptr_restrict with dmesg_restrict; kptr_restrict governs pointer output, including in /proc/kallsyms.
03You want to switch off unprivileged BPF now, but you may need to turn it back on later on this same running box without rebooting. Which setting fits, and why not the usual =1?
Incorrect — there is no value 3 for this knob, and a higher number is not inherently reversible.
Correct — =1 latches shut for the rest of the boot, while =2 gives the same protection now yet keeps the option to re-enable on a live box.
Incorrect — once set to 1 the kernel refuses to go back to 0 while the machine is up, which is exactly the trap to avoid here.
Incorrect — perf_event_paranoid governs perf, not BPF; they are separate knobs.

Capture a before and an after. Dump every value with sysctl -a into a file now, apply your config, dump it again, and diff the two. That one-page diff is what you staple to a change ticket, hand to a CIS benchmark (Center for Internet Security, which publishes hardening checklists) audit, and pull back up the next time a service starts misbehaving and someone asks what actually changed on this host.

Try this

Work through “Randomize the layout, close the link races” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: some of these are one-way doors. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related