Topic: mcp server use-after-free security
MCP server use-after-free security — memory safety in native addons and WASM
Use-after-free is one of the most exploitable memory safety vulnerabilities in native MCP server code. It occurs whenever C/C++ or Rust FFI code reads or writes through a pointer to memory that the allocator has already reclaimed — and in an MCP server, async tool handlers create exactly the timing window that makes this happen silently under load. In a WASM MCP server, the same class of bug surfaces when the JavaScript host retains a view into WASM linear memory after the module has called free() on that region.
Why use-after-free appears in MCP server native addons
MCP tool handlers are inherently asynchronous: a JSON-RPC request arrives over stdio or HTTP, the Node.js event loop dispatches it to a tool handler, and the handler may delegate expensive work to a native addon via a libuv worker thread. The worker thread receives pointers to data that originated in JavaScript — most often a raw char* or uint8_t* extracted from a Buffer or ArrayBuffer. The problem arises because V8's garbage collector does not know that native code is using that pointer. If the JavaScript-side Buffer object goes out of scope — because the handler function returned, or because no persistent reference was kept — V8 is free to collect it while the native worker is still reading it.
A concrete and common example is a tree-sitter-based code-analysis MCP server. The server exposes a parse_file tool that accepts source code as a string argument. The JavaScript wrapper extracts a char* from the input Buffer and passes it to a C parse job queued on the libuv thread pool. If the wrapper does not store the Buffer in a napi_ref before returning to the event loop, V8 may run a minor GC cycle between the time the work is queued and the time the work callback reads the pointer. The parse job then reads freed heap — returning corrupted AST data or crashing the server process.
This is not a hypothetical: it is a class of bug that appears in real published addons precisely because the failure mode is intermittent. Under light sequential load the GC timing window rarely closes before the native work completes. Under the concurrent, high-throughput invocation patterns that an agent orchestrator generates, the window hits regularly.
The async callback timing window — vulnerable vs. safe patterns
The vulnerable pattern extracts a raw pointer from a Node.js Buffer and queues it for use in a uv_work_t callback without pinning the Buffer against GC:
// VULNERABLE — Buffer may be GC'd before work_cb fires
struct ParseWork {
uv_work_t req;
const char *src; // raw pointer into JS heap
size_t src_len;
TSTree *result;
};
napi_value parse_async(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value argv[1];
napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
size_t len;
napi_get_value_string_utf8(env, argv[0], NULL, 0, &len);
ParseWork *w = calloc(1, sizeof(*w));
// BUG: we store a pointer to the underlying JS string data
// but hold no reference keeping the JS value alive.
napi_get_value_string_utf8(env, argv[0], (char *)w->src, len + 1, &w->src_len);
uv_queue_work(uv_default_loop(), &w->req, work_cb, after_cb);
// argv[0] Buffer may now be GC'd — w->src dangling
return NULL;
}
The safe pattern pins the JavaScript value with napi_create_reference before queuing the work, and releases the reference only inside the completion callback that runs back on the main thread after the worker has finished:
// SAFE — Buffer is pinned until after_cb releases the ref
struct ParseWork {
uv_work_t req;
napi_env env;
napi_ref buf_ref; // persistent reference keeping JS value alive
char *src; // copy owned by native code
size_t src_len;
TSTree *result;
};
napi_value parse_async(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value argv[1];
napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
ParseWork *w = calloc(1, sizeof(*w));
w->env = env;
// Pin the JS value — GC will not collect argv[0] while ref_count > 0
napi_create_reference(env, argv[0], 1, &w->buf_ref);
size_t len;
napi_get_value_string_utf8(env, argv[0], NULL, 0, &len);
w->src = malloc(len + 1);
napi_get_value_string_utf8(env, argv[0], w->src, len + 1, &w->src_len);
uv_queue_work(uv_default_loop(), &w->req, work_cb, after_cb);
return NULL;
}
void after_cb(uv_work_t *req, int status) {
ParseWork *w = (ParseWork *)req;
// Work is done — safe to release the pin now
napi_delete_reference(w->env, w->buf_ref);
free(w->src);
// ... resolve JS promise with w->result ...
free(w);
}
The alternative to pinning is to copy the data into native-owned memory immediately (as shown with malloc(len + 1) above) and never hold a pointer into V8-managed memory past the synchronous napi call that creates it. Either approach is correct; holding a raw pointer into V8 heap across an async boundary without a reference is always wrong.
WASM linear memory use-after-free
In a WebAssembly MCP server — for example, one compiled from Rust via wasm-pack or from C via emscripten — memory management happens inside the WASM module's linear memory space. The JavaScript host allocates and frees WASM memory by calling exported functions like wasm_alloc(size) and wasm_free(ptr). The host also reads WASM memory by constructing Uint8Array or DataView views over wasm.memory.buffer.
Use-after-free in this context occurs when a JavaScript view into WASM linear memory is constructed or read after wasm_free(ptr) has been called on that region:
// VULNERABLE — reading WASM memory after freeing the allocation
const ptr = wasm_alloc(1024);
wasm_process_tool_argument(ptr, encodedArg);
// ... some time passes, async operation completes ...
wasm_free(ptr); // allocator reclaims and may reuse this block
// BUG: this view now reads freed (and potentially reused) memory
const data = new Uint8Array(wasm.memory.buffer, ptr, 1024);
console.log("result:", data); // reads garbage or attacker-influenced data
The safe pattern nulls out the view reference immediately after freeing and validates that no outstanding views exist before calling free. In Rust/wasm-bindgen code, prefer RAII types (Box, Vec) that are dropped on the Rust side via #[wasm_bindgen] exported destructors — the JavaScript wrapper calls the destructor and then discards its reference to the pointer:
// SAFE — zero and null before freeing; use RAII on the Rust side
let view = new Uint8Array(wasm.memory.buffer, ptr, 1024);
const result = Array.from(view); // copy data out before freeing
view = null; // discard view
wasm_free(ptr); // safe to free now — no live views
Note that wasm.memory.buffer is an ArrayBuffer that can be detached and replaced if the WASM module grows its memory via memory.grow. Any existing Uint8Array views over the old buffer become detached (reads return 0, writes are discarded). This is a separate but related hazard: always re-derive views from wasm.memory.buffer after any operation that might grow WASM memory.
Detection in the MCP security audit
Automated detection of use-after-free in native MCP addons requires both static and dynamic analysis. Static analysis scans the addon source for three anti-patterns: (1) napi_get_value_string_utf8 or napi_get_buffer_info calls that extract a raw pointer, followed by a uv_queue_work call in the same function, without an intervening napi_create_reference; (2) libuv work callbacks (uv_work_cb-typed function pointers) that dereference raw pointer fields of the work struct where no corresponding napi_ref field exists in the struct definition; (3) JavaScript code in WASM wrapper modules that constructs new Uint8Array(wasm.memory.buffer, ptr, ...) at a program point where the control flow shows a prior wasm_free(ptr) call.
Dynamic analysis complements static checks: a heap timing stress test issues a large number of concurrent tool calls (typically 50–200 concurrent invocations) and monitors the server process for SIGSEGV, SIGABRT, or Node.js crash exit codes. Under AddressSanitizer instrumentation (-fsanitize=address applied to the native addon), the first use-after-free is reported with a precise stack trace rather than a silent data corruption or delayed crash. SkillAudit recommends that all native MCP addon CI pipelines run at least one test suite configuration under ASan.
SkillAudit detection
SkillAudit's Security axis statically analyzes native addon source files (C, C++, Rust FFI) included in the MCP server package. It flags every site where a pointer extracted from a napi_value is stored in a struct that is then passed to uv_queue_work without an accompanying napi_ref field, and reports it as a high-severity use-after-free risk. For WASM-based servers, SkillAudit parses the JavaScript host wrapper for Uint8Array or DataView constructions over wasm.memory.buffer and checks that they occur before — not after — any call to the module's free export. The LLM probe stresses the server with 100 concurrent tool invocations and checks whether any response contains corrupted data or whether the process exits unexpectedly between invocations. Run a free audit at skillaudit.dev to scan your native MCP addon for use-after-free patterns before your server reaches production.
Related: double-free security, heap corruption, security checklist.