Hardening is applied in the toolchain, so every package gets it without opting in.
Set in build/config/hardening.env and
loaded from stage 04, which builds what ships; the cross toolchain and
temporary tools (stages 01 and 02) are built without them.
| Flag | Defends against | Cost |
|---|---|---|
-D_FORTIFY_SOURCE=3 |
overflows in libc calls, with dynamic object sizes | negligible; needs optimization |
-fstack-protector-strong |
stack smashing | ~1% CPU |
-fstack-clash-protection |
stack clash | negligible |
-fcf-protection=full |
ROP/JOP, through Intel CET (shadow stack and IBT) | negligible on supporting CPUs |
| PIE (compiler default) | fixed-address exploits; enables full ASLR | ~2% on x86-64 |
-Wl,-z,relro,-z,now |
GOT/PLT overwrite | slower startup |
-Wl,-z,noexecstack |
executable stack payloads | none |
-ftrivial-auto-var-init=zero |
uninitialized-memory disclosure | ~0.5% |
-fno-delete-null-pointer-checks |
NULL checks removed as dead code | none |
-D_GLIBCXX_ASSERTIONS (C++) |
an index past a libstdc++ container's end, front or back of an empty one, or a null smart pointer dereferenced: the program aborts | small |
It also passes -fno-strict-aliasing, -Wl,-z,separate-code and
-Wl,--as-needed.
GCC is configured with --enable-default-pie and --enable-default-ssp, so a
package that ignores CFLAGS still gets both; stage 01 checks the PIE default
took. Do not add -pie to hardening.env: it links Scrt1.o, which
references main(), so every shared library fails to link.
A package that cannot build with a flag goes in
build/config/hardening-exceptions.txt as <package> <flag> # reason;
build/lib/common.sh fails the build on an entry without a reason. There are
two. glibc's -D_FORTIFY_SOURCE=3: glibc implements FORTIFY, and its own
--enable-fortify-source=3 applies it to everything but the loader, startup
and checking code (its elf, dlfcn, csu and debug directories), whose
programs pldd, sprof, pcprofiledump and sotruss-lib.so the audit
accepts by name. And Firefox's -fno-delete-null-pointer-checks, in the
browser image: under it GCC 14 cannot evaluate SpiderMonkey's compile-time
checks, which compare an object's address with null. With
CONFIG_DEFAULT_MMAP_MIN_ADDR=65536 no process can map the first 64 KiB, which
covers a null pointer dereferenced at a small offset, and Mozilla's own builds
delete these checks as well.
make audit-artifacts (tools/check-artifact-hardening.sh) reads the ELF
headers, dynamic symbols and code of every object. Stage 06 strips every
x86-64 program and library, and the debug sections of every static archive,
as it stages the root (the sysroot keeps its symbols for the build), then
runs the audit strict on that tree, so a finding
fails the build, and make acceptance runs it strict again on the root image
of the release it tests, mounted read-only, for the record. A writable and
executable segment, an executable stack or no GNU_STACK header at all (the
loader then makes the stack executable), text relocations or an RPATH into the
build tree always fail it. In strict mode so do a missing CET note, BIND_NOW
or RELRO, a non-PIE executable, a symbol table or debug section, any other
RPATH, an object whose code never reads the stack canary, and an object that
imports printf, fprintf, vfprintf, dprintf, syslog, longjmp or
another call that _FORTIFY_SOURCE=2 or 3 always turns into its _chk
variant, which shows a unit built without FORTIFY. Each of these passes only
where artifact-accepted.txt lists
the object with its reason. -fstack-protector-strong guards only functions
with a local array or an address-taken local, so an object with none of
those has no canary whatever its flags; such objects are listed by name, and a
package that loses the flag shows up as a new name. An entry that matches
nothing fails too, so the list holds only what the image has. A new finding is
fixed in its package's recipe, not by weakening the check. A call whose size
is unknown keeps its plain name under FORTIFY, so those prove nothing either
way: the record counts and lists the objects that call no _chk function.
Packages stage 04 builds before its glibc (the first built with
--enable-cet) link stage 01's crt files, which carry no CET note, so each is
built again right after glibc. Stage 04 also rebuilds GCC with the flags and
--enable-cet, and the step fails unless libgcc_s and libstdc++ (which
glibc's unwinder and every C++ program load) carry IBT and SHSTK and gcc
itself is PIE with BIND_NOW. No program stage 02 built for the chroot is
left in the image.
At run time, where the CPU and the kernel have shadow stacks, glibc turns a
process's on when the program and every library it starts with carry the
note, and then refuses to load a library without it ("rebuild shared object
with SHSTK support enabled"). Zone 0 keeps that rule: its objects without the
note (artifact-accepted.txt) are static programs or libraries others start
with (libgmp, under gcc, expr, factor and gawk), which leave a process
without a shadow stack, and none of its programs loads one later. A zone's
command runs with GLIBC_TUNABLES=glibc.cpu.x86_shstk=permissive
(zone_environment in spawn.rs): a program that loads a library without
the note while it has one thread, as Firefox loads libxul and NSPR, runs on
without its shadow stack instead of failing, and a program whose libraries
all carry it keeps its own. One that loads such a library once it has more
threads still fails. The zone filter allows map_shadow_stack, which
glibc's makecontext calls for a context's own shadow stack. No virtual
machine in CI has shadow stacks, so only a machine with them shows this.
ADR-005 makes hardened_malloc the system allocator. Stage 04 builds it without
-march=native and installs /usr/lib/libhardened_malloc.so. Stage 06 writes
/etc/ld.so.preload into the image's root, so every dynamically linked
process of the running system uses it (kryptikd and kryptik-wlproxy are
static musl binaries, which no preload reaches), and kryptikd writes each
zone its own preload naming only that library (a zone never sees the
host's). The build chroot never has the file.
hardened_malloc's guard pages are separate mappings, so vm.max_map_count is
1048576. The booted medium and the zones suite check that a process has the
library mapped. No benchmark numbers are claimed.
Three fragments in build/config/kernel/:
hardening.fragment (KSPP's
recommendations and what zones need: namespaces, cgroup v2, Landlock, seccomp,
dm-verity, dm-crypt), hardened.fragment (options only linux-hardened has,
ADR-009) and boot.fragment (ADR-013).
INIT_ON_ALLOCandINIT_ON_FREEby default: zeroed heap memory, against a broad class of info leaks, for a few percent.SLAB_FREELIST_HARDENED,SLAB_FREELIST_RANDOM: against heap grooming.- Lockdown in confidentiality mode: root cannot read kernel memory through
/dev/mem, kprobes or unsigned modules, which is what makes root in a zone weaker than kernel access. RANDOMIZE_BASE,RANDOMIZE_MEMORY: KASLR.MODULE_SIG_FORCE: only modules signed by the build load. The key is the kernel build's own (certs/signing_key.pem), made with the kernel tree, and never leaves the machine that used it: the Actions cache keeps the tree without it, so a run that restores the tree makes a new key, links the kernel with it and signs the modules again. Releases are no different: no module key is kept (release keys).KSTACK_ERASE,RANDSTRUCT_FULL: stack erasing and structure layout randomization (the 6.18 names; the oldGCC_PLUGIN_*symbols are derived and cannot be set).- Also on: KFENCE, trapping UBSAN bounds checks, page table checking,
DEBUG_VIRTUAL, a strict IOMMU by default, the EFI stub's early-DMA and reset-attack protections, the TPM as an entropy source where it is a chip of its own or Pluton (6.18 gives an AMD firmware TPM no hwrng, after AMD's PA-410),/proc/pid/memwrite protection, userspace shadow stacks. - Off:
bpf(), SELinux (LSMs arelandlock,lockdown,yama),/dev/mem,/proc/kcore, MSR and CPUID devices, legacy PTYs,binfmt_misc, kexec, hibernation, IA-32 emulation, debugfs, ftrace, kprobes, io_uring, sysrq, ACPI table overrides, core dumps,/proc/pid/pagemap. - Off as well: initramfs support and the EFI stub's handover entry, so
nothing that starts the kernel can give it code to run before the verified
root;
MSG_OOBon unix sockets, TCP MD5 signatures and HugeTLB pages, which any zone could reach; and what defconfig left on that nothing here uses: IP autoconfiguration, policy, multipath and multicast routing, NetLabel and SECMARK, NFLOG, tc's classifiers and actions, the network filesystems' DNS upcall, forced module unloading,pm_trace, which keeps its trace in the RTC, the cgroup debug controller,/dev/nvramand/dev/hpet. The power-management debug messages stay, off until root turns them on, for a laptop whose sleep stays shallow.
Stage 05 refuses a .config that lost any fragment line
(build/lib/kconfig-check.sh, shared with tools/resolve-kernel-config.sh
in CI): kconfig silently drops a line for an unmet dependency, an overriding
select or an invisible prompt, leaving a mitigation the fragment claims and
the kernel lacks.
kernel-hardening-checker, KSPP's reference list, runs on the resolved
.config and the shipped command line in stage 05 and CI
(tools/check-kernel-hardening.sh). Each failure it reports is fixed or
listed with its reason in
checker-accepted.txt, and
entries that start passing are reported as stale so the list shrinks.
Besides the root device, the command line stage 06 compiles into each signed
kernel carries mitigations=auto,nosmt nosmt pti=on page_alloc.shuffle=1 hash_pointers=always: every CPU mitigation with SMT off (ADR-011), page table
isolation even on CPUs the kernel believes unaffected (a few percent on
syscalls), randomized free page lists, and %p pointers hashed even where an
option would print them raw.
build/config/sysctl.d/ sets, among others,
kernel.kptr_restrict=2, kernel.dmesg_restrict=1,
kernel.yama.ptrace_scope=3 (no ptrace after boot),
kernel.perf_event_paranoid=3, vm.mmap_rnd_bits=32 and the
fs.protected_* settings. Every key in the file exists on the shipped
kernel, and make zones-test reads each back: what the kernel does not
build has no sysctl, so there is none for BPF (no bpf(); seccomp's classic
filters do not need it), kexec, userfaultfd, 32-bit mappings or core dumps.
Privilege transitions go through kryptikd, where they can be audited, not
through setuid binaries or file capabilities. A setuid binary needs a
justified entry in build/config/setuid-allowlist.txt, which lists only
su, the only way from a login to root; passwords change through it
(su -c 'passwd NAME'), so passwd runs as root only for root. A file
carrying capabilities (security.capability) needs one in
build/config/capability-allowlist.txt, which is empty. The recipes install
no other bit: shadow's nine other tools lose theirs after its install,
util-linux is built with --disable-makeinstall-setuid and
--disable-makeinstall-chown (wall's setgid tty is under that hook), and
inetutils without traceroute. Stage 06 runs tools/audit-setuid.sh over the
image's root and fails the build on any unlisted bit or capability, on a list
entry without a justification, on an entry whose file carries no bit or
capability (it would let the next file of that name in unread), and when it
cannot read a directory. It never takes a bit off: a bit the image should not
have is removed in its recipe. make acceptance runs the same audit on the
root image it tests, and make zones-test audits the installed root again:
the bits are on the listed binaries alone, no file carries capabilities, and
every sysctl reads back as build/config/sysctl.d says.
Every zoned process runs under a default-deny seccomp-bpf filter
(compartments/kryptikd/src/seccomp.rs) allowing about 200 syscalls. Anything
else is SECCOMP_RET_KILL_PROCESS, except that the set*id calls,
setgroups and capset fail with EPERM and inotify_init and
inotify_init1 with ENOSYS, so programs that try them carry on. unshare
and clone with namespace flags fail with EPERM too: Firefox, Chromium and
bubblewrap probe for user namespaces at start and must hear no, as the kernel
tells an unprivileged caller. clone3 fails with ENOSYS so libc falls back
to clone, the TIOCSTI and TIOCLINUX ioctls are killed, SIOCETHTOOL,
SIOCSMIIREG and the drivers' private range fail with EPERM (with the nic
zone's CAP_NET_ADMIN they would rewrite a NIC's EEPROM, flash or PHY, which
outlives a reinstall), and socket is limited to AF_UNIX, AF_INET,
AF_INET6 and NETLINK_ROUTE. A zone policy
file can widen this in named ways but never re-allow a denied syscall
(zone policy files).
| Denied | Why |
|---|---|
setns |
enters another zone's namespaces |
ptrace, process_vm_readv/writev |
another process's memory |
mount, umount2, pivot_root, chroot, the new mount API |
remount the filesystem out from under Landlock |
unshare |
nested namespaces, a known LPE surface |
bpf, perf_event_open |
long histories of privilege escalation |
userfaultfd |
kernel heap grooming |
keyctl, add_key, request_key |
kernel keyring, repeated CVEs |
init_module, finit_module, kexec_load |
load kernel code |
io_uring_* |
does I/O without syscalls, past the filter |
compartments/tests/adversarial.sh makes 12 of these calls under the filter
and expects SIGSYS; from unshare and a namespace clone it expects EPERM.
Other architectures are refused, and x32 calls (x86-64 numbers with bit 30 set) are killed before the allowlist. Each allowed syscall is a compare followed by its own ALLOW, so no jump spans the list: BPF jump offsets are one byte, and a single jump to a shared ALLOW breaks past 255 entries.
mprotect is allowed, since every dynamic linker needs it, so a zone can
defeat W^X. RELRO and BIND_NOW compensate: the GOT is read-only before
main() runs.
These mitigations raise the cost of exploitation; they do not make the system unexploitable. Compartmentalization (architecture) is the first line and hardening the second; the threat model says where both end.