Linux Binary Hardening: ASLR, PIE, RELRO, and FORTIFY_SOURCE
Problem
Memory corruption vulnerabilities — buffer overflows, use-after-free, format string bugs — have been known for decades, but they remain a primary vector for local privilege escalation and remote code execution on Linux systems. The OS and compiler ecosystem has developed a layered set of mitigations that make these vulnerabilities significantly harder to exploit. Together, they form the baseline for a hardened binary environment.
Many Linux deployments run with incomplete mitigations:
- ASLR disabled or partial. ASLR (Address Space Layout Randomisation) randomises the base address of stack, heap, and libraries on each process launch. Full ASLR requires PIE-compiled binaries; without PIE, the code segment sits at a fixed address, providing a reliable ROP gadget base.
- Binaries compiled without stack canaries. Stack canaries place a random value between local variables and the saved return address. A stack buffer overflow must overwrite the canary before reaching the return address; detecting a changed canary kills the process before the overwrite takes effect. Binaries compiled without
-fstack-protector-stronglack this protection. - No RELRO (Relocation Read-Only). The GOT (Global Offset Table) stores addresses of shared library functions resolved at runtime. A write primitive in a vulnerable binary can overwrite GOT entries to redirect execution. Full RELRO resolves all GOT entries at load time and marks the GOT read-only, eliminating this attack vector.
- Missing FORTIFY_SOURCE. FORTIFY_SOURCE replaces unsafe libc functions (
strcpy,sprintf,memcpy) with bounds-checked variants when the destination buffer size is known at compile time. Without it, common string functions are vulnerable to overflows even when the developer made no obvious error. - NX/DEP not enforced. The NX (No-eXecute) bit marks memory pages as either executable or writable, not both. Without NX, shellcode injected into data can execute directly. NX is typically enforced by hardware and kernel, but can be disabled per-process with
execstack.
Target systems: Ubuntu 22.04+, RHEL 9+, Debian 12+; GCC 11+, Clang 14+; kernel 5.15+; binaries compiled in-house or installed from package managers.
Threat Model
- Adversary 1 — Return-oriented programming (ROP) via fixed code base: A buffer overflow in a non-PIE binary provides a reliable code address for building ROP chains. ASLR is irrelevant for the code segment of non-PIE binaries. The attacker constructs a ROP chain using gadgets at known addresses.
- Adversary 2 — GOT overwrite for arbitrary execution: A write-what-where primitive in a vulnerable binary overwrites the GOT entry for a frequently called function (e.g.,
printf). The next call toprintfexecutes the attacker’s chosen address. Full RELRO prevents this. - Adversary 3 — Stack smashing via strcpy overflow: A buffer overflow via an unsafe string function overwrites the return address. Without a stack canary, the overwrite succeeds silently; with a canary, the modified canary is detected on function return.
- Adversary 4 — Heap spray via predictable addresses: Without ASLR, heap allocation addresses are deterministic. An attacker sprays the heap with shellcode or ROP payloads at predictable locations, then triggers a use-after-free or format string vulnerability to redirect execution.
- Adversary 5 — Format string exploitation:
printf(user_input)without a format argument allows reading from and writing to arbitrary stack addresses. FORTIFY_SOURCE converts dangerous libc calls to checked variants; the format string attack is detected and aborted. - Access level: All adversaries exploit a pre-existing vulnerability in a running process; they do not need prior authentication. Local privilege escalation needs a local user account.
- Objective: Gain arbitrary code execution in the context of the vulnerable process; pivot to root if the process has elevated privileges.
- Blast radius: A vulnerable SUID binary without hardening mitigations provides local root access. A vulnerable network service without mitigations provides remote code execution.
Configuration
Step 1: Verify Current Mitigation Status
# Check ASLR mode.
cat /proc/sys/kernel/randomize_va_space
# 0 = disabled, 1 = partial (stack/libs), 2 = full (stack/libs/heap/mmap). Use 2.
# Check system-wide ASLR.
sysctl kernel.randomize_va_space
# Expected: kernel.randomize_va_space = 2
# Check a specific binary for hardening flags.
# Install checksec.
apt-get install checksec # Ubuntu.
# or: pip install checksec
checksec --file=/usr/sbin/sshd
# Output:
# RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY
# Full Yes Yes Yes No No No Yes
# Check all SUID binaries for hardening status.
find / -perm -4000 -type f 2>/dev/null | \
xargs checksec --format=csv 2>/dev/null | \
grep -v ",Full,Yes,Yes,Yes," | \
grep -v "^File" | \
head -20
# Lines here are SUID binaries missing one or more hardening flags.
#!/usr/bin/env python3
# audit_binaries.py — batch audit for hardening flags.
import subprocess, json, sys
CRITICAL_PATHS = [
"/usr/bin", "/usr/sbin", "/bin", "/sbin",
"/usr/local/bin", "/usr/local/sbin",
]
def audit():
result = subprocess.run(
["checksec", "--output=json", "--recursive"] + CRITICAL_PATHS,
capture_output=True, text=True
)
data = json.loads(result.stdout)
issues = []
for binary, props in data.items():
missing = []
if props.get("relro") != "full": missing.append("RELRO")
if props.get("canary") != "yes": missing.append("stack-canary")
if props.get("nx") != "yes": missing.append("NX")
if props.get("pie") != "yes": missing.append("PIE")
if props.get("fortify") != "yes": missing.append("FORTIFY")
if missing:
issues.append({"binary": binary, "missing": missing})
return issues
for issue in audit():
print(f"MISSING {','.join(issue['missing'])}: {issue['binary']}")
Step 2: Enable Full ASLR
# /etc/sysctl.d/50-memory-hardening.conf
kernel.randomize_va_space = 2
# Apply immediately.
sysctl -p /etc/sysctl.d/50-memory-hardening.conf
# Verify.
cat /proc/sys/kernel/randomize_va_space
# Expected: 2
Step 3: Compile with Full Hardening Flags
For in-house software, enforce hardening at compile time:
# Makefile — hardening flags for all builds.
# GCC hardening flags.
HARDENING_CFLAGS = \
-O2 \
-fstack-protector-strong \ # Stack canaries on all functions with buffers.
-fstack-clash-protection \ # Probe stack pages to prevent stack clash.
-fcf-protection=full \ # Intel CET control-flow integrity (x86_64).
-D_FORTIFY_SOURCE=2 \ # FORTIFY_SOURCE: bounds-check unsafe libc calls.
-D_GLIBCXX_ASSERTIONS \ # C++ STL bounds checking.
-fPIC # Position-independent code (required for PIE).
HARDENING_LDFLAGS = \
-Wl,-z,relro \ # Mark GOT read-only after relocation.
-Wl,-z,now \ # Resolve all PLT entries at load (full RELRO).
-Wl,-z,noexecstack \ # Mark stack non-executable.
-Wl,-z,separate-code \ # Separate code from data segments.
-pie # Position-independent executable.
CFLAGS += $(HARDENING_CFLAGS)
LDFLAGS += $(HARDENING_LDFLAGS)
# CMake: apply hardening flags.
# CMakeLists.txt
add_compile_options(
-fstack-protector-strong
-fstack-clash-protection
-fcf-protection=full
-D_FORTIFY_SOURCE=2
-fPIC
)
add_link_options(
-Wl,-z,relro
-Wl,-z,now
-Wl,-z,noexecstack
-pie
)
# Or use the hardening-wrapper (Ubuntu/Debian):
# dpkg-buildflags --export=configure
# Provides DEB_BUILD_MAINT_OPTIONS=hardening=+all
Step 4: Verify Flags on Built Binaries
# Verify a freshly compiled binary has all mitigations.
gcc -o myapp main.c \
-fstack-protector-strong \
-D_FORTIFY_SOURCE=2 \
-pie -fPIC \
-Wl,-z,relro,-z,now,-z,noexecstack
checksec --file=myapp
# Expected: Full RELRO, Canary found, NX enabled, PIE enabled, FORTIFY
# Verify PIE: the binary should load at a randomised base address.
for i in 1 2 3; do
cat /proc/$(pidof myapp)/maps 2>/dev/null | head -1
done
# Each run should show a different base address if PIE + ASLR is working.
# Verify NX: stack should not be executable.
readelf -l myapp | grep GNU_STACK
# Expected: GNU_STACK 0x... 0x... RW # RW means non-executable.
# Bad: RWE means stack is executable.
# Verify RELRO.
readelf -l myapp | grep -i relro
readelf -d myapp | grep BIND_NOW
# BIND_NOW = full RELRO (resolve all symbols at load time).
Step 5: Kernel-Level Mitigations
# /etc/sysctl.d/50-memory-hardening.conf — kernel exploit mitigations.
# Full ASLR.
kernel.randomize_va_space = 2
# Prevent mmap at address 0 (null pointer dereference exploitation).
vm.mmap_min_addr = 65536
# Prevent unprivileged access to dmesg (limits kernel info leakage for KASLR bypass).
kernel.dmesg_restrict = 1
# Prevent unprivileged perf_events (used for side-channel attacks).
kernel.perf_event_paranoid = 3
# Disable core dumps for SUID programs (prevents info leakage via core files).
fs.suid_dumpable = 0
# Restrict ptrace to parent processes only (prevents process injection via ptrace).
kernel.yama.ptrace_scope = 1
# Or 2 to restrict to root only; or 3 to disable entirely.
Step 6: Debian/Ubuntu Package Hardening Profile
Debian-based distributions apply hardening flags automatically via dpkg-buildflags:
# Check current hardening flags for package builds.
dpkg-buildflags --status
# Enable all hardening options for package builds.
# /etc/dpkg/buildflags.conf
SET DEB_BUILD_MAINT_OPTIONS hardening=+all
# Verify flags are applied when rebuilding a package.
dpkg-buildflags --export=make | grep "CFLAGS\|LDFLAGS"
# Expected: includes -fstack-protector-strong, -D_FORTIFY_SOURCE=2, -pie, etc.
# Check if a package-installed binary has Debian hardening applied.
# The hardening-check tool (from devscripts) verifies this.
hardening-check /usr/bin/nginx
# Output:
# /usr/bin/nginx:
# Position Independent Executable: yes
# Stack protected: yes
# Fortify Source functions: yes (some)
# Read-only relocations: yes
# Immediate binding: yes
Step 7: Container Image Hardening
Ensure container images are built with hardened binaries:
# Dockerfile — build with hardening flags.
FROM golang:1.22-alpine AS builder
# Go compiles PIE by default on most architectures.
# Enable CGO hardening if using CGO.
ENV CGO_CFLAGS="-fstack-protector-strong -D_FORTIFY_SOURCE=2"
ENV CGO_LDFLAGS="-Wl,-z,relro,-z,now"
# Build with Go's built-in race detector for testing (not production).
RUN go build -buildmode=pie -o /app ./...
# Verify the Go binary has PIE.
# RUN checksec --file=/app
# Scan container images for binaries without hardening.
# Using checksec in CI.
docker run --rm \
-v "$(pwd)/dist:/dist:ro" \
alpine sh -c "apk add checksec && checksec --recursive /dist"
Step 8: Telemetry
binary_missing_pie_total{path} gauge
binary_missing_relro_total{path} gauge
binary_missing_canary_total{path} gauge
binary_missing_fortify_total{path} gauge
binary_missing_nx_total{path} gauge
aslr_enabled{host} gauge (0=off, 1=partial, 2=full)
kernel_exploit_mitigation_score{host} gauge (0-10 composite score)
Alert on:
aslr_enabled< 2 — ASLR is not at full strength; exploit mitigations degraded.binary_missing_pie_totalorbinary_missing_relro_totalnon-zero for SUID binaries — SUID binaries without full hardening are high-priority targets.- Any new SUID binary appearing in the filesystem without corresponding hardening flags — investigate provenance.
kernel.yama.ptrace_scope= 0 after a reboot — ptrace restrictions have been removed.
Expected Behaviour
| Signal | Unhardened binary | Hardened binary |
|---|---|---|
| Stack buffer overflow | Return address overwritten; code execution | Stack canary detects modification; process killed |
| GOT overwrite attack | Redirects execution to attacker address | Full RELRO: GOT is read-only; overwrite fails |
| ROP chain using fixed code addresses | Gadgets at predictable addresses | PIE + ASLR: code at random address; gadgets unpredictable |
| Format string write primitive | Arbitrary write to stack succeeds | FORTIFY_SOURCE: dangerous format call detected; abort |
| NX bypass via data injection | Shellcode injected in data executes | NX: data pages non-executable; execution causes SIGBUS |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| PIE compilation | Code at random address; ASLR effective for code segment | ~1-5% performance overhead on i386; negligible on x86_64/ARM64 | Acceptable for security-sensitive binaries |
Full RELRO (-z now) |
GOT read-only after load | Slightly longer process startup (all PLT resolved at load) | Typically < 100ms; acceptable |
FORTIFY_SOURCE=2 |
Bounds-checks unsafe libc calls | Very rare false positives if buffer sizes are miscalculated | Fix the underlying size calculation; don’t disable FORTIFY |
fcf-protection=full |
Intel CET control flow integrity | Requires Intel Tiger Lake+ or newer CPU | Falls back gracefully on older CPUs; no functional regression |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| FORTIFY_SOURCE false positive | Application crashes with “*** buffer overflow detected ***” | Crash in specific code path; abort signal | Identify the buffer size mismatch; fix the code (not the mitigation) |
| PIE breaks hardcoded address assumptions | Rare: some legacy code assumes fixed load address | Segfault on load; verified with readelf -d |
Refactor to not assume load address; or run without PIE as a temporary measure |
vm.mmap_min_addr breaks JIT |
JavaScript/JVM JIT engines may use low addresses | JIT compilation fails; fallback to interpreter | Increase JIT engine’s min address config; do not lower mmap_min_addr |
| Stack canary mismatch in forked process | Rare: canary not reset after fork in some custom memory managers | Crash on function return | Ensure canary is reset after fork; use glibc’s fork() which handles this |