GCC 14 has -fhardened. Stop writing out those nine flags by hand.
Published by RodHat

Somewhere in your build system there is a string that looks roughly like this:
-D_FORTIFY_SOURCE=3 -fstack-protector-strong -fstack-clash-protection \
-fPIE -Wl,-pie -Wl,-z,relro -Wl,-z,now -fcf-protection=full
You assembled it from different sources over several years. You forgot
-ftrivial-auto-var-init=zero because you did not know it existed. You
forgot to update it from FORTIFY_SOURCE=2 when GCC 12 made level 3 usable.
You never added -mbranch-protection=standard for your arm64 builds because
nobody reminded you.
GCC 14 shipped -fhardened. It is that whole list, correct and current, in
one flag.
What it enables
On x86-64, -fhardened expands to approximately:
-D_FORTIFY_SOURCE=3
-D_GLIBCXX_ASSERTIONS
-ftrivial-auto-var-init=zero
-fPIE -pie
-Wl,-z,relro -Wl,-z,now
-fstack-protector-strong
-fstack-clash-protection
-fcf-protection=full
On aarch64 it substitutes -mbranch-protection=standard for
-fcf-protection=full, which is the architecture-appropriate control-flow
integrity mechanism. GCC picks the right one for the target.
-D_FORTIFY_SOURCE=3 is the FORTIFY level that GCC 12 made stable. Level 3
adds bounds checking for more string and memory functions than level 2, and it
catches patterns that level 2 missed. If your code does not compile cleanly
with level 3, the compiler found a real problem. Fix the code.
-ftrivial-auto-var-init=zero zero-initializes local variables that have no
explicit initializer and would otherwise hold whatever was on the stack from a
prior frame. The compiler inserts initialization only for trivially-initializable
types. In practice, the overhead is below measurement noise. The benefit is that
the class of bugs where an uninitialized path exposes a prior frame’s contents
stops existing.
-Wl,-z,now makes the dynamic linker resolve all symbols at startup rather
than on first call. Combined with -Wl,-z,relro, the GOT (Global Offset Table)
becomes read-only before any application code runs. Without this, a write
primitive in an attacker’s hands can redirect function calls by overwriting GOT
entries. With it, those entries are frozen at startup.
What it does not enable
-fhardened is for production builds. It does not enable AddressSanitizer,
UBSanitizer, or any runtime sanitizer with significant overhead. Those belong
in development and CI builds, not shipping binaries.
It also does not enable link-time optimization or profile-guided optimization. Those are orthogonal.
In debug builds (where -D_GLIBCXX_ASSERTIONS adds C++ standard library
bounds checking), the assertion overhead is included. In release builds,
-D_GLIBCXX_ASSERTIONS is omitted from the expansion. If you want both,
pass both explicitly.
The Makefile change
# Before
CFLAGS = -O2 -D_FORTIFY_SOURCE=3 -fstack-protector-strong \
-fstack-clash-protection -fPIE -fcf-protection=full
LDFLAGS = -pie -Wl,-z,relro -Wl,-z,now
# After
CFLAGS = -O2 -fhardened
LDFLAGS = -fhardened
Pass -fhardened to both the compiler and linker. The linker-specific flags
(-z relro, -z now, -pie) only take effect when the flag reaches the
linker. If you pass it only to CFLAGS and not LDFLAGS, you get the
compile-time hardening but miss the load-time protections. Both or neither.
For CMake projects: add it to CMAKE_C_FLAGS and CMAKE_EXE_LINKER_FLAGS.
For meson: add to c_args and link_args. Same rule: both.
What breaks
If anything in your code relied on uninitialized stack contents — intentionally or because you never noticed — it will now behave differently. That is the correct behavior. You had a latent bug.
If you are building against glibc older than 2.34, FORTIFY_SOURCE=3 will
produce linker errors about missing __*_chk_fail symbols. Check: ldd --version.
Anything from 2022 or later supports level 3.
Programs that use dlopen() for plugin loading need to think through the
-z now change. Lazy binding exists because some symbol resolution failures
are recoverable at load time. With -z now, all failures happen at startup,
all at once. Usually this is exactly what you want: fail fast, fail at startup,
never fail in production mid-request. But if your plugin architecture is built
around optional late-loading, test the interaction before shipping.
Checking a binary after the fact
# readelf shows the loaded flags
readelf -d /usr/bin/ssh | grep -E 'FLAGS|BIND_NOW|RPATH'
# checksec (from pwntools or standalone) gives a summary
checksec --file=/usr/bin/ssh
checksec output for a properly hardened binary should show RELRO: Full,
Stack: Canary found, NX: enabled, PIE: enabled, FORTIFY: enabled. If any
of those are absent, the binary was not built with full hardening.
The prctl() options covered in Rod’s tips post are the runtime side of this: locking down process attributes after setup, which is what you do after the binary itself is already hardened at compile time. mseal() is the kernel-level memory protection that makes sealing those runtime invariants permanent. The compile-time flags, the runtime prctl flags, and the memory sealing are three separate layers that do different things. You want all three.
GCC 14 is in every major distro released in 2024 or later. If you are building
on something older, the individual flags still exist. When you upgrade, collapse
them into -fhardened and delete the manual list. You will stop missing the
flags you did not know about.
Sources
- GCC 14 Release Notes (GNU Compiler Collection)
- GCC Code Generation Options — -fhardened (GNU Compiler Collection)