Keamanan Siber

Subprocess Aman di Aplikasi PHP Kecil - Teruskan Argumen, Bukan Program Shell

Subprocess Aman di Aplikasi PHP Kecil - Teruskan Argumen, Bukan Program Shell

Aplikasi PHP kecil terkadang membutuhkan tool yang tidak disediakan oleh PHP sendiri: image converter, document renderer, media probe, atau perintah maintenance dengan cakupan terbatas. Memanggil executable bisa menjadi pilihan yang masuk akal. Langkah yang berbahaya adalah memperlakukan command line sebagai teks biasa lalu menyisipkan data request ke dalamnya.

Persoalannya bukan sekadar apakah tanda baca yang mencurigakan dapat dihapus. Persoalannya adalah apakah aplikasi dapat memisahkan tiga hal yang berbeda: executable yang dipilihnya, option yang dipilihnya, dan data yang diberikan untuk satu argumen. Begitu shell menerima satu string yang telah dirangkai, perbedaan itu bergantung pada parser bahasa lain.

Artikel ini mengembangkan pola defensif untuk PHP 7.4 atau yang lebih baru di Linux. Artikel ini tidak menyatakan bahwa proc_open() membuat setiap proses anak aman, dan tidak menawarkan process supervisor serbaguna. Tujuan yang lebih sempit adalah menjaga batas antara kontrol dan data, lalu mengidentifikasi kontrol yang masih diperlukan.

Shell adalah interpreter bahasa

CWE-78 dari MITRE menjelaskan OS command injection sebagai input yang dipengaruhi pihak luar dan mengubah perintah yang hendak dikirim aplikasi ke sistem operasi. Kata kuncinya adalah perintah. Shell tidak hanya menjalankan program: shell menginterpretasikan quoting, variable expansion, redirection, pipeline, command separator, dan sintaks lainnya.

Perhatikan bentuk kode berikut, tanpa perlu menggunakan attack payload:

$command = '/usr/bin/tool --mode fixed ' . $requestValue;
exec($command, $output, $exitCode);

Aplikasi bermaksud menjadikan $requestValue sebagai data. Shell menerima satu program yang berisi sintaks tetap sekaligus teks variabel. Ketepatannya kini bergantung pada aturan quoting, sistem operasi, locale, dan setiap perubahan pada string tersebut di masa mendatang. Manual PHP untuk exec() secara tegas memperingatkan bahwa data dari pengguna harus di-escape sebelum diteruskan ke fungsi tersebut.

Pertanyaan awal yang lebih baik adalah apakah subprocess memang diperlukan. OWASP OS Command Injection Defense Cheat Sheet dan CWE-78 sama-sama lebih menyarankan API atau library bahasa pemrograman ketika keduanya dapat melakukan operasi yang sama. Sebagai contoh, mkdir() milik PHP lebih jelas daripada menjalankan mkdir milik sistem. Library menghilangkan satu parser dan biasanya memberikan error yang lebih terstruktur kepada aplikasi.

Teruskan vektor argumen, bukan string perintah

Ketika program eksternal benar-benar diperlukan, PHP menyediakan pilihan struktural yang lebih kuat. Sejak PHP 7.4, manual proc_open() mengizinkan perintah diberikan dalam bentuk array. Dokumentasi PHP menyatakan bahwa proses kemudian dibuka secara langsung tanpa melalui shell, sementara PHP menangani escaping argumen yang diperlukan.

Perbedaannya terlihat dalam struktur data:

$process = proc_open(
    [
        '/usr/bin/tool',
        '--mode',
        'fixed',
        '--',
        $requestValue,
    ],
    $descriptors,
    $pipes
);

Setiap elemen array adalah satu argumen. Tanda baca shell di dalam $requestValue tidak diinterpretasikan ulang sebagai pipeline atau perintah kedua karena tidak ada shell yang mengurai array tersebut sebagai program shell. Secara konsep, pendekatan ini mirip dengan penggunaan prepared statement untuk database: struktur kontrol dan data berada di channel yang berbeda.

Analogi tersebut memiliki batas. Nilai dalam prepared SQL tidak dapat menjadi keyword SQL, tetapi sebuah argumen tetap diinterpretasikan oleh executable. Jika data variabel diawali tanda hubung, program dapat memperlakukannya sebagai option. Jika program menerima option yang membaca konfigurasi lain, menulis file yang dipilih, atau menjalankan helper, perilaku berbahaya dapat terjadi tanpa pernah memerlukan shell. OWASP menyebutnya argument injection.

Executable, option, dan operand memerlukan kebijakan terpisah

Review yang bermanfaat memberi label pada setiap elemen sebelum proses dimulai.

Jaga agar executable tetap

Jangan biarkan request memilih nama atau path binary. Gunakan path absolut yang dimiliki aplikasi, seperti /usr/bin/grep, alih-alih bergantung pada PATH. Manual PHP mencatat bahwa nama file sederhana pada elemen pertama array dicari melalui PATH; path absolut menghindari keputusan pencarian tersebut.

Jika aplikasi mendukung beberapa operasi, petakan identifier internal yang terbatas ke executable tetap dan template argumen tetap. Tolak identifier yang tidak dikenal. Jangan menerima field “command” sembarang lalu mencoba membersihkannya setelah itu.

Jaga agar option tetap jika memungkinkan

Option menentukan apa yang boleh dilakukan program. Option seharusnya berada di dalam kode aplikasi, bukan di dalam field formulir. Pengguna dapat memilih mode pada tingkat bisnis dari allowlist, tetapi aplikasi harus menerjemahkan pilihan tersebut menjadi sekumpulan option yang telah di-review.

Banyak utility Unix mengenali -- sebagai akhir option. Menempatkannya sebelum operand variabel dapat mencegah nilai yang diawali tanda hubung berubah menjadi option lain. Ini bukan jaminan universal: dokumentasi program yang dijalankan harus memastikan grammar argumennya.

Validasi operand berdasarkan maknanya

Pemanggilan terstruktur tidak menjawab apakah suatu operand sesuai. Nomor halaman harus diurai sebagai integer dalam rentang yang berguna. Format harus berasal dari allowlist terbatas. Path biasanya harus dibuat atau di-resolve oleh server, bukan diterima langsung dari browser. Teks bebas memerlukan batas panjang dan constraint khusus domain yang sesuai.

Validasi positif lebih tahan lama daripada memelihara daftar karakter yang tampak berbahaya pada shell kemarin. Validasi ini juga menangkap kesalahan biasa sebelum mencapai proses anak.

Escaping adalah fallback dengan batasan

Beberapa pekerjaan memang benar-benar memerlukan fitur shell seperti pipeline atau redirection. Dalam hal ini, shell adalah dependency yang disengaja, bukan kemudahan yang tidak terlihat. Perintah harus kecil, dibuat tetap sejauh mungkin, di-review untuk platform yang sebenarnya, dan hanya diberi argumen yang masing-masing telah di-escape.

escapeshellarg() milik PHP dirancang untuk mengubah satu string menjadi satu argumen shell. Fungsi ini tidak boleh disamakan dengan escapeshellcmd(), yang melakukan escaping pada seluruh string perintah tetapi, menurut manualnya sendiri, tetap mengizinkan jumlah argumen yang tidak terbatas.

Bahkan escapeshellarg() bukan alasan untuk lebih memilih shell. PHP mendokumentasikan perilaku berbeda di Windows, tempat beberapa karakter diganti, dan mencatat bahwa perilaku multibyte bergantung pada locale LC_CTYPE saat ini. Escaping menangani parsing oleh shell. Escaping tidak memvalidasi option executable, permission, penggunaan resource, atau aturan bisnis.

Contoh kecil di Linux

Contoh berikut mencari string literal dalam file teks milik aplikasi. Executable dan option dibuat tetap, -- mengakhiri parsing option untuk GNU grep, dan nilai variabel menempati satu elemen array.

<?php

declare(strict_types=1);

$needle = trim((string) ($_POST['needle'] ?? ''));

if ($needle === '' || strlen($needle) > 80 || str_contains($needle, "\0")) {
    http_response_code(422);
    exit('Invalid search text');
}

$descriptors = [
    0 => ['file', '/dev/null', 'r'],
    1 => ['pipe', 'w'],
    2 => ['pipe', 'w'],
];

$process = proc_open(
    [
        '/usr/bin/grep',
        '--fixed-strings',
        '--line-number',
        '--',
        $needle,
        '/srv/example/data/public-notes.txt',
    ],
    $descriptors,
    $pipes,
    '/srv/example',
    ['PATH' => '/usr/bin:/bin', 'LANG' => 'C']
);

if (!is_resource($process)) {
    throw new RuntimeException('Could not start search process');
}

$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);

$exitCode = proc_close($process);

if ($exitCode > 1) {
    error_log('grep failed: ' . substr($stderr, 0, 500));
    http_response_code(500);
    exit('Search failed');
}

echo htmlspecialchars($stdout, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

Contoh ini sengaja dibuat sempit. GNU grep menetapkan exit status 0 ketika ada kecocokan, 1 ketika tidak ada baris yang dipilih, dan lebih dari 1 ketika terjadi error, sehingga anggapan “non-zero berarti gagal” tidak tepat di sini. Output di-encode sebagai HTML karena pemanggilan proses yang aman tidak otomatis membuat output proses anak aman untuk response HTML.

Snippet ini juga tidak cocok untuk perintah sembarang atau output besar. Membaca stdout sepenuhnya sebelum stderr dapat menyebabkan deadlock ketika proses anak memenuhi satu pipe sementara proses induk menunggu pipe lainnya. Wrapper production untuk stream yang berpotensi besar memerlukan pembacaan serentak, batas byte, dan cleanup yang cermat. Dokumentasi proc_open() secara khusus memperingatkan tentang penutupan pipe dan deadlock.

Batasi apa yang dapat dilakukan proses anak

Memisahkan argumen mencegah satu kesalahan parsing yang penting. Hal tersebut tidak menjadikan proses anak sebagai kode tepercaya. Beberapa batas independen tetap penting:

  • Identity: jalankan PHP dan proses anak hanya dengan permission yang diperlukan untuk tugas tersebut. Least privilege mengurangi dampak kesalahan; prinsip ini tidak memperbaiki kesalahan itu.
  • Working directory: berikan direktori absolut secara eksplisit alih-alih mewarisi direktori yang tidak disengaja.
  • Environment: berikan environment minimal jika memungkinkan. Jangan berikan secret yang tidak diperlukan proses anak, dan jangan memercayai variabel seperti PATH untuk memilih kode.
  • Input: utamakan pipe atau file yang dikelola server untuk data berukuran besar. Argumen command line mungkin terlihat oleh proses lokal lain pada beberapa sistem dan tidak boleh memuat secret.
  • Output: batasi stdout dan stderr, perlakukan keduanya sebagai data tidak tepercaya, dan hindari mengembalikan diagnosis internal kepada pengunjung.
  • Time: tetapkan deadline. Proses anak dapat macet tanpa kerentanan injection apa pun.
  • Concurrency: batasi jumlah proses anak berbiaya tinggi yang dapat dibuat oleh satu request atau akun.

Penanganan timeout perlu dilakukan dengan hati-hati. Manual proc_terminate() menyatakan bahwa fungsi ini mengirim sinyal kepada proses dan segera mengembalikan hasil; fungsi ini tidak menunggu proses berhenti. Sebuah perintah juga dapat membuat proses turunan. Pembatalan yang tangguh bergantung pada sistem operasi dan model proses, sehingga web request sebaiknya tidak merancang process-tree manager universal secara spontan.

Arsitektur yang lebih bersih sering kali adalah memasukkan job dengan definisi sempit ke antrean dan membiarkan worker khusus menjalankannya di bawah service account yang dibatasi. Proses web kemudian memvalidasi dan mencatat maksud, alih-alih membiarkan koneksi HTTP tetap terbuka sembari mengelola program eksternal.

Amati hasil tanpa membocorkan detail internal

Proses induk harus mencatat apakah pembuatan proses berhasil, apakah deadline tercapai, exit status yang terdokumentasi, durasi, dan diagnosis yang dibatasi. Proses induk tidak boleh mencatat secret dari request, seluruh dokumen, atau string perintah hasil rangkaian yang memuat nilai sensitif.

proc_close() menunggu proses berhenti dan mengembalikan exit code-nya. Code tersebut hanya bermakna menurut dokumentasi program yang dijalankan. Standard error tidak otomatis berarti kegagalan, dan standard output yang kosong tidak otomatis berarti keberhasilan. Perlakukan interface executable sebagai kontrak API.

Checklist review

  1. Apakah API PHP atau library yang dipelihara dapat menggantikan perintah eksternal?
  2. Apakah executable menggunakan path absolut tetap yang dikontrol oleh aplikasi?
  3. Apakah argumen diberikan sebagai array ke proc_open(), bukan dirangkai sebagai teks shell?
  4. Apakah option dibuat tetap atau dipetakan dari allowlist terbatas?
  5. Apakah program mendukung penanda akhir option sebelum operand variabel?
  6. Apakah setiap operand divalidasi menurut makna bisnis, tipe, dan panjangnya?
  7. Apakah working directory, environment, identity, akses filesystem, dan akses network dibatasi?
  8. Apakah stdin, stdout, stderr, runtime, ukuran output, dan concurrency dibatasi?
  9. Apakah exit code diinterpretasikan menggunakan dokumentasi executable?
  10. Apakah log tetap berguna tanpa mengekspos secret atau detail proses anak kepada pengunjung?

Kesimpulan

Pertahanan subprocess yang paling kuat sering kali adalah tidak menjalankannya. Ketika PHP harus memanggil tool eksternal, langkah terkuat berikutnya bersifat struktural: pilih executable di dalam kode, pertahankan option di bawah kontrol aplikasi, dan teruskan setiap operand sebagai elemen array terpisah tanpa shell.

Desain tersebut mencegah data berubah menjadi sintaks shell, tetapi review belum selesai. Executable masih menginterpretasikan argumen, dan sistem operasi masih memberikan waktu, memory, file, akses network, serta privilege kepada proses anak. Validasi dan containment tetap diperlukan.

Karena itu, pertanyaan akhir yang berguna bukan “Apakah setiap karakter berbahaya sudah di-escape?” melainkan “Parser mana yang akan menerima nilai ini berikutnya, dan wewenang apa yang dimiliki parser tersebut?”

Referensi