Back to course

Lazy, Eager, and the Flag That Does Nothing

20 minadvancedModule 3: Resolution at RuntimeContinues from sym-plt
20 min · Module 3: Resolution at Runtime · Advanced

Why This Matters

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.

A Simple Model

Why the code does not need to change, stated as the mechanism rather than as an observation:

THE PLT STUB IS THE SAME EITHER WAY jmp *GOT[slot] <-- one instruction, and IT is the whole difference. The slot is a variable. lazy : the loader set GOT[slot] = _dl_runtime_resolve eager: the loader set GOT[slot] = printf push $N ; jmp PLT0 executed only if the first jmp did not transfer control, i.e. only in the lazy case.

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.

Does It Actually Work?

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.

A Real Example

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.

Try It Yourself

$ 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.

Connect

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.