Back to course

The PLT and the GOT

22 minadvancedModule 3: Resolution at RuntimeContinues from sym-hash
22 min · Module 3: Resolution at Runtime · Advanced

Why This Matters

A call to a function in another shared library cannot be a call instruction, because at link time nobody knows where that function is. It might be in a library that does not exist yet, loaded from a path chosen by configuration, on a machine with a different set of packages installed.

So the call site is redirected. Here is a real one, from a program built a moment ago:

$ objdump -d --no-show-raw-insn prog | grep -A2 'call'
  114c:	call   401030 <printf@plt>
  1154:	call   401040 <lib_fn@plt>

The call does not go to printf. It goes to printf@plt, a sixteen-byte stub in a section called .plt, which is a different name for the same function. Following that stub is the whole concept, and it is six instructions long.

A Simple Model

Two tables and a stub. The names are unfortunate and worth pinning down before anything else, because “GOT” means two different things depending on who is talking.

  .got          the Global Offset Table: every address the
                  linker knew at link time. Data references
                  point here so the loader can rewrite them.

  .got.plt      three reserved words, then one slot per PLT
                  entry. THE ONLY PART THAT IS WRITABLE BY
                  THE LOADER AT STARTUP on a lazy-binding
                  binary. This is what people mean when they
                  say "the GOT".

  .plt          executable stubs. One 16-byte stub per
                  imported function, plus a 16-byte PLT0
                  that is not a function at all.

That distinction is the one that trips people, so state it as a rule: .got.plt is where an address of a function goes, and it is writable; .got is where an address of data goes, and it is also writable, but for a different reason. Both are patched by the loader. Only .got.plt is patched by the PLT mechanism, and only on a lazy-binding binary.

Now the two relocation types, because they are what put the program and the library in the same conversation:

  R_X86_64_JUMP_SLOT   the loader writes the real address of
                       lib_fn into .got.plt[slot]. One slot
                       per imported FUNCTION.

  R_X86_64_GLOB_DAT    the loader writes the real address of
                       lib_data into .got[slot]. One slot
                       per imported DATA OBJECT.

Same shape, different table, different reason. A function needs a stub because the call instruction has to be redirected and there is no room in it. A datum needs a slot because the address is what is unknown, and the access instruction can encode an address. One needs indirection in the control flow, the other in the data.

Does It Actually Work?

Read the stubs. This is the actual disassembly, nothing elided:

$ objdump -d --no-show-raw-insn -j .plt prog

0000000000401020 <printf@plt-0x10>:          <-- PLT0. Not a function.
  401020:	push   0x2fca(%rip)        # 403ff0 <GOT+0x8>
  401026:	jmp    *0x2fcc(%rip)        # 403ff8 <GOT+0x10>
  40102c:	nopl   0x0(%rax)

0000000000401030 <printf@plt>:
  401030:	jmp    *0x2fca(%rip)        # 404000        <-- the slot
  401036:	push   $0x0                         <-- reloc index 0
  40103b:	jmp    401020 <PLT0>

0000000000401040 <lib_fn@plt>:
  401040:	jmp    *0x2fc2(%rip)        # 404008
  401046:	push   $0x1                         <-- reloc index 1
  40104b:	jmp    401020 <PLT0>

Now execute it, twice, and watch the state change.

  FIRST CALL to printf@plt

    1.  jmp *GOT[printf]      the slot holds the address of
                             _dl_runtime_resolve, because the
                             loader WROTE it there at startup
    2.  push $0               the index of printf's relocation
                             record, i.e. "look up record 0"
    3.  jmp PLT0
    4.  push GOT+8           the link_map pointer
    5.  jmp *GOT+0x10         _dl_runtime_resolve

    ... ld.so finds printf, writes its real address into
        GOT[printf], and jumps to it. The return lands on
        the instruction AFTER `call`.

  SECOND CALL to printf@plt

    1.  jmp *GOT[printf]      the slot NOW holds printf
    2.  push $0              never executed
    3.  jmp PLT0             never executed

The second call is three times shorter and never touches the resolver. The self-modifying part is the GOT slot, and it is the only thing that changes. Notice also what the first call costs: a push, a push, an indirect jump into the loader, a string comparison against every candidate symbol in every candidate library, a write to the GOT, and a jump back. That is the price of lazy binding, paid once per symbol, and it is why the second call is a single indirect jump.

And the security consequence, which is the reason -z now exists at all. Those two instructions between the jump and the PLT0 jump are an unconditional write to the stack followed by a jump to a fixed address. If an attacker can predict where the return address goes and write a value there, they own the instruction stream on the next call. The next concept measures what -z now actually does about it, and the answer is not what you would guess.

A Real Example

The GOT layout, read from a real binary, and note that the three reserved words are at the front:

$ readelf -SW prog | grep -E '\.got|\.plt '
  [11] .plt      PROGBITS  0000000000401020 001020 000030 10  AX
  [22] .got      PROGBITS  0000000000403fc8 002fc8 000020 08  WA
  [23] .got.plt  PROGBITS  0000000000403fe8 002fe8 000028 08  WA

  .got.plt  = 0x28 = 40 bytes = 5 words:
      +0x00  _DYNAMIC            the linker's pointer to .dynamic
      +0x08  link_map            pushed by PLT0
      +0x10  _dl_runtime_resolve jumped to by PLT0
      +0x18  printf              <- JUMP_SLOT 0
      +0x20  lib_fn              <- JUMP_SLOT 1

Three words of overhead for five imported functions is a terrible ratio, and it is why the overhead is a fixed cost rather than a per-symbol one. A program importing fifty functions has 53 words of .got.plt. The design has no way to avoid the three, because PLT0 needs them and PLT0 exists as soon as there is one import.

Now the data side, and the contrast is the point. Here is lib_data being read in the same program:

$ objdump -d --no-show-raw-insn prog | grep -A1 'lib_data>'
  1151:	mov    0x2e78(%rip),%rbx        # 3fd0 <lib_data>
  1158:	mov    (%rbx),%esi

Two instructions where the function needed a stub: load the address of lib_data from the GOT, then load the value through it. There is no PLT entry for data, no resolver call, and no lazy/eager distinction — lib_data is resolved during relocation processing at startup, unconditionally, because the address is needed before any code can run. A function’s address is needed only if the function is called; a datum’s address is needed by any instruction that touches it, and if no instruction touches it, there is no datum reference to resolve.

That asymmetry is the reason lazy binding can exist at all. If data references were lazy too, a program would need a check before every single memory access. Instead the loader resolves all data eagerly and all functions lazily, and the cost model falls out of that choice. Who Owns the Storage is the concept where this choice stops being free.

Try It Yourself

$ cd courses/sym/assets/samples
$ objdump -d --no-show-raw-insn -j .plt lt_lazy
$ readelf -SW lt_lazy | grep -E '\.got'
$ readelf -x .got.plt lt_lazy

Then count, rather than assume:

  1. lt_lazy imports 2 functions. How many words is
     .got.plt, and how many of them are the three
     reserved words? Now import 20 functions and recompute
     the overhead fraction.

  2. `push $0x0` and `push $0x1` are relocation INDICES.
     Read .rela.plt and confirm the mapping from index to
     symbol. Which file decides that numbering?

  3. Compile a program that imports a function and NEVER
     calls it. Does it still get a PLT entry? (Hint:
     -ffunction-sections and --gc-sections.)

  4. Take the PLT0 sequence: push GOT+8, jmp *GOT+0x10.
     ld.so must find a function taking (link_map, index)
     and returning the address. What are the C declarations?

Question 2 is the one that connects this concept back to the file format. The push $N is an index into .rela.plt, and .rela.plt is a section like any other — so the number is not a PLT property at all, it is a relocation-table property that the PLT is merely quoting. Change the relocation table and the push operand changes with it. That is why the stub is sixteen bytes of fixed shape: the operand has to fit in the same space every time.

Connect

This is the centre of the runtime module and both of its neighbours are prerequisites. Two Hash Tables, One Job built the structure on the far end of the jmp *GOT; this concept is the code that gets there. Lazy, Eager, and the Flag That Does Nothing is about the second instruction in each stub, and it contains the measurement that overturns the obvious assumption about what -z now does — so read it immediately after this one, or the six instructions above will be half-understood.

Back to the static half, the connection is that none of this machinery exists for a static link, and the reason is the previous module’s central property.The Algorithm, Measured gave a linker that knows every input file, so it can put a direct call in the code and write the resolved address into the instruction stream at link time. Order, Archives and the Map File gave the same thing from the other side: order mattered because the answer was computable. The PLT is what you have to build when the answer is not computable yet, and the six instructions are the smallest thing that works.

Forward, the third concept in the runtime group is where the data side of this one becomes a problem. Here we saw that a data reference needs no stub and is resolved eagerly, and that seemed like the cheaper option. Who Owns the Storage shows what the eager path does when the library’s copy and the program’s copy cannot both be right — and that the choice between them is made by a compile flag that has nothing to do with linking. Read the two instruction sequence above and the COPY case side by side; the contrast is the concept.

And the connection out of the course. The layout here — fixed-width stub, index operand, side table — is the same shape as a vtable, an exception landing pad, and a tail call thunk, and for the same reason: each is a case where the target is not known when the code is emitted, and the fix is to put the variable part somewhere the emitted code can reach indirectly and keep the emitted code fixed-width. The difference here is that the indirection is resolved once and then bypassed, which is why the steady-state cost of a resolved PLT call is a single indirect jump — and why the second and subsequent calls are indistinguishable in cost from a static call.