Fitur upload memberi pengguna kekuatan menaruh berkas di server kita — dan dari sudut pandang keamanan, itu berarti menyerahkan sebagian isi disk kepada pihak yang tidak kita kenal. Nama berkas, tipe berkas, bahkan isi berkas sepenuhnya dikendalikan pengirim; satu-satunya yang kita kendalikan adalah apa yang terjadi setelahnya. Chapter ini membedah trik penyerang pada upload — ekstensi palsu, ekstensi ganda, berkas polyglot — lalu membangun rantai validasi yang menguji nama, isi, dan ukuran sebelum berkas diperbolehkan tinggal di server. Mekanika dasar upload sendiri ($_FILES, move_uploaded_file()) sudah dibahas pada chapter Menangani File Upload
dan Uploads dan File Storage
.
Nama Berkas adalah Masukan Pengguna #
Kesalahan pertama yang membuka celah adalah percaya bahwa nama berkas mencerminkan isinya. Daftar berikut memetakan nama-nama yang perlu membuat kita curiga:
| Nama berkas | Mengapa berbahaya |
|---|---|
shell.php | kode PHP siap dieksekusi bila tersimpan di direktori dengan PHP aktif |
shell.php.jpg | ekstensi ganda; konfigurasi server lawas (AddHandler) mengeksekusi .php di posisi mana pun pada nama |
shell.phtml, shell.php5 | ekstensi lain yang terpetakan ke interpreter PHP |
.htaccess | berkas konfigurasi Apache — penyerang menulis aturan sendiri, misalnya mengaktifkan eksekusi |
shell.jpg (spasi di ujung) | trik pemotongan nama pada Windows dan beberapa filesystem |
Pola umumnya satu: penyerang ingin isi kode PHP berakhir di URL yang dieksekusi server. Ekstensi .jpg pada nama hanya alat meyakinkan validasi kita — sedangkan server yang menentukan eksekusi adalah pemetaan ekstensi ke handler, bukan niat pengguna. Karena itu pertahanan tidak berhenti di ekstensi; ia harus menguji isi berkas dan menjamin direktori penyimpanan tidak pernah mengeksekusi apa pun.
Content Sniffing dan Berkas Polyglot #
Validasi ekstensi mudah ditipu, jadi kita mengintip isi berkas dengan ekstensi fileinfo — dan penyerang mengetahui itu juga. Fungsi deteksi MIME bekerja dengan magic bytes: potongan byte awal yang menjadi tanda sebuah format. Masalahnya, magic bytes bisa disisipkan di depan isi lain:
<?php
$finfo = new finfo(FILEINFO_MIME_TYPE);
$contoh = [
'kode PHP' => "<?php system(\$_GET['perintah']); ?>",
'HTML + skrip' => '<script>alert("maling-kuki")</script>',
'GIF sah' => "GIF89a\x01\x00\x01\x00\x80\x00\x00",
'polyglot GIF+PHP' => "GIF89a\x01\x00\x01\x00\x80\x00\x00<?php system('id'); ?>",
];
foreach ($contoh as $label => $isi) {
echo str_pad($label, 18) . ' => ' . $finfo->buffer($isi) . "\n";
}Output yang Dihasilkan:
kode PHP => text/x-php
HTML + skrip => text/html
GIF sah => image/gif
polyglot GIF+PHP => image/gifDua baris terakhir adalah temuan paling penting: berkas polyglot — berlaku sah sebagai GIF sekaligus membawa kode PHP di dalamnya — dilaporkan image/gif oleh finfo karena magic bytes-nya memang sah. Serangan sejenis menargetkan HTML dan SVG: berkas “gambar” yang isinya <script> akan dijalankan browser saat dibuka langsung, mencuri cookie dari domain kita (stored XSS). Kesimpulannya bukan “finfo tidak berguna”, melainkan “finfo tidak cukup sendirian” — ia adalah satu mata rantai, bukan gerbang tunggal.
Rantai Validasi Upload #
Pertahanan yang benar adalah rantai: ekstensi diperiksa terhadap allowlist, isi diperiksa terhadap MIME yang diizinkan, dan ukuran dibatasi eksplisit. Ketiganya saling menutupi kelemahan masing-masing, seperti pada fungsi berikut:
<?php
function validasiUpload(string $namaAsli, string $isi, int $ukuran, array $aturan): array
{
$galat = [];
$ekstensi = strtolower(pathinfo($namaAsli, PATHINFO_EXTENSION));
if (!in_array($ekstensi, $aturan['ekstensi'], true)) {
$galat[] = "ekstensi .{$ekstensi} tidak diizinkan";
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($isi);
if (!in_array($mime, $aturan['mime'], true)) {
$galat[] = "tipe {$mime} tidak cocok";
}
if ($ukuran > $aturan['maksByte']) {
$galat[] = 'ukuran melebihi batas';
}
return $galat;
}
$aturan = [
'ekstensi' => ['jpg', 'jpeg', 'png', 'webp'],
'mime' => ['image/jpeg', 'image/png', 'image/webp'],
'maksByte' => 2 * 1024 * 1024,
];
$kandidat = [
['banner.jpg', "\xFF\xD8\xFF\xE0" . str_repeat('A', 200), 204],
['shell.php', "<?php system(\$_GET['perintah']); ?>", 34],
['shell.php.jpg', "<?php system(\$_GET['perintah']); ?>", 34],
['banner.jpg', "GIF89a\x01\x00\x01\x00\x80\x00\x00", 20],
];
foreach ($kandidat as [$nama, $isi, $ukuran]) {
$hasil = validasiUpload($nama, $isi, $ukuran, $aturan);
echo str_pad($nama, 16) . ' => ' . ($hasil === [] ? 'LOLOS' : 'DITOLAK: ' . implode('; ', $hasil)) . "\n";
}Output yang Dihasilkan:
banner.jpg => LOLOS
shell.php => DITOLAK: ekstensi .php tidak diizinkan; tipe text/x-php tidak cocok
shell.php.jpg => DITOLAK: tipe text/x-php tidak cocok
banner.jpg => DITOLAK: tipe image/gif tidak cocokMari kita baca vonisnya baris demi baris:
banner.jpgdengan magic bytes JPEG yang sah lolos — inilah pengguna yang jujur.shell.phpditolak dua kali: ekstensi tidak ada dalam allowlist danfinfomelaporkantext/x-php.shell.php.jpgadalah bintang contoh ini: ekstensinya lolos (.jpg), tetapi pemeriksaan MIME menangkap isinya yang bukan gambar — persis kombinasi yang lolos bila validasi hanya melihat nama.banner.jpgberisi GIF ditolak karenaimage/giftidak termasuk daftar MIME yang diizinkan — nama dan isi harus sepakat.
Pada aplikasi nyata, $isi dan $ukuran berasal dari berkas sementara ($_FILES['foto']['tmp_name'], $_FILES['foto']['size']), dan pemindahan hanya boleh dilakukan move_uploaded_file() — satu-satunya fungsi yang menolak berkas palsu bukan hasil upload sungguhan.
Nama Penyimpanan Acak di Luar document root #
Setelah rantai validasi lolos, nama asli berkas dibuang. Nama penyimpanan dibangun sepenuhnya di server — acak agar tidak bisa ditebak, dengan ekstensi hasil allowlist, dan diletakkan di luar document root:
<?php
// Struktur penyimpanan aman - sketsa pola, bukan kode siap produksi
// $ekstensi berasal dari hasil lolos allowlist pada validasiUpload() di atas
$ekstensi = 'jpg';
$namaSimpan = bin2hex(random_bytes(16)) . '.' . $ekstensi;
$folderSimpan = '/var/www/app/storage/uploads'; // di luar document root
$final = $folderSimpan . '/' . $namaSimpan;
// Pemetaan disimpan di database: nama_simpan <-> nama_asli <-> pemilik
// move_uploaded_file($_FILES['foto']['tmp_name'], $final);
Tiga keputusan pada pola ini bekerja bersama. Nama acak mematikan dua serangan sekaligus: tebakan URL berkas milik orang lain dan penimpaan berkas penting milik server. Folder di luar document root berarti berkas tidak punya URL sama sekali — pengambilan dilakukan lewat endpoint PHP yang memeriksa kepemilikan. Pemetaan di database menjaga UX: pengguna tetap melihat nama aslinya, server tetap tidak pernah memakai nama itu di disk.
Untuk hosting yang memaksa penyimpanan di dalam document root, eksekusi skrip di direktori upload harus dimatikan eksplisit. Pada Apache, cukup berkas .htaccess di dalam direktori upload yang mematikan engine PHP sama sekali:
php_flag engine offPada nginx, blok lokasi untuk /uploads/ menolak setiap permintaan yang berakhir .php — berkas tetap bisa diunduh sebagai data, tetapi tidak pernah dijalankan:
location /uploads/ {
location ~* \.php$ {
deny all;
}
}Zip Bomb dan Batas Ukuran #
Batas ukuran bukan sekadar kenyamanan — ia pertahanan terhadap dua serangan sumber daya. Serangan pertama klasik: mengunggah arsip zip bomb, berkas kecil yang berisi lapisan arsip bersarang dan mengembang menjadi puluhan gigabyte (bahkan petabyte) saat diekstrak. Penyerang tidak perlu melewati validasi sama sekali — arsip 42 KB bisa lolos semua pemeriksaan ukuran, lalu satu panggilan extractTo() meruntuhkan disk dan memori server. Bila aplikasi memproses arsip pengguna, periksa total ukuran terkompresi dari setiap entri sebelum mengekstrak, batasi jumlah berkas, dan ekstrak satu per satu sambil menghitung total byte yang keluar.
Serangan kedua lebih polos tampaknya: upload berkas raksasa untuk menghabiskan bandwidth dan disk. PHP membatasinya di level konfigurasi, dan nilai ini harus disetel sesuai kebutuhan nyata aplikasi:
; php.ini - batas bawaan untuk upload
upload_max_filesize = 5M
post_max_size = 25M
max_file_uploads = 20Ketiganya bekerja di fase yang berbeda: upload_max_filesize membatasi satu berkas, post_max_size membatasi total satu permintaan, dan max_file_uploads membatasi jumlah berkas per permintaan. Ingat bahwa ini pagar di pintu masuk — pemeriksaan $aturan['maksByte'] pada rantai validasi tetap perlu karena batas ini berlaku global untuk seluruh aplikasi, sedangkan kebutuhan tiap fitur biasanya lebih kecil.
Ringkasan #
Upload yang aman lahir dari asumsi bahwa nama, tipe, dan isi berkas semuanya bohong sampai terbukti sebaliknya. Allowlist ekstensi menyaring nama, finfo menyaring isi (dengan kesadaran penuh bahwa polyglot bisa lolos), batas ukuran menjaga sumber daya, nama simpan acak menghapus kendali penyerang atas path, dan lokasi di luar document root memastikan tidak ada URL yang mengeksekusi berkas pengguna. Dengan pintu masuk berkas kini terkunci, kita beralih ke kunci yang paling sering dipakai aplikasi: ID session — dan bagaimana ia bisa ditanam oleh penyerang pada chapter Session Fixation.