CWE-117: Improper Output Neutralization for Logs - Java
Overview
Log Injection occurs when untrusted data is written to log files without encoding, allowing attackers to forge log entries or leave terminal control sequences in the output.
Primary Defence: Use SLF4J/Logback or Log4j2 with JSON/ECS output (logstash-logback-encoder or JsonLayout) so control characters are encoded within fields and cannot forge log entries. Parameterized logging alone is not enough: logger.info("User: {}", username) keeps the template and the value apart at the API level, but a PatternLayout appender still writes a raw CR/LF straight into the output line. Configure the JSON encoder, or encode at the call site for appenders you cannot change. One caveat decides which route you need: the JSON encoder leaves the Unicode separators (\u0085, \u2028, \u2029) raw, because Jackson does not escape non-ASCII, while both call-site options below encode all three. If a stage in your pipeline splits lines before parsing them, the JSON sink alone is not sufficient.
Common Vulnerable Patterns
String Concatenation Allows Log Forgery
// VULNERABLE - String concatenation allows log forgery
logger.info("User " + username + " logged in");
// Attack example:
// username = "admin\r\nINFO: User hacker logged in as admin"
// Result: Creates fake log entry that appears legitimate
Why this is vulnerable:
- User input is concatenated directly into the log message.
- CR/LF characters (
\r,\n) can split log lines and forge entries. - Attackers can create fake security events or hide real activity.
- Text-based layouts write the injected lines as separate records.
String Formatting Without Sanitization
// VULNERABLE - String.format() with unencoded input
logger.warn(String.format("Failed login for %s from %s", username, ip));
// Attack example:
// username = "test\nINFO: Security audit disabled by administrator"
// Result: Injects fake administrative log message
Why this is vulnerable:
String.format()does not encode control characters.- Newline sequences (
\n,\r) create additional log records. - Forged entries can mimic admin/system messages.
- Log analysis tools may treat injected lines as legitimate events.
HTTP Headers in Logs
// VULNERABLE - Logging HTTP headers without encoding
String userAgent = request.getHeader("User-Agent");
logger.info("Request from: " + userAgent);
// Attack example:
// User-Agent: Mozilla/5.0\r\nINFO: ADMIN_ACCESS granted\r\nINFO: IP: 127.0.0.1
// Log output:
// Request from: Mozilla/5.0
// INFO: ADMIN_ACCESS granted
// INFO: IP: 127.0.0.1
// Result: Multiple fake log lines with forged admin access
Why this is vulnerable:
- HTTP headers are attacker-controlled inputs.
- CR/LF sequences can create multiple forged log lines.
- Attackers can inject fake access grants or audit events.
- Incident response and monitoring tools can be misled by forged records.
Secure Patterns
Use Parameterized Logging with SLF4J (Pair with JSON/ECS output)
// SECURE - SLF4J parameterized logging
public class UserService {
private static final Logger logger = LoggerFactory.getLogger(UserService.class);
public void logLogin(String username, String ipAddress) {
logger.info("Login successful. User: {}, IP: {}", username, ipAddress);
}
public void logFailedAttempt(String username, String reason) {
logger.warn("Login failed. User: {}, Reason: {}", username, reason);
}
}
Why this works:
- SLF4J keeps the template and parameters separate during formatting.
- It avoids string concatenation that can mix control characters into the template.
- JSON layouts write a newline inside a field as the two characters
\n, keeping it within one log record. - Without JSON encoding, CR/LF still render in text layouts, so pair the parameterized call with JSON output or explicit encoding to block log forging.
Use Manual Encoding
Prefer this over sanitization: escaping every relevant character preserves the injection attempt as forensic evidence.
// Helper method; place inside your logging utility or service class
private static String encodeForSingleLineTextLog(String input) {
if (input == null) return "";
StringBuilder out = new StringBuilder(input.length());
for (int i = 0; i < input.length(); i++) {
char ch = input.charAt(i);
// Escape the backslash first, OUTSIDE the control-character test.
// 0x5C is not a control character, so a case for it inside that test
// never runs and the encoding becomes ambiguous.
if (ch == '\\') { out.append("\\\\"); continue; }
// Encode full ASCII control range + DEL + C1 controls (NEL 0x85 included)
if (ch <= 0x1F || ch == 0x7F || (ch >= 0x80 && ch <= 0x9F)) {
switch (ch) {
case '\r': out.append("\\r"); break;
case '\n': out.append("\\n"); break;
case '\t': out.append("\\t"); break;
default: out.append(String.format("\\u%04x", (int) ch)); break;
}
continue;
}
// Encode the Unicode line separators, which sit outside that range
if (ch == '\u2028') { out.append("\\u2028"); continue; }
if (ch == '\u2029') { out.append("\\u2029"); continue; }
out.append(ch);
}
return out.toString();
// Shows "admin\\r\\nFAKE" - preserves evidence of injection attempt
}
logger.info("User {} performed {}", encodeForSingleLineTextLog(username), encodeForSingleLineTextLog(action));
Why this works:
- Every character that could end a line or drive a terminal becomes a visible escape sequence, so the record cannot split and the payload stays readable for whoever investigates it.
- The backslash case has to sit outside the control-character test.
\\is 0x5C, which is neither<= 0x1Fnor0x7Fnor a C1 control, so acase '\\'placed inside thatswitchnever executes. With it unreachable, an attacker who types the two characters\andnencodes toadmin\nFAKE- byte-identical to what a real newline produces - and the log stops recording which one arrived. That is the whole claim this section rests on. - NEL needs no case of its own: it is 0x85, already inside the C1 range the control branch covers. U+2028 and U+2029 are outside it and do need theirs.
- This is the path that works with a text-based
PatternLayout, which is exactly the case a JSON/ECS encoder does not cover. - Because it runs at the call site it protects only the calls that use it. Prefer a JSON/ECS encoder at the sink where you can configure one, and keep this for appenders you cannot change.
Use Apache Commons Text for Encoding
// SECURE - Use Apache Commons Text to encode for logs
import org.apache.commons.text.StringEscapeUtils;
public void logUserAction(String username, String action) {
String safeUsername = StringEscapeUtils.escapeJava(username);
String safeAction = StringEscapeUtils.escapeJava(action);
logger.info("User {} performed {}", safeUsername, safeAction);
}
// Attack example:
// username = "admin\r\nINFO: FAKE LOG ENTRY"
// Log output: User admin\\r\\nINFO: FAKE LOG ENTRY performed login
// Result: Single log line with visible escape sequences
Why this works:
StringEscapeUtils.escapeJava()converts control characters to escape sequences (\nbecomes\\n,\rbecomes\\r).- Encodes the ASCII control range 0x00-0x1F, including
\t,\b,\f, and neutralizes ANSI colour codes by escaping ESC (0x1B→\u001B). - It does encode the Unicode separators.
escapeJavaescapes everything outside the printable ASCII range, so NEL, U+2028 and U+2029 come out as\u0085,\u2028and\u2029. Verified on commons-text 1.15.0. Guidance claiming otherwise is stale - it predates theJavaUnicodeEscaper.outsideOf(32, 0x7f)stage. - It does not encode DEL (0x7F). That character is the upper bound of the same range and falls inside it, so a raw 0x7F reaches the log untouched. This is the one gap the manual encoder above closes.
- Escaping is reversible:
escapeJavaalso escapes the backslash, so a real newline and a literal\nin the input stay distinguishable in the output. - Works with any logging framework and text-based layouts.
- Requires Apache Commons Text dependency
Validate Input Before Logging
// SECURE - Reject invalid input before logging
// eventData is the already-decoded value: getParameter(), @RequestParam and
// getHeader() all hand you decoded text. Do not URL-decode it again here.
public void logEvent(String eventData) {
if (containsLineBreak(eventData)) {
logger.warn("Attempted log injection detected");
return;
}
logger.info("Event: {}", eventData);
}
private static boolean containsLineBreak(String value) {
if (value == null) return false;
for (int i = 0; i < value.length(); i++) {
char ch = value.charAt(i);
if (ch == '\r' || ch == '\n'
|| ch == '\u0085' || ch == '\u2028' || ch == '\u2029') {
return true;
}
}
return false;
}
Why this works:
- It checks CR, LF and all three Unicode separators, so a
%E2%80%A8that the container already decoded to U+2028 is caught rather than passed through. NEL has to be named explicitly here even though the encoder above folds it into the C1 range. - There is deliberately no
URLDecoder.decodecall. A version of this check that decodes first looks more careful and is worse in two ways:URLDecoder.decodethrowsIllegalArgumentExceptionon any bare%, so a comment reading "100% done" becomes a 500 rather than a log line, and it maps+to a space, silently corrupting values that were never form-encoded. Verified on JDK 26. The container decoded the value before your code saw it; decoding again is a second pass over already-decoded text. - The check runs on the same string that reaches the logger. If you transform the value between checking and logging, the check no longer describes what gets written.
- Rejected input produces a generic warning, so the audit trail records the attempt without putting attacker-controlled text in the log.
- This is defence in depth, not the fix: it protects the one call site it guards, and it discards the payload rather than preserving it. Pair it with encoding or JSON output.
Use Structured Logging with JSON/ECS Output
// SECURE - Structured logging with JSON
import net.logstash.logback.argument.StructuredArguments;
logger.info("User login",
StructuredArguments.keyValue("username", username),
StructuredArguments.keyValue("ipAddress", ipAddress),
StructuredArguments.keyValue("timestamp", System.currentTimeMillis())
);
// Outputs as JSON, preventing log injection:
// {"@timestamp":"2026-01-04T10:30:00.000Z","message":"User login","username":"admin\nFAKE","ipAddress":"192.168.1.1"}
Why this works:
- JSON output writes each event as a single structured record.
- A newline inside a field value is written as the two characters
\n, so it is data rather than a line break. The same holds for CR, tab and the rest of the ASCII control range. - Injected data cannot create extra log lines in the output stream, so this closes the CR/LF forging that the concatenation patterns above allow.
- It does not escape the Unicode separators. Both logstash-logback-encoder and Log4j2
JsonLayoutserialize through Jackson, and Jackson does not escape non-ASCII:writeValueAsStringemits U+0085, U+2028 and U+2029 as raw bytes inside the quoted field. Verified on Jackson 2.20.1. A JSON parser still reads one record, so this is not a forged entry - but anything that splits the stream on line boundaries before parsing it will disagree, andjava.util.Scanneris one such thing:nextLine()treats all three as terminators, whileBufferedReader.readLine()andString.lines()do not. If a shipper in your pipeline splits before it parses, encode the separators at the call site as well. - Structured fields make forged content easier to detect in log analysis.
- This is the default to reach for in production logging.
Logback Configuration (Additional Security)
Use JSON layouts/encoders so user input is escaped inside a single log record.
Logback (logstash-logback-encoder):
<configuration>
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>
<root level="INFO">
<appender-ref ref="JSON"/>
</root>
</configuration>
Log4j2 (JsonLayout):
<Configuration>
<Appenders>
<Console name="JsonConsole">
<JsonLayout compact="true" eventEol="true"/>
</Console>
</Appenders>
<Loggers>
<Root level="info">
<AppenderRef ref="JsonConsole"/>
</Root>
</Loggers>
</Configuration>
Common Pitfalls
- Parameterizing the happy path but concatenating in exception/debug logging: Switching the main
logger.info("User: {}", username)call to SLF4J placeholders is necessary and not sufficient - as the next pitfall says, it changes nothing unless the appender encodes. What it does buy is that the value arrives as a separate argument, which is what an encoding-aware layout needs in order to act on it. Acatchblock still building its message withlogger.error("Failed for " + username), or a leftoverlogger.debug(), forfeits even that: the logger receives one finished string, so a JSON encoder writes the injected newline inside themessagefield and aPatternLayoutwrites it straight to the line. The finding is closed per call site, not per class or per application. - Assuming SLF4J placeholders alone block CR/LF:
logger.info("User: {}", username)keeps the template and the value separate at the API level, but if the configured appender uses a text-basedPatternLayoutrather than a JSON/ECS encoder, the resolved value is still written into the output line as-is - a raw\ninusernamestill splits the line. Parameterization prevents template injection, not necessarily log forging, unless it's paired with an encoding-aware layout. - Assuming
escapeJava()covers the whole control range: it encodes 0x00-0x1F, the backslash, and every non-ASCII character including the three Unicode separators - but not DEL (0x7F), which sits on the inside edge of the range it preserves. A raw 0x7F reaches the log. It is not a line break, so it does not forge an entry, but it does mean "escapeJava handles all control characters" is not true as stated.