This is one of Dissecting Binaries with machine-code-analyzer, a series where I point the tool at real binaries and write up what falls out.
A modern binary defends itself in layers. None of the layers is a wall. Each one takes a single technique out of an attacker’s toolbox. It is the stack of them, and the way they interlock, that makes an exploit expensive to write.
The clearest way to see the layers is to remove them. Here is a small program with an indirect call through a function pointer and a stack buffer, the two things most of the mitigations care about:
static void greet(const char *name) { printf("hello, %s\n", name); }
static void shout(const char *name) { printf("HELLO, %s\n", name); }
typedef void (*handler)(const char *);
int main(int argc, char **argv)
{
handler table[] = { greet, shout };
char buf[64];
strcpy(buf, argc > 1 ? argv[1] : "world");
handler h = table[argc & 1]; /* target reached through a pointer */
h(buf); /* the indirect call */
return 0;
}
I compiled it twice. Once with every mitigation switched off:
$ gcc -O2 -fcf-protection=none -fno-stack-protector -no-pie \
-Wl,-z,norelro hardening-demo.c -o demo-weak
and once with the flags a distribution build uses:
$ gcc -O2 -fcf-protection=full -fstack-protector-strong -fPIE -pie \
-Wl,-z,relro,-z,now -D_FORTIFY_SOURCE=2 hardening-demo.c -o demo-hard
Same source, same compiler. The two binaries are not the same program at
the machine level at all. Here is the mitigation status of each, read
straight out of the ELF file and the disassembly by the hardening
module of my analyzer:
| Layer | demo-weak | demo-hard |
|---|---|---|
| PIE / ASLR | ✗ fixed load address | ✓ position independent |
| NX | ✓ non-executable | ✓ non-executable |
| RELRO | ✗ none | ✓ full, GOT read-only |
| Stack Canary | ✗ no protector | ✓ 1 function guarded |
| CET / IBT | ✗ endbr64 on 0% | ✓ endbr64 on 100% |
| FORTIFY | ✗ not used | ✓ _chk wrappers |
One build has a single layer standing, NX, which is on by default. The other has all six. Let me walk the rows, what each layer stops and how to spot it in the binary.
1. PIE and ASLR
The oldest trick in memory corruption is knowing where things are. If the code and data of a program sit at fixed addresses every time it runs, an attacker can hardcode them. Position Independent Executables let the loader place the whole image at a random base, and Address Space Layout Randomization picks a fresh base on every launch. Now the addresses an exploit needs are a moving target.
You see it in the ELF type. A non-PIE executable is ET_EXEC and loads
at a fixed address; a PIE is an ET_DYN shared object with an
interpreter, so the loader is free to slide it:
$ file demo-weak
ELF 64-bit LSB executable, ... not stripped
$ file demo-hard
ELF 64-bit LSB pie executable, ...
rabin2 -I from the radare2 suite calls the same thing pic:
false for the weak build, true for the hardened one.
2. NX, the Non-Executable Stack
Classic shellcode injection writes machine code into a stack buffer and jumps to it. The No-eXecute bit ends that: the stack and heap are mapped without execute permission, so bytes sitting there are data, never code. This is what pushed attackers from injecting code to reusing the code already in the binary, which is why the later layers are all about controlling reused code.
It lives in the GNU_STACK program header, whose flags say whether the
stack is executable:
$ eu-readelf -l demo-hard | grep GNU_STACK
GNU_STACK 0x0 0x0 0x0 0x0 0x0 RW 0x10
RW, not RWE: readable, writable, not executable. This one is on by
default now, in both my builds. You have to go out of your way with
-z execstack to turn it off, and a binary that has one is worth a hard
look.
3. RELRO
When a program calls into a shared library, the address is resolved
through a table of pointers, the GOT. If that table stays writable, an
overflow that reaches it can swap a resolved pointer and redirect a call
to anywhere. RELRO closes that. Full RELRO resolves every import eagerly
at startup (that is the BIND_NOW part) and then maps the table
read-only, so a later write faults.

Partial RELRO leaves the GOT writable. Full RELRO resolves everything at startup and then makes it read-only.
Partial RELRO (the default from just linking) protects some sections but
leaves the .got.plt writable. Full RELRO needs the now linker flag.
The two states are visible in the dynamic section:
$ eu-readelf -l demo-hard | grep RELRO
GNU_RELRO 0x2da8 ... R 0x1
$ eu-readelf -d demo-hard | grep -E 'BIND_NOW|FLAGS_1'
FLAGS BIND_NOW
FLAGS_1 NOW
The weak build has no GNU_RELRO segment at all, so rabin2 reports
relro no; the hardened build reports relro full.
4. The Stack Canary
A linear buffer overflow writes from a local buffer upward, toward the
saved registers and the return address. The stack protector puts a random
value, the canary, between the buffers and the return address, and checks
it just before the function returns. Overwrite the return address and you
have overwritten the canary first; the check fails and the program aborts
in __stack_chk_fail instead of returning into attacker data.

The canary sits between the buffers and the return address. A linear overflow has to destroy it on the way, and ret notices.
The compiler only spends a canary on functions that actually have a stack
buffer, so coverage is never total. In the machine code it is a load from
the thread control block at %fs:0x28 at entry and a compare before the
return. My analyzer counts those: zero functions in demo-weak, and in
demo-hard exactly one, main, the only function here with a buffer.
5. CET and Indirect Branch Tracking
Once the stack is non-executable and the return address is guarded,
attackers turn to indirect branches, calls and jumps through a register
or a pointer, and aim them at useful fragments of existing code. Intel
CET’s Indirect Branch Tracking answers this at the hardware level. Every
legal target of an indirect branch must begin with an endbr64
instruction. Land an indirect call or jump anywhere else and the CPU
raises a control-protection fault.

The indirect call may only land on an endbr64. Any other target, like the middle of a function, faults.
Our program has exactly the construct this protects, an indirect call through the pointer table:
call *(%rsp,%rbx,8)
Its targets are greet and shout. In the hardened build they each open
with the landing pad; in the weak build they do not:
# demo-weak, greet(): # demo-hard, greet():
mov %rdi,%rsi endbr64
xor %eax,%eax mov %rdi,%rdx
That one four-byte instruction is the difference between “this address is a valid call target” and “the CPU will fault if you branch here”. At the scale of a whole binary it is the single most common opening instruction on a modern system, which I wrote about separately.
6. FORTIFY
The last layer moves up to the source level. With _FORTIFY_SOURCE the
compiler, when it can see the size of a destination buffer, replaces
unbounded library calls with checked variants that know that size and
abort on an overflow. The substitution is visible in the call targets. In
the weak build the strcpy is a plain call:
call strcpy@plt
In the hardened build the same line became:
call __strcpy_chk@plt
__strcpy_chk gets the size of buf as an extra argument and traps if
the copy would run past it. Grep a binary’s imported symbols for names
ending in _chk and you know FORTIFY was in effect.
A Note on RUNPATH
There is a seventh thing the card checks, and it is the one where the good
state is off. A RUNPATH is a library search path baked into the
binary. If it points at a directory an attacker can write to, it is a
library hijack waiting to happen. A clean binary has none, and the tool
flags one in red when it finds it.
Reading It Back
None of this needs source or symbols. Every layer leaves a mark in the
ELF headers or the instruction stream: the ELF type for PIE, a program
header for NX and RELRO, a %fs:0x28 load for the canary, an endbr64
for CET, a _chk import for FORTIFY. The hardening card is just those
six checks collected into one picture, and the point of the picture is
that real binaries are rarely all green.
The interpreter on my own machine makes the point:
| Layer | /usr/bin/python3 |
|---|---|
| PIE / ASLR | ✗ no, fixed load address |
| NX | ✓ non-executable |
| RELRO | ~ partial, GOT still writable |
| Stack Canary | ✓ 4 functions guarded |
| CET / IBT | ✓ endbr64 on 97% |
| FORTIFY | ✓ _chk wrappers present |
CET covers 97% of its functions, NX and the stack protector are on, but
it is a fixed-address ET_EXEC with only partial RELRO. Not a scandal,
just a build that predates some of the defaults, and exactly the kind of
thing worth noticing.
Conclusion
The six layers are not independent walls, they are a chain, and that is the point. NX forces an attacker off the stack and into reusing existing code. PIE hides where that code is. The canary and RELRO guard the two classic ways to seize a return address or a call pointer. CET restricts where an indirect branch may land at all. FORTIFY stops a class of overflows before they start. Defeating a hardened binary means defeating several of these at once, and modern toolchains turn most of them on by default, so the baseline is high without anyone thinking about it.
The value of looking is that “most” is not “all”. Same source can become two very different binaries, and the binary in front of you may be missing a layer you assumed was there. It only takes a minute to check.
Running the Module
The six checks come from one command. The hardening module of
machine-code-analyzer takes a single ELF file and
prints the whole picture:
$ ./machine-code-analyzer.py hardening /usr/bin/python3
Hardening Analysis
==================
Number of analyzed functions: 1540
Load-time mitigations (ELF headers):
PIE (position independent): no - fixed load address
NX (non-executable stack): yes
RELRO: partial
FORTIFY (_chk wrappers): yes
CET / IBT (-fcf-protection): enabled
functions starting with endbr64: 1497 of 1540 [ 97.21% ]
note: static functions only called directly need no endbr64
Stack protector (-fstack-protector):
functions loading the canary (%fs:0x28): 4 of 1540 [ 0.26% ]
note: only functions with stack buffers get a canary,
and only analyzed function bodies are scanned - without
a .symtab the static functions are invisible
Retpoline thunks (__x86_indirect_thunk_*): 0
Speculation barriers (lfence): 0
Functions Without endbr64
=========================
expm1: 704 byte
tan: 464 byte
erf: 368 byte
asinh: 352 byte
...
The load-time block is read from the ELF headers, the CET and stack
protector numbers from the disassembly. The tail lists the largest
functions that do not open with endbr64. A missing landing pad only
matters if something reaches the function through a pointer, so it is a
list to skim, not an alarm; in python3 these are the libm math routines.
Add --graph-card to get the status card as an image, or --json-out FILE for the same data as JSON.