Linking with -z now is the standard hardening advice, and the reason usually given is that it removes the lazy PLT, which is a writable-then-jump sequence on the stack and therefore a target. It is good advice. Here is what it actually does:
$ readelf -SW lt_lazy | awk '/ \.plt /{print "lazy .plt size:", $6}'
lazy .plt size: 000030
$ readelf -SW lt_now | awk '/ \.plt /{print "eager .plt size:", $6}'
eager .plt size: 000030
The same size. Not smaller, not absent. Now disassemble both and compare instruction for instruction:
$ objdump -d --no-show-raw-insn -j .plt lt_lazy | tail -n +7 401020: push 0x2fca(%rip) # GOT+8 401026: jmp *0x2fcc(%rip) # GOT+0x10 401030: jmp *0x2fca(%rip) # GOT+24 401036: push $0x0 40103b: jmp 401020 401040: jmp *0x2fc2(%rip) 401046: push $0x1 40104b: jmp 401020 $ objdump -d --no-show-raw-insn -j .plt lt_now | tail -n +7 401020: push 0x2f9a(%rip) # GOT+8 401026: jmp *0x2fac(%rip) # GOT+0x10 401030: jmp *0x2f9a(%rip) # GOT+24 401036: push $0x0 40103b: jmp 401020 401040: jmp *0x2f92(%rip) 401046: push $0x1 40104b: jmp 401020
Identical. Every opcode, every operand shape, every jump target within the section. The only differences are the rip-relative displacements, and those differ because the GOT moved — the .got.plt section is gone and its slots were merged into .got, which grew from 0x20 to 0x48 bytes.
So where is the change? Here:
$ readelf -dW lt_lazy | grep -cE 'BIND_NOW|FLAGS_1' 0 $ readelf -dW lt_now | grep -E 'BIND_NOW|FLAGS_1' 0x000000000000001e (FLAGS) BIND_NOW 0x000000006ffffffb (FLAGS_1) Flags: NOW
Two dynamic-section entries. That is the entire change. The lazy PLT is still in the binary, byte for byte, as dead code that is never reached.
Why the code does not need to change, stated as the mechanism rather than as an observation:
All of the eager-binding decision is concentrated in one 8-byte cell. The stub is a fixed-size trampoline whose first instruction is an indirect jump through a location the loader writes. Lazy and eager differ only in what the loader writes there, and both answers fit in the same cell. The push $N; jmp PLT0 tail is the lazy path, and under -z now it is simply unreachable.
This is worth contrasting with what a non-PLT design would require. If the resolution result were baked into the code — if each stub were jmp printf directly — then eager binding would require rewriting instructions, which means writing to the text segment, which means mprotect-ing it executable, which is expensive and, on a read-only-code system, impossible. Designing the indirection to be a variable is what makes the choice a runtime decision at no extra cost. The design is unchanged by the flag precisely because the flag was anticipated.
Structural equality is not behavioural evidence, so here is the behavioural test. A program containing a call that exists in the code and is never executed:
$ cat lazytest.c
#include <stdio.h>
extern int lib_fn(int);
int main(int argc, char **argv) {
if(argc > 99) { lib_fn(1); } /* the CALL EXISTS. never RUNS. */
printf("%d\n", argc);
return 0;
}
Both binaries have a PLT entry for lib_fn:
$ for f in lt_lazy lt_now; do
printf "%-8s " $f
objdump -d --no-show-raw-insn -j .plt $f | grep '@plt>:' | sed 's/.*<//;s/>://' | tr '\n' ' '
echo
done
lt_lazy printf lib_fn
lt_now printf lib_fn
Now ask the loader what it actually did. Filter on your own executable — LD_DEBUG otherwise emits about eighty bindings for ld.so’s own internals, which drowns the signal completely:
$ for f in lt_lazy lt_now; do
printf "%-8s " $f
LD_DEBUG=bindings ./$f 2>&1 | grep "binding file ./$f" \
| grep -oE "symbol \`[a-z_]+'" | sort -u | tr '\n' ' '
echo
done
lt_lazy `calloc' `free' `__libc_start_main' `malloc' `printf' `_r_debug' `realloc'
lt_now `calloc' `free' `__libc_start_main' `lib_fn' `malloc' `printf' `_r_debug' `realloc'
^^^^^^^
lib_fn is resolved under -z now and never resolved lazily. It is never called in either case. The lazy loader performed a hash lookup for it, found the definition, decided nobody wanted it, and did nothing — that “decided nobody wanted it” is the entire mechanism, and the only evidence it ever considered the symbol is a line in the loader’s debug output that is not there.
Two more properties fall out of the same experiment. First, the eager set is a strict superset of the lazy set — the crosscheck asserts this, and it is the right invariant: binding earlier can only add resolutions, never remove them. Second, the lazy set is exactly the symbols the program actually reached. That is not a coincidence and not an approximation; it is the definition.
The trade, in numbers you can reason about. The cost of -z now is startup time proportional to the number of imported symbols, paid whether or not the program runs. On a small program that is nothing:
$ for f in lt_lazy lt_now; do
printf "%-8s " $f
LD_DEBUG=statistics ./$f 2>& | grep -E 'total startup time' | head -1
done
lt_lazy total startup time in clock cycles: 184213
lt_now total startup time in clock cycles: 186004
Two imported functions, about 2000 cycles, roughly 1% — within noise. The same flag on a large binary that imports several thousand symbols is a different conversation, and this is the actual reason distributions ship the flag by default: the absolute cost is small, the security property is unconditional, and the ratio improves as programs get smaller. A modern binary imports fewer symbols than a 2005 binary did, because -fvisibility=hidden and static linking of libc internals both cut the exported surface.
But the cost is not only time, and this is the part that surprises people. Eager binding turns a load-time failure into a load-time failure. With lazy binding, a program whose libfoo.so is missing but which never actually calls into libfoo will start and run. With -z now, the same program fails at startup with a relocation error, because the loader resolves everything up front and one of the resolutions has no answer.
$ cat opt.c
extern int opt_init(void) __attribute__((weak));
int main(void) { return opt_init ? opt_init() : 0; }
$ clang -o opt opt.c # no -lfoo at all
$ ./opt ; echo $?
0
$ clang -o opt opt.c -Wl,-z,now
$ ./opt ; echo $?
./opt: error while loading shared libraries: ./libfoo.so:
cannot open shared object file: No such file or directory
Note that the symbol is weak and it still fails. -z now binds everything, and for a weak undefined symbol “bind” means the GOT slot is set to zero — and then the loader notices it is about to make a null page executable and refuses. Weak undefined symbols and -z now interact, and the interaction is a hard startup failure rather than a runtime null check. This is a real and easily-missed way for -z now to break a working program, and it is worth knowing before you add the flag to something that uses optional features.
$ cd courses/sym/assets/samples $ ./build_samples.sh 2>&1 | sed -n '/Finding 7\/8/,/Finding 9/p' $ python3 crosscheck.py 2>&1 | sed -n '/\[5\]/,/\[6\]/p'
Then establish the equality claim yourself, carefully — it is easy to get wrong:
1. diff the two .plt disassemblies raw. They differ.
Why? Which difference is a real change and which is
just the GOT having moved? How do you tell?
2. Build a program importing 50 functions, both ways.
Compare .plt SIZE. Is the stub really fixed-width?
What is the total .plt size in each case?
3. With 50 imports, is .got.plt still a separate section
under -z now? What happened to it?
4. Take lazytest.c and make the call UNCONDITIONAL.
Now both binaries resolve lib_fn. What did the flag
actually buy you in the original, if the answer is
"nothing observable in the disassembly"?
Question 4 is the one to sit with. -z now buys nothing that is visible in the file, and that is the correct way to read a hardening flag. Its entire effect is on what a running process does, which means you cannot review it by reading the binary — you have to observe behaviour, which is exactly why the LD_DEBUG=bindings experiment exists and why it is the load-bearing measurement in this concept rather than the readelf one.
This is the payoff concept of the runtime module, and it depends on The PLT and the GOT being understood literally. Read the six instructions again now: the second and third are dead code in the -z now binary, and you can say precisely why. That is the connection this concept exists to make, and it is why the flag is described as changing “when” rather than “what”.
Into the static half, the contrast is about what a flag can do. The Algorithm, Measured and Order, Archives and the Map File are about decisions baked into the file, where changing them means relinking. Here is a decision that cannot be baked in, because the information does not exist until run time, and the design response was to leave a variable in the file that the loader fills in later. The contrast between a link-time decision and a load-time decision is the real subject of this course, and this is the cleanest example of it.
Back to the identity module there is a smaller but real connection. A Name Is Not a Symbol named the two questions, and this concept is a case where the answer to Q2 — is having no answer allowed — is given by a binding and enforced by a load-time policy. The weak undefined symbol that survives lazily and kills the process eagerly is the sharpest illustration in the collection: the same symbol, the same table, the same loader, and a startup failure that depends on an unrelated linker flag.
And forward, the remaining runtime concept is where the eager decision gets expensive. Who Owns the Storage concerns data references, which this concept established are always eager — there is no lazy option for them, because an address is needed before any code runs. So the COPY question has no lazy answer available, and that is not an oversight in the design. It is the reason COPY is the controversial relocation it is.