Safe Penetration Testing Labs · intermediate · ~13 min

Neutralize terminal-escape log injection

Detect and sanitize control/escape bytes that let attacker-controlled log text hijack a terminal.

Overview

Log files are read by humans in terminals, and terminals interpret control bytes. If untrusted input containing an ESC (0x1B) sequence reaches a log unfiltered, an attacker can move the cursor, clear the screen, recolour text or overwrite earlier lines — hiding their own activity from whoever is reading. The defence is straightforward and belongs at the point of writing: detect control bytes and replace them with a harmless placeholder. This lesson is about writing that sanitiser correctly, including the bounded-copy discipline it requires.

Why it matters

Log injection is a real and under-appreciated technique: an attacker who can influence a logged field — a username, a User-Agent, a filename — can manipulate what an analyst sees. Sanitising on write is the fix, and it is a good example of a defence that costs almost nothing and must be applied consistently. The bounded-copy pattern here is also the same one that prevents buffer overflows generally.

Core concepts

What is dangerous. ESC (0x1B) begins ANSI escape sequences. The other C0 controls (0x00-0x1F) and DEL (0x7F) can also reposition the cursor, ring the bell or corrupt the display. Carriage return alone is enough to overwrite a line.

What to allow. Tab (0x09) and newline (0x0A) are legitimate log formatting. Everything else in the control range should be replaced. Whether to allow CR (0x0D) is a deliberate choice — on its own it enables line overwriting, so replacing it is safer.

Unsigned comparison is essential. char may be signed, so a byte above 0x7F becomes negative and c < 0x20 is unexpectedly true. Cast to unsigned char before any range test.

Replace, do not drop. Substituting a placeholder such as . preserves the field's length and makes the tampering visible; silently deleting bytes hides that something was filtered.

Bounded output. The destination has a capacity, so the loop must stop at cap - 1 and always terminate. This is the same cap - 1 reservation as the string-extraction lesson.

Sanitise at the boundary. Filter when writing the log, not when reading it — by read time the damage is already recorded, and every reader would need its own filter.

Syntax notes

#include <stddef.h>

/* does this string contain anything a terminal would interpret? */
int has_control_escape(const char *s) {
    for (; *s; s++) {
        unsigned char c = (unsigned char)*s;      /* UNSIGNED - signed char breaks the test */
        if (c == 0x7F) return 1;                   /* DEL */
        if (c < 0x20 && c != '\t' && c != '\n') return 1;   /* C0 except tab/newline */
    }
    return 0;
}

/* copy to out, replacing control bytes with '.'; always NUL-terminates */
int sanitize_log_line(const char *in, char *out, size_t cap) {
    if (cap == 0) return 0;                        /* guard BEFORE cap - 1 (size_t wraps) */
    size_t o = 0;
    for (size_t i = 0; in[i] && o < cap - 1; i++) {   /* reserve one byte for the NUL */
        unsigned char c = (unsigned char)in[i];
        int safe = !(c == 0x7F || (c < 0x20 && c != '\t' && c != '\n'));
        out[o++] = safe ? (char)c : '.';           /* REPLACE, preserving length */
    }
    out[o] = '\0';
    return (int)o;
}

Key points:

  • The unsigned char cast is the correctness pivot of the whole lesson.
  • cap == 0 is guarded before cap - 1, because size_t is unsigned and 0 - 1 wraps to SIZE_MAX.
  • Replacement preserves length so the log's columns stay aligned and the filtering is visible.

Lesson

Logs are often read with cat or tail in a terminal. If an attacker gets raw input into a log line, embedded ANSI escape sequences can clear the screen, move the cursor, or rewrite earlier lines to hide their tracks.

The run below detects dangerous control bytes (ESC, DEL, and other C0 controls except tab/newline) and rewrites them to . — the standard fix, applied on the way in or on display.

Code examples

#include <stdio.h>
#include <stddef.h>
static int has_control_escape(const char*s){for(;*s;s++){unsigned char c=(unsigned char)*s;if(c==0x7f)return 1;if(c<0x20&&c!='\t'&&c!='\n')return 1;}return 0;}
static int sanitize_log_line(const char*in,char*out,size_t cap){if(cap==0)return 0;size_t o=0;for(size_t i=0;in[i]&&o<cap-1;i++){unsigned char c=(unsigned char)in[i];if(c==0x7f||(c<0x20&&c!='\t'&&c!='\n'))out[o++]='.';else out[o++]=(char)c;}out[o]='\0';return (int)o;}
int main(void){
    const char *evil = "user=admin\x1b[2K\x1b[1G access granted";   /* ANSI escapes rewrite the line */
    char safe[64];
    printf("dangerous? %s\n", has_control_escape(evil)?"YES":"no");
    sanitize_log_line(evil, safe, sizeof safe);
    printf("sanitized: %s\n", safe);
    return 0;
}

Line by line

Step Line What happens
1 (unsigned char)*s Promotes the byte without sign extension. With a signed char, 0x80 becomes negative and would wrongly satisfy c < 0x20.
2 c == 0x7F DEL is outside the C0 range but is still a control character.
3 c < 0x20 && c != '\t' && c != '\n' Flags every other control byte, including ESC (0x1B), while allowing legitimate log whitespace.
4 cap == 0 guard Checked first, because the cap - 1 below would otherwise wrap to a huge value and the bound would never stop the loop.
5 o < cap - 1 Leaves exactly one byte free so the terminator always fits.
6 out[o] = '\0' Runs on every path, so the result is always a valid C string even when the input was truncated.

Common mistakes

Reading bytes as signed char (mis-testing 0x7f and high bytes); allowing carriage return, which enables line-overwrite tricks.

Debugging tips

Compiler errors and warnings:

  • -Wchar-subscripts or -Wtype-limits hinting that a char comparison is always true or false — a sign you forgot the unsigned char cast.
  • -Wsign-compare between size_t o and an int capacity.

Runtime symptoms:

  • High bytes (accented characters, UTF-8) are replaced. The char is signed and became negative, so c < 0x20 matched. Cast to unsigned char.
  • A one-byte overflow, or a sanitizer report. The loop ran to cap instead of cap - 1, leaving no room for the terminator.
  • Crash when cap is 0. cap - 1 wrapped to SIZE_MAX. Guard cap == 0 first.
  • Escape sequences still appear in the log. You sanitised on read rather than on write, so the raw bytes were already stored.
  • Fields become misaligned. You dropped bytes instead of replacing them; replacement preserves length.
  • Tabs and newlines are stripped. They are deliberately allowed — check your exclusion list.

Technique: build an input containing a real ANSI sequence (\x1b[2J), a DEL, a tab, a newline and a high byte, then confirm exactly the right bytes are replaced and the output is terminated.

Memory safety

  • unsigned char before any range comparison. This is both a correctness and a safety habit: signed char promotion is the root of a large family of subtle byte-handling bugs.
  • Guard cap == 0 before computing cap - 1. size_t is unsigned, so the subtraction wraps to SIZE_MAX and the loop bound silently becomes unbounded — a straightforward buffer overflow.
  • Reserve one byte for the terminator and write it on every path, including the truncated case. An unterminated buffer will be read past its end by the next printf or strlen.
  • Return the written length so the caller can detect truncation rather than assuming everything fitted.
  • The input is untrusted by definition — it is attacker-influenced text — so it is never used as a format string, never executed, and never trusted to be terminated within the destination's capacity.
  • Never pass untrusted text as a format string (printf(user_input)); that is a separate and severe vulnerability class this sanitiser does not address.

Real-world uses

Concrete uses: Web servers, authentication daemons and application frameworks sanitise user-controlled fields — usernames, User-Agent strings, URLs, filenames — before writing them to logs, precisely because analysts read those logs in terminals. The same escaping problem appears in CI output, chat bots that echo user text, and any tool that prints untrusted strings to a console. Log-viewing tools often sanitise defensively on display as a second layer.

Professional best practices:

Beginner:

  • Cast to unsigned char before comparing byte ranges.
  • Reserve the terminator byte and always write it.

Intermediate:

  • Sanitise at the write boundary so the stored log is already safe; treat reader-side filtering as defence in depth, not the primary control.
  • Prefer structured logging (JSON with proper escaping) over free-form text — it removes this entire class of problem.
  • Log the fact that filtering occurred, so an analyst knows the field was modified rather than wondering about the dots.

Practice tasks

1. (Beginner) Detect control bytes. Implement int has_control_escape(const char *s) allowing tab and newline. Example: "ok" -> 0; "a\x1b[2Jb" -> 1. Concepts: unsigned comparison, allow-list.

2. (Beginner) High bytes. Confirm a string containing 0xC3 0xA9 (UTF-8 é) is not flagged, and explain why a signed char would flag it. Concepts: sign extension.

3. (Intermediate) Bounded sanitiser. Implement sanitize_log_line with the cap == 0 guard and the cap - 1 reservation, and run it under a sanitizer with a tiny capacity. Concepts: bounded copy, guaranteed termination.

4. (Intermediate) Report truncation. Extend the return value (or an out-parameter) so the caller can tell whether the line was truncated or modified. Concepts: honest interfaces.

Summary

Terminals interpret control bytes, so untrusted text reaching a log unfiltered lets an attacker move the cursor, clear the screen or overwrite earlier lines and hide their tracks. The defence is to detect ESC, the other C0 controls and DEL — allowing only tab and newline — and replace them with a placeholder so the field's length, and the fact of filtering, remain visible. Two details make the implementation correct: cast to unsigned char before any range comparison, or high bytes sign-extend to negative and get wrongly flagged; and guard cap == 0 before computing cap - 1, because size_t wraps and the bound would otherwise become unbounded. Sanitise when writing the log, not when reading it.

Practice with these exercises