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.
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 puts — puts 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
.rela.pltJUMP_SLOT list — imported names that survive strip.GNU_RELROvsBIND_NOW.- First-call vs second-call GOT value if I need to confirm lazy bind in a packer that rewrites PLT.
- 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'