Secure Coding in C · intermediate · ~10 min
## 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.
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.
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.
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.
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
; or $().Knowledge check: In system("ping -c1 " + host), what does the shell do if host is 8.8.8.8; cat /etc/passwd? Why?
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).
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
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?
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?
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.exec in the child, call _exit (not return/exit) to avoid flushing the parent's buffers twice.popen() or system() on the same data.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` and $() to substitute the output of another commandThis 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.
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.
#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.
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.
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.
popen() and thinking it is saferpopen(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.
/* 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."
NULL terminator or the post-exec error pathchar *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.
Compiler errors
fork/execvp/waitpid → include <unistd.h> and <sys/wait.h>.WEXITSTATUS undeclared → include <sys/wait.h>.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.waitpid, leaving a zombie; or you blocked waiting on a child that never execed.exit() instead of _exit() after a failed exec, flushing inherited buffers.Logic errors
system/popen somewhere on that data; grep your code for both.. and -; make sure both are allowed).Questions to ask when it doesn't work
system, popen, sh -c)?argv NULL-terminated and is the program name correct?execvp fail silently (no error path)?This topic is primarily a security concern (CWE-78, OS Command Injection), but the C mechanics also create memory-safety pitfalls:
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.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.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).
Concrete cases
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.ping, traceroute, or iptables with a user-supplied host. Consumer-router command injection is one of the most common embedded vulnerabilities.Professional best practices
Beginner rules:
fork + execvp (or your language's array-based exec). Avoid system/popen on any untrusted data.fork, execvp, waitpid) and clean up children with waitpid.Advanced habits:
execv) to avoid PATH-hijacking; consider a sanitized environment for the child.getaddrinfo instead of running ping) — the safest external command is the one you never run.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.
fork/execvp return values; reap the child with waitpid; print the child's exit code._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.
NULL).argv structure, NULL termination.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._-].
"report-2026.txt" → 1; "x; rm -rf ~" → 0; "" → 0; "-rf" → 0.unsigned char for isalnum.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.
time() + strftime; strip/avoid echoing attacker data.5. Capture child output safely. Run a program with fork + execvp and read its stdout via a pipe() (do not use popen).
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.pipe, dup2, fd lifetime, shell-free output capture.;, |, &&, $(), 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.fork + execvp with an explicit, NULL-terminated argv. No shell parses the input, so it stays a literal argument.pid_t pid = fork(); → in child build char *argv[] = {..., NULL}; and call execvp (it returns only on failure, then _exit(127)); in parent waitpid.-; cap length; drop privileges; use absolute paths.system/popen, blocklisting metacharacters, forgetting the NULL terminator or the post-exec error path.