Topic: mcp server dangling pointer security

MCP server dangling pointer security — stale references after realloc and container resize

A dangling pointer is a pointer that refers to memory that is no longer validly allocated at that address — the allocation has been freed, moved by realloc, or relocated by a container resize. Reading through a dangling pointer returns stale or attacker-influenced data. Writing through one corrupts whatever the allocator placed at the new occupant of that address. In MCP server native addons, the most common sources are realloc patterns where the original pointer is retained after the call, std::vector push_back that triggers a resize while iterators are outstanding, and WASM linear memory growth that detaches JavaScript TypedArray views over the old buffer.

The realloc dangling pointer pattern

realloc may return a different pointer than its input. If reallocation requires moving the block to a new address, the original pointer becomes invalid immediately. The most common bug is storing the original pointer in a second variable before the realloc call and then continuing to use the original variable after:

// C — VULNERABLE
char *buf = malloc(initial_size);
// ... fill buf with data ...

// Tool receives more data: grow the buffer
char *new_buf = realloc(buf, new_size);
if (!new_buf) { free(buf); return ALLOC_FAILED; }
// BUG: if realloc moved the block, buf is now a dangling pointer.
// The error-handling branch correctly frees buf on realloc failure,
// but the success path silently continues to use the old pointer if
// code elsewhere in the function still references 'buf'.

// This function continues using buf, not new_buf:
memcpy(buf + offset, new_data, new_data_len);  // writes through dangling ptr

The fix is to update the pointer variable in-place and never retain the old pointer after the realloc call:

// C — SAFE: use a temp for error handling only, then reassign
char *tmp = realloc(buf, new_size);
if (!tmp) { free(buf); buf = NULL; return ALLOC_FAILED; }
buf = tmp;  // buf is now valid; the old address is no longer referenced
memcpy(buf + offset, new_data, new_data_len);  // safe

std::vector resize and iterator invalidation

In C++ MCP addon code that accumulates tool results into a std::vector, push_back and insert operations trigger a resize when the vector's capacity is exhausted. A resize allocates a new backing buffer, copies all elements, and frees the old buffer. Any pointer, reference, or iterator to elements in the old buffer is now dangling.

// C++ — VULNERABLE
std::vector<ToolResult> results;
results.reserve(initial_capacity);

// Take a reference to the first element before push_back
const ToolResult &first = results[0];

for (const auto &call : pending_calls) {
  results.push_back(process(call));  // may trigger resize if capacity exceeded
  // After resize, 'first' is a dangling reference
}

// BUG: first refers to the old backing buffer; behavior is undefined
log("First result ID: " + first.id);

Safe patterns: access elements by index into the vector (which self-updates on resize), or reserve enough capacity upfront to guarantee no resize occurs, or complete all insertions before taking any references:

// C++ — SAFE: reserve exact count before any push_back
results.reserve(pending_calls.size());
for (const auto &call : pending_calls) {
  results.push_back(process(call));  // no resize possible; no iterator invalidation
}
const ToolResult &first = results[0];  // taken after all insertions complete

WASM memory.grow() and detached TypedArray views

WebAssembly's memory.grow(n) instruction increases the module's linear memory by n pages (each 64 KB). When this happens, the JavaScript ArrayBuffer backing the module's memory is detached — it becomes a zero-length buffer, and all existing TypedArray and DataView objects constructed over it become detached. Any read or write through a detached view silently returns 0 (reads) or is discarded (writes), or throws a TypeError depending on the engine.

// WASM host JS — VULNERABLE
const memory = wasmInstance.exports.memory;

// Take a view before any tool call that might grow memory
const inputView = new Uint8Array(memory.buffer);
inputView.set(encodedToolArgs, inputPtr);  // write args into WASM memory

// Call a WASM function that may internally call memory.grow()
wasmInstance.exports.process_tool_call(inputPtr, argsLen);

// BUG: if process_tool_call called memory.grow(), memory.buffer has been
// detached and replaced. This view points to the old ArrayBuffer.
const outputView = new Uint8Array(memory.buffer, outputPtr, outputLen);
// outputView is valid ONLY if memory.grow() was not called above.
// There is no way to tell from outside the WASM module whether it grew.

The safe pattern is to always re-derive views from memory.buffer immediately before use, never retaining a view across a call that might grow memory:

// WASM host JS — SAFE: re-derive view after each WASM call
function getMemoryView() {
  // Always read .buffer freshly — if memory grew, .buffer is the new object
  return new Uint8Array(wasmInstance.exports.memory.buffer);
}

let view = getMemoryView();
view.set(encodedToolArgs, inputPtr);

wasmInstance.exports.process_tool_call(inputPtr, argsLen);

// Re-derive AFTER the WASM call that might have grown memory
view = getMemoryView();
const output = view.slice(outputPtr, outputPtr + outputLen);  // copy out

For WASM modules compiled with memory growth disabled (--max-memory set equal to --initial-memory), memory.grow() always fails and memory.buffer is stable for the module's lifetime. If the module's WASM binary exports a grow call or if the host JavaScript ever calls memory.grow(), treat all existing views as potentially stale after any WASM function call.

Arena allocators for batch tool operations

A common mitigation for both realloc and iterator-invalidation dangling pointers in batch-processing MCP tool handlers is to use an arena allocator for the duration of the request: all allocations for a single tool call come from a fixed-size arena, and the entire arena is freed at the end of the call. Since no individual allocation is freed before the end of the call, no pointer into the arena can be dangling during the call's lifetime. This eliminates the realloc class entirely at the cost of pre-allocating the maximum needed memory upfront.

SkillAudit detection

SkillAudit's Security axis scans native addon C and C++ code for realloc call sites where the return value is stored in a new variable but the original variable continues to appear in subsequent code paths — a classic dangling-pointer-after-realloc pattern. In C++ code, it flags push_back or insert calls on vectors where a reference, pointer, or iterator to a vector element was taken prior to the modification. In WASM host JavaScript, SkillAudit identifies TypedArray or DataView constructions over wasm.memory.buffer that are retained (assigned to outer-scope variables) and then read after a WASM function call that could trigger memory growth. The stress-test probe exercises tool handlers with progressively increasing argument sizes designed to trigger buffer reallocation and checks for data corruption artifacts in responses. Run a free audit at skillaudit.dev to check your MCP native addon for dangling pointer vulnerabilities.

Related: use-after-free, heap corruption, Rust FFI memory safety, security checklist.