Topic: mcp server native addon memory safety

MCP server native addon memory safety — N-API buffer overflow, use-after-free, integer overflow

Most MCP servers are pure JavaScript or TypeScript, where V8's managed memory eliminates classic C-level memory safety vulnerabilities. But servers that wrap C or C++ libraries through Node.js native addons (N-API) reintroduce every memory safety class that JavaScript was designed to prevent. The vulnerability lives not in N-API itself — which is safe by design — but in the C code called through N-API. When an MCP tool passes user-controlled values across the JS/C boundary, the C side receives them without the bounds checking V8 provides. This page covers the three most exploitable patterns.

Pattern 1: buffer overflow via JS-supplied size parameter

N-API provides napi_get_buffer_info to retrieve the underlying byte pointer and length of a JavaScript Buffer. The correct pattern is to use that returned length as the size argument to the C function. The vulnerability appears when a C function accepts size separately from the buffer, and the size comes from a JavaScript integer argument rather than the actual buffer length.

In MCP servers, this commonly appears in tools that process binary payloads — image processing wrappers, compression libraries, cryptographic primitives. The LLM passes both a data buffer and a size parameter; the binding passes both to the C library without verifying they agree.

// WRONG: accepts size from JS arg[1] instead of actual buffer length
napi_value process_data_WRONG(napi_env env, napi_callback_info info) {
  napi_value args[2];
  size_t argc = 2;
  napi_get_cb_info(env, info, &argc, args, NULL, NULL);

  void* data;
  size_t actual_len;
  napi_get_buffer_info(env, args[0], &data, &actual_len);

  int64_t js_size;
  napi_get_value_int64(env, args[1], &js_size);

  // BUG: if js_size > actual_len, C function reads past buffer end
  c_library_process(data, (size_t)js_size);
  return NULL;
}

// RIGHT: use actual_len from N-API — V8 knows the real allocation
napi_value process_data(napi_env env, napi_callback_info info) {
  napi_value args[1];
  size_t argc = 1;
  napi_get_cb_info(env, info, &argc, args, NULL, NULL);

  void* data;
  size_t actual_len;
  napi_get_buffer_info(env, args[0], &data, &actual_len);

  c_library_process(data, actual_len); // trusted length from N-API
  return NULL;
}

// RIGHT for sub-ranges: accept offset+length but validate before use
void process_subrange(napi_env env, void* data, size_t actual_len,
                      int64_t offset_js, int64_t len_js) {
  if (offset_js < 0 || len_js < 0 ||
      (size_t)(offset_js + len_js) > actual_len) {
    napi_throw_range_error(env, "ERR_OUT_OF_RANGE",
      "offset + length exceeds buffer size");
    return;
  }
  c_library_process((char*)data + offset_js, (size_t)len_js);
}

Pattern 2: use-after-free from unguarded C pointer storage

Use-after-free occurs when a C pointer is freed but subsequently dereferenced. In N-API addons, it appears when a C resource (database handle, cipher context, connection object) is wrapped in a JavaScript external, freed by one tool call (a "destroy" or "close" tool), but the JS object persisting in session state still holds the raw pointer. The next tool call that reads the session state dereferences the freed memory.

// WRONG: raw pointer in JS external — no finalizer, no freed flag
// Tool: create_ctx → stores CipherCtx* in external
CipherCtx* ctx = cipher_ctx_new();
napi_create_external(env, ctx, NULL, NULL, &result); // no cleanup!

// Tool: destroy_ctx → frees C memory
CipherCtx* ctx = get_ctx_from_external(env, args[0]);
cipher_ctx_free(ctx); // frees C memory
// JS external still holds the pointer — use-after-free if accessed again

// RIGHT: struct with freed flag + finalizer that nulls on GC
typedef struct { CipherCtx* ptr; bool freed; } CtxWrapper;

static void ctx_finalizer(napi_env env, void* data, void* hint) {
  CtxWrapper* w = (CtxWrapper*)data;
  if (!w->freed && w->ptr) {
    cipher_ctx_free(w->ptr);
    w->ptr = NULL;
    w->freed = true;
  }
  free(w);
}

// Tool: create_ctx — use finalizer-equipped external
CtxWrapper* w = malloc(sizeof(CtxWrapper));
w->ptr = cipher_ctx_new();
w->freed = false;
napi_create_external(env, w, ctx_finalizer, NULL, &result);

// Every tool that uses the context: check freed flag first
static CtxWrapper* get_wrapper(napi_env env, napi_value val) {
  CtxWrapper* w = NULL;
  napi_get_value_external(env, val, (void**)&w);
  return w;
}

// In destroy_ctx tool:
CtxWrapper* w = get_wrapper(env, args[0]);
if (!w || w->freed) {
  napi_throw_error(env, "ERR_INVALID_STATE", "context already freed");
  return NULL;
}
cipher_ctx_free(w->ptr);
w->ptr = NULL;
w->freed = true;

// In any tool that uses the context:
CtxWrapper* w = get_wrapper(env, args[0]);
if (!w || w->freed || !w->ptr) {
  napi_throw_error(env, "ERR_INVALID_STATE", "context not valid");
  return NULL;
}
// safe to use w->ptr here

Pattern 3: integer overflow in allocation size calculation

When two user-supplied integers are multiplied to compute an allocation size — a count of elements and a per-element size — their product can overflow the integer type, wrapping to a small value. The resulting malloc(small_value) succeeds, and the subsequent write fills count × element_size bytes starting at the allocation, writing past its end.

// WRONG: count * elem_size can overflow uint32_t → malloc gets tiny buffer
uint32_t count    = (uint32_t)count_from_js;    // could be up to 2^32-1
uint32_t elem_sz  = (uint32_t)elem_size_from_js;

uint32_t total = count * elem_sz; // OVERFLOW: 1M * 4096 → 0 on uint32_t
char* buf = malloc(total);        // malloc(0) or too small
memcpy(buf, source, total);       // writes past buf

// RIGHT: validate inputs + check overflow before multiply
int64_t count_64   = count_from_js;
int64_t elem_sz_64 = elem_size_from_js;

// Sanity bounds first (stop unreasonably large inputs)
if (count_64 < 0 || count_64 > 10000000 ||
    elem_sz_64 < 0 || elem_sz_64 > 65536) {
  napi_throw_range_error(env, "ERR_RANGE", "inputs out of bounds");
  return NULL;
}

// Overflow check: a * b overflows size_t iff b != 0 && a > SIZE_MAX / b
size_t total;
if (elem_sz_64 > 0 &&
    (size_t)count_64 > SIZE_MAX / (size_t)elem_sz_64) {
  napi_throw_range_error(env, "ERR_RANGE", "allocation size overflows");
  return NULL;
}
total = (size_t)count_64 * (size_t)elem_sz_64;

char* buf = malloc(total);
if (!buf) { napi_throw_error(env, "ERR_OOM", "malloc failed"); return NULL; }
memcpy(buf, source, total); // safe

Detection with AddressSanitizer

All three patterns produce undefined behavior that may not crash immediately under normal test inputs. AddressSanitizer instruments every memory access at the point of violation. To run your MCP server's native addon under ASan:

# Rebuild addon with ASan instrumentation
CFLAGS="-fsanitize=address -fno-omit-frame-pointer -g" \
LDFLAGS="-fsanitize=address" \
npm rebuild

# Run tests — Node must be an ASan-built binary or use direct C tests
ASAN_OPTIONS="detect_leaks=1:halt_on_error=0:log_path=asan.log" \
node --experimental-detect-module test/native-addon-tests.js

# Alternatively: compile and run C unit tests directly under ASan
# (avoids Node binary ASan requirement)
gcc -fsanitize=address -fno-omit-frame-pointer -g \
  test/c_unit_tests.c src/native.c -o run_asan_tests
./run_asan_tests

# In CI (GitHub Actions example):
# - name: Native addon ASan test
#   run: |
#     CFLAGS="-fsanitize=address -fno-omit-frame-pointer" LDFLAGS="-fsanitize=address" npm rebuild
#     ASAN_OPTIONS="halt_on_error=1" node test/native-addon-tests.js

SkillAudit's Security axis flags any MCP server that includes .node compiled native addons and checks whether ASan-instrumented test runs appear in the CI configuration. Servers with native addons and no memory safety testing evidence receive a Security axis penalty independent of other findings.

→ MCP server memory safety — Node.js buffer limits and heap leak patterns
→ MCP server weak cryptography — MD5, ECB mode, short keys
→ MCP security for open-source maintainers: what reviewers check in 2026