Safe Penetration Testing Labs · intermediate · ~10 min
- Recognise the `system()` + string-building pattern as a command-injection red flag during code review. - Explain *why* escaping shell metacharacters is a fragile defence and argument vectors are a robust one. - Rewrite a vulnerable `system()` call into a shell-free `fork` + `execvp` version that passes arguments as a list. - Add input validation (allow-list) as a second layer of defence on top of the argument vector. - Write a small verification harness that proves the fix REJECTS malicious input and still ACCEPTS good input. - Log the security-relevant decision (accepted / rejected launch) without leaking sensitive data.
Security objective. The asset we protect here is the shell / operating system of a machine running a small C helper program. The threat is command injection (CWE-78): an attacker supplies text that a program splices into a shell command, causing the shell to run their commands instead of only the intended one. In this lesson you will learn to detect this pattern in a code review and remediate it so the untrusted input can never reach a shell interpreter.
This builds directly on your prerequisite, Command injection prevention. There you saw the vulnerability and the general principle. Here you do the hands-on engineering: take a concrete toy program that calls system() on a built-up string, and convert it into a version that launches the same program with fork + execvp, passing user input as a separate, literal argument the shell never parses.
The running example is an archive helper: it takes a name from the user and creates /tmp/<name>.tgz. Written with system() it is exploitable; written with execvp plus a validation allow-list it is not. Everything in this lesson runs on your own machine or a container — you are attacking code you own, in an isolated lab, to learn how to defend it.
Command injection is consistently one of the most damaging web and system vulnerabilities: it can hand an attacker full command execution as whatever user your program runs as. In authorized professional work this shows up constantly:
system(), popen(), and sh -c call. You must be able to decide quickly whether the argument is attacker-influenced and, if so, propose the correct fix — not a hand-wavy "escape it."execvp/parameterized API) instead of a shell string" is the fix clients act on.Knowing the remediation cold — and how to verify it — is what separates "I ran a scanner" from "I fixed the class of bug and proved it."
Definition. system(cmd) hands cmd to /bin/sh -c and lets the shell parse it. The shell is a full programming language: ;, |, &, $(...), backticks, >, <, *, and newlines are all operators.
How it works. If you build "tar -czf /tmp/" + name + ".tgz /data" and name is x.tgz /data; rm -rf ~ #, the shell sees a command separator (;) and runs a second command. The program never intended a second command — the shell created it from data.
When / when-not. system() is fine only for a fully constant command with no untrusted parts. The moment any part comes from a user, a file, an environment variable, or the network, it is unsafe.
Pitfall. People assume the input is "just a filename." Nothing enforces that assumption — the input is whatever bytes arrive.
Definition. The exec family (execvp, execv, execve, …) launches a program by passing an explicit argv[] array. There is no shell in the picture, so no string is ever parsed for metacharacters.
How it works. execvp("tar", (char*[]){"tar","-czf",path,"/data",NULL}) runs tar with exactly four arguments. If path contains ; or $(), those are ordinary characters in a filename — tar receives them literally. There is no interpreter to hijack.
When / when-not. Use an argument vector whenever you launch a subprocess with any untrusted data. Do not reach back for system() or popen() for convenience.
Pitfall. execvp searches $PATH for the program name. In hardened code prefer execv/execve with an absolute path (/usr/bin/tar) so a poisoned PATH cannot substitute a different binary.
Definition. "Escaping" means trying to neutralise every dangerous character before putting the input into a shell string.
Plain explanation. Shells have many metacharacters and quoting modes (single quotes, double quotes, $'...', backslash, IFS word-splitting, glob expansion, locale quirks). A hand-rolled escaper only has to miss one edge case to be bypassed. This is exactly what the lesson quiz points at.
When / when-not. Almost never hand-write shell escaping. If you truly must build a shell string, use a vetted library that quotes an entire argument — but the better answer is to avoid the shell (concept 2).
Pitfall. An escaper that looks correct on ASCII test cases can still be defeated by newlines, NUL bytes, or unusual locales. Robust defences remove the interpreter; they don't try to out-clever it.
Definition. An allow-list accepts only known-good input (e.g. name matches ^[A-Za-z0-9_-]{1,64}$) and rejects everything else.
How it works. Even after switching to execvp, validating the name means a value like ../../etc/passwd or an empty string is refused before you build a path — this stops path traversal and other logic abuse, not just injection.
When / when-not. Always validate untrusted input at the trust boundary. Validation is a second layer — it does not replace the argument vector, and the argument vector does not replace validation. Use both.
Pitfall. A deny-list ("block ; and |") is the mirror image of escaping: you will forget a character. Prefer allow-lists.
UNTRUSTED | TRUSTED (your process / host)
|
[ user / caller ] --name--> [ argv or ]==> validate(name)? --no--> REJECT + log
(attacker- [ stdin ] | | yes
controlled) | v
| build path "/tmp/<name>.tgz"
| |
===== TRUST BOUNDARY (the '|') =======| v
| fork() + execvp("tar", argv[])
ASSET PROTECTED: the shell / OS -------| | (NO /bin/sh involved)
command interpreter and host | v
ENTRY POINT: the 'name' value | tar runs with literal args
Entry point: the name value. Trust boundary: the point where that value enters your process. Asset: the OS command interpreter (and thereby the whole host). The insecure design lets untrusted data cross the boundary into a shell; the fix keeps it as inert data in an argv slot.
Knowledge check.
name="x; rm -rf /" — and why must this whole exercise stay on a machine you own or are authorized to test?The key structural change is string-to-shell becoming array-to-exec.
#include <unistd.h> /* fork, execvp, _exit */
#include <sys/wait.h> /* waitpid */
#include <sys/types.h>
/* Build an argv: each element is ONE literal argument.
* The list MUST be NULL-terminated. argv[0] is the program name. */
char *argv[] = { "tar", "-czf", out_path, "/data", NULL };
pid_t pid = fork(); /* create a child process */
if (pid == 0) { /* child: replace image with tar */
execvp(argv[0], argv); /* on success, never returns */
_exit(127); /* only reached if execvp failed */
}
/* parent: reap the child so it is not left a zombie */
int status;
waitpid(pid, &status, 0);
Key points: execvp takes the program name plus a NULL-terminated char *[]; on success it does not return, so the line after it runs only on failure. Use _exit (not exit) in the child after a failed execvp to avoid flushing the parent's buffers twice.
You are given a small program that builds a shell command from user input:
char cmd[256];
snprintf(cmd, sizeof cmd, "tar -czf /tmp/%s.tgz /data", user_supplied);
system(cmd);
system() runs its argument through the shell. That means any shell metacharacters in user_supplied (such as ;, |, or $()) are interpreted as commands, not as plain text. This is command injection.
The fix is not to escape the input. Escaping is fragile and easy to get wrong.
The real fix is to avoid the shell entirely:
fork to create a child process.execvp to run tar directly in that child.tar as argv[0] and user_supplied as a separate, literal argument.Because the arguments are passed as a list, the shell never sees the input. Metacharacters lose their special meaning and become ordinary characters.
Practice this rewrite on a few similar patterns.
Below: the vulnerable version, the secure rewrite, and a verification harness.
/* WARNING: intentionally vulnerable — use only in a local, isolated,
authorized lab. Do not deploy. */
#include <stdio.h>
#include <stdlib.h>
int archive_insecure(const char *name) {
char cmd[256];
/* User input is spliced straight into a shell command. */
snprintf(cmd, sizeof cmd, "tar -czf /tmp/%s.tgz /data", name);
return system(cmd); /* /bin/sh -c "..." -> injection point */
}
/* If name = "x /data; id #" the shell also runs `id`.
The program never intended a second command. */
#include <stdio.h>
#include <string.h>
#include <ctype.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
/* Allow-list: 1..64 chars, only [A-Za-z0-9_-]. Returns 1 if OK. */
int valid_name(const char *name) {
if (name == NULL) return 0;
size_t n = strlen(name);
if (n == 0 || n > 64) return 0;
for (size_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)name[i];
if (!(isalnum(c) || c == '_' || c == '-')) return 0;
}
return 1;
}
/* Returns 0 on success, -1 on rejected input, -2 on launch/child error. */
int archive_secure(const char *name) {
if (!valid_name(name)) {
/* Security decision: rejected. Log the DECISION, not raw bytes
you don't control (here the name is already known-safe-charset,
but we keep it short and bounded). */
fprintf(stderr, "archive: reject invalid name (len/charset)\n");
return -1;
}
char out_path[128];
if (snprintf(out_path, sizeof out_path, "/tmp/%s.tgz", name)
>= (int)sizeof out_path) {
fprintf(stderr, "archive: path too long\n");
return -1;
}
/* No shell: tar receives these four args literally, then NULL. */
char *argv[] = { "tar", "-czf", out_path, "/data", NULL };
pid_t pid = fork();
if (pid < 0) { perror("fork"); return -2; }
if (pid == 0) { /* child */
execvp(argv[0], argv); /* returns only on failure */
perror("execvp");
_exit(127);
}
int status; /* parent: reap child */
if (waitpid(pid, &status, 0) < 0) { perror("waitpid"); return -2; }
if (WIFEXITED(status) && WEXITSTATUS(status) == 0) {
fprintf(stderr, "archive: created /tmp/%s.tgz (ok)\n", name);
return 0;
}
fprintf(stderr, "archive: tar failed\n");
return -2;
}
#include <stdio.h> /* printf, NULL */
#include <assert.h>
/* Compile the two files together, or paste valid_name above this. */
int valid_name(const char *name);
int main(void) {
/* Malicious / malformed inputs MUST be rejected. */
const char *bad[] = {
"x; rm -rf /", /* command separator */
"$(reboot)", /* command substitution */
"a|b", /* pipe */
"../etc/passwd", /* path traversal / slash */
"", /* empty */
NULL
};
for (int i = 0; bad[i]; i++)
assert(valid_name(bad[i]) == 0);
/* Legitimate inputs MUST be accepted. */
const char *good[] = { "backup", "log_2026-07-08", "data-01", NULL };
for (int i = 0; good[i]; i++)
assert(valid_name(good[i]) == 1);
printf("all validation checks passed\n");
return 0;
}
Expected output of the harness: all validation checks passed (all asserts hold). If any assert fires, the program aborts and prints the failing file/line — that is the signal your validation is wrong. The secure archive_secure function, when called with a good name in a lab that has a /data directory, creates /tmp/<name>.tgz and logs archive: created ... (ok); with a bad name it logs the reject line and returns -1 without ever launching a process.
Walking the secure path with name = "backup" and then name = "x; rm -rf /":
| Step | Code | With "backup" |
With "x; rm -rf /" |
|---|---|---|---|
| 1 | valid_name(name) |
length 6, all [A-Za-z0-9_-] -> returns 1 |
contains space, ;, / -> returns 0 |
| 2 | reject branch | skipped | logs reject, returns -1 (STOP) |
| 3 | snprintf(out_path,...) |
"/tmp/backup.tgz", fits |
not reached |
| 4 | build argv[] |
{"tar","-czf","/tmp/backup.tgz","/data",NULL} |
not reached |
| 5 | fork() |
returns child pid to parent, 0 to child | not reached |
| 6 | child: execvp |
replaces image with real tar; args passed literally |
not reached |
| 7 | parent: waitpid |
blocks until tar exits, reads status |
not reached |
| 8 | check WEXITSTATUS |
0 -> log "ok", return 0 | not reached |
The crucial line is step 6. In the insecure version the entire string went through /bin/sh -c, so a ; started a new command. Here tar is handed a fixed four-element list; even if the filename contained a ;, tar would treat it as a character in a filename, not a command boundary — but because validation already ran in step 1, such input never gets this far. Two independent layers: validation (step 1) and no-shell launch (step 6).
Mistake 1 — "I'll just escape the dangerous characters."
WRONG: writing a function that backslash-escapes ;, |, & and keeping system().
WHY wrong: shells have many metacharacters and quoting modes; miss one (a newline, $, glob, locale quirk) and it is bypassed — the exact point of the quiz.
CORRECTED: remove the shell with execvp; add an allow-list.
RECOGNISE/PREVENT: any diff that keeps system()/popen() but adds an escaper should fail review.
Mistake 2 — Forgetting the NULL terminator in argv.
WRONG: char *argv[] = {"tar","-czf",out,"/data"};
WHY wrong: execvp reads past the array looking for NULL, causing undefined behaviour / garbage arguments.
CORRECTED: end the array with NULL.
RECOGNISE/PREVENT: crashes or bogus args to the child; always eyeball the trailing NULL.
Mistake 3 — Treating execvp as if it returns on success.
WRONG: code after execvp that assumes success.
WHY wrong: on success execvp never returns; the following lines run only on failure.
CORRECTED: put only error handling + _exit(127) after execvp.
Mistake 4 — Dropping validation because "execvp is safe now." WHY wrong: the argument vector stops shell injection but not path traversal, oversized names, or empty input. CORRECTED: keep both layers. Defence in depth.
Mistake 5 — Using exec in the child but never waitpid in the parent.
WHY wrong: the child becomes a zombie; over time you leak process slots.
CORRECTED: always waitpid (or handle SIGCHLD).
execvp fails with "No such file or directory". The program name is not on $PATH, or you passed an absolute path that doesn't exist. Debug: print argv[0]; try which tar; consider execv("/usr/bin/tar", argv) with an absolute path.waitpids and inspect status with WIFEXITED/WEXITSTATUS. A non-zero exit means tar ran but errored (e.g. /data missing in your lab — create it).#include <unistd.h> / <sys/wait.h>. Compile with -Wall -Wextra and treat warnings as bugs.assert in the harness aborts. That's the harness working: your valid_name accepted something it should reject (or vice-versa). Print the offending string and re-examine the allow-list.system, popen, sh -c, bash -c)? Is argv NULL-terminated? Did I validate before building the path? Am I checking the child's exit status?Security & safety — detection, logging, and C hygiene.
Detection & logging. For each launch decision, log: a timestamp, the source (user id / connection), the requested resource (the archive name or a hash of it), the security decision (accepted/rejected and why), the result (exit status), and a correlation id so one request can be traced across logs. A rejected value like x; rm -rf / should produce a clear reject invalid name line — a burst of these is a strong signal someone is probing for injection.
Never log: passwords, tokens, session cookies, private keys, full payment card numbers, or unnecessary PII. Do not echo raw attacker bytes verbatim into logs that a viewer's terminal or a log UI might interpret (log-injection / terminal-escape risk) — prefer a sanitised or length/charset summary. Here the name is already restricted to a safe charset before we log the success line.
False positives. A legitimate user who types a space or an accented character will be rejected. That is acceptable and expected; tune the allow-list to real valid names, and document why the rule is strict.
C memory safety for this pattern.
snprintf bounds the buffer, but you must check its return value: if it returns >= sizeof buf, the output was truncated — treat that as an error (as the code does), never as success.argv must be NULL-terminated or execvp reads out of bounds (UB)._exit (not exit) after a failed execvp in the child so the parent's stdio buffers aren't flushed twice.fork, execvp, and waitpid return values; ignoring them hides zombies and launch failures.unsigned char before isalnum — passing a negative char to a <ctype.h> function is undefined behaviour.Authorized real-world use case. A backup/report tool on a server takes a job name from an internal web form and shells out to tar/gzip. A reviewer spots system() with the name interpolated. The remediation — deployed after testing in staging — is exactly this: switch to an argument vector (execv with an absolute path), add an allow-list on the name, and log accept/reject decisions. The team then re-tests with the malicious inputs from the harness to confirm the fix.
Professional best-practice habits.
set -o pipefail/error checks if a shell is truly unavoidable elsewhere.Beginner vs advanced.
system() with fork+execvp, add a regex-style allow-list, write the reject/accept harness.posix_spawn or execve with a controlled environment; drop privileges before exec; sandbox the child (seccomp/pledge/capabilities); add structured audit logging with correlation ids; wire the harness into CI so a regression to system() fails the build.All tasks are lab-only — run on your own machine or a container. Each ends with remediate + verify.
Beginner 1 — Spot the sink.
Objective: in a given C file, list every call that reaches a shell (system, popen, execlp/execvp with a shell, sh -c).
Requirements: for each, state whether any argument is attacker-influenced and mark it a finding or not.
Constraints: read-only; no code changes yet.
Hints: grep for system(, popen(, sh -c. Concepts: sinks, trust boundary.
Conclusion: produce a short finding list you will remediate in task 3.
Beginner 2 — Write the allow-list.
Objective: implement int valid_name(const char *) accepting ^[A-Za-z0-9_-]{1,64}$.
Input/Output: "backup"->1, "x; rm"->0, ""->0.
Constraints: cast to unsigned char before isalnum; bound the length.
Hints: loop over characters; reject on first bad one. Concepts: allow-list validation.
Conclusion: verify with 5 good + 5 bad strings via assert.
Intermediate 1 — Rewrite to execvp.
Objective: convert the insecure archiver to fork+execvp with an argv list.
Requirements: NULL-terminate argv; waitpid and check WEXITSTATUS; _exit(127) on execvp failure.
Constraints: no system/popen anywhere.
Hints: build argv before fork. Concepts: argument vectors.
Conclusion: run with name="x; id #" and confirm no second command runs (check no unexpected output/side effect).
Intermediate 2 — Harden the launch.
Objective: switch to execv("/usr/bin/tar", argv) (absolute path) and drop to an unprivileged uid before exec if running as root (lab container).
Requirements: verify the binary path exists; handle execv failure.
Constraints: document why absolute path + least privilege matters.
Hints: setgid before setuid. Concepts: least privilege, PATH poisoning.
Conclusion: verify a poisoned PATH no longer changes which binary runs.
Challenge — Detection harness + logging.
Objective: build a test/log harness that feeds a list of malicious and benign names to archive_secure, asserts each is rejected/accepted correctly, and emits structured log lines (timestamp, source, name-summary, decision, result, correlation id).
Requirements: prove REJECT for injection/traversal/empty inputs and ACCEPT for valid ones; ensure no secret or raw-escape bytes are logged.
Constraints: lab-only; the child must never run for rejected input.
Hints: reuse the verification harness; add a fake logger you can grep. Concepts: mitigation verification, detection & logging.
Defensive conclusion: a reviewer reading your log can distinguish an attack (burst of rejects) from normal use, and your asserts prove the fix holds. Reset the lab afterward (see cleanup).
Authorization checklist (before any lab run): (1) It is my machine / my container / a CTF or intentionally-vulnerable VM. (2) No production data or real targets involved. (3) I have written authorization if the system isn't mine. (4) I will not point any test at a third-party host.
Lab cleanup / reset: remove created archives (rm -f /tmp/*.tgz inside the lab only), delete any test /data directory you made, restore $PATH if you altered it, and discard the container/VM snapshot so no test artifacts persist.
system()/popen() run their argument through /bin/sh, so any attacker-influenced part can inject extra commands (CWE-78).fork + execvp (or execv/posix_spawn), passing user input as a separate literal argument in a NULL-terminated argv.^[A-Za-z0-9_-]{1,64}$) to stop traversal/oversized/empty input; the two layers are independent.char *argv[]={"tar","-czf",path,"/data",NULL}; fork(); execvp(argv[0],argv); _exit(127); waitpid(...).NULL, assuming execvp returns, forgetting waitpid, dropping validation, casting char to isalnum unsafely.