Skip to content
• 7 min read

Arbitrary Pointer Manipulation Explained: When Memory-Safe Code Hands You the Keys

A memory-safe language can prevent whole classes of memory corruption, then leave one perfectly valid pointer in exactly the wrong place. This write-up breaks down how pointer corruption, stale length metadata, and indirect callbacks can turn an innocent note cache into code execution.

#Memory Safety #Pointer Corruption #RCE #Zig #Binary Exploitation
Arbitrary Pointer Manipulation Explained: When Memory-Safe Code Hands You the Keys hero illustration
Listen to Research
AI Narration
0:00 / --:--

Arbitrary Pointer Manipulation Explained: When Memory-Safe Code Hands You the Keys

Imagine walking into a building where none of the doors are broken. The locks work, the guard works, and the paperwork is beautifully organized. Then the guard lets you replace the room number he is about to visit.

That is roughly what pointer manipulation bugs can look like, even in software written in a language that cares deeply about memory safety. You do not always need a classic heap overflow or use-after-free. Sometimes a perfectly valid pointer is enough, provided you can make the program point it at the wrong object and then use it in a sensitive operation such as a read or an indirect callback.

In this article, we will build a generic note-cache scenario, turn a small pointer primitive into an arbitrary read and then control-flow hijack, and finish with the design changes that actually close the hole.

What Is Pointer Corruption, Anyway?

Pointer corruption means the program is still holding a value that is valid as a pointer, but that pointer now references the wrong place.

That distinction matters.

With a classic buffer overflow, you write past a boundary and damage neighboring memory. With pointer corruption, every individual write can still be inside a valid buffer. You changed the pointer first, and the later write simply obeyed it.

The pen never left the paper. You just changed the address at the top of the page.

Consider an object like:

Object
├── payload pointer
├── payload length
└── callback pointer

If an operation lets an attacker influence the payload pointer without validating where it is allowed to point, every later operation that trusts it inherits the problem.

Why Should You Care?

This pattern is dangerous because tiny primitives can chain together:

  • Arbitrary read: make the program read from an address it did not intend to expose.
  • Information disclosure: leak a pointer and recover a PIE base or another useful address.
  • Metadata corruption: redirect a pointer or length so later operations work on a different object.
  • Control-flow hijacking: if the object carries a callback or function pointer, that pointer becomes a target.
  • RCE: an indirect call through a corrupted callback can land on a function that already exists inside the process.

The annoying part is that every individual step can look reasonable. The danger appears when you compose them.

Worked Example: A Generic Note Service

Assume a small TCP service that stores temporary notes.

Each note object looks like this:

+0x00  payload *
+0x08  length
+0x10  callback *

And the service exposes simplified commands:

PUT key data
GET key length
PATCH key data
RENDER key
DEL key

The intended behavior is straightforward:

  • PUT creates a note.
  • GET returns a bounded amount of data.
  • PATCH updates the payload.
  • RENDER invokes a callback to print it.
  • DEL removes the note.

The trouble starts when PATCH calculates its write boundary from the current payload pointer instead of from trusted object ownership.

Step 1: Leak a Pointer

Suppose GET returns slightly more than the caller really needs and exposes bytes beyond the payload:

payload bytes
node metadata
...

You may recover values such as:

payload1 = 0x7f...
payload2 = 0x7f...

If the node is placed immediately before the payload at a known offset:

node = payload - 0x20

you now have a map of the object’s layout.

The leak does not give you code execution. It gives you the floor plan.

Step 2: Redirect the Payload Pointer

If PATCH lets you overwrite object metadata after the payload starts, you can rewrite:

node1->payload

so it points at:

node2->callback

The resulting state becomes:

node1->payload = &node2->callback
node1->length  = 8

Now RENDER node1 no longer treats node1’s original payload as its data. It treats node2’s callback pointer as data.

That is the moment the small bug becomes an arbitrary read primitive.

Step 3: Turn the Leak into a PIE Base

Assume the normal callback lives at a fixed offset:

callback = PIE_base + 0x12340

Then:

PIE_base = callback - 0x12340

Once the PIE base is known, any other known symbol can be calculated:

target = PIE_base + target_offset

ASLR can move the image, but offsets within the same image remain stable.

Step 4: Overwrite the Callback

Now you want to change the function pointer.

If the original callback and the desired target live in the same PIE image, a partial overwrite of the low bytes may be enough.

For example:

original callback:
0x7f xx xx 12 34 56

target function:
0x7f xx xx ab cd ef

You only need to change:

ab cd ef

instead of rewriting all eight bytes.

This technique is useful because it preserves the unknown high bytes while replacing the portion that differs.

Step 5: Trigger Indirect Execution

Finally:

RENDER node2
        |
        v
node2->callback(...)
        |
        v
target function

If that target function reaches command execution in the process context, the primitive becomes RCE.

The interesting part is what you did not need. You did not have to corrupt the allocator. You did not have to inject an entire code blob. You convinced the application to use a valid pointer for the wrong purpose.

The Core Primitive

The entire chain can be summarized as:

Pointer leak
    ↓
Derive object layout
    ↓
Corrupt payload pointer
    ↓
Read sensitive pointer
    ↓
Recover PIE base
    ↓
Corrupt callback
    ↓
Trigger indirect call
    ↓
Code execution

That is why “memory safe” is not the same thing as “exploit safe.”

A language can enforce strong memory rules and still allow application logic to transform:

"pointer to bytes"

into:

"pointer to executable metadata"

through perfectly valid operations.

Vulnerable Code Example

This small generic Zig example illustrates the problem:

const Node = struct {
    payload: []u8,
    callback: *const fn ([]u8) void,
};

fn patch(node: *Node, new_data: []const u8) !void {
    // Dangerous: the write target comes from mutable payload state.
    if (new_data.len > node.payload.len + 24)
        return error.TooLarge;

    @memcpy(node.payload.ptr, new_data);
}

The dangerous part is not @memcpy itself. The problem is that node.payload.ptr is assumed to be trustworthy because the type is valid. If an attacker can influence that pointer, a later write may target object metadata.

And because the same object contains a callback, metadata corruption can become control-flow corruption.

Patched Version

A safer design keeps storage ownership separate from sensitive object metadata:

const Node = struct {
    storage: []u8,
    callback: *const fn ([]u8) void,
};

fn patch(node: *Node, new_data: []const u8) !void {
    if (new_data.len > node.storage.len)
        return error.TooLarge;

    @memcpy(node.storage[0..new_data.len], new_data);
}

The design rule is simple:

untrusted input -> bytes
trusted code    -> object pointers

Not:

untrusted input -> object pointers

Defense: How to Fix

  1. Do not let untrusted input modify object pointers.
    If the user needs to update data, update the bytes. Do not let them choose an address or slice base.

  2. Separate metadata from attacker-controlled storage.
    Keeping payload, length, and callback tightly coupled increases the impact of a single metadata bug.

  3. Make ownership explicit.
    The storage an attacker can write should not also hold the metadata that controls execution.

  4. Do not derive write bounds from mutable pointers.
    Bounds should come from trusted allocation or container ownership, not from a pointer that can itself be redirected.

  5. Treat function pointers as control-flow state.
    Any path that indirectly reaches a callback should be isolated from attacker-controlled mutation. Better yet, avoid mutable function pointers in the first place when a dispatch table or enum-based operation can do the job.

  6. Check object invariants after mutations.
    Before PATCH or RENDER, verify that payload still belongs to storage owned by the current object and that length is within that storage.

  7. Keep primitives isolated.
    If an API exposes GET, PATCH, RENDER, and DEL, make sure one operation cannot silently invalidate the assumptions another one relies on.

Testing / Audit Points

When reviewing similar source or binaries, look for these patterns:

AreaQuestion
Pointer fieldsCan external input influence a pointer or slice base?
Length fieldsCan the stored length diverge from the real allocation?
CallbacksIs there an indirect call through mutable object state?
SerializationCan user input affect struct metadata or layout?
Read pathsCan a read operation walk outside the expected payload?
Write pathsAre write limits derived from mutable pointer state?
ASLRDoes any leak expose a code pointer that can reveal a PIE base?

Common Myth: “But Zig Is Memory Safe”

The language can prevent many classes of memory corruption. It does not infer your application’s security invariants for you.

If the program says:

this pointer is valid

and the attacker can make that pointer reference a different valid object, the compiler is not going to stop and announce:

“That callback was supposed to remain private.”

That is a design responsibility.

Final Thoughts

The most interesting exploitation primitives are not always the ones that make the process write past the end of an array. Sometimes every access is technically in bounds, every pointer has a valid type, and every operation is legal.

Then one pointer gets redirected.

After that, the application does the rest of the work for the attacker.

Memory safety can shrink the attack surface dramatically, but it does not replace sound object invariants and trust boundaries.

If an attacker can choose where a sensitive pointer points, you may have already handed them the key.

⌘
Suggested Searches