Safe Penetration Testing Labs · intermediate · ~13 min

Extract embedded strings from a blob

Reimplement the core of the `strings` tool: find printable runs and pull out the first one safely.

Overview

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.

Why it matters

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.

Core concepts

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.

Syntax notes

#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.

Lesson

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.

Code examples

#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;
}

Line by line

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'.

Common mistakes

Forgetting the trailing run after the loop; computing cap-1 when cap is 0 (size_t underflow).

Debugging tips

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:

  • A one-byte heap or stack overflow, or a sanitizer report. You copied cap characters and then wrote the terminator. It must be cap - 1 characters.
  • Crash when cap is 0. cap - 1 wrapped to a huge value. Guard cap == 0 before the subtraction.
  • Long runs counted several times. You incremented the counter whenever run >= minlen instead of only when it equals it.
  • Runs at the very end of the buffer are missed. The end-of-run test must also fire when i + 1 == n.
  • Almost no strings found in a Windows binary. Its text is UTF-16LE; an ASCII-only scan sees single characters separated by NULs.

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.

Memory safety

  • 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.
  • Guard 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.
  • Always terminate. A returned buffer that is not NUL-terminated will be read past its end by the caller's next printf or strlen.
  • Never use 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.
  • Read-only input, caller-owned output — the analysis never modifies the sample and never allocates on the caller's behalf, so ownership is unambiguous.

Real-world uses

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:

  • Reserve the NUL byte explicitly and write the reasoning in a comment.
  • Start with a minimum length of 4 and adjust from what the output looks like.

Intermediate:

  • Scan for UTF-16LE as well as ASCII; on Windows binaries most of the interesting text is wide.
  • Record each string's offset — where a string lives is often as informative as its content.
  • Feed the extracted strings into further triage (URL and IP extraction, known-indicator matching) rather than reading them by eye.

Practice tasks

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.

Summary

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.

Practice with these exercises