A recent IEEE Spectrum piece on the php2shell vulnerability argues that AI is forcing us to pay off decades of old security debt. The mechanism is worth understanding concretely, because it is probably sitting in your codebase right now: a shell command built by concatenating strings.
That pattern was sloppy in 2005 and mostly harmless if inputs were controlled. An LLM agent changes the math, because now a model assembles the arguments from untrusted text and passes them into code that trusts them.
By the end you'll be able to find these paths, close them, and keep your agent features without giving them a shell.
Why agents break old assumptions
Classic command injection assumes an attacker controls one field, like a filename. Your defense was validation on that field.
An agent changes the threat model in two ways:
- The model itself is the attacker's channel. A prompt-injected document can steer what the agent passes as an argument.
- The agent often runs with broad permissions, because it needs them to be useful.
So the old code path — one exec() call fed by a now-LLM-generated string — becomes a direct route to arbitrary commands. The php2shell class of vulnerabilities is exactly this: model output reaching a shell with nothing structural in between.
The fix is not better prompting. It is removing the shell from the path.
Find the dangerous calls
Search for the functions that invoke a shell rather than a binary directly.
grep -rnE 'exec\(|shell_exec|system\(|passthru|popen|subprocess|os\.system' src/ In PHP, all of these except a properly-used proc_open build a command string and hand it to /bin/sh. Any string interpolation into them is a finding. In Python, subprocess.run(cmd, shell=True) and os.system are the same bug.
Rank what you find by two questions: does the argument include model or user output, and does the process run with credentials or file access the agent needs? Fix those first.
Stop building command strings
The structural fix is to pass arguments as a list, so the shell never parses them.
import subprocess # Wrong: shell=True lets any metacharacter in filename run commands # subprocess.run(f"convert {filename} out.png", shell=True) # Right: list args, no shell result = subprocess.run( ["convert", filename, "out.png"], capture_output=True, timeout=30, check=True, ) With a list, filename = "img.png; rm -rf /" is treated as one weird filename. The shell never sees it.
In PHP, escapeshellarg() is the bandage, not the cure:
// Bandage: escape the interpolated value $cmd = 'convert ' . escapeshellarg($filename) . ' out.png'; // Better: avoid the shell entirely $cmd = ['convert', $filename, 'out.png']; exec($cmd, $output, $code); PHP's exec() accepts an array form, which bypasses shell interpretation. Use it wherever your PHP version supports the call shape.
Assume the argument is hostile
Even without a shell, a hostile filename can still do damage: path traversal, reading credentials, overwriting files. Constrain the argument to a structure you chose.
from pathlib import Path ALLOWED_SUFFIXES = {".png", ".jpg", ".jpeg"} def safe_input(name: str, workdir: Path) -> Path: path = (workdir / name).resolve() if not path.is_relative_to(workdir): raise ValueError("path escapes workdir") if path.suffix.lower() not in ALLOWED_SUFFIXES: raise ValueError("unsupported file type") return path resolve() collapses ../ sequences, and the is_relative_to check catches escapes. Do this before the value reaches any subprocess.
Put the agent in a cage anyway
Argument hygiene fixes known paths. Agents improvise, so add a second layer:
- Run agent-invoked tools under a dedicated OS user with read access to the working directory and nothing else.
- On Linux, use
bwrapor a systemdsystemd-run --private-userssandbox to block network and filesystem access outside the task. - Set
timeouton every subprocess call, as in the example above, so a stuck model-driven loop cannot pin a worker.
Defense here is cheap because you only need it on a handful of tool entry points, not across the whole codebase.
What prompting cannot fix
Telling the model "never pass semicolons" fails for a simple reason: the attacker does not talk to your instruction, they talk to the model through whatever content you fed it. A poisoned invoice or README can redirect the agent mid-task.
Treat model output the way you treat user input in a form. Validate structure, never trust intent.
Key takeaways
-
shell=True,os.system, and PHP string-builtexec()calls are the exact pattern AI agents turn into remote code execution. - Pass subprocess arguments as lists so
/bin/shnever parses model-generated text. - Constrain argument values with resolve-and-contain checks before they reach any process.
- Sandbox agent-invoked tools under a least-privilege user with timeouts; prompting is not a security control.
- Grep your codebase for shell-invoking functions now — the paths that take agent output are the ones to fix first.
Source
AI is forcing us to pay off 40 years of technical debt — I added the concrete mechanism, working fixes in Python and PHP, and the sandboxing layer the source does not cover.
Support this work
These write-ups are researched and published with no paywall, sponsor, or tracking. If one saved you an afternoon, a small tip keeps them coming.
USDT, USDC or USDD · TRC-20 (Tron)
TFTNsfyomKrnUutRjBTGVULp19ByW29KbY