Di post LFI kemarin gua metain file tree target lewat php://filter, dan salah satu nama yang nongol dari situ itu admin/fetch_url.php. Waktu itu gua cuma nulis “fetch URL biasanya bau SSRF” terus lanjut, tanpa pernah buka endpoint-nya sama sekali. Post ini gua balik ke endpoint itu dan buktiin tebakan asal tadi. Ternyata beneran ada.
”Import from URL” kedengeran enggak berbahaya
Halaman admin-nya judulnya “Import from URL”, subtitle-nya “Fetch product data from an external source”. Niatnya kebaca jelas: admin nempel link ke feed produk supplier, server yang ngambil isinya, data masuk katalog. Fitur yang wajar banget ada di toko online.
Parameter yang dia terima cuma satu, url, lewat GET. Nilainya dioper apa adanya ke file_get_contents(). Enggak ada cek skema, enggak ada cek host (CWE-918).
Asumsi yang kepasang di situ: yang ditempel admin itu “alamat http”. Padahal yang dioper ke fungsinya cuma string, dan file_get_contents() nerima jauh lebih banyak dari sekadar http.
Masalahnya file_get_contents enggak cuma ngerti http
file_get_contents() itu fungsi filesystem, bukan HTTP client. Dia jalan di atas sistem stream wrapper PHP yang sama kayak fopen(), jadi selain http:// dan https:// dia juga ngerti file://, php://, ftp://, data://, phar://, glob://, dan daftar di manual PHP itu masih lanjut ke bawah.
Kalau aplikasi enggak ngunci skema yang boleh, yang milih skema bukan developer, tapi penyerang.
Jadi gua tuker http jadi file.
GET /admin/fetch_url.php?url=file:///etc/passwd HTTP/1.1
Host: exam.redacted
Enggak buka Burp buat yang ini, cukup address bar browser, yang otomatis ngubah payload-nya jadi ?url=file%3A%2F%2F%2Fetc%2Fpasswd.

Halaman balikin Result from: file:///etc/passwd, terus isi file-nya kebaca utuh di bawahnya. root:x:0:0:root:/root:/bin/bash, daemon, bin, sys, turun sampai www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin. Uid 33, orang yang sama yang kemarin ngejalanin id buat gua lewat include(). Enggak ada filter yang harus dibypass, enggak ada encoding trick, enggak ada WAF yang ngelawan. Tiga slash dan satu nama file.
Dua bug numpang satu parameter
Sisi pertama, local file read. Lewat file://, apa pun yang bisa dibaca user www-data bisa ditarik keluar. /etc/passwd itu cuma target yang paling gampang diverifikasi, bukan yang paling mahal. Yang mahal: source code aplikasi dan file config yang isinya kredensial database.
Sisi kedua, SSRF yang beneran, yaitu kalau skema-nya tetep http:// tapi host-nya diarahin ke dalam. http://127.0.0.1:8080, http://10.0.0.5, atau endpoint metadata cloud di 169.254.169.254. Request-nya dikirim sama server, dari dalam jaringan, dari IP yang dipercaya. Firewall dan ACL yang nutup akses dari luar enggak kepake di situ, karena yang ngetok pintu bukan orang luar, tapi server sendiri. Itu inti SSRF: penyerang minjem posisi server.
Gua berhenti di file:///etc/passwd. Scope exam-nya pembuktian bug, jadi http://127.0.0.1 dan tetangga-tetangganya enggak gua terusin.
Sink-nya beda, akarnya sama
Gampang ketuker sama LFI kemarin, padahal beda. Yang ngebedain itu sink-nya, fungsi tempat input user mendarat.
LFI kemarin mendarat di include(). Isi file dieksekusi sebagai PHP, makanya data:// sama php://input bisa naik jadi RCE.
Yang ini mendarat di file_get_contents(). Isi file dibaca jadi string terus ditampilin, enggak ada yang dieksekusi. Dampaknya information disclosure (CWE-200), bukan code execution. Masih parah kalau yang kebaca config.php, tapi kelasnya beda.
Akarnya mirip banget: nilai dari request masuk ke fungsi filesystem atau network tanpa ada satu pun yang mastiin nilainya masuk akal dulu. Satu codebase, dua tempat yang enggak nyambung, kebiasaan ngoding yang sama.
Allowlist skema, terus allowlist host
Yang pertama dipasang: kunci skema. Cuma http dan https yang lolos, sisanya tolak. Allowlist, bukan blacklist. Kalau lo cuma ngeblok file://, masih ada php://, data://, ftp://, phar://, plus wrapper custom yang bisa didaftarin sendiri lewat stream_wrapper_register(). Daftar yang harus diblok enggak ada ujungnya, daftar yang boleh cuma dua. Blacklist buat kasus ini buang-buang waktu.
Kalau sumber datanya sebenernya terbatas, misal cuma beberapa supplier, allowlist host sekalian. Ini obat paling ampuh dan OWASP juga naruh ini di depan: aplikasi cuma boleh ngirim request ke tujuan yang udah dikenal. Kalau gua yang mikirin fiturnya, buat import katalog model gini mending enggak nerima URL dari user sama sekali. Suruh upload file-nya.
Kalau host-nya beneran harus bebas, resolve DNS dulu, terus tolak IP yang ngarah ke dalam sebelum konek:
127.0.0.0/8dan::1, loopback10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7, jaringan privat169.254.0.0/16, link-local, termasuk endpoint metadata cloud169.254.169.254
Satu jebakan di sini: ngecek IP-nya sekali di awal doang enggak cukup. Antara lo resolve hostname dan lo konek, record DNS-nya bisa ganti. Namanya DNS rebinding, dan bentuknya TOCTOU, kelas bug yang sama persis kayak voucher yang bisa di-redeem dua kali. Validasinya harus nempel ke IP yang benar-benar dikonek, bukan ke hasil resolve yang udah basi.
Terus, jangan pake file_get_contents() buat URL dari user. Pindah ke cURL, dan batesin protokolnya di level library:
<?php
function fetchExternal(string $url): string
{
$parts = parse_url($url);
$scheme = $parts['scheme'] ?? '';
$host = $parts['host'] ?? '';
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
throw new InvalidArgumentException('Cuma http/https yang boleh');
}
$ips = gethostbynamel($host); // resolve dulu, baru dinilai
if ($ips === false) {
throw new InvalidArgumentException('Host enggak keresolve');
}
foreach ($ips as $ip) {
$isPublic = filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
if ($isPublic === false) {
throw new InvalidArgumentException("IP internal ditolak: {$ip}");
}
}
$port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 5,
CURLOPT_RETURNTRANSFER => true,
// paksa konek ke IP yang udah dinilai, biar DNS-nya enggak
// bisa ganti di antara cek dan konek
CURLOPT_RESOLVE => ["{$host}:{$port}:{$ips[0]}"],
]);
$body = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($body === false) {
throw new RuntimeException($err !== '' ? $err : 'Request gagal');
}
return $body;
}
FILTER_FLAG_NO_PRIV_RANGE nutup range privat, FILTER_FLAG_NO_RES_RANGE nutup yang reserved termasuk loopback dan link-local, jadi enggak usah nulis sendiri daftar CIDR di atas. CURLOPT_FOLLOWLOCATION dimatiin, karena redirect itu jalan tikus favorit buat SSRF: request pertama ke host yang lolos validasi, hop kedua dilempar ke http://169.254.169.254. Kalau redirect emang dibutuhin, tiap hop harus divalidasi ulang dari nol. Di PHP 8.3 ke atas, CURLOPT_PROTOCOLS bisa diganti CURLOPT_PROTOCOLS_STR => 'http,https'.
Soal php.ini, ada satu hal yang sering salah paham. allow_url_fopen = Off layak dimatiin kalau aplikasinya enggak butuh ngambil file lewat URL, tapi itu enggak nutup bug di atas: file:// termasuk wrapper yang enggak kena allow_url_fopen sama sekali, manual PHP nulis jelas “Restricted by allow_url_fopen: No”. Jadi dengan setting itu, remote fetch-nya mati, file read lokalnya masih jalan.
Dua lapis terakhir: jangan balikin response mentah ke user. Halaman yang nampilin Result from: ... plus isi file-nya persis yang bikin bug ini enak dieksploitasi. Kalau respons hasil fetch enggak dikembalikan, yang tersisa cuma blind SSRF, masih bug tapi nilai bocornya jauh lebih kecil. Dan di sisi jaringan, pasang egress filtering atau paksa semua outbound lewat proxy, biar kalau validasi di aplikasinya kelewat, paketnya tetep enggak nyampe ke mana-mana.
Sama kayak yang kemarin, ini berhenti di titik pembuktian: satu file kebaca, validasinya kebukti enggak ada, udahan. Studi kasusnya sendiri dari lab exam bootcamp cyber security yang emang disiapin buat dieksploitasi kayak gini.
Sumber:
- CWE-918: Server-Side Request Forgery (SSRF)
- CWE-200: Exposure of Sensitive Information to an Unauthorized Actor
- OWASP WSTG, Testing for Server-Side Request Forgery
- OWASP Cheat Sheet, Server Side Request Forgery Prevention
- PHP Manual, file_get_contents
- PHP Manual, Supported Protocols and Wrappers
- PHP Manual, file:// wrapper
- PHP Manual, allow_url_fopen
- PayloadsAllTheThings, Server Side Request Forgery