Skip to content

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; id and see uid=… 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:

  1. Don't shell out at all. Hash the file with a native API (crypto.createHash) — no child process, no shell, nothing to inject into.
  2. 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.
  3. 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

npm run docker:down && npm run docker:up
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 call sha256sum).
  • When you must run a binary, use execFile/spawn with an argument array; never exec, never shell: 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 timeout and maxBuffer to 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 in src/lib/.
  • [ ] Shell-based execution replaced by native hashing, or execFile/spawn with arg arrays.
  • [ ] No shell: true combined 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.