Skip to content

CWE-117: Improper Output Neutralization for Logs - Go

Overview

Log injection vulnerabilities in Go applications occur when user-controlled data is written to logs without proper sanitization, allowing attackers to forge log entries, inject malicious content, obscure their activities, or exploit log processing systems. Attackers insert special characters—primarily newlines (\n, \r), ANSI escape sequences, or format strings—to manipulate how logs appear to administrators, security monitoring tools, and log aggregation systems.

The most common attack is newline injection: inserting \n or \r\n to create fake log entries. For example, a user supplies username admin\nLOGIN SUCCESSFUL for user=attacker which gets logged as two lines, making it appear that "attacker" successfully logged in when they didn't. This obscures actual authentication failures or creates false audit trails. ANSI escape sequence injection can hide log entries by clearing terminal screens or changing text colors to match backgrounds when logs are viewed. Control characters (\x00, \x08) can corrupt log files or exploit parsing vulnerabilities in log processors.

Log injection attacks target different systems: human administrators reading logs in terminals (ANSI escapes, terminal control), SIEM/log aggregation tools that parse logs based on newline delimiters (forged entries), compliance auditors reviewing access logs (false audit trails), and automated parsers that expect one event per line (breaking parsers with embedded newlines). The impact ranges from hiding malicious activity and creating plausible deniability to triggering vulnerabilities in downstream log processing systems (injection into Elasticsearch, Splunk, etc.).

Primary Defence: Use structured logging (JSON format) with proper escaping. Sanitize user input before logging by removing or escaping newlines, control characters, and ANSI sequences. Never log untrusted data directly. Use Go 1.21+ slog package with structured attributes, or dedicated logging libraries (logrus, zap) that handle escaping automatically. Validate log input server-side.

Common Vulnerable Patterns

Direct User Input in Log Messages

// VULNERABLE - Logging unsanitized user input
package main

import (
    "log"
    "net/http"
)

func loginHandler(w http.ResponseWriter, r *http.Request) {
    username := r.FormValue("username")
    password := r.FormValue("password")

    if !authenticate(username, password) {
        // DANGEROUS: User input directly in log message
        log.Printf("Failed login attempt for user: %s", username)
        http.Error(w, "Login failed", http.StatusUnauthorized)
        return
    }

    log.Printf("Successful login for user: %s", username)
    // ... grant access
}

func authenticate(username, password string) bool {
    return false
}

// ATTACK:
// POST username=admin%0ASUCCESS: Login successful for user=attacker
// Log output:
// 2024/01/15 10:30:45 Failed login attempt for user: admin
// SUCCESS: Login successful for user=attacker
//
// Appears as two separate log entries. Attacker creates fake success message.

Why this is vulnerable: log.Printf writes the formatted string directly to the log file or stdout. If username contains \n (newline), it creates a new line in the log. The attacker injects admin\nSUCCESS: Login successful for user=attacker, which appears as two log entries after URL decoding. The second line looks like a genuine successful login for "attacker", when actually it's part of a failed login attempt. Administrators or log parsers treating each line as a separate event are fooled into seeing a successful login that never occurred. This obscures failed authentication attempts and creates false audit trails.

Printf-style Format String Risks

// VULNERABLE - User input with format string specifiers
import (
    "log"
    "net/http"
)

func searchHandler(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query().Get("q")

    // DANGEROUS: User input could contain format specifiers
    log.Printf("Search query: " + query)

    // Process search...
}

// ATTACK:
// GET /search?q=%s%s%s%s%s%n
// Log tries to interpret %s as format specifiers
// May cause panic or undefined behavior

Why this is vulnerable: While Go's log.Printf is safer than C's printf (no memory corruption), passing user input as the format string still causes issues. If query contains %s, %d, or other format specifiers, log.Printf tries to find corresponding arguments. With no arguments provided, this causes runtime errors or panics. More subtly, %v could accidentally print internal state. Always use log.Printf("Search query: %s", query) with query as an argument, not concatenated into the format string. This vulnerability is less severe in Go than C but still represents dangerous practice.

ANSI Escape Sequence Injection

// VULNERABLE - ANSI terminal escape sequences in logs
import (
    "log"
    "net/http"
)

func errorHandler(w http.ResponseWriter, r *http.Request) {
    errorMsg := r.URL.Query().Get("error")

    // DANGEROUS: ANSI escapes can manipulate terminal output
    log.Printf("Error from user: %s", errorMsg)

    http.Error(w, "Error logged", http.StatusOK)
}

// ATTACK:
// GET /error?error=\x1b[2J\x1b[H\x1b[3JHarmless%20error
// ANSI sequences:
// \x1b[2J - Clear entire screen
// \x1b[H  - Move cursor to home position
// \x1b[3J - Clear scrollback buffer
//
// When admin views logs in terminal, screen is cleared
// All previous log history appears to vanish

Why this is vulnerable: ANSI escape sequences control terminal behavior: clearing screens, changing colors, moving cursors, or hiding text. When administrators view logs in terminals (via tail, less, or SSH sessions), these sequences execute. \x1b[2J clears the screen, hiding all previous log entries. An attacker can make their malicious activity invisible by clearing the screen before their log entry appears. Color manipulation (\x1b[30m for black text on black background) can hide log lines visually. While the data is still in the log file, viewing it in a terminal renders it invisible, allowing attackers to evade real-time monitoring.

Multi-line Input Breaking Log Parsers

// VULNERABLE - Unchecked newlines breaking structured log parsing
import (
    "encoding/json"
    "log"
    "net/http"
)

type LogEntry struct {
    Action string `json:"action"`
    User   string `json:"user"`
    IP     string `json:"ip"`
}

func auditLog(action, user, ip string) {
    entry := LogEntry{
        Action: action,
        User:   user,
        IP:     ip,
    }

    jsonBytes, _ := json.Marshal(entry)

    // DANGEROUS: json.Marshal doesn't prevent newlines in values
    // If user contains \n, log appears as multiple lines
    log.Println(string(jsonBytes))
}

func actionHandler(w http.ResponseWriter, r *http.Request) {
    user := r.FormValue("user")

    auditLog("file_access", user, r.RemoteAddr)
}

// ATTACK:
// POST user=alice","ip":"BYPASSED"}\n{"action":"admin_access","user":"eve
// Logged JSON: {"action":"file_access","user":"alice","ip":"BYPASSED"}
// {"action":"admin_access","user":"eve","ip":"<actual_ip>"}
//
// Log aggregator sees two JSON objects, second shows fake admin access

Why this is vulnerable: While json.Marshal escapes newlines as \n in JSON strings (preventing actual newline characters), a sophisticated attacker can still break JSON structure by injecting "} to close the string early and add new fields or objects. Even with proper JSON escaping, embedding newlines in user data breaks log aggregators expecting one JSON object per line. Parsers reading line-by-line may interpret multi-line JSON as multiple separate events. The safer approach is strict input validation (rejecting newlines) or using logging libraries with built-in sanitization that ensure one log entry per line.

Insufficient Sanitization

// VULNERABLE - Incomplete sanitization
import (
    "log"
    "net/http"
    "strings"
)

func sanitizeLog(input string) string {
    // INSUFFICIENT: Only removes \n, not \r or other control chars
    return strings.ReplaceAll(input, "\n", "")
}

func activityLog(w http.ResponseWriter, r *http.Request) {
    activity := r.FormValue("activity")

    // Attempts sanitization but incomplete
    sanitized := sanitizeLog(activity)
    log.Printf("User activity: %s", sanitized)
}

// ATTACK 1:
// POST activity=test%0D%0AAdmin access granted
// \r\n (carriage return + newline) still creates new line
// Only \n was removed, \r remains
//
// ATTACK 2:
// POST activity=\x00\x08\x1b[31mERROR
// Null bytes, backspace, ANSI red color - all pass through

Why this is vulnerable: Sanitization must be comprehensive. Removing only \n leaves \r (carriage return), \r\n (Windows line endings), and control characters (\x00-\x1f) unaddressed. \r can overwrite the beginning of the log line in some contexts. \x00 (null byte) can truncate strings in C-based log processors. \x08 (backspace) can delete characters in terminal output. ANSI escape sequences (\x1b[) remain untouched. Complete sanitization must remove or escape all control characters (0x00-0x1f), ANSI escapes, and both \n and \r.

Secure Patterns

Structured Logging with Go 1.21+ slog

// SECURE - Using slog with structured attributes
package main

import (
    "log/slog"
    "net/http"
    "os"
)

func main() {
    // SECURE: JSON handler automatically escapes all special characters
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    }))

    http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
        username := r.FormValue("username")

        // SECURE: Structured logging with key-value pairs
        if !authenticate(username, "") {
            logger.Info("Failed login attempt",
                slog.String("username", username),
                slog.String("ip", r.RemoteAddr),
                slog.String("user_agent", r.UserAgent()),
            )
            http.Error(w, "Login failed", http.StatusUnauthorized)
            return
        }

        logger.Info("Successful login",
            slog.String("username", username),
            slog.String("ip", r.RemoteAddr),
        )
    })

    http.ListenAndServe(":8080", nil)
}

func authenticate(username, password string) bool {
    return false
}

// ATTACK ATTEMPT:
// POST username=admin%0ASUCCESS: fake log
//
// Logged as JSON:
// {"time":"2024-01-15T10:30:45Z","level":"INFO","msg":"Failed login attempt","username":"admin\nSUCCESS: fake log","ip":"192.168.1.1"}
//
// Newline is JSON-escaped as \n in the username field
// Log processors see single JSON object, not multiple lines
// Attack fails

Why this works: slog with JSONHandler writes each log entry as a single JSON object on one line. Special characters in field values (newlines, quotes, backslashes) are properly JSON-escaped according to RFC 8259. User input admin\nSUCCESS becomes "admin\\nSUCCESS" in JSON (escaped backslash-n), not a literal newline. Log aggregators parsing JSON see this as a string value containing the characters backslash and 'n', not a line break. Each log entry remains a single line, preventing log injection. Structured logging also provides machine-readable logs for better parsing and analysis.

Input Sanitization for Text Logs

// SECURE - Comprehensive input sanitization
import (
    "log"
    "net/http"
    "regexp"
    "strings"
    "unicode"
)

// Remove all control characters and ANSI escape sequences
func sanitizeForLog(input string) string {
    // Remove ANSI escape sequences: \x1b followed by bracket and commands
    ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
    cleaned := ansiRegex.ReplaceAllString(input, "")

    // Remove all control characters (0x00-0x1F, 0x7F)
    cleaned = strings.Map(func(r rune) rune {
        if unicode.IsControl(r) {
            return -1 // Remove character
        }
        return r
    }, cleaned)

    return cleaned
}

func secureLoginLog(w http.ResponseWriter, r *http.Request) {
    username := r.FormValue("username")

    // SECURE: Sanitize before logging
    sanitized := sanitizeForLog(username)

    if !authenticate(sanitized, "") {
        log.Printf("Failed login: username=%q ip=%s", sanitized, r.RemoteAddr)
        http.Error(w, "Login failed", http.StatusUnauthorized)
        return
    }

    log.Printf("Successful login: username=%q ip=%s", sanitized, r.RemoteAddr)
}

// ATTACK ATTEMPT:
// POST username=admin%0ASUCCESS%0Auser=attacker
//
// After sanitization: "adminSUCCESSuser=attacker"
// Newlines removed, log remains single line
// Log: Failed login: username="adminSUCCESSuser=attacker" ip=192.168.1.1

Why this works: The regex removes ANSI escape sequences (\x1b[...). strings.Map with unicode.IsControl removes all control characters including newlines (\n, \r), null bytes (\x00), backspace (\x08), tabs (\t), and other non-printable characters (0x00-0x1F, 0x7F). Using %q in Printf provides additional safety by Go-escaping the string (showing \n literally if any remained). The result is a single-line log entry with all injection attempts neutered. User input can still be logged for debugging but cannot manipulate log structure.

Logrus with Structured Fields

// SECURE - Logrus with JSON formatting
import (
    "net/http"

    "github.com/sirupsen/logrus"
)

var log = logrus.New()

func init() {
    // SECURE: JSON formatter escapes all special characters
    log.SetFormatter(&logrus.JSONFormatter{
        TimestampFormat: "2006-01-02T15:04:05Z07:00",
    })
    log.SetLevel(logrus.InfoLevel)
}

func handleAPIRequest(w http.ResponseWriter, r *http.Request) {
    apiKey := r.Header.Get("X-API-Key")
    endpoint := r.URL.Path

    // SECURE: Structured logging with Fields
    log.WithFields(logrus.Fields{
        "endpoint":   endpoint,
        "api_key":    maskAPIKey(apiKey),
        "ip":         r.RemoteAddr,
        "method":     r.Method,
        "user_agent": r.UserAgent(),
    }).Info("API request")

    // Process request...
}

func maskAPIKey(key string) string {
    if len(key) < 8 {
        return "***"
    }
    return key[:4] + "****" + key[len(key)-4:]
}

// Output (JSON on single line):
// {"endpoint":"/api/users","api_key":"abcd****xyz1","ip":"10.0.0.1","level":"info","method":"GET","msg":"API request","time":"2024-01-15T10:30:45Z","user_agent":"curl/7.68.0"}
//
// Even if user_agent contains \n or ANSI codes, they're JSON-escaped

Why this works: Logrus with JSONFormatter produces one JSON object per log entry, similar to slog. All field values are JSON-encoded, automatically escaping special characters. User-controlled data (user_agent, endpoint) cannot break out of JSON string encoding to inject newlines or control characters. The WithFields pattern encourages structured logging with typed data rather than string concatenation. This is both injection-safe and machine-parseable for log aggregation systems (ELK, Splunk, etc.). Sensitive data like API keys are masked before logging.

Zap for High-Performance Structured Logging

// SECURE - Uber's Zap with production config
import (
    "net/http"

    "go.uber.org/zap"
)

var logger *zap.Logger

func init() {
    var err error
    // SECURE: Production config uses JSON encoding
    logger, err = zap.NewProduction()
    if err != nil {
        panic(err)
    }
}

func transactionHandler(w http.ResponseWriter, r *http.Request) {
    userID := r.FormValue("user_id")
    amount := r.FormValue("amount")

    // SECURE: Strongly-typed fields prevent injection
    logger.Info("Transaction initiated",
        zap.String("user_id", userID),
        zap.String("amount", amount),
        zap.String("ip", r.RemoteAddr),
        zap.String("session_id", getSessionID(r)),
    )

    // Process transaction...
}

func getSessionID(r *http.Request) string {
    return "session123"
}

// Output (JSON):
// {"level":"info","ts":1705320645.1234567,"caller":"main.go:42","msg":"Transaction initiated","user_id":"user123\nfake","amount":"100","ip":"10.0.0.1","session_id":"session123"}
//
// Newline in user_id is JSON-escaped, output remains single line

Why this works: Zap's production configuration uses JSON encoding with automatic escaping. The strongly-typed field API (zap.String, zap.Int, etc.) encourages proper structured logging. Zap is also highly performant (zero-allocation in hot paths), making it suitable for high-throughput applications where logging overhead matters. Like slog and logrus, JSON encoding prevents log injection by escaping all special characters in field values. Additional benefits include caller information, nanosecond timestamps, and built-in sampling for high-volume logs.

Validation and Rejection Approach

// SECURE - Reject invalid input instead of sanitizing
import (
    "fmt"
    "log"
    "net/http"
    "regexp"
)

var alphanumericRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)

func strictLoginHandler(w http.ResponseWriter, r *http.Request) {
    username := r.FormValue("username")

    // SECURE: Validate username format
    if !alphanumericRegex.MatchString(username) {
        // SECURE: Log rejection without including invalid username
        log.Printf("Login attempt with invalid username format from IP: %s", r.RemoteAddr)
        http.Error(w, "Invalid username format", http.StatusBadRequest)
        return
    }

    // Username is validated, safe to log
    if !authenticate(username, r.FormValue("password")) {
        log.Printf("Failed login: username=%s ip=%s", username, r.RemoteAddr)
        http.Error(w, "Login failed", http.StatusUnauthorized)
        return
    }

    log.Printf("Successful login: username=%s ip=%s", username, r.RemoteAddr)
}

// ATTACK ATTEMPT:
// POST username=admin%0Afake
//
// Regex match fails (newline not in [a-zA-Z0-9_-]+)
// Log: "Login attempt with invalid username format from IP: 192.168.1.1"
// Invalid username never appears in log

Why this works: Input validation rejects malicious input before it reaches logging code. The regex allows only alphanumeric characters, underscore, and hyphen - no newlines, control characters, or special symbols. Invalid usernames are rejected entirely and never logged, eliminating injection risk. This approach is ideal when input format is well-defined (usernames, UUIDs, email addresses). Logging only the IP address for invalid attempts still provides audit trail without exposing attack payloads. Combining validation with structured logging provides defense-in-depth.

Additional Resources