Topic: mcp server integer underflow security
MCP server integer underflow security — arithmetic underflow in counters and size calculations
Integer underflow is the silent sibling of integer overflow: subtracting from an unsigned integer when the result would be negative wraps the value around to a very large positive number. In MCP server code that uses C, C++, Rust native addons, or WASM modules, this pattern appears in rate-limit counters, buffer capacity calculations, and bounded-queue implementations. The result is typically a check that should fail instead passes — allowing more data than intended, larger allocations than expected, or more operations than the rate limit permits.
Why integer underflow appears in MCP server rate-limit and size logic
MCP tool handlers frequently implement their own resource accounting: a per-client request counter, a rolling window rate limiter, a bounded in-memory queue for batched tool calls. These implementations commonly use unsigned integers because the values being tracked are always non-negative by design — request counts, buffer sizes, queue depths. The problem arises when the subtraction that decrements these values is applied to a zero-valued counter (double-decrement) or when a size calculation computes a difference that the caller assumed could never be negative.
Consider a simple rate limiter tracking remaining capacity:
// C — VULNERABLE
// remaining_slots is size_t (unsigned)
size_t remaining_slots = max_requests - active_requests;
if (remaining_slots > 0) {
// allow request
}
When active_requests exceeds max_requests — which can happen if a race condition allows two increments before the check fires — the subtraction underflows. remaining_slots wraps to SIZE_MAX or some large value like 18446744073709551615. The guard condition remaining_slots > 0 is always true. Every subsequent request is admitted until the counter is corrected, effectively disabling the rate limit.
In WASM-compiled MCP servers, the same pattern surfaces in memory size calculations. A tool that partitions a fixed-size WASM heap into per-invocation slots might compute:
// C compiled to WASM — VULNERABLE
uint32_t slot_size = (heap_end - heap_ptr) / num_pending_calls;
// If heap_ptr was advanced past heap_end by a prior bug,
// heap_end - heap_ptr wraps to a large uint32_t.
// slot_size becomes enormous; the allocation proceeds and writes
// far outside the intended heap region.
The WASM linear memory model provides no memory-protection hardware fault on out-of-bounds writes within the module's own heap — writes past the intended region land in adjacent WASM memory, corrupting unrelated data structures.
The double-decrement race pattern
In Node.js MCP servers that use shared mutable state across concurrent tool calls, a related pattern involves non-atomic decrement on a counter that tracks in-flight requests:
// JavaScript — VULNERABLE under concurrent load
let inFlightCount = 0;
async function handleToolCall(args) {
inFlightCount++;
try {
return await processArgs(args);
} finally {
inFlightCount--; // Race: two concurrent finallyblocks may decrement
// below zero if one call was already counted twice
}
}
function isCapacityAvailable() {
return inFlightCount < MAX_CONCURRENT;
}
JavaScript's single-threaded event loop prevents simultaneous execution, but the await yields control. If two calls enter and both reach inFlightCount++ before either returns, then both decrement in their finally block. If a third decrement occurs due to a retry path that doesn't check whether the first decrement already fired, inFlightCount can go negative. Once negative, the isCapacityAvailable() check is permanently true regardless of actual load. JavaScript uses signed doubles for all numbers, so underflow to a large positive value doesn't occur — but going negative means the capacity gate is permanently open, which is the same practical outcome.
Safe patterns: validate before subtract, use saturating arithmetic, signed types for differences
The straightforward fix for C/C++ and Rust is to validate the operands before performing unsigned subtraction:
// C — SAFE
if (active_requests > max_requests) {
// this shouldn't happen; treat as capacity full
return CAPACITY_EXCEEDED;
}
size_t remaining_slots = max_requests - active_requests;
if (remaining_slots > 0) {
// allow request
}
In Rust, use saturating subtraction, which returns zero rather than wrapping on underflow:
// Rust — SAFE
let remaining = max_requests.saturating_sub(active_requests);
if remaining > 0 {
// allow request
}
For buffer size calculations where the result is used in allocation, use a signed type for the intermediate difference, check that it is positive, and only then cast to an unsigned size:
// C — SAFE
ptrdiff_t available = (ptrdiff_t)heap_end - (ptrdiff_t)heap_ptr;
if (available <= 0 || (size_t)available < required_size) {
return ALLOC_FAILED;
}
void *slot = malloc(required_size); // proceed with validated size
In JavaScript, use explicit non-negative clamping:
// JavaScript — SAFE
async function handleToolCall(args) {
inFlightCount = Math.max(0, inFlightCount) + 1;
try {
return await processArgs(args);
} finally {
inFlightCount = Math.max(0, inFlightCount - 1);
}
}
SkillAudit detection
SkillAudit's Security axis scans native addon source and WASM host wrapper code for unsigned subtraction operations where the minuend is a user-influenced value (tool argument, request counter, queue depth) and the result is used as a size, capacity, or guard condition without a prior range check. In JavaScript tool handler code, SkillAudit flags shared mutable counter variables that are decremented in finally or catch blocks without a clamping expression. The LLM-probe stress test issues concurrent tool calls designed to trigger counter racing and checks whether rate limits continue to be enforced under load. Run a free audit at skillaudit.dev to check whether your MCP server's capacity accounting is underflow-safe.
Related: buffer boundary security, heap corruption, security checklist.