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, letting attackers forge log entries, obscure their activity, or exploit the systems that process the logs. 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 consumers: human administrators reading logs in terminals (ANSI escapes, terminal control), SIEM and log aggregation tools that split on newline delimiters and expect one event per line (forged entries, broken parsers), and compliance auditors reviewing access logs (false audit trails). 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: Emit JSON and let the encoder do the escaping - Go 1.21+ slog with a JSONHandler, or logrus/zap with their JSON formatters. Pass values as attributes (slog.String("username", username)), never concatenated into the message. Where a text log is unavoidable, encode the control characters to visible escape sequences rather than deleting them, so the log still records what the attacker sent; deleting is the last resort, because a stripped payload cannot be investigated afterwards. The standard log package escapes nothing and is not a safe sink for untrusted values.
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 see a successful login that never occurred, which buries the failed attempt and leaves a false audit trail.
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 IS the format string, and it is not parameterised
log.Printf("Search query: " + query)
// Process search...
}
// ATTACK 1 - the injection:
// GET /search?q=admin%0ASUCCESS:+login+for+attacker
// The newline is written straight through and forges a second entry.
//
// ATTACK 2 - corrupting the message:
// GET /search?q=%s%s%s
// Output: Search query: %!s(MISSING)%!s(MISSING)%!s(MISSING)
// No panic - fmt reports missing operands inline and carries on.
Why this is vulnerable: The log-injection defect here is the concatenation, not the verb. query is appended to the format string and log.Printf writes the result verbatim, so a newline in it forges an entry exactly as in the previous pattern - moving to log.Printf("Search query: %s", query) does not fix that either, because the standard log package escapes nothing. The format specifiers are a separate and much smaller problem: Go's fmt does not panic on missing operands and cannot corrupt memory the way C's printf can. It substitutes %!s(MISSING) for each one and keeps going, so a %s in user input garbles the message and consumes nothing it should not. Claims that this panics or triggers undefined behaviour do not hold - running it prints the MISSING markers and returns normally. Treat this as two habits worth breaking: never build a format string from input, and never rely on the log package for untrusted values.
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=%1B[2J%1B[H%1B[3JHarmless%20error
// (%1B is the URL encoding of ESC, 0x1B - a literal \x1b in a query
// string arrives as the four characters backslash, x, 1, b)
// Decoded 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, so an attacker who prefixes a payload with it wipes every entry above their own out of the administrator's view. 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.
Hand-marshalled JSON Written Through the Standard Logger
// VULNERABLE - correctly marshalled JSON, emitted through a logger that prefixes it
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: not because of the JSON, but because log.Println prefixes it.
// With the default flags the line is "2024/01/15 10:30:45 {...}", which is
// not a JSON document, so the aggregator that parses this stream drops it.
log.Println(string(jsonBytes))
}
func actionHandler(w http.ResponseWriter, r *http.Request) {
user := r.FormValue("user")
auditLog("file_access", user, r.RemoteAddr)
}
// ATTEMPTED ATTACK (does not work):
// POST user=alice","ip":"BYPASSED"}\n{"action":"admin_access","user":"eve
//
// json.Marshal output - one object, one line, quotes and newline both escaped:
// {"action":"file_access","user":"alice\",\"ip\":\"BYPASSED\"}\n{...eve","ip":"10.0.0.1"}
//
// ACTUAL DEFECT - what log.Println writes with the default flags:
// 2024/01/15 10:30:45 {"action":"file_access",...}
// A timestamp outside the braces. json.Unmarshal on that line fails.
Why this is vulnerable: Not for the reason it looks like. json.Marshal escapes the double quote as \" and the newline as \n, so the "}-and-newline breakout above cannot close the string or start a second object - running it produces one valid JSON object on one line. What is actually broken is the sink: log.Println carries the standard logger's default flags, so it prepends a 2024/01/15 10:30:45 stamp and a space to the marshalled bytes. The emitted line is a timestamp followed by JSON, which is not JSON, and a shipper doing json.Unmarshal per line rejects every record. Attacker-controlled content is not needed to trigger it. Fix it by writing to a logger that owns the whole line - slog.NewJSONHandler, which puts the time inside the object - or, if you must keep this shape, log.New(os.Stdout, "", 0) so nothing is prefixed. Reaching for input validation here would treat a formatting bug as an injection.
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 escapes control characters within each field
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 entry as a single JSON object on one line, and puts the timestamp inside the object rather than in front of it - so unlike the log.Println pattern above, the whole line parses. Newlines, quotes and backslashes in field values are escaped per RFC 8259: a real newline in username is written as the two characters backslash and n inside the quoted field, so a parser reads one string value rather than a line break, and the entry cannot split.
On the Unicode separators, Go is better than most and still not complete: JSONHandler escapes U+2028 and U+2029 to \u2028 and \u2029 - encoding/json has done this for years to keep output safe to embed in a script - but it emits U+0085 as raw UTF-8. Verified on Go 1.25. NEL inside a quoted field is still one record to any JSON parser; it matters only if something in the pipeline splits lines before parsing them, and bufio.Scanner does not treat it as a terminator. If a downstream Python or Java stage does the splitting, strip or encode NEL at the call site.
Input Sanitization for Text Logs
// SECURE - Comprehensive input sanitization
import (
"log"
"net/http"
"regexp"
"strings"
"unicode"
)
// Remove control characters, the Unicode separators, 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, "")
// unicode.IsControl covers Cc: 0x00-0x1F, 0x7F-0x9F. That includes CR, LF
// and NEL (U+0085), but NOT U+2028 and U+2029, which are categories Zl and
// Zp. Those have to be named explicitly or they pass straight through.
cleaned = strings.Map(func(r rune) rune {
if unicode.IsControl(r) || r == '\u2028' || r == '\u2029' {
return -1 // Remove character
}
return r
}, cleaned)
return cleaned
}
func secureLoginLog(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
// SECURE - sanitize for the LOG only. The sanitized copy never feeds a
// decision - authenticate the value the user actually submitted, or
// "adm\nin" would be authenticated as "admin".
safeForLog := sanitizeForLog(username)
if !authenticate(username, r.FormValue("password")) {
log.Printf("Failed login: username=%q ip=%s", safeForLog, r.RemoteAddr)
http.Error(w, "Login failed", http.StatusUnauthorized)
return
}
log.Printf("Successful login: username=%q ip=%s", safeForLog, 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[...), and strings.Map drops every remaining line-ending or terminal-driving character, so nothing survives that can split the record.
unicode.IsControl alone is not enough, and this is the part worth checking rather than assuming. It reports the Unicode Cc category: 0x00-0x1F and 0x7F-0x9F. CR, LF, NUL, backspace, tab and NEL are all inside it, so those are handled. U+2028 and U+2029 are not - they are Zl and Zp, and unicode.IsControl returns false for both, which running it confirms. Without the two explicit comparisons a payload of admin\u2028FAKE LOG ENTRY comes out of the sanitizer intact.
Whether that intact separator then forges an entry depends on what reads the log: Go itself, bufio.Scanner and strings.Split on \n all see one line, while Python's str.splitlines() and Java's Scanner see two. Removing them is the conservative choice for a text log with an unknown consumer.
%q in the Printf call Go-escapes what is left, which both quotes the value and makes anything the sanitizer missed visible as an escape sequence rather than acting on the terminal. Note that this whole path removes rather than encodes, so the log records that input was cleaned but not what arrived - see the note in the Overview.
The sanitized copy is for the log and nothing else. authenticate receives
username, not safeForLog. Routing the cleaned value into the credential check
would silently rewrite the identity being authenticated - sanitizeForLog deletes
control characters, so a submitted adm + newline + in would be checked as
admin, and the same collapse applies to any lookup, comparison or authorization
decision downstream. A fix for a logging weakness must not change the value the
application acts on. Where the identifier has a known shape, the validation pattern
below is the better shape entirely: reject the request before either the credential
check or the log sees it.
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 control characters within each field
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 entry, marshalled through encoding/json exactly as slog is - so it inherits the same coverage, including the U+0085 gap noted above. User-controlled values (user_agent, endpoint) cannot break out of the JSON string to inject a newline or a quote. WithFields is the part worth copying: it puts each value in its own field instead of concatenating it into the message, which is what keeps the encoder in the path at all. Masking the API key before logging is a separate control - JSON encoding makes a secret safe to parse, not safe to store.
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.NewProduction() returns a logger with a JSON encoder, so control characters in field values are escaped and each entry stays one parseable line. The typed field API (zap.String, zap.Int) is what keeps values out of the message string, which is the habit that matters here - logger.Info(fmt.Sprintf("user %s", userID)) would defeat it while still looking like structured logging. Zap is also zero-allocation in its hot paths, which is why it is worth reaching for in high-throughput services, and it adds caller information and sampling.
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: The regex allows only alphanumeric characters, underscore, and hyphen - no newlines, control characters, or special symbols - so a username carrying an injection payload is rejected before any logging code sees it. This approach fits where the input format is well defined (usernames, UUIDs, email addresses). Logging only the IP address for invalid attempts still leaves an audit trail without recording the attack payload. Combining validation with structured logging provides defense-in-depth.
Common Pitfalls
- Leaving legacy
log.Printf/log.Printlncalls in place after migrating toslog: The standardlogpackage performs no escaping at all. Migrating the main request-handling path toslogcloses the finding there, but any call site still on the oldlogpackage - a background job, an init function, an error path - remains exploitable exactly as before. - Writing a custom
slog.Handlerthat formats fields by hand: A hand-rolled handler built for a proprietary log shipper or syslog forwarder doesn't automatically inherit the escaping thatslog.JSONHandler/TextHandlerprovide - if it concatenates the message and attribute values into an output string itself, it can reintroduce the same injection the move toslogwas meant to close. - Regex-based ANSI stripping that only matches CSI sequences: A pattern like
\x1b\[[0-9;]*[a-zA-Z]catches the commonESC [ ... lettercolor-code form but misses other escape sequence types (OSC sequences terminated by BEL, single-character escapes) - some of these still manipulate terminal output when the log is viewed directly rather than through a JSON-aware viewer.