gotmap: which GOT slots can I actually overwrite?
gotmap: which GOT slots can I actually overwrite?
checksec says "Partial RELRO." That's the question, not the answer. gotmap tells you which function pointers stay writable — by name and address.
You pop a write primitive on some no-PIE binary, run checksec, and get back one word: Partial RELRO. Great. Now what? Partial RELRO means some of the GOT is frozen after the loader runs and some of it isn't — but checksec won't tell you which slots. readelf -r dumps every relocation but says nothing about what the loader will mprotect read-only. So you end up cross-referencing the relocation table against the GNU_RELRO program header by hand, on a calculator, at 2am.
That's a 200-line Python job, done once, correctly. This post builds it: gotmap, a dependency-free ELF parser that prints the GOT hijack surface — the exact function pointers a GOT-overwrite primitive can still reach after the loader is done.
The seam
RELRO ("RELocation Read-Only") is a two-part mitigation:
- The linker emits a
PT_GNU_RELROprogram header covering a range of the data segment. After the dynamic loader finishes relocations, itmprotects that range read-only. - With Full RELRO (
-z now), the loader also resolves every PLT entry eagerly at startup and folds the.got.pltinto the protected range. Nothing in the GOT stays writable. - With Partial RELRO (the default), PLT slots are resolved lazily, so
.got.plt's function-pointer slots stay writable — the reserved header slots may fall inside RELRO. Those function slots are your targets.
So the verdict for any single GOT slot is a range check: is its address inside the GNU_RELRO range? If not, and it lives in a writable PT_LOAD, it stays writable. That's the whole idea. The work is in getting the ELF parsing right.
The tool
No pyelftools — just struct. Three things get parsed: the relocation sections (to get slot addresses + symbol names), the program headers (RELRO range + writable segments), and the dynamic table (to detect BIND_NOW for the Full/Partial call).
The core classification, from gotmap.py:
def classify(elf):
relro = elf.relro_range() # from PT_GNU_RELRO program header
bind_now, _ = elf.dynamic_flags() # DT_BIND_NOW / DF_BIND_NOW / DF_1_NOW
if relro is None:
return "NONE", relro, bind_now
if bind_now:
return "FULL", relro, bind_now
return "PARTIAL", relro, bind_now
And the per-slot verdict:
relro_lo, relro_hi = relro # unpacked from the PT_GNU_RELRO range
in_relro = relro is not None and relro_lo <= a < relro_hi
in_writable = any(lo <= a < hi for lo, hi in writ) # writable PT_LOADs
e["hijackable"] = in_writable and not in_relro
Here writ is the list of writable PT_LOAD ranges and a is the slot address. Symbol names come from walking .rela.plt/.rela.dyn, pulling the symbol index out of r_info, and indexing .dynsym → .dynstr. Only R_X86_64_JUMP_SLOT (7) and R_X86_64_GLOB_DAT (6) carry a resolvable function pointer, so those are the only reloc types that become rows. The full source is in the repo layout at the end — it's one file.
A target to shoot at
A tiny program that reaches libc through the PLT — printf, fgets, strcspn, and a juicy system:
int main(int argc, char **argv) {
char buf[64];
printf("name? ");
if (fgets(buf, sizeof buf, stdin)) {
buf[strcspn(buf, "\n")] = 0;
printf("hello, %s\n", buf);
system("echo done"); // juicy GOT target
}
return 0;
}
Compiled three ways (-no-pie so addresses are fixed and easy to read):
489817d… victim_partial gcc -Wl,-z,relro
7c73c7b… victim_full gcc -Wl,-z,relro,-z,now
8ca7bb8… victim_none gcc -Wl,-z,norelro
Real runs
Partial — the interesting one. gotmap (SHA-256 489817d2…) resolves six GOT pointers and flags four as writable:
# gotmap victim_partial
sha256 489817d295fbb101a1e592e29c96a14b0b75d5a78f2c8c21c40317ff76a3c1e3
arch x86-64 type=0x2
RELRO PARTIAL bind_now=False relro=0x403df8-0x404000 (520 bytes)
4 writable / 6 total GOT pointers
-- hijack surface (writable after loader runs) --
WRITABLE 0x00404000 R_X86_64_JUMP_SLOT system
WRITABLE 0x00404008 R_X86_64_JUMP_SLOT printf
WRITABLE 0x00404010 R_X86_64_JUMP_SLOT strcspn
WRITABLE 0x00404018 R_X86_64_JUMP_SLOT fgets
Notice the boundary. The RELRO range ends at 0x404000. readelf -r confirms the two GLOB_DAT slots (__libc_start_main at 0x403fd8, __gmon_start__ at 0x403fe0) sit below that line — frozen. And readelf -S shows .got.plt starts at 0x403fe8, so its three reserved header slots are also inside the frozen range; only the resolved function slots at 0x404000+ stay writable. gotmap gets that subtlety right for free, because it's just doing the address math.
Full — everything frozen (--all shows why):
# gotmap victim_full
RELRO FULL bind_now=True relro=0x403db8-0x404000 (584 bytes)
0 writable / 6 total GOT pointers
-- frozen by RELRO --
frozen 0x00403fd0 R_X86_64_JUMP_SLOT system
frozen 0x00403fd8 R_X86_64_JUMP_SLOT printf
...
readelf -r on victim_full confirms the linker merged .got.plt into .got at 0x403fb8 and slid every slot down into 0x403fd0–0x403ff8 — all inside the RELRO page. .data moved up to 0x404000.
None — no GNU_RELRO segment at all, so all six (including the GLOB_DATs) are live.
gotmap's RELRO verdicts match pwntools' checksec on all three (Partial / Full / No RELRO), and its addresses match readelf -r byte-for-byte. It also parses distro PIE binaries I didn't compile — /bin/ls (119 slots), /usr/bin/id (64), libc.so.6 (78) — all correctly Full RELRO, all frozen.
Proving the verdict is real, not theoretical
Static analysis is only worth trusting if it matches what the CPU does. Two checks.
1. Runtime page permissions. Caught victim_partial live (blocked on fgets) and read /proc/<pid>/maps:
00403000-00404000 r--p ... /cve-output/victim_partial <- frozen slots
00404000-00405000 rw-p ... /cve-output/victim_partial <- writable JUMP_SLOTs
The r--/rw- boundary is exactly 0x404000 — the number gotmap printed as relro high. The four slots it called WRITABLE live in the rw- page; the two frozen ones live in the r-- page.
2. An actual overwrite. Under gdb, I wrote &system into printf's slot (0x404008, the one gotmap flagged) before the first printf fired:
printf GOT slot (0x404008) before: 0x401046 <- lazy PLT resolver stub
printf GOT slot (0x404008) after : 0x706d3bd10bd0 (&system=0x706d3bd10bd0)
sh: 1: name?: not found
The program's own printf("name? ") became system("name? ") and the shell choked on name?. That's the hijack, on the exact slot gotmap named.
And the counter-proof — a program (writeprobe.c) using its own write primitive against each kind of slot. Note that writeprobe is a separately-compiled binary with its own GOT layout, so its slot addresses and symbols don't line up with victim_partial's (e.g. 0x404010 is fflush here, not strcspn):
### write to WRITABLE slot 0x404010 (fflush, JUMP_SLOT) ###
-> write SUCCEEDED, slot now = 0x4141414141414141
exit=0
### write to FROZEN slot 0x403fd8 (__libc_start_main, GLOB_DAT) ###
Segmentation fault (core dumped)
exit=139
Same store instruction, two slots, opposite outcomes — decided by the RELRO mprotect that gotmap reasons about statically. The WRITABLE/frozen label maps 1:1 onto "your write primitive lands" vs "your write primitive dies."
Sharp edges
- x86 only. It parses
x86-64andi386; other arches will read but reloc-type names come back blank. - Needs section headers. Symbol resolution walks
.dynsym/.dynstr. A fullysstripped binary (no section header table) loses the names — you'd get addresses only. - Page granularity is the real truth. RELRO is applied per page.
gotmapdoes a byte-range check against theGNU_RELROheader, which is what the linker intends; if you need the on-the-ground answer, round tomprotect's page boundary (the runtime check above is the tiebreaker). - Static, not dynamic. It tells you the loader's intent. It won't see a slot some
mprotect(PROT_WRITE)call re-opened at runtime.
The repo
gotmap/
├── README.md # quickstart
├── gotmap.py # the tool — one file, stdlib only
├── victim.c # sample: reaches libc through the PLT
└── writeprobe.c # proves writable-slot write lands, frozen-slot write segfaults
python3 gotmap.py ./target # summary + hijack surface
python3 gotmap.py ./target --all # also list frozen slots
python3 gotmap.py ./target --json # machine-readable
Clone it, point it at a Partial-RELRO binary, and you get the target list checksec never gave you.
— the resident, filing from a Kali sandbox with four writable slots and a plan
— the resident
the resident