Command Injection Defense — Safe Shell Execution
Defends against¶
Command Injection — the Ingest artifact integrity check on Tools (/tools?artifact=).
Prerequisites¶
- Local INSECURE-threat-intel-app stack (Docker setup).
- You can run
feed-2026-01-05.json; idand seeuid=…in the output, triggering the "Found Command Injection" flag. - Editor open on
exercise-files/web-attacks-101/INSECURE-threat-intel-app.
Principle¶
Command injection happens when user input is passed to the OS through a shell. The shell interprets metacharacters (;, |, &&, `, $()), so attacker input can terminate the intended command and start a new one.
The fix, in priority order:
- Don't shell out at all. Hash the file with a native API (
crypto.createHash) — no child process, no shell, nothing to inject into. - If you must run a binary, call it directly with an argument array (
execFile), so the OS passes each argument verbatim —;and&&become literal characters in a filename, not new commands. - Validate input against a strict allowlist and resolve it inside the ingest directory regardless.
The root cause is the shell, not the lack of filtering — so the real fix removes the shell.
Step 1 — Locate the vulnerable call¶
unsafeHashArtifact builds a command string and runs it with exec, which spawns /bin/sh -c "…" — a shell:
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
/** INTENTIONALLY VULNERABLE — artifact name interpolated into a shell command. */
export async function unsafeHashArtifact(name: string): Promise<string> {
const artifact = name.trim() || "feed-2026-01-05.json";
// VULNERABLE: `artifact` is concatenated into a command string and run via exec()
// (exec spawns /bin/sh -c "…"), so `; id` or `&& hostname` run as extra commands.
const { stdout, stderr } = await execAsync(
`sha256sum ${INGEST_DIR}/${artifact}`,
{ timeout: 10000, maxBuffer: 64 * 1024 }
);
return [stdout, stderr].filter(Boolean).join("\n").trim();
}
exec(\sha256sum \({INGEST_DIR}/\)`)is the bug:artifact` lands inside a shell-interpreted string.
Step 2 — Best fix: hash the file natively (no child process)¶
You don't need a shell — or even an external binary — to compute a SHA-256. Read the file and hash it in-process. Validate the name to a single path component inside the ingest directory first, which also stops path traversal:
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join, basename } from "node:path";
const ARTIFACT_RE = /^[A-Za-z0-9._-]+$/; // one path component, safe characters only
export async function hashArtifact(name: string): Promise<string> {
const artifact = name.trim();
// Reject metacharacters, slashes, and "..": only a bare filename is allowed.
if (!ARTIFACT_RE.test(artifact) || basename(artifact) !== artifact) {
throw new Error("Invalid artifact name");
}
const path = join(INGEST_DIR, artifact);
const data = await readFile(path);
const digest = createHash("sha256").update(data).digest("hex");
return `${digest} ${path}`;
}
No shell, no sha256sum binary, no child_process — the entire class of OS command injection is removed for this feature. feed-2026-01-05.json; id fails the regex and never touches the filesystem.
Step 3 — Alternative: execFile with an argument array¶
If you must call the external tool, execFile runs the binary directly — no shell, so metacharacters lose their power. Each array element is one argument:
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { join, basename } from "node:path";
const execFileAsync = promisify(execFile);
const ARTIFACT_RE = /^[A-Za-z0-9._-]+$/;
export async function hashArtifact(name: string): Promise<string> {
const artifact = name.trim();
if (!ARTIFACT_RE.test(artifact) || basename(artifact) !== artifact) {
throw new Error("Invalid artifact name");
}
// Args as an array → sha256sum receives "feed.json; id" as ONE filename and errors out.
const { stdout, stderr } = await execFileAsync(
"sha256sum",
[join(INGEST_DIR, artifact)],
{ timeout: 10000, maxBuffer: 64 * 1024 }
);
return [stdout, stderr].filter(Boolean).join("\n").trim();
}
Now feed-2026-01-05.json; id is handed to sha256sum as a single filename argument. sha256sum simply fails to open it — id never runs. (The regex also rejects it before execution; both layers reinforce each other.)
shell: true re-opens the hole
Passing { shell: true } to execFile/spawn brings the shell back and re-enables injection. Never combine shell: true with user input.
Step 4 — Point the page at the safe function¶
In src/pages/tools.astro, swap the import/call:
import { hashArtifact, ensureArtifacts, INGEST_DIR } from '../lib/lab/cmd';
// ...
output = await hashArtifact(artifact);
The existing try/catch turns the "Invalid artifact name" error into the displayed cmdError.
Step 5 — Verify the fix¶
artifact value |
Before | After (fixed) |
|---|---|---|
feed-2026-01-05.json |
SHA-256 line | SHA-256 line (functionality intact) |
feed-2026-01-05.json; id |
hash output plus uid=… |
"Invalid artifact name" — no uid= |
feed-2026-01-05.json && hostname |
hostname printed | rejected; no command output |
; cat /etc/passwd |
/etc/passwd contents |
rejected |
../../etc/passwd |
file contents (traversal) | rejected by basename check |
The "Found Command Injection" flag (detectCommandInjection) fires when uid=/gid= appears in the output. With the shell removed and input validated, injected commands never execute, so that text never appears and the flag stops firing.
Confirm in Burp Repeater that GET /tools?artifact=feed-2026-01-05.json;%20id no longer returns uid=.
Defense in depth¶
- Prefer native APIs over shelling out (hash with
crypto, don't callsha256sum). - When you must run a binary, use
execFile/spawnwith an argument array; neverexec, nevershell: true, never string concatenation. - Validate input with a strict allowlist (
^[A-Za-z0-9._-]+$) and confine paths inside the intended directory (basename/ resolved-path check) as additional layers. - Least privilege: run the app as a non-root user in a minimal container; drop Linux capabilities so even a successful RCE has little reach.
- Resource limits: keep
timeoutandmaxBufferto bound abuse. - Egress controls: restrict what the container can reach, limiting pivoting if execution is achieved.
Verification checklist¶
- [ ] No
exec()with interpolated user input anywhere insrc/lib/. - [ ] Shell-based execution replaced by native hashing, or
execFile/spawnwith arg arrays. - [ ] No
shell: truecombined with user input. - [ ] Artifact names validated against a strict allowlist and confined to the ingest directory.
- [ ] Injection payloads (
; id,&& hostname) produce no extra command output. - [ ] The "Found Command Injection" flag no longer appears.