📌 你的網站像是一位助理,你很忙請他幫你去拿東西
當這位助理可以被騙去幫壞人拿東西
甚至跑到你家後院拿東西
結果把機密交給攻擊者!
📌 不要在 production 執行
把使用者提供的 URL 直接傳給 file_get_contents()
伺服器會替攻擊者做任意外發請求
<?php
$url = isset($_GET['url']) ? $_GET['url'] : '';
if (!$url)
{
echo "provide ?url=";
exit;
}
echo file_get_contents($url);
?>
http
/ https
parse_url()
與 filter_var()
驗證<?php
function is_private_ip($ip)
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4))
{
$long = ip2long($ip);
$ranges = [
['start' => ip2long('10.0.0.0'), 'end' => ip2long('10.255.255.255')],
['start' => ip2long('172.16.0.0'), 'end' => ip2long('172.31.255.255')],
['start' => ip2long('192.168.0.0'), 'end' => ip2long('192.168.255.255')],
['start' => ip2long('127.0.0.0'), 'end' => ip2long('127.255.255.255')],
['start' => ip2long('169.254.0.0'), 'end' => ip2long('169.254.255.255')],
['start' => ip2long('0.0.0.0'), 'end' => ip2long('0.255.255.255')],
['start' => ip2long('100.64.0.0'), 'end' => ip2long('100.127.255.255')],
['start' => ip2long('192.0.0.0'), 'end' => ip2long('192.0.0.255')],
['start' => ip2long('192.0.2.0'), 'end' => ip2long('192.0.2.255')],
['start' => ip2long('198.18.0.0'), 'end' => ip2long('198.19.255.255')],
['start' => ip2long('198.51.100.0'), 'end' => ip2long('198.51.100.255')],
['start' => ip2long('203.0.113.0'), 'end' => ip2long('203.0.113.255')],
['start' => ip2long('224.0.0.0'), 'end' => ip2long('239.255.255.255')],
['start' => ip2long('240.0.0.0'), 'end' => ip2long('255.255.255.255')],
];
foreach ($ranges as $r)
{
if ($long >= $r['start'] && $long <= $r['end']) return true;
}
return false;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))
{
$ip = strtolower($ip);
if ($ip === '::1') return true;
if (strpos($ip, 'fe80:') === 0) return true;
if (strpos($ip, 'fe9') === 0) return true;
if (strpos($ip, 'fea') === 0) return true;
if (strpos($ip, 'feb') === 0) return true;
if (strpos($ip, 'fc') === 0 || strpos($ip, 'fd') === 0) return true;
if (strpos($ip, '::ffff:') === 0) return true;
if (strpos($ip, '2001:db8:') === 0) return true;
if ($ip === '::') return true;
}
return false;
}
function resolve_all_ips($hostname)
{
$ips = [];
$a = @dns_get_record($hostname, DNS_A);
if ($a !== false)
{
foreach ($a as $r)
{
if (isset($r['ip'])) $ips[] = $r['ip'];
}
}
$aaaa = @dns_get_record($hostname, DNS_AAAA);
if ($aaaa !== false)
{
foreach ($aaaa as $r)
{
if (isset($r['ipv6'])) $ips[] = $r['ipv6'];
}
}
$fallback = @gethostbyname($hostname);
if ($fallback && $fallback !== $hostname)
{
$ips[] = $fallback;
}
return array_values(array_unique($ips));
}
function safe_fetch($url, $allowed_hosts = [])
{
if (!filter_var($url, FILTER_VALIDATE_URL))
{
throw new Exception("invalid url");
}
$parts = parse_url($url);
if ($parts === false || !isset($parts['scheme']) || !isset($parts['host']))
{
throw new Exception("invalid url parts");
}
$scheme = strtolower($parts['scheme']);
$host = strtolower($parts['host']);
$port = isset($parts['port']) ? $parts['port'] : ($scheme === 'https' ? 443 : 80);
if (!in_array($scheme, ['http', 'https'], true))
{
throw new Exception("unsupported scheme");
}
if (!empty($allowed_hosts) && !in_array($host, $allowed_hosts, true))
{
throw new Exception("host not allowed");
}
if (filter_var($host, FILTER_VALIDATE_IP))
{
throw new Exception("direct IP access not allowed");
}
$ips = resolve_all_ips($host);
if (empty($ips))
{
throw new Exception("cannot resolve host");
}
foreach ($ips as $ip)
{
if (is_private_ip($ip))
{
throw new Exception("target resolves to private IP: {$ip}");
}
}
$target_ip = $ips[0];
$ch = curl_init();
$request_url = $scheme . '://' . $target_ip;
if (isset($parts['path'])) $request_url .= $parts['path'];
if (isset($parts['query'])) $request_url .= '?' . $parts['query'];
if (isset($parts['fragment'])) $request_url .= '#' . $parts['fragment'];
curl_setopt($ch, CURLOPT_URL, $request_url);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Host: {$host}"
]);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_MAXREDIRS, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
curl_setopt($ch, CURLOPT_BUFFERSIZE, 128);
curl_setopt($ch, CURLOPT_NOPROGRESS, false);
curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function($ch, $dltotal, $dlnow, $ultotal, $ulnow) {
if ($dlnow > 2 * 1024 * 1024)
{
return 1;
}
return 0;
});
$body = curl_exec($ch);
$info = curl_getinfo($ch);
$err = curl_error($ch);
$errno = curl_errno($ch);
curl_close($ch);
if ($errno !== 0)
{
throw new Exception("curl error: {$err}");
}
$http_code = isset($info['http_code']) ? $info['http_code'] : 0;
if ($http_code < 200 || $http_code >= 400)
{
throw new Exception("http error: {$http_code}");
}
if (strlen($body) > 2 * 1024 * 1024)
{
throw new Exception("content too large");
}
return $body;
}
$allowed = ['api.trusted.example', 'images.cdn.example'];
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
try
{
$url = isset($_GET['url']) ? $_GET['url'] : '';
if (!$url)
{
throw new Exception("no url");
}
if (strlen($url) > 2048)
{
throw new Exception("url too long");
}
$result = safe_fetch($url, $allowed);
header('Content-Type: text/plain; charset=utf-8');
echo substr($result, 0, 1024);
}
catch (Exception $e)
{
http_response_code(400);
header('Content-Type: text/plain; charset=utf-8');
echo "error: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8');
}
?>
把所有出站 HTTP 統一導到受控的 egress proxy(或「URL fetch service」)
proxy 可在 network 層阻擋 metadata 與私有 IP、做速率限制與完整日誌
<?php
function fetch_via_proxy($url, $proxy = 'http://127.0.0.1:3128')
{
if (!filter_var($url, FILTER_VALIDATE_URL)) throw new Exception("invalid url");
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_PROXY, $proxy);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
curl_setopt($ch, CURLOPT_TIMEOUT, 6);
$body = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($err) throw new Exception("proxy curl error: $err");
return $body;
}
?>
requests.get(user_url)
,所有 fetch 功能需經過安全審核📌 切勿在未經授權的主機或雲端測試
利用應用伺服器代為發出請求的能力
讓攻擊者從外部誘導伺服器去讀取內部資源
攻擊方 → 可能取得憑證、掃描內網,甚至完全控制雲端角色
防護方 →「白名單優先、最少權限、egress proxy/網路隔離與日誌化」