This is a reversing lab, not a survey. Target is a 20-line PIE I keep in labs/plt_lab/ (not shipped). Goal: prove on the wire of the process that puts@plt does not contain puts until the first call, then the GOT slot is patched. After that, Partial RELRO vs Full RELRO is a one-command check, not a slogan.

Lazy PLT bind then libc
Figure 1. First call walks resolver; later calls jump through a filled GOT slot.

Lab binary

 1/* plt_lab.c — toy, no network, no privs */
 2#include <stdio.h>
 3#include <unistd.h>
 4
 5int main(void) {
 6    const char *id = "plt-lab";
 7    puts(id);
 8    puts(id);
 9    return 0;
10}
1cc -O0 -fPIE -pie -Wl,-z,lazy -o plt_lab plt_lab.c
2file plt_lab
3# plt_lab: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked, not stripped

On x86_64 the same commands apply; only the disassembly mnemonics change. I recorded this run on aarch64 because that is what I reverse on phones.

File-level facts before touching the debugger

1$ readelf -d plt_lab | egrep 'NEEDED|FLAGS|BIND_NOW|GNU_RELRO'
2 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
3 0x000000000000001e (FLAGS)              BIND_NOW     ; absent here — lazy
4 0x000000006ffffffb (FLAGS_1)            Flags: PIE
5
6$ readelf -l plt_lab | grep GNU_RELRO
7  GNU_RELRO      0x0000000000000d80 0x000000000000fd80 0x000000000000fd80

GNU_RELRO present without BIND_NOW is Partial RELRO: .data.rel.ro is made read-only, the GOT used by PLT stays writable so the resolver can patch it.

1$ readelf -r plt_lab | head
2Relocation section '.rela.plt' at offset 0x6c8 contains 2 entries:
3  Offset          Info           Type           Sym. Value    Sym. Name + Addend
400000000000fd8  000200000402 R_AARCH64_JUMP_SLO 0000000000000000 puts@GLIBC_2.17 + 0
5
6$ readelf -s plt_lab | egrep 'puts|main'
7     2: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND puts@GLIBC_2.17 (2)
8    12: 00000000000007a4    48 FUNC    GLOBAL DEFAULT   14 main

puts is UND. The JUMP_SLOT offset 0xfd8 is the GOT slot, file-relative; at runtime add the load bias.

Disassembly of the PLT stub

 1$ objdump -d plt_lab | sed -n '/Disassembly of section .plt/,+24p'
 200000000000006b0 <.plt>:
 3    6b0:   a9bf7bf0    stp     x16, x30, [sp, #-16]!
 4    6b4:   90000090    adrp    x16, 1000 <__FRAME_END__+...>
 5    6b8:   f9400e11    ldr     x17, [x16, #24]
 6    6bc:   91006210    add     x16, x16, #0x18
 7    6c0:   d61f0220    br      x17            ; first-call: resolver
 8
 900000000000006d0 <puts@plt>:
10    6d0:   90000090    adrp    x16, 1000
11    6d4:   f9401211    ldr     x17, [x16, #32]  ; GOT[puts]
12    6d8:   91008210    add     x16, x16, #0x20
13    6dc:   d61f0220    br      x17

main only ever calls the stub:

 100000000000007a4 <main>:
 2    7a4:   a9be7bfd    stp     x29, x30, [sp, #-32]!
 3    7a8:   910003fd    mov     x29, sp
 4    7ac:   90000000    adrp    x0, 0 <_init-0x6b0>
 5    7b0:   91204000    add     x0, x0, #0x810
 6    7b4:   97ffffc7    bl      6d0 <puts@plt>
 7    7b8:   90000000    adrp    x0, 0
 8    7bc:   91204000    add     x0, x0, #0x810
 9    7c0:   97ffffc4    bl      6d0 <puts@plt>
10    7c4:   52800000    mov     w0, #0
11    7c8:   a8c27bfd    ldp     x29, x30, [sp], #32
12    7cc:   d65f03c0    ret

There is no direct bl into libc. If a write-up shows bl 0x7f....puts, that dump was taken after bind or the tool already resolved symbols through the GOT.

Reproduction: GOT slot before and after the first puts

I use gdb. Break on the PLT stub, not on putsputs does not exist in this module.

 1$ gdb -q ./plt_lab
 2(gdb) set disable-randomization on     ; lab only, so numbers repeat
 3(gdb) break *0x5555555506d0            ; puts@plt, bias depends on run
 4# on PIE with disable-randomization, gdb prints the relocated VA:
 5(gdb) break puts@plt
 6Breakpoint 1 at 0xaaaaaaab06d0
 7(gdb) run
 8Breakpoint 1, 0xaaaaaaab06d0 in puts@plt ()
 9
10(gdb) x/gx $x16+32
110xaaaaaaab0fd8: 0x0000aaaaaaab06e0     ; still points near PLT, NOT libc
12
13(gdb) finish
14plt-lab
15(gdb) x/gx 0xaaaaaaab0fd8
160xaaaaaaab0fd8: 0x0000fffff7e8c4a0     ; libc puts, [REDACTED] ASLR slide
17(gdb) info symbol 0xfffff7e8c4a0
18puts in section .text of /lib/aarch64-linux-gnu/libc.so.6

Second puts@plt hit: same GOT word, already libc. Resolver did not run again. That is the whole lazy-bind story, measured, not recited.

On a Full RELRO build:

1cc -O0 -fPIE -pie -Wl,-z,relro,-z,now -o plt_now plt_lab.c
2readelf -d plt_now | grep BIND_NOW
3# 0x000000000000001e (FLAGS)              BIND_NOW

gdb at _start: GOT[puts] already holds the libc VA. There is nothing for a runtime patch to write. That is why GOT-overwrite notes that skip the RELRO check are noise.

What I actually look for in a stripped sample

  1. .rela.plt JUMP_SLOT list — imported names that survive strip.
  2. GNU_RELRO vs BIND_NOW.
  3. First-call vs second-call GOT value if I need to confirm lazy bind in a packer that rewrites PLT.
  4. Never treat a GOT VA from a screenshot as stable across boots.

Sanitization: libc load addresses in this note are replaced with a repeating gdb session under set disable-randomization on. Production traces get the slide [REDACTED].

Detection / hardening

1checksec --file=plt_lab
2# RELRO           STACK CANARY      NX            PIE
3# Partial RELRO   No canary found   NX enabled    PIE enabled

Ship Full RELRO (-Wl,-z,relro,-z,now) unless a documented lazy-bind requirement exists. For incident work, a writable GOT plus a leftover JMP_SLOT to system is a hunting lead, not a conclusion.

Commands appendix

1readelf -d "$1" | egrep 'NEEDED|BIND_NOW|FLAGS_1'
2readelf -r "$1" | grep JUMP_SLOT
3objdump -d "$1" | less +/'.plt'
4gdb -q "$1" -ex 'b puts@plt' -ex 'run'