Topic: mcp server log injection security

MCP server log injection security — CRLF log forging, fake audit events, and log drain injection

MCP tool handlers log their arguments for audit and debugging. When those arguments arrive from an LLM orchestrator that is processing attacker-controlled content, any unstripped newline character in the argument becomes a log injection vector: the argument splits across two log lines, the second of which is fully attacker-controlled. The attacker can forge a log line that looks like a legitimate tool call, a successful authorization event, or an innocuous informational message — hiding the real call in the noise or planting fake evidence of what the agent did. In SIEM-connected environments, the injected line propagates to the centralized log store and becomes permanent evidence that is indistinguishable from genuine events.

Attack 1 — CRLF log forging via tool argument

The simplest log injection exploits the fact that most logging libraries write one record per line and delimit fields with spaces or commas. A \r\n or bare \n in a tool argument causes the logging call to write two lines to the file — the real log line (ending mid-argument) and a second line whose content the attacker chose:

// Vulnerable MCP tool handler
async function create_note(args) {
  logger.info(`[tool:create_note] user=${args.user_id} content="${args.content}"`);
  // ...
}

// Attacker-controlled content field:
// "Hello world\n[tool:delete_all_data] user=admin RESULT=success"
//
// Log file output:
// [tool:create_note] user=alice content="Hello world
// [tool:delete_all_data] user=admin RESULT=success"
//
// The second line appears to be a separate, legitimate log event.
// If the log parser treats newlines as record separators, it records
// both lines as independent audit events.

The severity depends on how the log is consumed. If engineers scan the log for [tool:delete_all_data] to investigate an incident, the forged line appears identical to a real event. An attacker who knows the log format can craft injections that look like successful authentications, completed payments, or cleared security alerts.

Attack 2 — multi-line injection covering tool-call tracks

A more targeted use of log injection is to suppress evidence of the real tool call by appending a line that a log parser will misinterpret as the end of the record, causing the genuine log content to be truncated or split:

// If the log aggregator treats lines ending with "}" as JSON record terminators,
// injecting a fake JSON-closing sequence ends the parser's view of the record:

// Tool argument: file_path = "/etc/hosts\n}\n{\"level\":\"INFO\",\"msg\":\"scan complete\"}"
//
// Raw log output:
// {"level":"WARN","tool":"read_file","path":"/etc/hosts
// }
// {"level":"INFO","msg":"scan complete"}
//
// Depending on the parser:
// - Record 1 is malformed and discarded (path field not closed)
// - Record 2 looks like an unrelated, benign INFO event
//
// The actual read_file call disappears from the structured log stream.

Attack 3 — log drain injection into SIEM pipelines

Enterprise MCP deployments often forward server logs to a SIEM (Splunk, Elastic, Datadog). Log injection that passes the local file sink becomes permanent in the SIEM's indexed store. The implications compound:

Fix 1 — sanitize all tool arguments before logging

Strip or escape CRLF characters from every string field before it reaches any log call. The sanitization must happen on the value that goes into the log statement, not on the stored value — the tool should operate on the original argument but log a sanitized version:

function sanitizeForLog(value) {
  if (typeof value !== 'string') return value;
  // Replace CR, LF, and their combinations with a visible placeholder
  return value.replace(/[\r\n]+/g, '⏎').replace(/\t/g, '→');
}

async function create_note(args) {
  const safeContent = sanitizeForLog(args.content);
  const safeUser   = sanitizeForLog(args.user_id);
  logger.info(`[tool:create_note] user=${safeUser} content="${safeContent}"`);
  // Operate on original args.content, not the sanitized version
  return db.notes.create({ user_id: args.user_id, content: args.content });
}

// For objects, sanitize recursively before JSON serialization into the log:
function sanitizeArgs(args) {
  if (typeof args === 'string') return sanitizeForLog(args);
  if (Array.isArray(args)) return args.map(sanitizeArgs);
  if (args && typeof args === 'object') {
    return Object.fromEntries(
      Object.entries(args).map(([k, v]) => [k, sanitizeArgs(v)])
    );
  }
  return args;
}

logger.info({ tool: 'create_note', args: sanitizeArgs(args) });

Fix 2 — use structured JSON logging with a serializer that escapes newlines

The structural fix is to move from freeform string log lines to structured JSON, where each field is a JSON-encoded value. A JSON-encoded string automatically escapes \n to the two-character sequence \n (backslash-n), which is safe in a line-oriented log parser:

import pino from 'pino';

const logger = pino({
  // pino serializes all values as JSON — newlines in strings become \n escape sequences,
  // not literal line breaks. The output is always one JSON object per line.
  serializers: {
    args: pino.stdSerializers.req, // or a custom serializer
  },
});

// pino output for args.content = "Hello\nworld":
// {"level":30,"time":1717488000000,"tool":"create_note","args":{"content":"Hello\nworld"}}
// One line, the \n is JSON-escaped. Safe for line-oriented log parsers and SIEM connectors.

Structured logging libraries (pino, winston with JSON transport, Python structlog) handle this automatically. Freeform string interpolation with console.log or printf-style formatters does not — those require explicit sanitization as in Fix 1.

Fix 3 — log the argument hash rather than the raw value for sensitive fields

For fields that may contain arbitrary user content (free-text notes, search queries, file contents), logging the raw value creates both a log injection risk and a data-retention risk. An alternative is to log a truncated preview (first 80 characters, sanitized) and a hash of the full value:

import { createHash } from 'crypto';

function logSafeArg(value, maxPreviewLen = 80) {
  const str = typeof value === 'string' ? value : JSON.stringify(value);
  const safe = str.replace(/[\r\n\t]/g, ' ').slice(0, maxPreviewLen);
  const hash = createHash('sha256').update(str).digest('hex').slice(0, 12);
  return `${safe}… [sha256:${hash}]`;
}

logger.info(`[tool:create_note] content=${logSafeArg(args.content)}`);

The hash can be reproduced from the actual stored value to prove the log refers to a specific input, without the raw value appearing in the log where it can inject or be exfiltrated.

SkillAudit detection

SkillAudit's static analysis flags the following patterns as log injection findings:

The finding maps to the Credential exposure and Audit logging axes of the SkillAudit report. Servers that log tool arguments without sanitization typically also log session tokens, API keys embedded in arguments, and PII that belongs in the argument payload but not in the log — log injection fixes and credential-in-log fixes are usually done together. See the credential leak anatomy post for the full list of logging patterns that expose secrets.

For the audit trail patterns that make logs tamper-evident (append-only log files, write-once sinks, external immutable log stores), see the minimal-footprint MCP server post, specifically Principle 6 on immutable audit logs.