Secure Coding in C · intermediate · ~10 min

Command injection prevention

## What you will learn - Explain what **command injection** is and exactly how user-controlled text turns into attacker-controlled commands. - Recognize why `system()`, `popen()`, and any "build a string, hand it to a shell" pattern are dangerous. - Run an external program **without a shell** using `fork` + `execvp` with an explicit `argv` array. - Tell the difference between *escaping*, *blocklisting*, and *allowlisting*, and why allowlisting (fail-closed) is the strongest input policy. - Validate and constrain arguments so untrusted input can never be reinterpreted as a command. - Verify a fix actually closes the hole, and log the right things (never secrets) for detection.

Overview

Many real programs need to run other programs: a backup tool calls tar, a web app shells out to convert to resize an image, a network utility calls ping. The easy way to do this in C is system("some command " + input) — and it is also one of the most exploited mistakes in software history.

The problem is who interprets the string. system() does not run your program directly. It launches /bin/sh (the system shell) and asks the shell to parse your whole string. The shell is a full programming language: it treats characters like ;, |, &&, $(), and backticks as instructions, not as plain text. If any part of that string came from a user, the user can smuggle in their own instructions. That is command injection.

This lesson builds directly on exec — replacing the process image. There you learned that execvp replaces the current process with a named program and passes arguments as a list (argv), not as one parsed line. That list-based design is exactly what makes it safe here: when you pass "; rm -rf /" as one element of argv, the target program receives it as a single literal argument. No shell ever looks at it, so nothing special happens.

The terminology to anchor on: a shell parses and runs command lines; shell metacharacters are the characters a shell treats specially; an argument vector (argv) is the explicit list of strings a program receives; allowlisting means accepting only inputs that match a known-good pattern and rejecting everything else.

Why it matters

Command injection sits at or near the top of every serious vulnerability list (it is part of OWASP's "Injection" category and has its own CWE-78). A single injectable call can give an attacker the ability to read any file the process can read, delete data, install a backdoor, or pivot deeper into a network — all with the privileges of your program. Programs that run as a service account, a web server, or (worst case) root turn a small bug into full system compromise.

It matters in C specifically because C makes the dangerous path convenient: system() is one short call, while doing it correctly takes a few more lines. Beginners reach for the short version, ship it, and create a hole that automated scanners and attackers find quickly. Knowing the safe fork/execvp pattern — and making it your default — removes an entire class of critical bugs from your code.

The defensive payoff is large and cheap: the secure pattern is not slower at runtime, it is more predictable, and it composes well with input validation. Learning it once protects every program you write afterward.

Core concepts

Ethics and authorization

Everything below is defensive. The one injection example is labeled and is for a local, isolated, authorized lab only (your own machine or a container). Never test injection against systems you do not own or lack written permission to test.

Threat model (a tool that shells out to resize an image)

  ENTRY POINT            TRUST BOUNDARY            ASSET
  user-supplied  ──────▶ | your C program | ─────▶ filesystem,
  filename               | (runs as svc)  |        other users' data,
  (untrusted)            |                |        the OS itself
                         +----------------+
  Attacker goal: get text from the entry point reinterpreted
  as a *command* once it crosses into the program.

Concept 1 — The shell as an interpreter

A shell (/bin/sh, bash, etc.) reads a line of text and parses it into commands, arguments, redirections, and substitutions. system(str) hands str to the shell, so every shell rule applies.

system("echo hello; rm file")
        │
        ▼  shell parses the line
   command 1: echo hello
   command 2: rm file      ← the ';' started a NEW command
  • When to use a shell: when you genuinely need shell features (pipes, globbing) AND there is no untrusted input in the string. That is rare in application code.
  • When NOT to: any time part of the string is user/network/file-derived.
  • Pitfall: thinking "my input is just a filename, it's fine." A filename is text, and text can contain ; or $().

Knowledge check: In system("ping -c1 " + host), what does the shell do if host is 8.8.8.8; cat /etc/passwd? Why?

Concept 2 — Shell metacharacters

These characters change a shell's behavior. Treat any of them in untrusted input as a red flag:

Metachar Effect
; end one command, start another
& / && / || background / run-if-success / run-if-fail
| pipe output into another command
` and $( ) command substitution — run a command, insert its output
> < >> redirect output/input to files
* ? [ ] filename globbing
$VAR variable expansion
newline a line break is a command separator too

Trying to blocklist this set is fragile: you will miss a case, and quoting/escaping rules differ between shells. Prefer allowlisting (Concept 4).

Concept 3 — Argument vectors: bypassing the shell entirely

execvp(file, argv) runs file directly via the OS and delivers argv as a fixed array of strings. There is no parsing step where text could become a command. This is the heart of the fix and comes straight from the exec prereq.

argv passed to ping (NO shell involved):
  argv[0] = "ping"
  argv[1] = "-c1"
  argv[2] = "8.8.8.8; cat /etc/passwd"   ← one literal argument
  argv[3] = NULL                          ← array MUST end in NULL

ping simply fails to resolve that weird "hostname" — nothing executes. We pair execvp with fork so the parent keeps running and can collect the child's exit status with waitpid.

fork()        execvp() in child         waitpid() in parent
  │  parent ─────────────────────────────▶ blocks until child done
  └─ child ─▶ becomes "ping" ─▶ exits ─────▶ status reported
  • When to use: essentially always, when launching a known program with arguments.
  • Pitfall: forgetting the NULL terminator in argv (undefined behavior), or forgetting that execvp only returns on failure — so always have an error path after it.

Knowledge check (predict the output): With argv = {"echo", "a; b", NULL} and execvp("echo", argv), what prints — a; b, or a then a second command b?

Concept 4 — Allowlisting vs blocklisting (fail closed)

Blocklisting = reject known-bad characters. Allowlisting = accept only known-good characters and reject everything else.

blocklist:  "reject if it contains ; | & $ ..."   ← fails OPEN on the
                                                   trick you forgot
allowlist:  "accept only [A-Za-z0-9._-], else reject" ← fails CLOSED

Defense in depth means doing both: skip the shell (execvp) and validate the argument with an allowlist. Even argument-only input can be abused if a target program treats a leading - as an option ("argument injection"), so constraining the character set and rejecting a leading - is still valuable.

Knowledge check (explain in your own words): Why is an allowlist safer than a blocklist for input you do not fully control?

Syntax notes

The safe building blocks, annotated:

#include <unistd.h>     // fork, execvp, _exit
#include <sys/wait.h>   // waitpid
#include <sys/types.h>  // pid_t

pid_t pid = fork();             // -1 on error, 0 in child, >0 (child pid) in parent
if (pid == 0) {                 // ---- child path ----
    char *argv[] = { "ping", "-c1", user_arg, NULL };  // explicit, NULL-terminated
    execvp("ping", argv);       // returns ONLY if exec failed
    _exit(127);                 // use _exit (not exit) after a failed exec in a child
} else if (pid > 0) {           // ---- parent path ----
    int status;
    waitpid(pid, &status, 0);   // reap the child, read its status
}

Key rules:

  • argv[0] is the program name by convention; the array must end in NULL.
  • execvp searches PATH; use execv with an absolute path if you want to avoid PATH surprises.
  • After a failed exec in the child, call _exit (not return/exit) to avoid flushing the parent's buffers twice.
  • Never reintroduce a shell with popen() or system() on the same data.

Lesson

The danger of system()

A common mistake is building a command string from user input and passing it to system():

system("cmd " + user_data);

system() runs your string through /bin/sh, the system shell. The shell does not treat your input as plain text. It interprets special characters such as:

  • ; to chain commands
  • && to run a second command if the first succeeds
  • backticks ` and $() to substitute the output of another command

This is called command injection: the attacker injects their own commands into one you meant to run.

For example, if an attacker types ; rm -rf / as input, the shell sees a second command and runs it. Now you have a serious problem.

The safe replacement

Instead of system(), use fork plus execvp with an explicit argument array (argv).

  • fork creates a new child process.
  • execvp replaces that child with the program you name, receiving its arguments as a list rather than one shell string.

Because no shell is involved, none of those special characters are interpreted. The user's input stays a single argument to the program, so it can never become a command of its own.

Code examples

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>

/* Allowlist: accept only hostname-like text we are willing to pass on.
   Returns 1 if safe, 0 otherwise. Fails CLOSED on anything unexpected. */
static int is_safe_host(const char *s) {
    if (s == NULL || s[0] == '\0') return 0;   /* reject empty */
    if (s[0] == '-') return 0;                  /* reject leading '-' (option-look-alike) */
    if (strlen(s) > 253) return 0;              /* max DNS name length */
    for (const char *p = s; *p; p++) {
        unsigned char c = (unsigned char)*p;
        if (!(isalnum(c) || c == '.' || c == '-')) return 0; /* allowlist set */
    }
    return 1;
}

/* Run `ping -c1 <host>` WITHOUT a shell. Returns child exit code, or -1 on error. */
static int run_ping(const char *host) {
    pid_t pid = fork();
    if (pid < 0) { perror("fork"); return -1; }

    if (pid == 0) {                 /* child: become ping */
        char *argv[] = { "ping", "-c1", (char *)host, NULL };
        execvp("ping", argv);       /* returns only on failure */
        perror("execvp");
        _exit(127);                 /* convention: 127 = exec failed */
    }

    int status;
    if (waitpid(pid, &status, 0) < 0) { perror("waitpid"); return -1; }
    if (WIFEXITED(status)) return WEXITSTATUS(status);
    return -1;
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "usage: %s <host>\n", argv[0]);
        return 2;
    }
    const char *host = argv[1];

    if (!is_safe_host(host)) {
        /* Log the REJECTION (safe to log: it is not a secret). */
        fprintf(stderr, "rejected unsafe host argument\n");
        return 1;
    }

    int rc = run_ping(host);
    printf("ping exit code: %d\n", rc);
    return rc == 0 ? 0 : 1;
}

What it does: it takes one command-line argument, validates it against a strict allowlist, and only then runs ping -c1 <host> via fork + execvp — never through a shell. Defense in depth: even if the allowlist had a gap, the absence of a shell means metacharacters cannot become commands.

Expected output: for ./a.out example.com you get the normal ping output followed by ping exit code: 0 (assuming the host responds). For ./a.out "8.8.8.8; rm -rf ~" the program prints rejected unsafe host argument and exits with status 1 — and crucially, even without the allowlist, ; would have been a harmless literal character to ping.

Edge cases: an empty argument, a leading - (could look like a ping option), names longer than DNS allows, and any byte outside [A-Za-z0-9.-] are all rejected. execvp failing (e.g. ping not installed) is caught and surfaced as exit code 127.

Line by line

Walkthrough of the key example, top to bottom:

Step Code What happens
1 main checks argc != 2 enforce exactly one argument; otherwise print usage and exit 2
2 host = argv[1] grab the untrusted input (do not trust it yet)
3 is_safe_host(host) validate before use — see below
4 reject path if invalid, log a non-secret rejection and exit 1 (fail closed)
5 run_ping(host) only reached for validated input
6 fork() parent gets child PID; child gets 0
7 child: build argv, execvp child becomes ping; input rides as argv[2], a literal
8 parent: waitpid parent blocks, then reads exit status

Inside is_safe_host, trace the input "8.8.8.8; rm -rf ~":

  s[0]=='8'      not empty, not '-'  → keep going
  scan chars ... '8' ok, '.' ok ... ' ' (space)  → NOT in allowlist
  return 0  → rejected before any process is created

Now trace a good input "example.com":

  every char is alnum or '.'  → loop finishes
  return 1  → run_ping proceeds
  fork → child argv = {"ping","-c1","example.com",NULL}
  execvp runs ping; ping resolves example.com and pings once
  parent waitpid → WEXITSTATUS = 0 → main prints "ping exit code: 0"

The value of host never changes; what changes is where it goes: it is delivered as one slot of an array, so it is data, not code.

Common mistakes

Mistake 1 — Building a command string for system()

WARNING: Intentionally vulnerable training example — use only in a local, isolated, authorized lab. Do not deploy.

/* INSECURE */
char cmd[256];
snprintf(cmd, sizeof cmd, "ping -c1 %s", host);
system(cmd);   /* host = "x; rm -rf ~" runs rm! */

Why it is unsafe: system sends the whole string to /bin/sh, which interprets ; as a command separator. The host value becomes a second command.

Secure fix: the fork + execvp version from the Code section — no shell, plus an allowlist.

How to test the fix (mitigation verification): run the program with host = "x; touch /tmp/pwned" in your lab. With the vulnerable version, /tmp/pwned appears. With the fixed version it does not (the argument is rejected, and even if passed, ping treats it as a literal). Use ls /tmp/pwned to confirm; clean up afterward.

Mistake 2 — Switching to popen() and thinking it is safer

popen(cmd, "r") also runs through /bin/sh. It is the same vulnerability with a different name. Use fork/exec with pipe() if you need to read a child's output.

Mistake 3 — Escaping/blocklisting instead of allowlisting

/* fragile: tries to strip bad chars */
if (strchr(host, ';')) reject();  /* misses |, $(), backticks, newline... */

Why wrong: you will forget a metacharacter, and shell quoting rules are subtle. Corrected: keep input off the shell entirely, and accept only an allowlisted character set. Recognize this mistake when you see a hand-rolled list of "bad characters."

Mistake 4 — Forgetting the NULL terminator or the post-exec error path

char *argv[] = { "ping", "-c1", host };  /* no NULL → undefined behavior */
execvp("ping", argv);                     /* if it fails, code falls through */

Corrected: end argv with NULL, and put _exit(127) (or error handling) immediately after execvp.

Debugging tips

Compiler errors

  • implicit declaration of fork/execvp/waitpid → include <unistd.h> and <sys/wait.h>.
  • WEXITSTATUS undeclared → include <sys/wait.h>.
  • passing const char * where char * expected in argv → cast as (char *)host; the exec* prototype predates const.

Runtime errors

  • execvp: No such file or directory → the program is not on PATH; install it or use an absolute path with execv.
  • Child seems to hang → you forgot waitpid, leaving a zombie; or you blocked waiting on a child that never execed.
  • Output looks doubled → you used exit() instead of _exit() after a failed exec, flushing inherited buffers.

Logic errors

  • The injection "works" in your test → you are still calling system/popen somewhere on that data; grep your code for both.
  • Valid input is rejected → your allowlist is too tight (e.g. legitimate hostnames contain . and -; make sure both are allowed).

Questions to ask when it doesn't work

  1. Does any code path put untrusted text into a shell (system, popen, sh -c)?
  2. Is argv NULL-terminated and is the program name correct?
  3. Did execvp fail silently (no error path)?
  4. Is the input validated before it is used, and does validation fail closed?

Memory safety

Security and safety

This topic is primarily a security concern (CWE-78, OS Command Injection), but the C mechanics also create memory-safety pitfalls:

  • Buffer overflow when building commands: snprintf with a too-small buffer (or sprintf with none) can truncate or overflow. The fix removes the need to build a command string at all — execvp takes separate arguments.
  • Bounds and length checks: validate input length (the example caps at 253 bytes) before copying or scanning, so a giant argument cannot blow past buffers downstream.
  • argv lifetime and termination: every argv element must point to a valid, NUL-terminated C string for the child's lifetime, and the array itself must end in NULL. A missing terminator is undefined behavior — execvp reads past the end of the array.
  • const-correctness vs exec: the cast (char *)host is required by the legacy prototype; do not actually mutate the pointed-to string.
  • Privilege: if your process is privileged (setuid/root/service), an injection is far worse. Drop privileges before doing risky work, and prefer running external tools with the least privilege needed.

Detection and logging

Log rejections and exec failures with enough context to investigate: a timestamp, the rule that fired ("unsafe host argument"), and the source if you track one. Never log secrets, passwords, tokens, or full credential strings. It is fine to log that an argument was rejected; avoid echoing large attacker-controlled blobs verbatim into logs (log injection is a thing too — strip control characters).

Real-world uses

Concrete cases

  • Web apps that shell out: image thumbnailers calling convert, PDF tools calling gs, video sites calling ffmpeg. A filename or option built from a request is the classic injection point — and the source of many real CVEs.
  • Network/admin utilities: routers and IoT firmware that run ping, traceroute, or iptables with a user-supplied host. Consumer-router command injection is one of the most common embedded vulnerabilities.
  • CI/CD and build scripts: a job that runs a command containing a branch name or PR title attacker-controlled text in automation.

Professional best practices

Beginner rules:

  • Default to fork + execvp (or your language's array-based exec). Avoid system/popen on any untrusted data.
  • Validate input with an allowlist and fail closed.
  • Always check return values (fork, execvp, waitpid) and clean up children with waitpid.

Advanced habits:

  • Use absolute paths (execv) to avoid PATH-hijacking; consider a sanitized environment for the child.
  • Run external tools with least privilege (drop setuid, restrict via seccomp/AppArmor/containers where available).
  • Add detection: log and alert on rejected/anomalous arguments; review code for every exec/shell call in audits.
  • Where possible, replace shelling out with a library call (e.g. resolve a host with getaddrinfo instead of running ping) — the safest external command is the one you never run.

Practice tasks

Beginner

1. Replace a system call. Given a snippet that runs system("ls -l <dir>") with a user-supplied directory, rewrite it using fork + execvp so no shell is involved.

  • Requirements: check fork/execvp return values; reap the child with waitpid; print the child's exit code.
  • Concepts: argument vectors, _exit after failed exec.

2. Build a NULL-terminated argv. Write a function that, given a program name and exactly two arguments, constructs a correct argv array and runs it with execvp in a child.

  • Hint: the array length is 4 (name + 2 args + NULL).
  • Concepts: argv structure, NULL termination.

Intermediate

3. Allowlist validator. Implement int is_safe_arg(const char *s) that returns 1 only if s is non-empty, has no leading -, and contains only [A-Za-z0-9._-].

  • I/O example: "report-2026.txt" → 1; "x; rm -rf ~" → 0; "" → 0; "-rf" → 0.
  • Constraints: no dynamic allocation; treat bytes as unsigned char for isalnum.
  • Concepts: allowlisting, fail-closed validation. (Relates to the Allowlist a safe argument exercise.)

4. Reject-and-log path. Extend task 3: when input is rejected, write a single-line log entry to stderr containing a timestamp and the rule name, but not the raw input.

  • Hint: use time() + strftime; strip/avoid echoing attacker data.
  • Concepts: defensive logging, no-secrets rule.

Challenge

5. Capture child output safely. Run a program with fork + execvp and read its stdout via a pipe() (do not use popen).

  • Requirements: create the pipe, dup2 the write end onto the child's stdout, close unused ends in both processes, read all output in the parent, and waitpid to get the exit status. Validate the program argument with your allowlist first.
  • Constraints: close every file descriptor you open; handle short reads in a loop.
  • Concepts: pipe, dup2, fd lifetime, shell-free output capture.

Summary

  • Command injection happens when untrusted text reaches a shell, which interprets ;, |, &&, $(), backticks, redirects, and globs as commands.
  • system() and popen() both run /bin/sh on your string — never feed them untrusted data. Input like ; rm -rf / becomes a real command.
  • The fix from the exec lesson: fork + execvp with an explicit, NULL-terminated argv. No shell parses the input, so it stays a literal argument.
  • Most important syntax: pid_t pid = fork(); → in child build char *argv[] = {..., NULL}; and call execvp (it returns only on failure, then _exit(127)); in parent waitpid.
  • Add defense in depth: validate with an allowlist (fail closed), not a blocklist; reject leading -; cap length; drop privileges; use absolute paths.
  • Common mistakes: building strings for system/popen, blocklisting metacharacters, forgetting the NULL terminator or the post-exec error path.
  • Remember: the safest external command is the one you replace with a library call. When you must run one, skip the shell and validate the input.

Practice with these exercises