Skip to content

CWE-117: Improper Output Neutralization for Logs - C# / ASP.NET

Overview

Log Injection occurs when untrusted data is written to log files without encoding, allowing attackers to forge log entries, hide malicious activity, or inject scripts into log viewing applications.

Primary Defence: Use structured logging with JSON/ECS output - AddJsonConsole() on Microsoft.Extensions.Logging, or a Serilog JSON/ECS sink - and pass values through named placeholders rather than interpolating them. Message templates alone are not the fix: ILogger keeps the template and the values apart, but the default console formatter writes them back into a line of text, so the CR/LF is still emitted. Configure the formatter. Where you cannot, encode at the call site (\n → the two characters \n) rather than stripping, so the log still records what was submitted; stripping is the last resort, because it silences the scanner and the investigator equally.

OWASP Alignment

This guidance implements the OWASP Logging Cheat Sheet recommendation (Event Collection section):

"Perform sanitization on all event data to prevent log injection attacks e.g. carriage return (CR), line feed (LF) and delimiter characters"

See also: OWASP Log Injection Attack demonstrating newline-based log forging.

The cheat sheet stops at CR, LF and delimiters. This page goes further in one respect that matters in practice: it also covers \u0085 (NEL), \u2028 (Line Separator) and \u2029 (Paragraph Separator). A .Replace("\r", "") .Replace("\n", "") pair looks like it has handled line breaks and does nothing about any of the three.

It also differs from the cheat sheet on how to neutralise them. OWASP says sanitize; the recommendation here is to encode to a visible escape sequence instead, because a stripped payload cannot be investigated afterwards. The character set [\x00-\x1F\x7F\u0085\u2028\u2029] is the right coverage; use it to decide what to encode, not as a Regex.Replace to empty string.

Common Vulnerable Patterns

String Concatenation Allows Log Forgery

// VULNERABLE - String concatenation allows log forgery
logger.LogInformation("User " + username + " logged in");

// Attack example:
// username = "admin\r\nUser hacker logged in as admin"
// Result: Two separate log lines, second appears legitimate:
//   User admin
//   User hacker logged in as admin

Why this is vulnerable:

  • User input is concatenated directly into the log message.
  • CR/LF sequences (\r, \n) can split a single log entry into multiple lines.
  • Attackers can forge entries that look legitimate or hide activity.
  • Whether that split reaches the file depends on the layout: a text-based one writes the injected lines as separate records.

String Interpolation Without Sanitization

// VULNERABLE - String interpolation without encoding
logger.LogWarning($"Failed login for {username} from {ipAddress}");

// Attack example:
// username = "test\r\nSUCCESS: admin login from 127.0.0.1"
// Result: Creates fake success entry after failed login attempt

Why this is vulnerable:

  • String interpolation does not encode control characters.
  • Newlines injected into username or ipAddress create fake log records.
  • A forged success entry written after the real failure hides it from anyone reading the log.
  • Log parsers may treat injected lines as valid events.

Unicode Newline Injection (Bypasses Basic Sanitization)

// VULNERABLE - Only removes ASCII newlines, not Unicode
string sanitized = userInput.Replace("\r", "").Replace("\n", "");
logger.LogInformation($"Input: {sanitized}");

// Attack example:
// userInput = "test\u2028CRITICAL: Database breach detected\u2029Emergency shutdown initiated"
//
// The bytes written are ONE line - .NET does not treat U+2028/U+2029 as line
// endings, and StringReader.ReadLine over this text returns a single line.
// What splits it is the consumer. Rendered in an editor, a web log viewer, or
// parsed by a stage written in Python (str.splitlines) or Java (Scanner), the
// same bytes read as three entries:
//   Input: test
//   CRITICAL: Database breach detected
//   Emergency shutdown initiated

Why this is vulnerable:

  • ASCII-only replacement leaves the Unicode separators intact, and a .Replace("\r", "").Replace("\n", "") pair reads as though it has handled line breaks.
  • Whether they forge an entry depends on what reads the log, not on .NET. Writing U+2028 does not split the line: StringReader.ReadLine() and File.ReadLines() break on CR and LF only, which running it confirms. The exposure is in the consumers - str.splitlines() in Python and java.util.Scanner in Java both treat U+0085, U+2028 and U+2029 as terminators, as do many editors and web-based log viewers. A mixed-language pipeline is the normal case, so the safe assumption is that something downstream will split.
  • That also makes this hard to spot in testing: the developer checks the file, sees one line, and concludes the input was harmless. The forged entry appears in the SIEM.
  • Developers usually test with \n and \r only, so these variants survive review.

Secure Patterns

Use Structured Logging with Parameter Binding (Pair with JSON/ECS output)

// SECURE - Structured logging (automatic encoding with JSON/ECS output)
public class UserService
{
    private readonly ILogger<UserService> _logger;

    public void LogLogin(string username, string ipAddress)
    {
        _logger.LogInformation(
            "User login successful. Username: {Username}, IP: {IPAddress}",
            username,
            ipAddress
        );
    }
}

The message template above is only half of it. ILogger holds the template and the values apart, but the formatter decides what reaches the file, and the default console formatter renders them straight back into a line of text. Turn on a JSON formatter so the values are written as fields:

// SECURE - JSON console formatter: values are serialized, not interpolated
using System.Text.Json;  // JsonWriterOptions

builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole(options =>
{
    options.JsonWriterOptions = new JsonWriterOptions { Indented = false };
});
Serilog equivalent
// SECURE - one JSON object per event
// Requires the Serilog.Formatting.Compact package
Log.Logger = new LoggerConfiguration()
    .WriteTo.Console(new Serilog.Formatting.Compact.CompactJsonFormatter())
    .CreateLogger();

Why this works:

  • The named placeholders keep each value in its own field rather than pasting it into the line, and the JSON formatter serializes those fields. A CR or LF in username is written as \n inside the field, so the record cannot split.
  • .NET is the ecosystem where this also covers the Unicode separators. System.Text.Json's default encoder escapes everything outside a conservative ASCII set, so U+0085, U+2028 and U+2029 come out as \u0085, \u2028 and \u2029. Verified on .NET 10. Guidance written for Java or Node does not transfer here - their encoders emit all three raw. Note that setting JavaScriptEncoder.UnsafeRelaxedJsonEscaping to make logs more readable gives this back.
  • A line-oriented shipper downstream therefore still sees one event per record.
  • Structured fields keep the metadata queryable, which is the point of doing this at the sink rather than encoding at each call site.

Create Manual Encoding Helper (Unicode-Aware)

Where you cannot configure a JSON formatter, encode at the call site. This helper covers the full control range and keeps the payload readable.

// SECURE - Manual encoding helper (Unicode-aware)
using System.Text;

public class SecureLogger
{
    private readonly ILogger _logger;

    private string EncodeForSingleLineTextLog(string input)
    {
        if (string.IsNullOrEmpty(input)) return string.Empty;

        // Encode full control range to preserve attack evidence
        var sb = new StringBuilder(input.Length);
        foreach (var ch in input)
        {
            if (ch == '\r') { sb.Append("\\r"); continue; }
            if (ch == '\n') { sb.Append("\\n"); continue; }
            if (ch == '\t') { sb.Append("\\t"); continue; }
            if (ch == '\\') { sb.Append("\\\\"); continue; }
            if (ch == '\u0085' || ch == '\u2028' || ch == '\u2029')
            {
                sb.Append("\\u").Append(((int)ch).ToString("x4"));
                continue;
            }
            if (ch <= 0x1F || ch == 0x7F || (ch >= 0x80 && ch <= 0x9F))
            {
                sb.Append("\\u").Append(((int)ch).ToString("x4"));
                continue;
            }
            sb.Append(ch);
        }
        return sb.ToString();
        // Shows "admin\\r\\nFAKE" - preserves evidence of injection attempt
    }

    public void LogUserAction(string username, string action)
    {
        _logger.LogInformation(
            "User {Username} performed {Action}",
            EncodeForSingleLineTextLog(username),  // RECOMMENDED: Use encoding
            EncodeForSingleLineTextLog(action)
        );
    }
}

Why this works:

  • Encoding approach (RECOMMENDED): Converts the full control range to visible escape sequences, so the log still records what was submitted.
  • Works with text-based log layouts when JSON/ECS output is not available.
  • Encoding is strongly preferred to removal because security teams can see exactly what was attempted.

Validate Input Before Logging

// SECURE - Validation before logging

// eventData is the value as the application sees it. Model binding, Request.Query
// and Request.Form have already percent-decoded it, so do NOT decode again here:
// a second pass turns a user's literal "100%25" into "100%" and their "a+b" into
// "a b". Decode only where you know the value is still encoded, and then check the
// decoded form - a raw-string check on "%0D%0A" passes and decodes to CRLF later.
public void LogSecureEvent(string eventData)
{
    if (ContainsControlCharacter(eventData))
    {
        _logger.LogWarning("Attempted log injection detected in event data");
        return;
    }

    _logger.LogInformation("Event: {EventData}", eventData);
}

// The whole range this page puts in scope, not just the line separators:
// ASCII controls and DEL, the C1 range, and the two Unicode separators.
private static bool ContainsControlCharacter(string value)
{
    if (string.IsNullOrEmpty(value)) return false;

    foreach (char ch in value)
    {
        if (ch <= 0x1F || ch == 0x7F || (ch >= 0x80 && ch <= 0x9F)
                || ch == '\u2028' || ch == '\u2029')
        {
            return true;
        }
    }
    return false;
}

Why this works:

  • It checks the whole control range, not just the characters that end a line. A version testing only CR, LF and the three separators lets ESC, NUL and DEL through, so ANSI terminal control still reaches whoever tails a text log - and this page names exactly that range as in scope. NEL is covered by the C1 test rather than a case of its own, since it is 0x85.
  • There is no WebUtility.UrlDecode call, and that is a deliberate reversal. The check has to run on the string that reaches the logger, which in ASP.NET Core is already decoded: model binding, Request.Query and Request.Form decode before your action sees the value. Decoding a second time corrupts ordinary input - + becomes a space, and a literal % survives only because WebUtility.UrlDecode returns it unchanged rather than throwing the way Java's URLDecoder does. Keep the decode only where the value genuinely is still encoded, and see the pitfall below for the case that motivates it.
  • Suspicious data is replaced with a safe, generic warning, so the audit trail records the attempt without logging attacker-controlled content.
  • It provides defense-in-depth alongside encoding/structured logging. It rejects rather than encodes, so it discards the payload - that is the trade for a value with a known-good shape.

Common Pitfalls

  • String-interpolating into the message instead of using a placeholder: logger.LogInformation($"User: {username}") looks parameterized but isn't - the interpolation runs before the call, so ILogger receives one already-built string as its message template with nothing left to encode as a separate field. The Roslyn analyzer rule CA2254 flags exactly this, but it's easy to reintroduce in new code that wasn't reviewed against it.
  • Checking for CR/LF before the value is decoded: Validating a query-string or form value for newlines works only if the check runs after URL-decoding. A value containing %0D%0A passes a raw-string check untouched, then decodes to a real CR/LF later in the request pipeline (model binding, a downstream call to WebUtility.UrlDecode) - after the check already ran.
  • Encoding the parameterized sink but leaving a text-formatted one active: Configuring Serilog or NLog's production sink to emit JSON/ECS closes the finding for that sink, but a default console sink used in local development, a self-log/diagnostics output, or a legacy Trace.WriteLine call elsewhere in the pipeline still renders the same parameterized values into plain text, where CR/LF are not encoded.

Additional Resources