Safe Penetration Testing Labs · intermediate · ~13 min
Detect and sanitize control/escape bytes that let attacker-controlled log text hijack a terminal.
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.
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.
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.
#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:
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.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.
#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;
}
| 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. |
Reading bytes as signed char (mis-testing 0x7f and high bytes); allowing carriage return, which enables line-overwrite tricks.
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:
char is signed and became negative, so c < 0x20 matched. Cast to unsigned char.cap instead of cap - 1, leaving no room for the terminator.cap is 0. cap - 1 wrapped to SIZE_MAX. Guard cap == 0 first.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.
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.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.printf or strlen.printf(user_input)); that is a separate and severe vulnerability class this sanitiser does not address.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:
unsigned char before comparing byte ranges.Intermediate:
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.
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.