Topic: mcp server double-free security
MCP server double-free security — concurrent tool calls and heap corruption in native addons
A double-free occurs when a heap allocation is freed twice. In glibc's ptmalloc and musl malloc, the second free() corrupts the allocator's tcache or fastbin metadata, creating a write-what-where primitive that an attacker can use to redirect execution. In MCP servers with native addons, this vulnerability class is almost exclusively triggered by concurrent tool invocations: two parallel requests share a cached native resource, and a reference-counting race causes both cleanup paths to call free() on the same pointer.
Double-free in concurrent MCP tool handlers
MCP servers are expected to handle concurrent tool invocations. An agent orchestrator may issue multiple tool calls in parallel — either within a single MCP session using batching, or by running concurrent agent workers each making tool calls. When the MCP server's native addon maintains a shared resource pool — a database connection pool, a compiled regex cache, a file handle cache, a tree-sitter parser instance — each concurrent invocation may obtain a reference to the same underlying native object.
The double-free arises at cleanup time. Worker A finishes first, decrements the reference count to zero, and calls free() on the shared object. Worker B, running concurrently on a separate libuv thread, also decrements what it believes is the reference count — but the decrement is not atomic, so both workers read the count as one, both decrement to zero, and both call free(). The allocator receives two free() calls for the same chunk.
The insidious aspect of this vulnerability is its load-dependency. Under sequential testing — a single tool call at a time — the race window never closes and the bug is invisible. Unit tests that do not exercise concurrent invocations provide no signal. The bug surfaces only under the concurrent, pipelined invocation patterns that agent orchestrators generate in production, often manifesting first as an intermittent server crash reported as "the MCP server stopped responding."
A reference-counting race condition — vulnerable vs. safe code
The following C++ example shows a connection pool with a manually implemented reference count that is not thread-safe:
// VULNERABLE — non-atomic reference count causes double-free under concurrency
struct Connection {
int ref_count; // plain int — not atomic, not mutex-protected
void *handle;
};
void connection_retain(Connection *c) {
c->ref_count++; // data race: two threads can both read 1, both write 2
}
void connection_release(Connection *c) {
c->ref_count--; // data race: two threads can both read 1, both write 0
if (c->ref_count == 0) {
close_handle(c->handle);
free(c); // DOUBLE-FREE: both threads reach here
}
}
// In two libuv work callbacks running concurrently:
// Thread A: connection_release(conn) → ref_count-- → 0 → free(conn)
// Thread B: connection_release(conn) → ref_count-- → 0 → free(conn) ← double-free
The fix is to use std::atomic<int> with fetch_sub, which returns the value before the decrement, making the "did I reach zero" check race-free:
// SAFE — atomic reference count prevents double-free
#include <stdatomic.h>
struct Connection {
atomic_int ref_count;
void *handle;
};
void connection_retain(Connection *c) {
atomic_fetch_add_explicit(&c->ref_count, 1, memory_order_relaxed);
}
void connection_release(Connection *c) {
// fetch_sub returns the OLD value; if it was 1, we just decremented to 0
int prev = atomic_fetch_sub_explicit(&c->ref_count, 1, memory_order_acq_rel);
if (prev == 1) {
close_handle(c->handle);
free(c); // only one thread reaches here — the one that saw prev == 1
}
}
// In C++, use std::shared_ptr which wraps this pattern in its control block:
// std::shared_ptr<Connection> conn = pool.acquire();
// — shared_ptr's control block uses atomic operations internally
In modern C++ codebases, prefer std::shared_ptr<T> over manual reference counting entirely. The shared_ptr control block uses atomic operations internally and has been audited extensively. The only case to reach for manual atomic reference counting is in C code or in performance-critical hot paths where the shared_ptr control block allocation is measured to be a bottleneck.
Heap exploitation consequences
After a double-free, the freed chunk appears twice in the allocator's free list — most commonly in glibc's tcache for small allocations. The next two malloc() calls for the same size class both return the same memory address. The two allocation sites then both believe they own the same region of memory and write to it independently.
If one of those two allocations is attacker-influenced — for example, the allocation that holds a tool argument buffer, whose size and contents come from the model — the attacker can write arbitrary bytes into the memory that the other allocation also holds. In a Node.js native addon, the second allocation might be a napi_value holding a JavaScript string, a function pointer table, or a callback reference. Overwriting a function pointer with attacker-controlled bytes achieves arbitrary code execution in the Node.js process — with the full privilege set of the process running the MCP server.
Even without precise exploitation, a double-free is always a denial-of-service: glibc detects many double-free conditions in the allocator's own integrity checks and raises SIGABRT with "free(): double free detected in tcache", crashing the server process. Under load testing with concurrent invocations, this manifests as periodic server crashes that take down the entire MCP session and any agent work in progress.
Detection and prevention
AddressSanitizer (ASan) is the primary development-time tool for catching double-free. Compile the native addon with -fsanitize=address and run the test suite with any concurrent invocation scenario — ASan intercepts both free() calls and reports a precise stack trace for both the original free and the double-free site:
# Build the addon with AddressSanitizer
node-gyp configure -- -Dasan=1
CFLAGS="-fsanitize=address -g" LDFLAGS="-fsanitize=address" node-gyp build
# Run tests — any double-free will abort with a stack trace
ASAN_OPTIONS=abort_on_error=1 node test/concurrent.js
Valgrind's Memcheck tool provides similar detection for POSIX builds without recompilation, at higher runtime overhead. For Rust-based native addons using FFI, the solution is architectural: use Arc<T> instead of raw pointers for any shared native resource. Rust's borrow checker makes double-free impossible in safe code — the compiler rejects programs where two owners attempt to free the same value. When crossing the FFI boundary, use Arc::into_raw and Arc::from_raw carefully and document every ownership transfer at FFI call sites.
In CI, add a concurrent stress test that issues N parallel tool calls and checks that the server process exits cleanly (exit code 0) after all calls complete. If any call results in a connection drop or the process exits with SIGABRT, the test fails.
SkillAudit detection
SkillAudit's Security axis analyzes native addon source for manual free() or delete calls on pointer types that appear in function parameters typed as raw pointers, where the calling context shows that the function is invoked from a thread pool (libuv work callback, C++ std::thread lambda, or pthread_create start routine). It flags each such site as a potential double-free if the pointer was also passed to another concurrently executing function in the same call graph. Additionally, SkillAudit checks for non-atomic integer fields named ref_count, refcount, or similar in structs that are shared across thread contexts, and reports them as thread-unsafe reference counting. The LLM probe issues 50 concurrent tool calls to each tool in the server's manifest and monitors for SIGABRT exits or unexpected connection drops. Run a free audit at skillaudit.dev to surface double-free risks in your native MCP addon before they cause production crashes.
Related: use-after-free security, heap corruption, race condition security.