iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0
Security

資安菜鳥的30天挑戰系列 第 26

[DAY26]假裝幫了你的忙

  • 分享至 

  • xImage
  •  

SSRF是甚麼?

📌 你的網站像是一位助理,你很忙請他幫你去拿東西

當這位助理可以被騙去幫壞人拿東西

甚至跑到你家後院拿東西

結果把機密交給攻擊者!

攻擊原理

  • SSRF 發生在伺服器端會根據使用者提供的 URL/host 去做 outbound request
  • 如果伺服器沒對目標 URL 做嚴格限制,攻擊者透過內網 IP、file:// 路徑或特殊協定,讓伺服器替他去讀取內部資源或執行不當動作
  • 擷取 cloud instance metadata(含憑證)、掃描內網、觸發內部 API、引導伺服器做進一步攻擊
  • Image fetch / avatar URL、SSO / OAuth 回呼 URL、webhook 設定、server-side SSR (SSR 渲染)

範例

📌 不要在 production 執行

把使用者提供的 URL 直接傳給 file_get_contents()

伺服器會替攻擊者做任意外發請求

<?php
	$url = isset($_GET['url']) ? $_GET['url'] : '';
	if (!$url) 
	{
	    echo "provide ?url=";
	    exit;
	}
	echo file_get_contents($url);
?>
  • 可被用來讀取內網服務(127.0.0.1、10.x 等),觸發內部 API
  • 攻擊者用 DNS 指向內網 IP

安全作法

  • 只允許 http / https
  • parse_url()filter_var() 驗證
  • 解析 hostname 的 A/AAAA 紀錄並檢查是否落在私有 IP 範圍(阻擋私有/鏈路本地/loopback/metadata)
  • 禁止跟隨 redirect(避免透過 redirect 指向內網)
  • 設定 timeout、最大下載大小並記錄請求
<?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');
}
?>
  • 白名單 (allowed_hosts) 為主:只允許你預先知道並可信的 host
  • DNS 解析結果必須檢查(不要只檢查 hostname 字串)— 因為 DNS 可能指向內網 IP(DNS rebinding)
  • 禁止跟隨 redirect,避免 target 首先回傳 302 指向內網
  • 啟用 TLS 驗證並給予嚴格 timeout/size limit 以防塞住資源
  • 日誌必須記下來源 IP、user-supplied URL 與 resolve 結果,便於偵測

使用 egress proxy

把所有出站 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;
}
?>
  • proxy 帶來單一檢查點:可在 proxy layer 做白名單、IP 黑白清單、限制協定、檢測 DNS rebinding、全紀錄。
  • proxy 可以放在不同網段,使 web server 無直接網路權限(network segmentation)

偵測指標

  1. 伺服器 outbound request 到內網/非典型 IP(例:web server 突然去 169.254.169.254 或 10.0.0.0/8)。
  2. 應用層帶有外部 user-controlled URL 的參數 的大量出現或異常大小/頻率。
  3. 短時間內向多個內部端點發送請求(掃描內網的行為)。
  4. 代理/egress log 出現 unusual target hosts(需與 baseline 比較)。
  5. DNS 查詢序列可疑(例如某個 public 域名解析成內網 IP 或出現大量解析變化 → 可能 DNS rebinding)。

防護清單

  • 在應用層加入白名單(只允許特定 host / domain)
  • 阻擋到 metadata/私有 IP 範圍的 outbound request(egress ACL)
  • 將可做 URL-fetch 的功能停用或改成異步且被代理(egress proxy)執行並限制目標清單
  • 在 proxy/edge 加入最大 body/timeout 限制與日誌
  1. DNS 防護:防止 DNS rebinding(驗證 Host header 與 resolved IP)
  2. 最小權限:不讓 web server 擁有不必要的網路權限
  3. 測試與審計:每次部署檢查能否向內網/metadata 發出 request
  4. 代碼審查:避免在代碼中直接 requests.get(user_url),所有 fetch 功能需經過安全審核
  5. 雲端防護:針對 cloud metadata 使用 provider 的加強機制(如 AWS IMDSv2)

常見誤區

  • 以為「只要過濾 http://169.254.169.254 就安全」——攻擊者會用 DNS、IPv6、或中介 redirect,必須做綜合檢查
  • 只檢查 hostname 字串,而不解析到 IP(容易被 DNS 指向內網 IP)
  • 把防護放在 client 端(瀏覽器)而非伺服器或網路層——SSRF 是 server-side 問題,防護要在 server/egress

結論

📌 切勿在未經授權的主機或雲端測試

利用應用伺服器代為發出請求的能力

讓攻擊者從外部誘導伺服器去讀取內部資源

攻擊方 → 可能取得憑證、掃描內網,甚至完全控制雲端角色

防護方 →「白名單優先、最少權限、egress proxy/網路隔離與日誌化」


上一篇
[DAY25]看似好人的壞人
下一篇
[DAY27]我要無限升級!
系列文
資安菜鳥的30天挑戰30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言