Safe Penetration Testing Labs · intermediate · ~13 min
Reimplement the core of the `strings` tool: find printable runs and pull out the first one safely.
The strings utility is often the very first tool pointed at an unknown binary, because embedded text — URLs, file paths, registry keys, error messages, command-line flags — tells you a great deal before you disassemble anything. Reimplementing it is a run-length problem: scan the buffer, track how long the current run of printable bytes is, and end the run at the first non-printable byte. Runs shorter than a minimum length are noise and get discarded. Extracting a run into a caller-supplied buffer then adds the classic C obligation — reserve one byte for the NUL terminator.
String extraction is the highest-value-per-effort step in binary triage, and writing it yourself teaches the bounded-copy discipline that C demands everywhere. The cap - 1 reservation for the terminator is exactly the reasoning that prevents off-by-one buffer overflows, which remain one of the most common classes of vulnerability in C code.
Printable run. A maximal sequence of bytes in the printable ASCII range (0x20..0x7E). Anything outside it — a NUL, a control byte, a high byte — terminates the run.
Minimum length filters noise. Binary data produces short accidental runs constantly; requiring four or more characters removes most of them. Too high a threshold hides short but meaningful strings such as cmd or a two-letter flag.
Counting versus extracting. Counting only needs a running length and a counter. Extracting must also copy bytes out, which introduces the capacity question.
Reserve one byte for the NUL. With a destination of cap bytes, at most cap - 1 characters may be copied. Writing cap characters and then a terminator writes one byte past the end — the textbook off-by-one overflow.
Report truncation honestly. If a run is longer than the buffer, the caller should be able to tell. Returning the copied length (or a distinct truncation signal) is better than silently shortening.
Wide strings exist too. Real tools also scan for UTF-16LE (ASCII bytes interleaved with zeros), which is how Windows binaries store most of their text — a whole class of strings a naive ASCII-only scan misses.
#include <stddef.h>
static int is_printable(unsigned char c) { return c >= 0x20 && c <= 0x7E; }
/* how many printable runs of at least minlen are present */
int count_printable_runs(const unsigned char *b, size_t n, int minlen) {
if (minlen < 1) minlen = 1;
int count = 0; size_t run = 0;
for (size_t i = 0; i < n; i++) {
if (is_printable(b[i])) {
run++;
if (run == (size_t)minlen) count++; /* count once, when it first qualifies */
} else run = 0; /* non-printable ends the run */
}
return count;
}
/* copy the first qualifying run into out; returns its length or -1 if none */
int first_printable_run(const unsigned char *b, size_t n, int minlen,
char *out, size_t cap) {
if (minlen < 1) minlen = 1;
if (cap == 0) return -1;
size_t start = 0, run = 0;
for (size_t i = 0; i < n; i++) {
if (is_printable(b[i])) {
if (run == 0) start = i;
run++;
} else run = 0;
/* run ends here if the next byte is not printable or the buffer ends */
if (run >= (size_t)minlen && (i + 1 == n || !is_printable(b[i+1]))) {
size_t len = run < cap - 1 ? run : cap - 1; /* RESERVE the NUL byte */
for (size_t k = 0; k < len; k++) out[k] = (char)b[start + k];
out[len] = '\0';
return (int)len;
}
}
return -1;
}
Key points:
run == minlen counts each run exactly once, no matter how long it grows.cap - 1 is the whole safety argument; guard cap == 0 first so the subtraction cannot wrap.size_t is unsigned — cap - 1 with cap == 0 wraps to a huge value, which is why that guard comes first.The first thing an analyst runs on an unknown binary is strings — embedded URLs, file paths, and commands are often sitting in plain text. A string is just a run of printable bytes at least N long.
The run below walks a mock ELF-ish blob, counts qualifying runs, and copies the first one into a bounded buffer — because a triage tool must never trust an attacker-controlled length.
#include <stdio.h>
#include <stddef.h>
static int count_printable_runs(const unsigned char *b,size_t n,int ml){if(ml<1)ml=1;int c=0;size_t r=0;for(size_t i=0;i<n;i++){if(b[i]>=0x20&&b[i]<=0x7e)r++;else{if((int)r>=ml)c++;r=0;}}if((int)r>=ml)c++;return c;}
static int first_printable_run(const unsigned char *b,size_t n,int ml,char*out,size_t cap){if(ml<1)ml=1;size_t i=0;while(i<n){if(b[i]>=0x20&&b[i]<=0x7e){size_t s=i;while(i<n&&b[i]>=0x20&&b[i]<=0x7e)i++;size_t len=i-s;if((int)len>=ml){if(cap>0){size_t cp=(len<cap-1)?len:cap-1;for(size_t j=0;j<cp;j++)out[j]=(char)b[s+j];out[cp]='\0';}return (int)len;}}else i++;}if(cap>0)out[0]='\0';return -1;}
int main(void){
unsigned char blob[] = {0x7f,'E','L','F',0,0,1,'/','b','i','n','/','s','h',0,0,'P','A','S','S','=','s','3','c','r','e','t',0};
char first[32];
int n = first_printable_run(blob,sizeof blob,4,first,sizeof first);
printf("printable runs (>=4) : %d\n", count_printable_runs(blob,sizeof blob,4));
printf("first string : \"%s\" (len %d)\n", first, n);
return 0;
}
| Step | Line | What happens |
|---|---|---|
| 1 | is_printable(b[i]) |
Classifies one byte; anything outside 0x20..0x7E ends the current run. |
| 2 | run++ |
Extends the run. start was recorded when the run began. |
| 3 | run == minlen |
Fires exactly once per run — the moment it becomes long enough to count. |
| 4 | else run = 0 |
A non-printable byte resets the counter, which is what makes runs maximal. |
| 5 | cap == 0 guard |
Checked before cap - 1, because size_t is unsigned and 0 - 1 wraps to SIZE_MAX. |
| 6 | len = run < cap-1 ? run : cap-1 |
Copies at most cap-1 characters, leaving exactly one byte for out[len] = '\0'. |
Forgetting the trailing run after the loop; computing cap-1 when cap is 0 (size_t underflow).
Compiler errors and warnings:
-Wsign-compare comparing size_t run with int minlen; cast once, as above.-Wchar-subscripts if is_printable takes a plain char.Runtime symptoms:
cap characters and then wrote the terminator. It must be cap - 1 characters.cap is 0. cap - 1 wrapped to a huge value. Guard cap == 0 before the subtraction.run >= minlen instead of only when it equals it.i + 1 == n.Technique: test a buffer whose only qualifying run is the final bytes, and one where the run is longer than cap. Those two cases catch the end-of-buffer and truncation bugs that ordinary samples hide.
cap - 1 is the entire lesson. A destination of cap bytes holds at most cap - 1 characters plus one terminator. Copying cap characters and then terminating writes one byte past the end — a classic off-by-one overflow.cap == 0 first. size_t is unsigned, so cap - 1 becomes SIZE_MAX and the bound check passes trivially, turning the copy into an unbounded write.printf or strlen.strlen on the input. A binary blob contains NULs; the length must be passed explicitly or the scan stops at the first one.unsigned char for classification so high bytes do not sign-extend into negative values.Concrete uses: strings is the standard first pass in malware triage, firmware analysis, memory-dump forensics and CTF work — it surfaces URLs, IP addresses, file paths, mutex names, error messages and embedded commands. The same run-scanning logic recovers text from corrupted files and unallocated disk space in forensic carving.
Professional best practices:
Beginner:
Intermediate:
1. (Beginner) Count runs. Implement count_printable_runs counting each qualifying run exactly once. Example: a buffer with runs of 3, 5 and 8 at minlen = 4 -> 2. Concepts: run-length scanning.
2. (Beginner) Classify bytes. Implement is_printable and confirm it accepts 0x20 and 0x7E but rejects 0x1F and 0x7F. Concepts: boundary values.
3. (Intermediate) Extract safely. Implement first_printable_run with the cap - 1 reservation and the cap == 0 guard. Requirements: run it under a sanitizer with a deliberately tiny cap. Concepts: bounded copy, off-by-one prevention.
4. (Intermediate) Report offsets. Extend the counter to also record where each qualifying run starts. Concepts: making triage output actionable.
Extracting embedded strings is run-length scanning: track the current run of printable bytes, reset it at the first non-printable one, and count runs that reach a minimum length — incrementing when the run equals the threshold so long runs are not counted repeatedly. Extraction adds the C obligation that defines this lesson: a destination of cap bytes holds at most cap - 1 characters plus the terminator, and because size_t is unsigned you must guard cap == 0 before computing cap - 1 or it wraps to a huge value. Pass an explicit length rather than using strlen, since a binary blob legitimately contains NUL bytes.