Skip to content

CWE-117: Improper Output Neutralization for Logs

Overview

Log Injection occurs when untrusted input is written to a log without encoding, so control characters in the value become part of the log's structure instead of its data. An attacker who controls such a value can forge entries that read as genuine system events, or bury their own activity.

Relationship to Other CWEs

Report a log-injection finding here rather than against CWE-20 (Improper Input Validation), which MITRE marks Discouraged because it gets used where a more specific weakness is the accurate one. A scanner that reports CWE-20 for log injection is following an older CWE view which files this page under it; MITRE's current parent is CWE-116 (Improper Encoding or Escaping of Output), which has no page here.

The pages around it differ by which part of the logging call is at fault:

  • CWE-117 (this page) - an untrusted value reaches a log without being encoded, so control characters in it become the record's structure instead of its content
  • CWE-93 (CRLF Injection) - the same CR/LF trick against any line-based sink, HTTP and email headers included. Where that sink is a log the two overlap, and this page is the wider one there: a record is also broken by tab, NUL, ANSI escapes and the Unicode separators, which a CR/LF filter does not touch
  • CWE-778 (Insufficient Logging) - the opposite defect at the same sink. There the record is missing; here it exists and can be forged. Closing CWE-778 routinely opens this one, because the username, path or user agent a new audit entry records is attacker-controlled
  • CWE-134 (Use of Externally-Controlled Format String) - the attacker controls the format template the logger interprets, rather than a value substituted into it. The first move is the same, giving the call a literal message and passing untrusted data as a parameter, but it does not close this page's finding: a parameter is only neutralized if the sink encodes it

OWASP Classification

A09:2025 - Security Logging & Alerting Failures

Risk

Medium: Forged or buried entries mislead incident response, and control characters that reach a terminal, log viewer or analysis tool are interpreted as formatting or structure rather than shown as text.

Remediation Steps

Core Principle: Use structured JSON/ECS logging to separate data from structure so untrusted input is always encoded inside fields and cannot forge entries or inject control characters.

Locate the log injection vulnerability

Start from the finding and work out which untrusted value reaches which logging call:

  • Source: where the untrusted data enters - an HTTP parameter, header or cookie, an external file, a database row, a network request
  • Sink: the logging statement it ends up in (logger.info(), log.warn(), console.log(), etc.)
  • Missing control: the point between the two where the value should have been encoded and was not

Use structured JSON/ECS logging (Primary Defense)

  • JSON/ECS output at the logging sink: Configure the logger to emit JSON (or ECS JSON) so control characters are encoded within fields rather than rendered as line breaks. Encoding at the sink covers every call site and keeps the attacker's payload readable in the record.
  • Coverage: JSON encoding always escapes the ASCII control range (\x00-\x1F), which is where CR and LF live, so it closes the classic forged entry. It is not uniform beyond that. Whether the Unicode separators \u0085, \u2028 and \u2029 come out escaped or raw depends on the encoder, and several mainstream ones emit them raw. Each language page states which side its encoder is on.
  • Raw separators only forge an entry if something splits before it parses. Inside a quoted JSON field they are ordinary characters, and a JSON parser reads one record regardless. They become line breaks where a line-oriented stage runs first: Python's str.splitlines() and Java's Scanner treat all three as terminators, while BufferedReader.readLine() and .NET's StringReader.ReadLine() do not. Encode them explicitly when the pipeline has such a stage.
  • Use framework-provided JSON/ECS formatters: Logback/Log4j2 JSON, Serilog ECS/JSON, python-json-logger/structlog, winston/pino JSON.
  • One event per line: Configure JSON layouts with an end-of-event delimiter (e.g., eventEol=true) so entries are not merged, and check that nothing prepends a timestamp or level to the JSON object - a line that is only mostly JSON breaks the aggregator that parses it.
  • Use placeholders instead of concatenating user data into log strings (logger.info("User: {}", username) not logger.info("User: " + username)). This keeps the template and the value separate at the API level; on its own it does not encode anything, so it is a prerequisite for the JSON sink rather than a substitute for it.
  • Fallback when JSON/ECS isn't available: Encode the full ASCII control range (\x00-\x1F, \x7F) plus the Unicode separators to visible sequences; do not remove them. Escape the backslash too, or a literal admin\nFAKE and a real newline encode to the same output.
  • Truncate very long strings to prevent log flooding - 1000 characters is a reasonable cap. Truncate before encoding, so the cut cannot land inside an escape sequence.
  • Use the framework's context features: MDC (SLF4J/Logback), LogContext/scopes (.NET), contextvars (Python) and equivalents attach request-scoped values as fields rather than splicing them into the message, so they inherit the sink's encoding automatically.
  • Disable colorized output in production, or encode ANSI escape sequences along with the rest of the control range.

Validate and restrict log content

  • Check that logged data matches the type you expect - numeric, email address, and so on
  • For enumerated values, validate against a known-good list before logging
  • Don't log passwords, tokens, credit cards or PII, or redact them first
  • Log only what debugging and auditing need

Monitor and audit log files for injection attempts

  • Review logs regularly for suspicious or malformed entries
  • Alert on newlines, control characters and unusual entry formats in the log stream
  • Use log signing or SIEM integrity checks to detect tampering
  • Keep audit logs separate from debug logs
  • Limit who can read and write the log files

Test with log injection payloads

  • value\n[2024-01-01] ADMIN LOGIN SUCCESS - an ASCII newline forging a second entry
  • value\u2028FAKE LOG ENTRY - a Unicode line separator, which a .replace() covering only \n and \r misses
  • \x1b[31mERROR\x1b[0m - ANSI color codes
  • Extremely long input, to confirm truncation applies
  • \r, \n, \t, \0 (null bytes), \u0085, \u2028, \u2029 - each control character and separator on its own
  • Ordinary input, to confirm legitimate entries are still recorded correctly

Common Vulnerable Patterns

Logging user input directly without sanitization

# Dangerous: user input in log
logger.info('User input: %s', user_input)

Why this is vulnerable: Newline characters (\n, \r) in the input split one log entry into several lines, so an attacker can append a forged entry that reads as a legitimate system event, or bury their own activity. ANSI escape sequences reach the log the same way and are interpreted as formatting commands by terminals and some viewers.

Secure Patterns

# RECOMMENDED: JSON/ECS logging (preserves audit trail)
# Control characters are encoded inside the field value
logger.info('User input', extra={'user_input': user_input})
# Attack attempt visible in the field: "test\nFAKE LOG ENTRY"

Why this works: The logger serializes the field value rather than pasting it into a line, so a newline inside user_input is written as the two characters \ and n within the quoted field and the record stays one entry. Encoding happens at the sink, which means it applies to every call site rather than the ones a developer remembered, and the attacker's payload survives intact for whoever investigates.

Encode control characters (Fallback)

# Alternative: Encode control characters when JSON/ECS output is not available
encoded_input = encode_control_chars(user_input)  # \n → \\n, \r → \\r, \x00 → \\u0000
logger.info('User input: %s', encoded_input)

Why this works: Converting each control character to a visible escape sequence makes it text rather than a functional newline, so the entry cannot split, and the security team can still read what the attacker sent. Encode the backslash as well: without that, an attacker who types the two characters \ and n produces output identical to a real newline, and the log stops distinguishing the two. Because this runs at the call site rather than at the sink, it only protects the calls that use it - see the pitfalls below.

# NOT RECOMMENDED: Remove control chars (loses critical forensic evidence)
clean_input = remove_control_chars(user_input)  # regex: [\x00-\x1F\x7F\u0085\u2028\u2029]
logger.info('User input: %s', clean_input)
# Attack becomes: "testFAKE LOG ENTRY" - you don't see the injection attempt
# Security teams cannot see what attackers attempted

Why this is a last resort: Removing ASCII control characters (\r, \n, \t, etc.) and Unicode newlines prevents log forging, but it also removes the evidence of the attempt, so incident responders never see what the attacker sent. Use it only when JSON/ECS output and encoding are both infeasible, such as a legacy system with no control over the log format.

Common Pitfalls

  • Parameterizing some log calls but not others: Switching the main login/audit path from string concatenation to a parameterized call (logger.info("User: {}", username)) puts that call site in reach of the fix - and only in reach of it, because placeholders neutralize nothing on their own; the sink still has to emit JSON, or the value still has to be encoded. Meanwhile a separate catch block, a debug statement, or an older code path that still concatenates (logger.info("User: " + username)) bypasses even an encoding-aware layout, since it hands the logger one pre-built string with nothing left to encode as a field. Both halves have to hold, and they are closed per call site rather than per application.
  • Stripping only ASCII carriage-return/line-feed: A filter that removes only the two ASCII newline characters blocks the obvious injection but leaves the Unicode NEL, Line Separator, and Paragraph Separator code points, plus other control characters (tab, NUL, ANSI escape sequences), untouched - several log viewers and terminals still treat those as line breaks or formatting commands.
  • Hand-building "structured" logs with string concatenation: Assembling a JSON-looking log line by concatenating strings ('{"user":"' + username + '"}') instead of using a real JSON encoder reintroduces the exact injection the structured format was meant to prevent - a value containing " or } can still forge or break out of the intended field.
  • Fixing the primary logger but missing a secondary sink: Configuring the main application logger with a JSON/ECS encoder while a separate console.log/print/Trace.WriteLine debug statement, error-handling path, or a different appender/handler still writes unencoded text - the finding is closed for one sink and still live for the other.

Language-Specific Guidance

Concrete logging framework configuration for each stack:

  • C# - ILogger, Serilog, NLog with message templates
  • Go - slog, logrus, zap with structured logging
  • Java - Log4j, Logback, SLF4J with parameterized logging
  • JavaScript/NodeJS - winston, pino, bunyan with safe log formatting
  • Python - logging module, structlog for structured logging

Additional Resources