Topic: mcp server heap corruption security

MCP server heap corruption security — buffer overflows and allocator metadata attacks in native MCP code

Heap corruption via buffer overflow is the most direct path from a malformed tool argument to arbitrary code execution in a native MCP server addon. Unlike stack overflows, heap overflows write past the end of a heap allocation into adjacent allocator metadata or into a neighboring chunk — and MCP servers are uniquely exposed because their tool arguments arrive from a language model that may synthesize semantically plausible but unexpectedly long inputs. A file path, a query string, or a code snippet can all exceed a fixed heap buffer and overwrite the allocator's free-list pointers.

Fixed-size heap buffers in tool argument processing

The canonical vulnerable pattern in MCP native addons is a fixed-size heap allocation sized to an expected maximum, followed by an unchecked copy of a tool argument into that buffer. A code-analysis addon might allocate 4096 bytes for a file path or 65536 bytes for a source code input — numbers that seem generous based on the developer's mental model of normal inputs. The allocator places the next chunk immediately after the buffer, with its 8- or 16-byte chunk header (containing size, flags, and the fd free-list pointer) at exactly buf + allocation_size.

MCP servers are particularly exposed to this pattern because tool arguments come from model-generated text. The model does not enforce length limits by itself — it generates whatever argument text fulfills the instruction it received. A prompt injection attack or a model confusion scenario can produce a file path like /home/user/project/ followed by thousands of repeated path components, or a code snippet padded to an extraordinary length. The native addon receives this as a valid UTF-8 string and copies it into the fixed buffer, overflowing into the adjacent chunk.

The security consequence depends on what occupies the adjacent chunk. If it is the chunk header of another allocation, the overflow corrupts the allocator's free-list. If it is the data of another active allocation, the overflow corrupts live data — potentially a function pointer, a napi callback reference, or a cryptographic key buffer. Either outcome is exploitable; the first leads to allocator-level write primitives, the second leads to direct data corruption that may be reflected in the server's response to the model.

Overflowing into tcache metadata

glibc's ptmalloc uses a per-thread cache (tcache) for small allocations. Each freed chunk stores a forward pointer (fd) to the next free chunk at the start of the chunk's data region. When the allocator services the next allocation of that size class, it pops the front of the tcache list and returns the chunk. If a heap overflow overwrites the fd pointer of the next chunk, the allocator's next allocation of that size class returns the attacker-controlled address — a classic tcache poisoning primitive:

// VULNERABLE — fixed-size buffer, unchecked copy
void process_path_arg(const char *user_input) {
    char *buf = malloc(256);          // allocates 256-byte chunk
    // Next tcache chunk header is at buf+256
    strcpy(buf, user_input);          // if user_input > 255 bytes, overflows into
                                      // the next chunk's fd pointer
    do_work(buf);
    free(buf);
}

// With user_input = "A" * 256 + "\xc0\xde\xad\xbe\xef\x00..." (pointer value):
// tcache[idx_256]->fd is now 0xbeaddeadc0 — next malloc(256) returns that address

The safe pattern validates input length explicitly before any allocation or copy, uses size-bounded copy functions, and prefers dynamic allocation sized to the actual input when the maximum is not tightly bounded:

// SAFE — validate length, use bounded copy, allocate dynamically
#define MAX_PATH_LEN 4096

void process_path_arg(const char *user_input, size_t input_len) {
    // Reject inputs that exceed the policy maximum before touching the heap
    if (input_len > MAX_PATH_LEN) {
        report_error("path argument exceeds maximum length");
        return;
    }
    // Allocate exactly what we need — no wasted headroom that could hide overflows
    char *buf = malloc(input_len + 1);
    if (!buf) { report_error("allocation failed"); return; }
    memcpy(buf, user_input, input_len);
    buf[input_len] = '\0';
    do_work(buf);
    free(buf);
}

Note that strlcpy silently truncates rather than rejecting — useful as a defense-in-depth measure but not a substitute for length validation, because silently truncating a file path may cause the addon to operate on the wrong file.

Validating input length before allocation — the napi pattern

In a Node.js native addon, tool arguments arrive via the napi API. The safe input-handling pattern involves two validation steps: a length check in the JavaScript wrapper before calling into native code, and a mandatory length check in the native handler itself. Never rely solely on the JS side to enforce limits — defense in depth requires that the native layer validate independently:

// JavaScript wrapper — first validation layer
function callNativeParser(inputStr) {
    if (typeof inputStr !== 'string') throw new TypeError('expected string');
    if (inputStr.length > 65536) throw new RangeError('input too large');
    return native.parse(inputStr);
}

// C native handler — second validation layer
napi_value native_parse(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);

    // First call with NULL buffer to get the required size
    size_t str_len;
    napi_get_value_string_utf8(env, argv[0], NULL, 0, &str_len);

    // Validate before allocation — str_len is byte count, not character count
    if (str_len > 65536) {
        napi_throw_range_error(env, "INPUT_TOO_LARGE", "input exceeds 64 KiB limit");
        return NULL;
    }

    // Allocate dynamically based on actual length — no fixed-size guess
    char *buf = malloc(str_len + 1);
    size_t written;
    napi_get_value_string_utf8(env, argv[0], buf, str_len + 1, &written);
    // written <= str_len — no overflow possible

    parse_input(buf, written);
    free(buf);
    return NULL;
}

The two-call pattern — first with NULL to get the size, then with an appropriately sized buffer — is the canonical napi idiom and eliminates the fixed-size buffer hazard entirely when combined with an explicit maximum check.

Heap hardening at the linker level

Compiler and linker flags provide an additional layer of protection that raises the cost of exploiting any heap overflow that slips through code-level mitigations. These should be applied unconditionally to native MCP addon builds.

-D_FORTIFY_SOURCE=2 instructs glibc to replace unsafe string and memory functions with bounds-checked variants at compile time when the destination buffer size is statically known. A strcpy into a char buf[256] becomes a __strcpy_chk that aborts if the copy would overflow. This provides no protection for heap buffers allocated with malloc (size unknown at compile time), but it catches stack and global buffer overflows automatically.

-z relro -z now (passed to the linker) marks the Global Offset Table read-only after startup. The GOT is the primary target for function pointer overwrites in heap exploitation — making it read-only raises the bar significantly for turning a heap overflow into a code execution primitive.

In test environments, set MALLOC_CHECK_=3 to enable glibc's built-in heap integrity checker, which aborts immediately on detected corruption rather than allowing silent propagation. AddressSanitizer (-fsanitize=address) subsumes all of the above for CI builds, adding byte-granularity shadow memory that catches every out-of-bounds heap write with a precise stack trace:

# binding.gyp — production hardening flags
{
  "targets": [{
    "target_name": "my_addon",
    "cflags": [
      "-D_FORTIFY_SOURCE=2",
      "-fstack-protector-strong",
      "-Wformat",
      "-Wformat-security"
    ],
    "ldflags": ["-Wl,-z,relro,-z,now"],
    "sources": ["src/addon.cc"]
  }]
}

SkillAudit detection

SkillAudit's Security axis scans native addon source for three heap corruption indicators. First, it flags every use of unsafe C string functions — strcpy, strcat, sprintf — applied to a destination buffer whose bytes were sourced from napi_get_value_string_utf8, regardless of whether the allocation is fixed-size or dynamic. Second, it identifies malloc calls with a numeric literal size constant (e.g., malloc(4096)) that appear in the same function as a copy from a napi string argument, flagging the mismatch between fixed allocation and variable-length input. Third, it inspects binding.gyp and CMakeLists.txt for the presence of -D_FORTIFY_SOURCE in the compile flags and reports its absence as a hardening gap. The LLM probe sends tool arguments at 1x, 10x, and 100x the typical length for each string parameter and observes whether the server returns a structured error or crashes. Run a free audit at skillaudit.dev to check your native MCP addon for heap overflow risks before an adversarial model input reaches it.

Related: use-after-free, double-free, stack overflow, MCP server security checklist.