Topic: mcp server buffer boundary security

MCP server buffer boundary security — off-by-one errors in buffer indexing

An off-by-one error in buffer indexing writes or reads exactly one element past the end of an allocated region. In C and C++ MCP server native addons, this is most often caused by a loop condition using <= instead of <, or by a null terminator placement that assumes a buffer of size n can hold an n-byte string plus its terminator. The write lands in the byte immediately after the buffer — which in a heap allocator is typically the metadata header for the adjacent chunk. Corrupting this metadata can turn a single off-by-one into a heap exploitation primitive.

Why off-by-one errors appear in MCP server argument processing

MCP tool handlers that process string arguments in native code are the most common source of off-by-one errors in the corpus. The pattern is almost always the same: the tool handler receives a JSON string argument, extracts its length using a napi call, allocates a buffer of that length, then copies the string into the buffer — with a fencepost error on the terminator.

// C — VULNERABLE: allocates exactly n bytes but writes n+1
size_t len;
napi_get_value_string_utf8(env, argv[0], NULL, 0, &len);
// len is the byte count of the string, not including '\0'
char *buf = malloc(len);         // BUG: should be malloc(len + 1)
napi_get_value_string_utf8(env, argv[0], buf, len + 1, NULL);
// The API writes len bytes + 1 null terminator → 1 byte past buf

The consequence depends on what the allocator placed immediately after buf. On glibc, malloc stores chunk size metadata in the word immediately following the allocated region. Overwriting even one byte of that metadata corrupts the chunk size field. On the next free(buf) call, the allocator reads the corrupted size field and updates its free-list with a wrong-sized chunk, enabling a later allocation to overlap with still-live data. This class of corruption is the entry point for a family of heap exploitation techniques that can escalate to controlled write-what-where primitives.

Loop bounds: <= vs <

The second common source is array-processing loops in tool handlers that parse structured argument data:

// C — VULNERABLE: iterates len+1 times; last iteration writes buf[len]
for (size_t i = 0; i <= len; i++) {
    buf[i] = transform(input[i]);
}

The <= condition processes the element at index len. For a buffer of len elements (valid indices 0 through len-1), buf[len] is the adjacent byte. The write is exactly one past the end — silent, no crash, no segfault on most configurations. The corruption accumulates until a later allocation or free trips over the corrupted adjacent chunk and the crash occurs in code far removed from the actual fencepost error, making diagnosis difficult.

In WASM-compiled code, the same pattern is present but the crash behavior differs: WASM linear memory does not have inter-chunk metadata in the same layout as glibc heap, so the one-byte overwrite may corrupt a different adjacent data structure depending on the allocator in use (emmalloc, dlmalloc, or the Rust allocator).

Safe patterns: allocate n+1, use < not <=, use strlcpy/snprintf

The allocation fix is mechanical: always add 1 to the length when allocating a string buffer that will hold a null terminator.

// C — SAFE
size_t len;
napi_get_value_string_utf8(env, argv[0], NULL, 0, &len);
char *buf = malloc(len + 1);    // +1 for null terminator
if (!buf) return ALLOC_FAILED;
napi_get_value_string_utf8(env, argv[0], buf, len + 1, NULL);
buf[len] = '\0';                // explicit terminator within bounds

For loops, the rule is equally mechanical: use < as the loop termination condition, not <=. When copying from input to buf, the loop should terminate when the index reaches the count, not exceeds it:

// C — SAFE
for (size_t i = 0; i < len; i++) {
    buf[i] = transform(input[i]);
}

For string copies, use strlcpy or snprintf with an explicit size limit rather than manual indexing. Both functions guarantee null termination and will not write past the specified buffer size:

// C — SAFE using strlcpy (available on BSD/macOS; use a shim on Linux)
strlcpy(dest, src, dest_size);   // copies at most dest_size-1 chars + null

// C — SAFE using snprintf
snprintf(dest, dest_size, "%s", src);   // never writes past dest_size bytes

In Rust, the borrow checker and safe slice operations prevent off-by-one writes at compile time. When writing native addons that interoperate with C, use std::slice::from_raw_parts with lengths derived from validated sources, and prefer CStr::from_ptr with explicit lifetime management over raw pointer arithmetic:

// Rust — SAFE: slice bounds are checked at creation
let slice = unsafe {
    std::slice::from_raw_parts(ptr, len)  // panics if ptr is null or len overflows isize
};
// Subsequent indexing of slice[0..len] is bounds-checked

Build-time detection: -fsanitize=address and -Warray-bounds

AddressSanitizer (-fsanitize=address) instruments every memory access and reports the exact line of the off-by-one write at runtime, with the full allocation stack trace and the corruption offset. Running the native addon test suite under ASan will catch off-by-one errors that reach the buffer boundary in any test case. For builds where ASan overhead is too high, -Warray-bounds catches a subset of off-by-one errors statically at compile time when the buffer size is a compile-time constant.

SkillAudit detection

SkillAudit's Security axis inspects native addon C and C++ source for malloc(len) patterns followed by string copy calls that write len + 1 bytes, and flags them as potential off-by-one allocation errors. Loop bounds are checked for <= conditions on array iteration where the upper bound is the array length. In Rust FFI code, SkillAudit flags from_raw_parts calls where the length argument is derived from an unvalidated tool argument. The stress-test probe issues tool calls with maximum-length string arguments and monitors for heap corruption artifacts in subsequent tool call responses. Run a free audit at skillaudit.dev to check your MCP native addon for buffer boundary errors before they reach production.

Related: heap corruption, integer underflow, use-after-free, security checklist.