Banyak operasi PHP berujung pada satu pertanyaan: berkas (atau direktori) mana yang dibuka? Ketika sebagian jawabannya datang dari pengguna — parameter ?file= pada endpoint unduhan, nama avatar, nama laporan — penyerang memegang kendali atas path yang dihasilkan. Path traversal adalah teknik memanjat keluar dari direktori yang seharusnya membatasi akses, cukup dengan tiga karakter: ../. Chapter ini membedah anatomi serangannya dari sudut pandang penyerang, menguji galat pertahanan yang paling sering salah tulis (pemeriksaan prefix), lalu membangun gerbang realpath() yang benar. Versi pertahanan lengkap dengan konteks filesystem lebih luas tersedia di chapter Filesystem Security
; fokus kita di sini adalah cara berpikir penyerang melawan setiap baris validasi.
Anatomi Serangan ../ #
Skenarionya khas: endpoint unduhan menyambung parameter GET ke direktori data. Kode rentan berbentuk file_get_contents('/var/www/app/data/' . $_GET['file']), dan penyerang mengirim ../../../../etc/passwd untuk membaca berkas sistem. Untuk memahami mengapa ini berhasil — dan mengapa pemeriksaan naif gagal menangkapnya — mari simulasikan normalisasi path sebagai manipulasi string murni:
<?php
function normalisasiPath(string $path): string
{
$bagian = [];
foreach (explode('/', str_replace('\\', '/', $path)) as $segmen) {
if ($segmen === '' || $segmen === '.') {
continue;
}
if ($segmen === '..') {
array_pop($bagian);
continue;
}
$bagian[] = $segmen;
}
return '/' . implode('/', $bagian);
}
$base = '/var/www/app/data';
$input = '../../../../etc/passwd';
$gabungan = $base . '/' . $input;
echo 'String mentah : ' . $gabungan . "\n";
echo 'Cek naif lolos? : ' . var_export(str_starts_with($gabungan, $base), true) . "\n";
echo 'Setelah normalisasi : ' . normalisasiPath($gabungan) . "\n";
echo 'Masih di dalam base? : ' . var_export(str_starts_with(normalisasiPath($gabungan), $base . '/'), true) . "\n";
$saudara = '/var/www/app/data-rahasia';
echo 'Direktori saudara lolos tanpa garis miring? : '
. var_export(str_starts_with($saudara, $base), true) . "\n";Output yang Dihasilkan:
String mentah : /var/www/app/data/../../../../etc/passwd
Cek naif lolos? : true
Setelah normalisasi : /etc/passwd
Masih di dalam base? : false
Direktori saudara lolos tanpa garis miring? : trueEmpat baris terakhir adalah pelajaran inti chapter ini:
- String mentah tetap diawali
$base— pemeriksaan prefix yang diterapkan sebelum normalisasi meloloskan serangan karena../-nya “tersembunyi” di tengah string. - Normalisasi memangkas empat tingkat:
data→app→www→var→ akar, sehingga path final menjadi/etc/passwd— di luar base, dan sistem operasi menyelesaikan../dengan cara yang persis sama saat berkas dibuka. - Pemeriksaan setelah normalisasi menangkap kebenarannya:
false. - Jebakan garis miring: prefix tanpa
/di ujung meloloskan direktori saudaradata-rahasia— kesalahan satu karakter yang membuka pintu lain.
Kamus Payload Penyerang #
Penyerang jarang mengirim ../ polos; bentuknya disamarkan agar lolos dari filter string yang naif. Berikut kamus yang layak dihafal:
../../../../etc/passwd memanjat empat tingkat lalu membaca berkas sistem
%2e%2e%2f%2e%2e%2fetc%2fpasswd ../ yang di-URL-encode; lolos dari pencarian "../" polos
%252e%252e%252f double encoding, untuk server yang mendecode dua kali
....// lolos dari str_replace('../', '') yang dijalankan sekali
/etc/passwd path absolut menimpa base bila input dipakai langsung
rahasia.txt%00.jpg null byte memotong ekstensi pada PHP < 5.3.4Dua pelajaran dari kamus di atas. Pertama, filter berbasis pencarian dan penggantian string selalu kalah oleh variasi encoding — pertahanan harus bekerja pada path yang sudah selesai dinormalisasi, bukan pada teks mentahnya. Kedua, varian path absolut menunjukkan pola rentan yang berbeda: ketika kode memakai input secara langsung (include($_GET['halaman'])), tidak ada base untuk dilanggar — input menjadi path penuh seketika.
realpath sebagai Gerbang Containment #
Pertahanan yang benar menyerahkan normalisasi kepada mesin yang berwenang: realpath() menyelesaikan seluruh ../, ., dan double-slash, mengikuti symlink, lalu mengembalikan path absolut final — atau false bila berkasnya tidak ada. Keputusan menerima atau menolak diambil setelah hasil final itu ada. Mari uji gerbang ini terhadap baterai serangan di direktori sementara:
<?php
$baseDir = sys_get_temp_dir() . '/kursus-php-traversal-base';
@mkdir($baseDir . '/laporan', 0777, true);
file_put_contents($baseDir . '/laporan/q1.txt', 'data laporan');
$rahasia = sys_get_temp_dir() . '/kursus-php-traversal-podium.txt';
file_put_contents($rahasia, 'JANGAN BOCOR');
function resolveAman(string $base, string $input): string|false
{
$path = realpath($base . '/' . urldecode($input));
if ($path === false) {
return false;
}
return str_starts_with($path, rtrim($base, '/') . '/') ? $path : false;
}
$permintaan = [
'laporan/q1.txt',
'../kursus-php-traversal-podium.txt',
'%2e%2e%2fkursus-php-traversal-podium.txt',
'....//kursus-php-traversal-podium.txt',
'laporan/../../kursus-php-traversal-podium.txt',
];
foreach ($permintaan as $p) {
$hasil = resolveAman($baseDir, $p);
echo str_pad($p, 46) . ' => ' . ($hasil === false ? 'DITOLAK' : 'DIIZINKAN') . "\n";
}
unlink($baseDir . '/laporan/q1.txt');
rmdir($baseDir . '/laporan');
rmdir($baseDir);
unlink($rahasia);Output yang Dihasilkan:
laporan/q1.txt => DIIZINKAN
../kursus-php-traversal-podium.txt => DITOLAK
%2e%2e%2fkursus-php-traversal-podium.txt => DITOLAK
....//kursus-php-traversal-podium.txt => DITOLAK
laporan/../../kursus-php-traversal-podium.txt => DITOLAKTiga keputusan desain pada resolveAman() adalah kuncinya:
urldecode()sebelum menyambung membuka kembali%2e%2e%2fmenjadi../— pertahanan bekerja pada bentuk yang paling jujur, bukan yang paling tersamarkan.realpath()menolak berkas yang tidak ada sekaligus membongkar semua trik: input....//ternyata hanya nama direktori biasa, input traversal menghasilkan path final di luar base, dan symlink yang menunjuk keluar ikut terbongkar karenarealpath()mengikutinya sampai ujung.- Containment memakai
rtrim($base, '/') . '/'— prefix diakhiri garis miring, menutup jebakan direktori saudara yang kita buktikan di bagian sebelumnya.
Jebakan basename #
Fungsi basename() sering dianggap sebagai pertahanan instan: ia memangkas seluruh komponen direktori sehingga ../ tidak mungkin lolos. Dugaan itu benar setengah — dan setengah yang salah inilah yang dieksploitasi:
<?php
foreach (['../../../etc/passwd', 'config.php', '....//init.php', 'data/../../rahasia.txt'] as $minta) {
echo str_pad($minta, 30) . ' => ' . basename($minta) . "\n";
}Output yang Dihasilkan:
../../../etc/passwd => passwd
config.php => config.php
....//init.php => init.php
data/../../rahasia.txt => rahasia.txtTraversal memang mati — tetapi perhatikan dua baris berikutnya: config.php dan init.php lolos utuh. basename() hanya menghapus direktori; ia tidak tahu apa pun tentang nama berkas mana yang boleh. Kode include($dir . '/' . basename($_GET['file'])) tetap membocorkan berkas aplikasi mana pun yang berada di dalam $dir. Gunakan basename() sebagai pembersih, lalu tutup dengan allowlist nama — pasangan lengkapnya dibahas di bagian berikutnya dan di chapter Filesystem Security
.
Null Byte: Sejarah yang Masih Menjadi Pelajaran #
Sebelum PHP 5.3.4, string PHP boleh mengandung byte kosong (\0) dan fungsi filesystem berhenti membaca path pada byte itu. Payload rahasia.txt%00.jpg melewati validasi ekstensi .jpg, lalu saat berkas dibuka, sistem hanya melihat rahasia.txt. Era itu sudah lama berakhir — PHP modern menolak path berkarakter nol sebelum menyentuh filesystem sama sekali:
<?php
$path = 'data/rahasia.txt' . "\0" . '.jpg';
file_get_contents($path);Output yang Dihasilkan:
Fatal error: Uncaught ValueError: file_get_contents(): Argument #1 ($filename) must not contain any null bytes in /home/z/my-project/php-course/content/php/web-security/path-traversal/.verify_tmp.php:3
Stack trace:
#0 /home/z/my-project/php-course/content/php/web-security/path-traversal/.verify_tmp.php(3): file_get_contents('data/rahasia.tx...')
#1 {main}
thrown in /home/z/my-project/php-course/content/php/web-security/path-traversal/.verify_tmp.php on line 3PHP menghentikan program dengan ValueError alih-alih memotong path secara diam-diam — keputusan yang mengubah bug senyap menjadi error yang terlihat jelas. Sejarah null byte tetap relevan sebagai pengingat: setiap tempat di mana data pengguna berpindah antar bahasa (SQL, shell, filesystem, header HTTP) punya karakter-karakter istimewanya sendiri, dan pertahanan harus menghilangkan karakter itu di perbatasan, bukan berharap tiap lapisan menanganinya sama.
Allowlist Nama Berkas #
Untuk fitur dengan daftar berkas yang memang terbatas — dokumen resmi, template, lampiran tetap — pertahanan paling kokoh bukan memfilter, melainkan mencocokkan dengan daftar eksplisit:
<?php
$unduhanResmi = ['persyaratan.pdf', 'panduan-peserta.pdf', 'formulir-pendaftaran.docx'];
function bolehUnduh(string $minta, array $daftarResmi): string|false
{
return in_array($minta, $daftarResmi, true) ? $minta : false;
}
foreach (['panduan-peserta.pdf', '../../.env', 'persyaratan.pdf%00.jpg', 'tak-ada.txt'] as $minta) {
echo str_pad($minta, 26) . ' => ' . (bolehUnduh($minta, $unduhanResmi) ?: 'DITOLAK') . "\n";
}Output yang Dihasilkan:
panduan-peserta.pdf => panduan-peserta.pdf
../../.env => DITOLAK
persyaratan.pdf%00.jpg => DITOLAK
tak-ada.txt => DITOLAKPerbandingan in_array() strict tidak meninggalkan ruang tafsir: tidak ada pola yang bisa “mirip” dengan anggota daftar, sehingga traversal, encoding, dan null byte sama-sama gugur sebagai nama yang tidak dikenal. Untuk berkas yang jumlahnya tidak bisa didaftar (upload pengguna), prinsipnya berubah bentuk: nama penyimpanan dibangun dari identitas numerik dan ekstensi yang lolos allowlist — bukan dari nama asli klien — persis seperti pola namaPenyimpananAman() pada chapter Filesystem Security
. Lapisan terakhir berada di konfigurasi: direktif open_basedir membatasi seluruh operasi file PHP hanya pada direktori tertentu, menjadi pagar defense-in-depth saat satu titik validasi terlewat.
Ringkasan #
Path traversal mengeksploitasi fakta bahwa ../ adalah bahasa sistem operasi: tiga karakter yang memindahkan titik baca ke mana pun penyerang mau. Pemeriksaan string mentah selalu tertipu oleh encoding dan bentuk samaran, sehingga keputusan harus diambil setelah realpath() menyelesaikan path final, dengan prefix yang diakhiri garis miring dan allowlist untuk nama yang diizinkan. Null byte mengajarkan pelajaran yang lebih umum: karakter istimewa harus dilucuti di perbatasan antar-bahasa. Topik berikutnya menyentuh perbatasan paling berbahaya antara pengguna dan server: berkas yang sengaja diunggah — pada chapter File Upload Security.