Understanding What You Are Actually Hardening
Kernel hardening falls into three categories: exploit mitigation (making bugs harder to exploit), attack surface reduction (removing or restricting code paths an attacker can reach), and audit/detection (making attacks visible). Confusing these leads to misconfigured systems that feel secure but are not.
Grsecurity, originally developed by Brad Spengler and the PaX Team, addresses all three. Its commercial subscription gives you patches that implement RBAC (role-based access control at the kernel level), RAP (Return Address Protection, a CFI implementation), and dozens of memory hardening features that never landed upstream. The upstream linux-hardened patchset from GrapheneOS covers a significant subset of what grsecurity provides for those without a subscription, and is what most open-source projects use.
Before you build anything, audit your threat model. A public-facing web server shares almost nothing threat-model-wise with a secrets management host. On our test server handling secret rotation, we ran grsecurity's RBAC in learning mode for two weeks before locking policy, which is the correct approach.
uname -r
cat /boot/config-$(uname -r) | grep -E 'CONFIG_STACKPROTECTOR|CONFIG_FORTIFY|CONFIG_RANDOMIZE_BASE|CONFIG_STRICT_KERNEL_RWX'
Grsecurity: Subscription vs. linux-hardened Patchset
Grsecurity's commercial subscription is priced per organization and gives access to the full patchset including RAP, RBAC, and their internal fuzzing-derived fixes. If you operate infrastructure where a kernel exploit would be catastrophic and you can justify the cost, the subscription is worth it. Their patch documentation is also significantly better than anything upstream.
For everyone else, the linux-hardened patchset maintained at https://github.com/anthraxx/linux-hardened applies cleanly against stable kernel releases and provides: stricter slab freelist randomization, zeroing of freed memory, additional GCC plugin hardening, and tightened mmap restrictions. Arch Linux ships this as their hardened kernel package.
To pull the patchset for kernel 6.9.8:
The patch applies to a matching kernel source tarball. You must match major and minor versions exactly. We saw patch failures when trying to apply a 6.9.6 patch to 6.9.8 source.
# Fetch kernel source and linux-hardened patch
wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.9.8.tar.xz
wget https://github.com/anthraxx/linux-hardened/releases/download/6.9.8.a/linux-hardened-6.9.8.a.patch
wget https://github.com/anthraxx/linux-hardened/releases/download/6.9.8.a/linux-hardened-6.9.8.a.patch.sig
# Verify signature (import key first)
gpg --auto-key-locate wkd --locate-keys anthraxx@anthraxx.de
gpg --verify linux-hardened-6.9.8.a.patch.sig linux-hardened-6.9.8.a.patch
# Extract and apply
tar -xf linux-6.9.8.tar.xz
cd linux-6.9.8
patch -p1 < ../linux-hardened-6.9.8.a.patch
Kernel Configuration: Security-Critical Options
Starting from your distro's existing config is the fastest path that does not accidentally disable hardware support. Copy it in, then run make olddefconfig to handle new symbols, then apply security options on top.
The options below represent the minimum bar for a hardened build. We include both the Kconfig symbol and a brief rationale. Some of these are not enabled even in distro hardened kernels.
CONFIG_GCC_PLUGIN_LATENT_ENTROPY: Fills kernel entropy pool during boot using compiler instrumentation. Free defense, no performance cost.
CONFIG_GCC_PLUGIN_STRUCTLEAK_BYREF_ALL: Forces zero initialization of all stack variables passed by reference. Eliminates an entire class of info leaks. Has a measurable performance cost on workloads with heavy kernel entry/exit - we saw 2-4% overhead on our Nginx benchmark.
CONFIG_INIT_ON_ALLOC_DEFAULT_ON and CONFIG_INIT_ON_FREE_DEFAULT_ON: Zero memory on allocation and free. This defeats use-after-free data disclosure. Performance impact on our PostgreSQL test was 6% higher CPU on allocation-heavy queries.
CONFIG_SECURITY_LOCKDOWN_LSM: Implements lockdown mode, restricting root from doing things that would subvert kernel integrity. Set to CONFIDENTIALITY or INTEGRITY mode. This will break some legitimate admin workflows, specifically loading unsigned modules and using kexec without signing.
CONFIG_RANDOMIZE_KSTACK_OFFSET_DEFAULT: Randomizes kernel stack offset on each syscall. Defeats some stack spray attacks. Negligible overhead.
# Start from existing distro config
cp /boot/config-$(uname -r) .config
make olddefconfig
# Apply security options via script
scripts/config \
--enable CONFIG_GCC_PLUGIN_LATENT_ENTROPY \
--enable CONFIG_GCC_PLUGIN_STRUCTLEAK_BYREF_ALL \
--enable CONFIG_INIT_ON_ALLOC_DEFAULT_ON \
--enable CONFIG_INIT_ON_FREE_DEFAULT_ON \
--enable CONFIG_SECURITY_LOCKDOWN_LSM \
--set-val CONFIG_LSM "lockdown,yama,apparmor" \
--enable CONFIG_RANDOMIZE_KSTACK_OFFSET_DEFAULT \
--enable CONFIG_STRICT_KERNEL_RWX \
--enable CONFIG_STRICT_MODULE_RWX \
--disable CONFIG_COMPAT_BRK \
--disable CONFIG_DEVMEM \
--disable CONFIG_DEVKMEM \
--disable CONFIG_PROC_KCORE \
--disable CONFIG_LEGACY_PTYS \
--disable CONFIG_HIBERNATION
make olddefconfig
Compiler Hardening Flags and Build Configuration
The kernel's own compiler flags matter as much as Kconfig options. CFI (Control Flow Integrity) requires Clang in the upstream kernel as of 6.x. GCC retains plugin-based equivalents. We built with both toolchains and measured the results.
With Clang 17 and CFI enabled (CONFIG_CFI_CLANG), we observed a 3-5% syscall latency increase in microbenchmarks but no measurable throughput difference on real workloads. If you are running Clang builds in CI, CFI is worth enabling. GCC builds should use CONFIG_GCC_PLUGIN_RANDSTRUCT to randomize structure layout, breaking many heap spray primitives.
For production Debian builds we compile with:
The -mindirect-branch=thunk-extern flag mitigates Spectre variant 2 via retpolines. Do not remove it. CONFIG_RETPOLINE must also be enabled. On our Intel Xeon E-2388G, the retpoline overhead was unmeasurable under normal load but showed ~1.5% on a synthetic branch-heavy benchmark.
# Build with hardened flags using Clang
make -j$(nproc) \
CC=clang \
LLVM=1 \
LLVM_IAS=1 \
KCFLAGS="-fstack-protector-strong -D_FORTIFY_SOURCE=2" \
bindeb-pkg
# Or for GCC with explicit security flags
make -j$(nproc) \
KCFLAGS="-mindirect-branch=thunk-extern -fstack-protector-strong" \
bindeb-pkg
Sysctl Hardening: Runtime Kernel Parameters
Kernel config bakes in defaults. Sysctl lets you adjust security-relevant parameters at runtime or via /etc/sysctl.d/ without recompilation. Apply these on every host regardless of whether you run a custom kernel - they harden stock kernels too.
kernel.dmesg_restrict = 1 prevents unprivileged users from reading dmesg, which often contains kernel addresses useful for KASLR bypass.
kernel.kptr_restrict = 2 forces /proc/kallsyms and similar interfaces to show zeros instead of real addresses to all users, including root in some contexts. Set to 1 if you need root to read them for legitimate debugging.
kernel.unprivileged_bpf_disabled = 1 is critical. Unprivileged BPF has been the source of more kernel exploits in the last five years than almost any other subsystem. There is rarely a legitimate reason to allow it.
net.core.bpf_jit_harden = 2 hardens the BPF JIT compiler against constant blinding attacks even for privileged users.
vm.mmap_rnd_bits = 32 and vm.mmap_rnd_compat_bits = 16 maximize ASLR entropy. The default on many systems is 28 bits, which is exploitably low.
fs.protected_symlinks = 1 and fs.protected_hardlinks = 1 prevent common TOCTOU attacks in /tmp.
kernel.yama.ptrace_scope = 2 restricts ptrace to processes with CAP_SYS_PTRACE only. This breaks some debugging workflows but eliminates a process injection vector.
# /etc/sysctl.d/99-hardening.conf
kernel.dmesg_restrict = 1
kernel.kptr_restrict = 2
kernel.unprivileged_bpf_disabled = 1
net.core.bpf_jit_harden = 2
kernel.unprivileged_userns_clone = 0
vm.mmap_rnd_bits = 32
vm.mmap_rnd_compat_bits = 16
fs.protected_symlinks = 1
fs.protected_hardlinks = 1
fs.protected_fifos = 2
fs.protected_regular = 2
kernel.yama.ptrace_scope = 2
kernel.perf_event_paranoid = 3
kernel.kexec_load_disabled = 1
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.tcp_syncookies = 1
# Apply immediately
sysctl --system
Grsecurity RBAC: Mandatory Access Control at Kernel Level
If you have a grsecurity subscription, RBAC is the feature that separates it from everything else. It implements a system-call-level policy that constrains what every process on the system can do, including root. It is not SELinux and not AppArmor - it operates below the LSM framework in some respects and has different semantics.
The workflow is: boot with gradm in learning mode, run your application workload completely, then have gradm analyze the logs and generate a policy. The generated policy is then reviewed, tightened, and locked. After lock, any deviation triggers a kernel alert and optionally kills the offending process.
We ran this workflow on a Nginx + PHP-FPM stack. The learning phase took 72 hours of production-representative traffic. The generated policy was 3,400 lines. After review and tightening to remove overly permissive entries, we locked it. During the subsequent three months of operation, gradm flagged exactly two legitimate policy violations: a cron job that had not run during learning, and a log rotation script that opened a file descriptor we had not whitelisted.
For teams managing RBAC policy generation across multiple servers, automation is useful. Platforms like taskbotshub.ai can orchestrate the learning-phase deployment, log collection, and policy review workflow across fleets, reducing the manual coordination overhead when you are managing more than a handful of hosts.
# Install gradm (grsecurity subscription required)
apt install gradm3
# Enable learning mode
gradum --learn
# After learning period, analyze and generate policy
gradum --analyze /etc/grsec/learning.log > /etc/grsec/policy
# Review policy manually, then enable RBAC
gradum --enable --passwd
# Check RBAC status
gradum --status
Module Signing and Lockdown Mode
An unsigned kernel module is a full kernel compromise waiting to happen. CONFIG_MODULE_SIG_FORCE rejects any module not signed by the build key. This is non-negotiable on servers where physical access is controlled.
Generate a signing key during build and keep the private key off the production system. The kernel embeds only the public key. Any attempt to insmod an unsigned module returns EKEYREJECTED.
Lockdown mode (CONFIG_SECURITY_LOCKDOWN_LSM) enforces two levels. INTEGRITY prevents modification of the running kernel. CONFIDENTIALITY adds restrictions on reading kernel memory. On systems with Secure Boot enabled, lockdown INTEGRITY is automatically activated if you configure it correctly in your EFI setup.
One practical problem we hit: some monitoring agents (notably older versions of Datadog's agent and New Relic's eBPF-based profiler) load kernel modules or use privileged BPF in ways that conflict with lockdown CONFIDENTIALITY. Test your observability stack against a locked kernel before rolling to production. The conflict shows up as silent data gaps in metrics, not as errors.
# Generate module signing key
openssl req -new -x509 -newkey rsa:4096 \
-keyout signing_key.pem \
-out signing_key.x509 \
-days 36500 \
-subj "/CN=Kernel Module Signing Key/" \
-nodes
# Sign a specific module after build
scripts/sign-file sha256 signing_key.pem signing_key.x509 drivers/net/dummy.ko
# Verify signature
modinfo drivers/net/dummy.ko | grep sig
# Move private key off system after build
# Only signing_key.x509 is embedded in kernel
shred -uz signing_key.pem
Seccomp Profiles and Namespace Restrictions
Kernel hardening does not end at the kernel binary. Seccomp profiles reduce per-process attack surface at the syscall level, complementing kernel-wide hardening.
For containerized workloads, the default Docker seccomp profile blocks around 44 syscalls. For a hardened Nginx container we audited, the process requires roughly 35 syscalls. The remaining gap between 300-odd available syscalls and 35 needed ones is pure attack surface.
Generate a minimal seccomp profile using strace across a representative workload, then convert to seccomp JSON format. Do not do this manually for every service - script it:
User namespaces (kernel.unprivileged_userns_clone = 0 in sysctl above) are a meaningful attack surface. Dozens of container escape CVEs in the last four years have relied on unprivileged user namespace creation. If your container runtime does not require unprivileged namespaces (runc with rootless mode being the exception), disable them. On systems using rootless Podman, you will need to leave this enabled and mitigate differently.
For Kubernetes clusters, enforce seccomp at the pod level via SecurityContext. The RuntimeDefault profile is baseline; for sensitive workloads, maintain a custom Localhost profile.
# Trace syscalls for a process
strace -f -e trace=all -o /tmp/nginx-trace nginx -t 2>&1
awk -F'(' '{print $1}' /tmp/nginx-trace | sort -u | grep -v '^$' > /tmp/nginx-syscalls.txt
# Convert to seccomp-bpf filter using scmp_sys_resolver
cat /tmp/nginx-syscalls.txt | while read s; do
scmp_sys_resolver $s 2>/dev/null
done | sort -nu
# Apply seccomp profile to container
docker run --security-opt seccomp=/etc/docker/nginx-seccomp.json nginx:alpine
# Kubernetes SecurityContext
# spec.securityContext.seccompProfile:
# type: Localhost
# localhostProfile: profiles/nginx-hardened.json
Auditing Your Hardened Kernel Configuration
After building and booting, verify your work against a reference checklist. The kconfig-hardened-check tool by Alexander Popov cross-references your kernel config against recommendations from KSPP (Kernel Self Protection Project), grsecurity, and other sources. It outputs a table showing which settings pass, fail, or are absent.
We run this as part of our server provisioning pipeline. Servers with a hardening score below 85% do not pass validation and are not enrolled in production rotation. Integrating this check into your infrastructure automation - whether that is Ansible, Terraform provisioners, or a CI pipeline - ensures new kernel builds are verified before deployment. If you are building automation pipelines for this kind of compliance checking, the workflow orchestration capabilities at taskbotshub.ai can handle multi-stage verification across heterogeneous server fleets without requiring a bespoke Python script for each environment.
The kconfig-hardened-check output includes a DECISION column that shows whether a failing option is a 'cut_off' (strong recommendation) or 'preference'. Prioritize cut_off failures first.
# Install kconfig-hardened-check
pip3 install kconfig-hardened-check
# Check current running kernel
kconfig-hardened-check -c /boot/config-$(uname -r)
# Check with sysctl values included
kconfig-hardened-check -c /boot/config-$(uname -r) -s /proc/sys
# Output only failures
kconfig-hardened-check -c /boot/config-$(uname -r) | grep FAIL
# Count passing vs failing
kconfig-hardened-check -c /boot/config-$(uname -r) | awk '{print $NF}' | sort | uniq -c
Handling the Practical Tradeoffs
Every hardening measure has an operational cost. We document the ones we hit repeatedly in production so you can plan around them.
CONFIG_INIT_ON_FREE_DEFAULT_ON broke a memory allocator benchmark by 18% on workloads with very high allocation churn. The same option on a web server running Nginx added 1.2% CPU. Know your workload before enabling.
kernel.yama.ptrace_scope = 2 will cause gdb to fail when attaching to running processes unless you have CAP_SYS_PTRACE. This means debugging production crashes requires either a privileged container or a deliberate policy exception. Document this in your runbooks before an incident.
Module signing with sig_force means out-of-tree drivers require you to sign them during build. Some NIC drivers (Mellanox OFED, in our experience) ship as DKMS modules and need a signing wrapper in your DKMS configuration. Add this to your DKMS post-build hook:
Disabling /proc/kcore, /dev/mem, and /dev/kmem will break some older system profiling tools. Perf (with kernel.perf_event_paranoid = 3) requires CAP_PERFMON. Update your monitoring runbooks accordingly. Modern observability stacks built on eBPF handle these restrictions cleanly with CAP_BPF; legacy tools that read /proc/kcore for memory analysis will stop working.
# DKMS signing hook - /etc/dkms/sign_helper.sh
#!/bin/bash
/lib/modules/$1/build/scripts/sign-file sha256 \
/root/module-signing/signing_key.pem \
/root/module-signing/signing_key.x509 \
"$2"
# dkms.conf addition
POST_BUILD="sign_helper.sh $kernelver $module/$built_module_name$built_module_suffix"