$RodHat_
MOTD

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

Published by

GCC 14 has -fhardened. Stop writing out those nine flags by hand.
Photo: AI-generated — no human photographer / RodHat AI Cover

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

  1. GCC 14 Release Notes (GNU Compiler Collection)
  2. GCC Code Generation Options — -fhardened (GNU Compiler Collection)