Topic: mcp server null byte injection security

MCP server null byte injection security — NUL character exploits in AI tool handlers

Null byte injection — inserting a NUL character (, \x00) into a string argument — is an old vulnerability class that keeps reappearing wherever high-level language strings pass through lower-level boundaries. In MCP servers, this matters because AI models can supply arbitrary Unicode in tool arguments, including control characters that a human would never type but a model may produce when following a prompt-injected instruction or interpolating from untrusted content. The NUL character is particularly dangerous because it acts as a string terminator in C-family runtimes and is invisible in most log formatters, making it easy to miss.

Why NUL bytes are dangerous at the JS/OS boundary

Modern JavaScript strings are UTF-16 and happily contain NUL characters — "\x00".length === 1. But when that string crosses into OS-level APIs (file opens, process spawns, socket addresses), the NUL is treated as the C-string terminator. The string is silently truncated at the first NUL. This truncation is invisible to the JavaScript-level validation that ran on the full string.

Node.js partially mitigates this for filesystem calls: since Node.js 7.x, fs.open and related calls throw ERR_INVALID_ARG_VALUE when a path contains NUL bytes. But not all path operations go through this guard, and third-party modules that call native code via N-API addons may not apply the same check.

Vulnerability pattern 1: filename extension bypass via NUL truncation

A file upload tool that validates the extension before writing:

// Vulnerable
async function saveUpload(filename, content) {
  const ext = path.extname(args.filename).toLowerCase();
  if (!['.txt', '.md', '.json'].includes(ext)) {
    throw new Error('Disallowed file type');
  }
  await fs.writeFile(path.join(UPLOAD_DIR, args.filename), args.content);
}

An argument of "malicious.php\x00.txt" passes the extension check — path.extname reads the full string and returns ".txt". But when the name reaches a C-backed file write in a native module or is used in a shell command, it's truncated to "malicious.php". The upload directory now contains an executable PHP file where the server expected a text file.

Node.js's own fs.writeFile will throw on the NUL — but if the filename is passed through a native module before reaching Node's fs layer, or used in a child_process.exec call to invoke a file management script, the guard is absent.

Vulnerability pattern 2: NUL in SQL string arguments poisoning log queries

Log analysis tools that construct SQL queries from tool-supplied strings are susceptible to NUL-based query truncation:

// Vulnerable
async function searchLogs(query) {
  // If args.query contains \x00, the parameterized value may be
  // silently truncated by some database drivers, changing query semantics
  const rows = await db.all(
    'SELECT * FROM logs WHERE message LIKE ?',
    [`%${args.query}%`]
  );
  return rows;
}

SQLite's C library treats \x00 as a string terminator in text comparisons. A search for "%error\x00%" may match nothing, or match rows it shouldn't, depending on the driver's handling of embedded NUL in text values. This isn't a code execution vector but it can produce incorrect audit results — the tool reports "no errors found" because the NUL truncated the search term.

Vulnerability pattern 3: NUL in log output enabling log injection

NUL bytes in strings that are written to log files or forwarded to log aggregation systems can corrupt log entries. Some log parsers (especially Logstash and Splunk's older ingest pipeline) silently drop everything after the first NUL — turning a single log line into a truncated fragment that omits the security-relevant portion. This is a log evasion vector: a prompt-injection payload that includes \x00 can ensure that the audit trail of the tool call is truncated before the sensitive part.

Safe pattern: NUL sanitization at handler entry

Strip or reject NUL bytes before any string argument reaches business logic:

function sanitizeString(value, name) {
  if (typeof value !== 'string') throw new Error(`${name} must be a string`);
  if (value.includes('\x00')) {
    throw new Error(`${name} contains null byte — rejected`);
    // Alternative: strip instead of reject:
    // return value.replace(/\x00/g, '');
  }
  return value;
}

async function saveUpload(filename, content) {
  const safeName = sanitizeString(args.filename, 'filename');
  // Now safe: \x00 rejected before any path or write operation
  const ext = path.extname(safeName).toLowerCase();
  if (!['.txt', '.md', '.json'].includes(ext)) throw new Error('Disallowed type');
  await fs.writeFile(path.join(UPLOAD_DIR, safeName), content);
}

Safe pattern: broader control character rejection

NUL is not the only dangerous control character. In environments where strings pass through shell commands or terminal output, other control characters (\x01\x1f, \x7f, and Unicode control blocks) can trigger unexpected behavior. A stricter sanitizer for filenames:

function sanitizeFilename(value) {
  if (typeof value !== 'string') throw new Error('Filename must be a string');
  // Reject control characters (U+0000–U+001F, U+007F, U+0080–U+009F)
  if (/[\x00-\x1f\x7f-\x9f]/.test(value)) {
    throw new Error('Filename contains disallowed control characters');
  }
  // Reject path traversal sequences
  if (value.includes('..') || value.includes('/') || value.includes('\\')) {
    throw new Error('Filename contains disallowed path characters');
  }
  return value;
}

Safe pattern: JSON Schema pattern validation

For tool schemas, use a restrictive pattern that implicitly excludes control characters:

const schema = {
  type: 'object',
  properties: {
    filename: {
      type: 'string',
      pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$'
      // Only ASCII alphanumeric, dot, hyphen, underscore — no control chars
    }
  }
};

SkillAudit detection

The Security axis flags null byte injection risk through static analysis: string arguments used in fs.* calls, child_process.* calls, or SQL query construction without a preceding NUL check. The LLM-probe layer sends test arguments containing and observes whether the server rejects cleanly, silently strips, or exhibits unexpected behavior (path truncation, query truncation, empty log line). Findings are classified MEDIUM for log evasion vectors and HIGH where filename truncation or shell-command truncation enables code execution or file type bypass.

Check your server's null byte handling at skillaudit.dev. Related: path traversal security for the companion class of filesystem argument vulnerabilities.