Linux Binary Hardening: RELRO, PIE, NX, and CET
Binary hardening is one of the most reliable ways to reduce exploitability. Modern Linux toolchains can add protections like RELRO, PIE, NX, and CET with simple build flags. In a lab, you can compile the same program with and without these protections and see how the attack surface changes.
This post explains what each mitigation does, how to verify it, and how to enable it in your build pipeline.
Why these mitigations matter
- RELRO makes the Global Offset Table read-only after relocation, blocking GOT overwrite attacks.
- PIE turns the binary into a position-independent executable so ASLR can randomize the base address.
- NX marks memory pages as non-executable, preventing injected shellcode execution.
- CET adds control flow integrity using shadow stacks and indirect branch tracking.
Each mitigation blocks a class of exploit techniques. Combined, they raise the bar significantly.
Check current binaries
Use checksec to inspect existing binaries. This gives you a quick view of which protections are active.
1
2
sudo apt install -y checksec
checksec --file=/bin/ls
A typical output might show PIE enabled and NX enabled, but CET might be missing depending on your CPU and compiler.
Compile a sample program
Create a small C program with a classic buffer overflow pattern so you can see the difference between hardened and unhardened builds.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// vuln.c
#include <stdio.h>
#include <string.h>
void greet(char *name) {
char buf[32];
strcpy(buf, name);
printf("Hello %s\n", buf);
}
int main(int argc, char **argv) {
if (argc < 2) return 1;
greet(argv[1]);
return 0;
}
Compile without protections:
1
gcc -o vuln vuln.c
Compile with hardening flags:
1
2
3
4
5
gcc -o vuln_hardened vuln.c \
-fstack-protector-strong \
-D_FORTIFY_SOURCE=2 \
-Wl,-z,relro -Wl,-z,now \
-fPIE -pie
If your toolchain supports CET, add:
1
gcc -o vuln_hardened_cet vuln.c -fcf-protection=full -fstack-protector-strong -Wl,-z,relro -Wl,-z,now -fPIE -pie
Verify the protections
Run checksec on the new binaries:
1
2
checksec --file=./vuln
checksec --file=./vuln_hardened
You should see RELRO as “Full”, NX as “Enabled”, and PIE as “Enabled” in the hardened build. CET will show as “SHSTK” and “IBT” when supported.
RELRO modes and impact
There are two RELRO modes: partial and full. Partial RELRO protects the GOT but still allows lazy binding. Full RELRO with -z now resolves all symbols at startup and then locks the GOT. This blocks a common exploitation path but can increase startup time slightly. For most services, the tradeoff is worth it.
In a lab, compare the process startup time of the hardened binary with and without -z now. It is a good reminder that security controls often have performance implications.
ASLR and PIE verification
PIE only helps if ASLR is enabled. Verify ASLR with cat /proc/sys/kernel/randomize_va_space and ensure it is set to 2. You can also use readelf -h to confirm the binary type is DYN, which indicates PIE.
1
2
cat /proc/sys/kernel/randomize_va_space
readelf -h ./vuln_hardened | grep Type
If ASLR is disabled, even a PIE binary will load at a predictable address, which defeats the purpose.
CET caveats
CET is not universally supported. Some toolchains emit CET notes but the kernel or CPU may ignore them. Use readelf -n to check for CET properties and test on hardware that supports it. If you distribute binaries across mixed environments, assume CET is best-effort and keep other mitigations enabled.
Automated checks in CI
Make hardening checks part of CI. You can run checksec or hardening-check and fail the build if a mitigation is missing. This prevents accidental regression when compiler flags change or when a new build system is introduced.
Add a small script that verifies ELF properties and exits non-zero on failure. This is a low-effort guardrail that keeps your binaries consistent over time.
Stack canaries and FORTIFY
Stack canaries detect stack buffer overflows by placing a guard value before the return address. The compiler inserts checks that abort the program if the canary changes. -fstack-protector-strong enables canaries for more functions, which is a sensible default.
_FORTIFY_SOURCE=2 adds lightweight bounds checks for common libc calls when the compiler can determine buffer sizes at compile time. It will not catch everything, but it is essentially free and provides extra defense for common strcpy and memcpy mistakes.
Runtime hardening complements
Compiler flags are only part of the story. Use seccomp to restrict syscalls, and consider running services under AppArmor or SELinux. These controls can stop an exploit even if the binary is compromised, because the process cannot open arbitrary files or execute unexpected syscalls.
In a lab, try running the vulnerable binary under a simple seccomp profile that only allows read, write, and exit. You will see how quickly an exploit fails when it cannot call execve or mprotect.
Sanitizers for pre-production
AddressSanitizer (ASan) and UndefinedBehaviorSanitizer (UBSan) are not production mitigations, but they are excellent for pre-production testing. Compile a debug build with -fsanitize=address,undefined and run your test suite. You will catch memory bugs early, before they reach production builds.
In a lab, run the vulnerable program with ASan enabled and observe the detailed crash report. This teaches you how sanitizers surface issues and why they are valuable even in small projects.
Operationalizing in build pipelines
Make these flags part of your default build. In a Makefile, add them to CFLAGS and LDFLAGS so every target inherits the protections.
1
2
CFLAGS += -fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIE
LDFLAGS += -Wl,-z,relro -Wl,-z,now -pie
For CMake projects, use add_compile_options and add_link_options to keep the flags consistent.
Limitations and caveats
These mitigations reduce exploitability, but they do not prevent logic flaws. A vulnerable API endpoint is still vulnerable. Also note that some legacy applications break with -Wl,-z,now because it forces immediate binding. Test thoroughly.
CET depends on hardware support and a recent kernel. If the CPU or kernel does not support it, the flag is ignored. That is fine; the other mitigations still help.
Takeaways
Binary hardening should be the default. With a few compiler flags, you can block a huge class of memory corruption attacks. In a lab, build the same program with and without protections and observe how exploitation changes. That hands-on insight will pay off when you assess real systems.