Improper Output Neutralization for Logs
Description
Improper Output Neutralization for Logs occurs when software constructs log entries from user-controlled input without properly neutralizing special characters that could be interpreted by log viewing tools or log processing systems. Attackers can inject fake log entries, forge audit trails, inject malicious content into SIEM systems, or exploit log viewing applications. This is commonly called "Log Injection" or "Log Forging."
Risk
Log injection undermines the integrity of audit trails and security monitoring. Attackers can inject fake entries to cover their tracks, create false evidence, or trigger alerts to distract security teams. In SIEM systems, injected entries can trigger automated responses or bypass detection rules. Log4Shell (CVE-2021-44228) demonstrated that log frameworks can execute injected content. Even without code execution, forged logs can have legal implications by corrupting evidence and audit records required for compliance.
Solution
Sanitize all user input before including in log entries. Encode or escape special characters (newlines, CRLF, control characters). Use structured logging formats (JSON) instead of plaintext. Validate log entry format at the logging framework level. Implement log integrity verification (signing, hashing). Configure SIEM systems to detect injection attempts. Use separate logging for user-controlled data vs system events.
Common Consequences
| Impact | Details |
|---|---|
| Integrity | Scope: Log Forgery Attackers can inject fake log entries, corrupting audit trails and forensic evidence. |
| Non-Repudiation | Scope: Evidence Tampering Injected logs can create false evidence or remove attribution of malicious actions. |
| Availability | Scope: System Manipulation SIEM systems may execute automated responses based on forged log entries. |
Example Code + Solution Code
Vulnerable Code
# VULNERABLE: Direct logging of user input
import logging
logger = logging.getLogger(__name__)
@app.route('/login', methods=['POST'])
def login_vulnerable():
username = request.form['username']
password = request.form['password']
# Attacker username: "admin\n[CRITICAL] System compromised!"
logger.info(f"Login attempt for user: {username}")
# Resulting log:
# INFO Login attempt for user: admin
# [CRITICAL] System compromised!
if authenticate(username, password):
logger.info(f"User {username} logged in successfully")
return redirect('/dashboard')
else:
logger.warning(f"Failed login for user: {username}")
return 'Login failed', 401
# VULNERABLE: Log injection in error handling
@app.route('/api/data')
def get_data_vulnerable():
user_id = request.args.get('id')
try:
data = fetch_data(user_id)
return jsonify(data)
except Exception as e:
# User controls error message content!
logger.error(f"Error fetching data for {user_id}: {str(e)}")
return 'Error', 500
// VULNERABLE: Java logging without sanitization
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@RestController
public class VulnerableLogging {
private static final Logger logger = LoggerFactory.getLogger(VulnerableLogging.class);
@PostMapping("/login")
public ResponseEntity<?> login(@RequestParam String username, @RequestParam String password) {
// Attacker: username = "admin\n2024-01-01 [ERROR] Payment failed for card 4111-1111-1111-1111"
logger.info("Login attempt for user: {}", username);
if (authenticate(username, password)) {
logger.info("User {} logged in successfully", username);
return ResponseEntity.ok().build();
} else {
logger.warn("Failed login for user: {}", username);
return ResponseEntity.status(401).build();
}
}
// VULNERABLE: Exception message in logs
@GetMapping("/data/{id}")
public ResponseEntity<?> getData(@PathVariable String id) {
try {
return ResponseEntity.ok(dataService.find(id));
} catch (Exception e) {
// Exception message may contain user input
logger.error("Error getting data: {}", e.getMessage());
return ResponseEntity.status(500).build();
}
}
}
// VULNERABLE: Node.js logging
const logger = require('winston').createLogger({ /* config */ });
app.post('/login', (req, res) => {
const { username, password } = req.body;
// Attacker: username = "admin\n[ALERT] Security breach detected from IP 192.168.1.1"
logger.info(`Login attempt for user: ${username}`);
if (authenticate(username, password)) {
logger.info(`User ${username} logged in successfully`);
res.redirect('/dashboard');
} else {
logger.warn(`Failed login for user: ${username}`);
res.status(401).send('Login failed');
}
});
// VULNERABLE: User agent logging
app.use((req, res, next) => {
// User-Agent header is user-controlled!
logger.info(`Request from: ${req.headers['user-agent']} to ${req.path}`);
next();
});
Fixed Code
# SAFE: Sanitized logging
import logging
import re
logger = logging.getLogger(__name__)
def sanitize_log_input(value):
"""Remove characters that could cause log injection."""
if value is None:
return ''
# Convert to string
value = str(value)
# Remove newlines, carriage returns, and other control characters
value = re.sub(r'[\r\n\t]', ' ', value)
# Remove ANSI escape sequences
value = re.sub(r'\x1b\[[0-9;]*m', '', value)
# Limit length
return value[:200]
@app.route('/login', methods=['POST'])
def login_safe():
username = request.form.get('username', '')
password = request.form.get('password', '')
# Sanitize before logging
safe_username = sanitize_log_input(username)
logger.info("Login attempt for user: %s", safe_username)
if authenticate(username, password):
logger.info("User %s logged in successfully", safe_username)
return redirect('/dashboard')
else:
logger.warning("Failed login for user: %s", safe_username)
return 'Login failed', 401
# SAFE: Structured logging (JSON format)
import json
import structlog
structlog.configure(
processors=[
structlog.processors.JSONRenderer()
]
)
log = structlog.get_logger()
@app.route('/login', methods=['POST'])
def login_structured():
username = request.form.get('username', '')
# Structured logging - data is properly escaped
log.info(
"login_attempt",
username=username, # Will be JSON-escaped
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent', '')
)
if authenticate(username, request.form.get('password', '')):
log.info("login_success", username=username)
return redirect('/dashboard')
else:
log.warning("login_failed", username=username)
return 'Login failed', 401
# SAFE: Custom log formatter that escapes
class SafeFormatter(logging.Formatter):
def format(self, record):
# Sanitize all string arguments
if record.args:
record.args = tuple(
sanitize_log_input(arg) if isinstance(arg, str) else arg
for arg in record.args
)
return super().format(record)
handler = logging.StreamHandler()
handler.setFormatter(SafeFormatter('%(asctime)s - %(levelname)s - %(message)s'))
logger.addHandler(handler)
// SAFE: Java with sanitized logging
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.owasp.encoder.Encode;
@RestController
public class SecureLogging {
private static final Logger logger = LoggerFactory.getLogger(SecureLogging.class);
@PostMapping("/login")
public ResponseEntity<?> login(@RequestParam String username, @RequestParam String password) {
// Sanitize before logging
String safeUsername = sanitizeForLog(username);
logger.info("Login attempt for user: {}", safeUsername);
if (authenticate(username, password)) {
logger.info("User {} logged in successfully", safeUsername);
return ResponseEntity.ok().build();
} else {
logger.warn("Failed login for user: {}", safeUsername);
return ResponseEntity.status(401).build();
}
}
private String sanitizeForLog(String input) {
if (input == null) {
return "";
}
// Remove newlines and control characters
String sanitized = input
.replaceAll("[\r\n\t]", " ")
.replaceAll("[\\x00-\\x1F\\x7F]", "");
// Limit length
if (sanitized.length() > 200) {
sanitized = sanitized.substring(0, 200) + "...";
}
return sanitized;
}
}
// SAFE: Using structured logging with Logback + JSON
// logback.xml configuration:
/*
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<fieldNames>
<message>msg</message>
</fieldNames>
</encoder>
</appender>
*/
@Service
public class AuditService {
private static final Logger auditLogger = LoggerFactory.getLogger("AUDIT");
public void logAuditEvent(String action, String userId, Map<String, Object> details) {
// Using MDC for structured logging
MDC.put("action", action);
MDC.put("userId", sanitizeForLog(userId));
MDC.put("timestamp", Instant.now().toString());
try {
auditLogger.info("Audit event: {}", sanitizeForLog(action));
} finally {
MDC.clear();
}
}
}
// SAFE: Custom Logback filter
public class LogSanitizingFilter extends Filter<ILoggingEvent> {
@Override
public FilterReply decide(ILoggingEvent event) {
// Examine and sanitize log messages here
return FilterReply.NEUTRAL;
}
}
// SAFE: Node.js with sanitized logging
const winston = require('winston');
function sanitizeLogInput(value) {
if (value === null || value === undefined) {
return '';
}
return String(value)
// Remove newlines and carriage returns
.replace(/[\r\n\t]/g, ' ')
// Remove ANSI escape codes
.replace(/\x1b\[[0-9;]*m/g, '')
// Remove other control characters
.replace(/[\x00-\x1F\x7F]/g, '')
// Limit length
.substring(0, 200);
}
// Custom format that sanitizes
const sanitizeFormat = winston.format((info) => {
// Sanitize message
if (typeof info.message === 'string') {
info.message = sanitizeLogInput(info.message);
}
// Sanitize all metadata
for (const [key, value] of Object.entries(info)) {
if (key !== 'level' && key !== 'timestamp' && typeof value === 'string') {
info[key] = sanitizeLogInput(value);
}
}
return info;
});
const logger = winston.createLogger({
format: winston.format.combine(
sanitizeFormat(),
winston.format.timestamp(),
winston.format.json() // JSON output for structured logging
),
transports: [new winston.transports.Console()]
});
app.post('/login', (req, res) => {
const { username, password } = req.body;
// Safe to log - will be sanitized
logger.info('Login attempt', {
username: username,
ipAddress: req.ip,
userAgent: req.headers['user-agent']
});
if (authenticate(username, password)) {
logger.info('Login success', { username });
res.redirect('/dashboard');
} else {
logger.warn('Login failed', { username });
res.status(401).send('Login failed');
}
});
// SAFE: Using Pino for high-performance structured logging
const pino = require('pino');
const pinoLogger = pino({
formatters: {
log: (obj) => {
// Sanitize all string values
const sanitized = {};
for (const [key, value] of Object.entries(obj)) {
sanitized[key] = typeof value === 'string' ?
sanitizeLogInput(value) : value;
}
return sanitized;
}
}
});
// Usage
pinoLogger.info({ username: req.body.username }, 'Login attempt');
Exploited in the Wild
Log4Shell (CVE-2021-44228)
While primarily a JNDI injection vulnerability, Log4Shell demonstrated how logging user-controlled data can lead to remote code execution when the logging framework processes special syntax.
SIEM Bypass Attacks
Attackers have used log injection to create fake "all clear" messages or trigger false alarms to distract security teams during actual attacks.
Audit Trail Manipulation
Financial systems have been attacked through log injection to cover tracks of fraudulent transactions by forging audit entries.
Tools to test/exploit
-
Burp Suite — inject log payloads through request parameters.
-
Manual testing with CRLF sequences (%0d%0a).
-
Custom scripts to test log injection patterns.
CVE Examples
-
CVE-2021-44228 — Log4Shell.
-
CVE-2019-17571 — Apache Log4j deserialization.
-
CVE-2017-5929 — QOS.ch Logback JNDI.
References
-
MITRE. "CWE-117: Improper Output Neutralization for Logs." https://cwe.mitre.org/data/definitions/117.html
-
OWASP. "Log Injection." https://owasp.org/www-community/attacks/Log_Injection