TEKNİK YAZI / Backend Sistemleri

PHP exec() ve proc_open(): Güvenli Komut Çalıştırma

PHP exec() ve proc_open() ile komut çalıştırırken enjeksiyonu önleme: argüman dizisi, escapeshellarg ve --, zaman aşımı, stderr okuma ve kök dizin sınırı.

PHP’de exec(), shell_exec(), system() ve passthru() fonksiyonları verilen komut dizesini Unix benzeri sistemlerde /bin/sh -c üzerinden kabuğa gönderir. Kullanıcı girdisi bu dizeye karışırsa kabuk ;, |, && veya $(...) gibi karakterleri yorumlar ve saldırgan kendi komutunu çalıştırabilir; buna komut enjeksiyonu (command injection) denir. PHP 7.4 ve sonrasında proc_open() komutu argüman dizisi olarak da kabul eder ve bu biçimde kabuk hiç devreye girmez.

Aşağıdaki bölümler fonksiyonlar arasındaki farkı, enjeksiyonun nasıl oluştuğunu, proc_open() ile zaman aşımı ve çıktı okumayı, kabuk dizesi kaçınılmaz olduğunda escapeshellarg() ile -- kullanımını ve dosya yollarını bir kök dizinle sınırlamayı ele alıyor. Örnekler Linux ve macOS içindir ve PHP 8.0+ gerektirir.

Fonksiyonlar arasındaki fark

FonksiyonDönüş değeriÇıkış kodu
exec($cmd, $out, $code)Çıktının son satırı; tüm satırlar $out dizisine eklenirÜçüncü parametre
system($cmd, $code)Son satır; çıktı ayrıca doğrudan yazdırılırİkinci parametre
passthru($cmd, $code)Başarıda null, hatada false; ham çıktı aktarılırİkinci parametre
shell_exec($cmd)Tüm çıktı tek dize; çıktı yoksa veya hata olursa nullAlınamaz
proc_open($cmd, ...)Süreç kaynağı ve ayrı stdin, stdout, stderr borularıproc_close() dönüşü

exec() satır sonlarını kırpar ve $out dizisi doluysa yeni satırları sonuna ekler; aynı diziyi tekrar kullanacaksanız önce unset() edin. shell_exec() hata ile boş çıktıyı ayıramadığı için başarının önemli olduğu yerde exec() veya proc_open() tercih edilir.

<?php
exec('uname -s', $lines, $code);
echo "Çıkış kodu: $code\n";
echo implode("\n", $lines), "\n";

// system() son satırı döndürür; çıkış kodu ikinci parametreye yazılır
$last = system('uname -r', $code);

Bu iki komut sabit olduğu için güvenlidir. Sorun, dizeye dışarıdan gelen bir değer eklendiğinde başlar.

Komut enjeksiyonu nasıl oluşur?

<?php
// Yanlış: kullanıcı girdisi doğrudan kabuk dizesine ekleniyor
$host = $_GET['host'] ?? '';
$output = shell_exec("ping -c 3 $host");

host değeri olarak example.com; id gönderilirse kabuk önce ping, ardından id komutunu çalıştırır. ; ve | karakterlerini silen bir kara liste güvenilir değildir; ters tırnak, $(...), yeni satır ve && gibi başka yollar da vardır.

İlk savunma, harici komutu hiç çalıştırmamaktır. Dosya okumak için SplFileObject, arşiv için ZipArchive veya PharData, sıkıştırma için gzencode() gibi PHP’nin kendi araçları yeterliyse kabuğa ihtiyaç kalmaz. OWASP da birincil savunma olarak işletim sistemi komutlarını doğrudan çağırmaktan kaçınmayı önerir.

proc_open() ile argüman dizisi (PHP 7.4+)

Komut dizi olarak verildiğinde PHP süreci kabuk olmadan doğrudan başlatır ve argümanların kaçırılmasını kendisi üstlenir. Dizinin her elemanı programa tek bir argüman olarak ulaşır; ; veya $(...) yalnızca sıradan karakterdir. İlk eleman dizin ayırıcısı içermiyorsa program PATH içinde aranır.

Aşağıdaki fonksiyon komutu çalıştırır, stdout ve stderr’i birlikte okur ve süre dolarsa süreci sonlandırır:

<?php
declare(strict_types=1);

/**
 * Komutu kabuk olmadan çalıştırır; stdout, stderr ve çıkış kodunu döndürür.
 */
function runCommand(array $command, float $timeout = 10.0, ?string $cwd = null): array
{
    $descriptors = [
        0 => ['pipe', 'r'], // stdin
        1 => ['pipe', 'w'], // stdout
        2 => ['pipe', 'w'], // stderr
    ];

    $process = proc_open($command, $descriptors, $pipes, $cwd);
    if (!is_resource($process)) {
        throw new RuntimeException('Süreç başlatılamadı');
    }

    fclose($pipes[0]); // sürece girdi göndermiyoruz
    stream_set_blocking($pipes[1], false);
    stream_set_blocking($pipes[2], false);

    $open = [1 => $pipes[1], 2 => $pipes[2]];
    $output = [1 => '', 2 => ''];
    $deadline = microtime(true) + $timeout;
    $timedOut = false;

    // İki boruyu birlikte izlemek, dolan stderr tamponunun süreci kilitlemesini önler
    while ($open) {
        $remaining = $deadline - microtime(true);
        if ($remaining <= 0) {
            $timedOut = true;
            break;
        }

        $read = array_values($open);
        $write = $except = null;
        $seconds = (int) $remaining;
        $micro = (int) (($remaining - $seconds) * 1_000_000);
        if (stream_select($read, $write, $except, $seconds, $micro) === false) {
            continue; // örneğin sinyal kesintisi; süre kontrolü döngü başında
        }

        foreach ($read as $pipe) {
            $fd = array_search($pipe, $open, true);
            $output[$fd] .= (string) fread($pipe, 8192);
            if (feof($pipe)) {
                fclose($pipe);
                unset($open[$fd]);
            }
        }
    }

    foreach ($open as $pipe) {
        fclose($pipe);
    }

    if ($timedOut) {
        proc_terminate($process); // önce SIGTERM: kapanması için süre tanı
        $graceUntil = microtime(true) + 2;
        while (proc_get_status($process)['running'] && microtime(true) < $graceUntil) {
            usleep(50_000);
        }
        if (proc_get_status($process)['running']) {
            proc_terminate($process, 9); // yalnız kendi başlattığımız alt süreç: SIGKILL
        }
    }

    $exitCode = proc_close($process);

    return [
        // PHP 8.3 öncesinde proc_get_status() sonrası proc_close() -1 döndürür
        'exit_code' => $timedOut ? null : $exitCode,
        'stdout' => $output[1],
        'stderr' => $output[2],
        'timed_out' => $timedOut,
    ];
}

Bu yapının önemli noktaları:

Kullanıcıdan gelen bir host adına ping gönderen örnek:

<?php
function pingHost(string $host): array
{
    $isIp = filter_var($host, FILTER_VALIDATE_IP) !== false;
    $isHostname = filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false;
    if (!$isIp && !$isHostname) {
        throw new InvalidArgumentException('Geçersiz host');
    }

    // -- sonrasındaki değer seçenek olarak değil, işlenen (operand) olarak okunur
    return runCommand(['ping', '-c', '3', '--', $host], 10.0);
}

$result = pingHost($_GET['host'] ?? '');
if ($result['timed_out'] || $result['exit_code'] !== 0) {
    error_log('ping başarısız: ' . $result['stderr']);
}

FILTER_VALIDATE_DOMAIN bayraksız kullanıldığında çok gevşektir; kabuk karakterlerini bile kabul eder. FILTER_FLAG_HOSTNAME ise host adının harf veya rakamla başlamasını ve yalnız harf, rakam ve tire içermesini şart koştuğu için -c1 gibi seçenek benzeri değerler doğrulamadan geçmez; -- ikinci bir güvencedir. Biçim doğrulaması hedefin kendisini sınırlamaz: localhost veya iç ağdaki bir adres de geçerli bir host’tur. İç ağa istek gitmemesi gerekiyorsa IP adresleri için FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE bayraklarını kullanın; host adlarını çözümledikten sonra aynı kontrolden geçirin.

Kabuk dizesi gerekiyorsa: escapeshellarg() ve —

Eski kodda exec() kalacaksa ya da boru hattı veya yönlendirme gibi bir kabuk özelliği gerekiyorsa her dış değer ayrı ayrı escapeshellarg() ile kaçırılır. Fonksiyon değeri tek tırnak içine alır ve içindeki tek tırnakları kaçırır; böylece değer kabukta tek bir argüman olarak kalır.

<?php
$query = trim($_GET['q'] ?? '');
if ($query === '') {
    throw new InvalidArgumentException('Arama metni boş');
}

$pattern = escapeshellarg($query);
exec("grep -F -c -- $pattern /var/log/myapp/app.log", $lines, $code);

// grep eşleşme bulursa 0, bulamazsa 1, hata olursa 2 döndürür
if ($code > 1) {
    throw new RuntimeException("grep başarısız oldu: $code");
}

Dikkat edilecek üç sınır vardır:

Dosya yollarını bir kök dizinle sınırlamak

Kullanıcı bir dosya adı gönderdiğinde ../../etc/passwd gibi göreli yollar veya sembolik bağlantılar izin verilen dizinin dışına çıkabilir. Uzantı veya karakter kontrolü bunu çözmez. Güvenilir yöntem, yolu realpath() ile çözümlemek ve sonucu izinli kök dizinle, sonuna dizin ayırıcısı ekleyerek karşılaştırmaktır:

<?php
function resolveInside(string $root, string $relative): string
{
    $base = realpath($root);
    if ($base === false) {
        throw new RuntimeException('Kök dizin bulunamadı');
    }

    $path = realpath($base . DIRECTORY_SEPARATOR . $relative);
    if ($path === false) {
        throw new InvalidArgumentException('Dosya bulunamadı');
    }

    // Ayırıcıyla karşılaştırmak /var/log/myapp-eski gibi komşu dizinleri dışarıda tutar
    if ($path !== $base && !str_starts_with($path, $base . DIRECTORY_SEPARATOR)) {
        throw new InvalidArgumentException('Yol izin verilen dizinin dışında');
    }

    return $path;
}

$file = resolveInside('/var/log/myapp', $_GET['file'] ?? '');
if (!is_file($file)) {
    throw new InvalidArgumentException('Bir dosya seçilmedi');
}

$lines = filter_var($_GET['lines'] ?? 100, FILTER_VALIDATE_INT, [
    'options' => ['min_range' => 1, 'max_range' => 1000],
]);
if ($lines === false) {
    throw new InvalidArgumentException('Geçersiz satır sayısı');
}

$result = runCommand(['tail', '-n', (string) $lines, '--', $file], 5.0);

realpath() hem .. bölümlerini hem sembolik bağlantıları çözer ve dosya yoksa false döndürür. Komuta kullanıcının gönderdiği değer değil, çözümlenmiş yol verilir. Kök dizine saldırganın yazabildiği durumda denetim ile kullanım arasında bir bağlantı değiştirilebilir; bu yüzden kök dizine yalnız güvenilen süreçler yazabilmelidir. Komuta verilecek dosya bir kullanıcı yüklemesinden geliyorsa önce içeriğini doğrulayın; görseller için adımlar PHP ile Base64 görsel yükleme yazısında var.

Uzun süren işler ve arka plan süreçleri

Web isteğinin içinden nohup komut & ile arka plan süreci başlatmak, PID takibini, yeniden başlatmayı, log dosyalarını ve kapanışı uygulamaya yükler. Uzun işleri bir kuyruğa yazıp ayrı bir arka plan işçisine (worker) devretmek ve bu süreci servis yöneticisiyle çalıştırmak daha sağlamdır; bu yapı daemon ve systemd yazısında anlatılıyor. Bir süreci PHP’den durdurmanız gerekiyorsa proc_open() ile başlatıp proc_terminate() kullanmak, PID’yi bir dosyadan okuyup kill çalıştırmaktan daha güvenlidir, çünkü sinyal başlattığınız sürecin kendisine gider.

Ek önlemler

Sonuç

Kullanıcı girdisi alan her komutta varsayılan seçim proc_open() ve argüman dizisi olmalıdır: kabuk devre dışı kalır, stdout ve stderr ayrı okunur, zaman aşımı uygulanabilir. exec() ve shell_exec() sabit komutlar veya kabuk özelliğinin gerçekten gerektiği durumlar için kalabilir; o durumda her değer escapeshellarg() ile kaçırılır, seçenekler -- ile bitirilir ve dosya yolları kök dizine göre doğrulanır. İş PHP’nin kendi fonksiyonlarıyla yapılabiliyorsa harici komut hiç çalıştırılmamalıdır.

Birincil kaynaklar