SSRF verstehen – Teil 3: Fortgeschrittene Techniken & sichere Python-Demo

Filter-Umgehungen: wie simple Checks ausgetrickst werden

  • DNS-Rebinding: warum ein harmloser Host plötzlich „intern“ wird
  • Nicht-HTTP-Protokolle (z. B. file://, gopher://) – was daran riskant ist
  • Lab-Demo in Python/Flask: gefahrlos nachvollziehen, wie ein SSRF eine interne POST-Anfrage „durchleitet“
  • Sichere Gegenmaßnahmen — mit praxistauglichem Python-Code

1) Filter-Umgehungen in der Praxis

Viele Abwehrversuche scheitern an Kleinigkeiten:

a) Varianten von „localhost“

  • http://localhost
  • http://127.0.0.1
  • http://127.0.0.1:80/
  • http://[::1]/ (IPv6)
  • http://2130706433/ (dezimal-kodierte 127.0.0.1)
  • http://0x7f000001/ (hex-kodiert)

Merke: Ein String-Vergleich auf „localhost“ reicht nicht. Du musst auflösen → in IP verwandeln → IP-Ranges prüfen.

b) Offene Umleitungen (Open Redirects)

Selbst wenn du „nur example.com erlaubst“, aber https://example.com/go?to=http://169.254.169.254 zurück auf eine interne IP umleitet, bist du angreifbar. Folge-Requests nach Redirects müssen erneut validiert werden.

c) Header-Manipulation

Manche Backends entscheiden anhand von Host/X-Forwarded-Host etc., wohin weitergeleitet wird. Wenn dein Code diese Header blind übernimmt, kann SSRF durchrutschen.


2) DNS-Rebinding in Kurzform

Idee: Eine Domain zeigt beim ersten Lookup auf eine „gute“ IP (z. B. öffentlich), später (oder in einem anderen Rebind-Fenster) auf eine interne IP (z. B. 10.0.0.5).
Wenn deine App DNS erst prüft (Allowlist) und dann bei der echten Anfrage nicht erneut die IP validiert, kann der zweite Schritt ins Interne gehen.

Gegenmittel:

  • JEDEN effektiven Hop prüfen: vor Request, nach Redirect, und idealerweise auf Socket-Ebene (IP nach Verbindungsaufbau inspizieren und notfalls abbrechen).
  • Resolver-Cache und DNS-Pinning (z. B. IP zur Domain cachen und nur diese IP zulassen).

3) Nicht-HTTP-Protokolle

Viele Standardfunktionen unterstützen mehr als http(s)://:

  • file:// → lokale Dateien lesen (z. B. /etc/passwd)
  • gopher:// → roher TCP-Dialog, kann „synthetische“ Requests bauen
  • ftp://, dict://

Policy: In Web-Apps fast immer streng auf HTTPS/HTTP whitelisten. Alles andere blocken.


4) Lab-Demo (Python, Flask): „Interner POST via SSRF“ – gefahrlos

Ziel der Demo:
Wir simulieren zwei Services auf derselben Maschine (nur lokal!):

  • public_app (Flask): nimmt eine URL entgegen und lädt Inhalte → absichtlich verwundbar
  • internal_api (Flask): nur lokal erreichbar, nimmt POST mit JSON an und speichert einen „geheimen Zettel“ (Spielzeugfunktion)

Wichtig:

  • Das ist nur für die lokale Testumgebung gedacht.
  • Kein echtes Ziel, keine sensiblen Daten.
  • Wir zeigen Konzept, keine Exploits für reale Systeme.

4.1 Interner Dienst (nur lokal)

# internal_api.py
from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET_STORE = []

@app.route("/note", methods=["POST"])
def note():
    data = request.get_json(silent=True) or {}
    SECRET_STORE.append({
        "title": data.get("title", "untitled"),
        "body": data.get("body", "")
    })
    return jsonify({"ok": True, "count": len(SECRET_STORE)})

@app.route("/notes", methods=["GET"])
def notes():
    return jsonify(SECRET_STORE)

if __name__ == "__main__":
    # Lauscht nur auf localhost / 127.0.0.1
    app.run(host="127.0.0.1", port=5001, debug=True)

4.2 Verwundbare „öffentliche“ App

# public_app_vuln.py
from flask import Flask, request, Response
import requests

app = Flask(__name__)

@app.route("/fetch")
def fetch():
    url = request.args.get("url")
    if not url:
        return "missing ?url=", 400
    # ABSICHTLICH UNSICHER:
    # - keine Scheme-Validierung
    # - keine Ziel-Validierung
    # - follow_redirects ist implizit erlaubt
    try:
        r = requests.get(url, timeout=5)
        # wir leiten die Antwort roh weiter
        return Response(r.content, status=r.status_code, headers=dict(r.headers))
    except Exception as e:
        return f"error: {e}", 500

if __name__ == "__main__":
    app.run(port=5000, debug=True)

Was passiert hier?

  • /fetch?url=... holt ohne Prüfung beliebige Ziele.
  • Ein Angreifer kann so die öffentliche App als Proxy gegen 127.0.0.1:5001 (den internen Dienst) verwenden.

4.3 „POST durch SSRF“-Prinzip

Reale Angriffe missbrauchen Protokolle wie gopher://, um POSTs zu synthetisieren. In unserem Labor zeigen wir dasselbe ohne solche Protokolle: wir implementieren im internen Dienst zusätzlich einen GET-Helper, der aus einer Query einen echten POST intern erzeugt (nur zu Demo-Zwecken!).

Warum? So kannst du gefahrlos sehen, wie SSRF funktional „einen POST auslöst“, ohne obskure Protokolle zu brauchen.

# internal_api.py (zusätzlicher Demo-Endpoint)
from urllib.parse import unquote

@app.route("/note-helper", methods=["GET"])
def note_helper():
    """
    Demo ONLY: /note-helper?title=...&body=...
    Dieser interne GET-Endpoint erzeugt serverseitig einen POST auf /note.
    """
    import requests
    title = request.args.get("title", "from-helper")
    body = request.args.get("body", "")
    r = requests.post("http://127.0.0.1:5001/note", json={"title": title, "body": body}, timeout=2)
    return jsonify({"helper_ok": True, "post_status": r.status_code})

Ablauf (nur lokal, Lab):

  1. Starte internal_api.py (Port 5001, localhost).
  2. Starte public_app_vuln.py (Port 5000).
  3. Rufe an der öffentlichen App auf: http://127.0.0.1:5000/fetch?url=http://127.0.0.1:5001/note-helper?title=Lab&body=Hello → Die öffentliche App fragt intern /note-helper ab.
    /note-helper führt intern den POST auf /note aus.
  4. Prüfe die gespeicherten Notizen: http://127.0.0.1:5001/notes → Ein Eintrag mit title="Lab", body="Hello" ist da.

Was du gelernt hast:

  • Ein SSRF-„Fetcher“ reicht, um interne Aktionen auszulösen.
  • In der echten Welt würde ein Angreifer statt unseres Hilfsendpoints z. B. Nicht-HTTP-Protokolle oder fehlkonfigurierte interne Endpoints ansteuern, um POSTs oder Administrative Befehle abzusetzen. Das zeigen wir nicht gegen reale Ziele.

5) Härtung in Python

5.1 Strikte Allowlist (Domains) & Scheme-Check

from urllib.parse import urlparse
import ipaddress, socket, requests

ALLOWED_HOSTS = {"api.example.com", "cdn.example.com"}  # Beispiel
ALLOWED_SCHEMES = {"http", "https"}

def is_private_ip(ip: str) -> bool:
    ip_obj = ipaddress.ip_address(ip)
    return ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local or ip_obj.is_multicast

def resolve_and_check(hostname: str) -> bool:
    # DNS → IPs auflösen und alle prüfen
    try:
        for res in socket.getaddrinfo(hostname, None):
            ip = res[4][0]
            if is_private_ip(ip):
                return False
        return True
    except socket.gaierror:
        return False

def safe_fetch(url: str) -> requests.Response:
    parsed = urlparse(url)

    # Scheme whitelisten
    if parsed.scheme not in ALLOWED_SCHEMES:
        raise ValueError("scheme not allowed")

    # Host whitelisten (optional – je nach Use-Case)
    # Wenn du eine strikte Domain-Whitelist nutzt:
    if parsed.hostname not in ALLOWED_HOSTS:
        raise ValueError("host not allowed")

    # DNS auflösen und IPs prüfen (gegen Rebinding & private Netze)
    if not resolve_and_check(parsed.hostname):
        raise ValueError("target resolves to a private/reserved IP")

    # Request mit Schutzmaßnahmen
    s = requests.Session()
    s.trust_env = False  # ignoriert Proxy-Umgebungsvariablen
    headers = {"User-Agent": "safe-fetch/1.0"}
    # Keine weiterführenden Redirects auf „neue“ Hosts (oder Redirects komplett verbieten)
    r = s.get(url, headers=headers, timeout=5, allow_redirects=False)

    # Wenn du Redirects erlaubst, **jeden Hop erneut prüfen**:
    redirect_hops = 0
    while r.is_redirect and redirect_hops < 3:
        loc = r.headers.get("Location", "")
        next_url = requests.compat.urljoin(url, loc)
        p2 = urlparse(next_url)
        if p2.scheme not in ALLOWED_SCHEMES:
            raise ValueError("redirect scheme not allowed")
        if p2.hostname not in ALLOWED_HOSTS:
            raise ValueError("redirect host not allowed")
        if not resolve_and_check(p2.hostname):
            raise ValueError("redirect target resolves to private/reserved IP")
        r = s.get(next_url, headers=headers, timeout=5, allow_redirects=False)
        redirect_hops += 1

    return r

5.2 Verwundbare App „fixen“

# public_app_fixed.py
from flask import Flask, request, Response
from safe_fetch_lib import safe_fetch  # (Code aus 5.1 als Modul auslagern)

app = Flask(__name__)

@app.route("/fetch")
def fetch():
    url = request.args.get("url")
    if not url:
        return "missing ?url=", 400
    try:
        r = safe_fetch(url)
        # Nur ausgewählte Header durchlassen (keine Hop-by-hop, keine sensiblen)
        passthrough = {}
        for k, v in r.headers.items():
            lk = k.lower()
            if lk in {"content-type", "content-length"}:
                passthrough[k] = v
        return Response(r.content, status=r.status_code, headers=passthrough)
    except Exception as e:
        # Loggen (intern), für Nutzer generische Fehlermeldung
        return "invalid or blocked target", 400

if __name__ == "__main__":
    app.run(port=5000, debug=True)

5.3 Weitere Schutzschichten (empfohlen)

  • Egress-Firewall/Proxy: Der App-Container darf nur zu explizit erlaubten Hosts raus.
  • Cloud-Metadata schützen:
    • AWS: IMDSv2 erzwingen, Metadata-Zugriff nur aus vertrauenswürdigen Komponenten.
    • In Containern Metadata-IP (169.254.169.254) per Netzwerkrouting blocken, falls App sie nicht braucht.
  • Zeit-/Größenlimits: timeout, max response size, keine Chunked-Surprises.
  • Strict TLS: Zertifikate validieren, keine verify=False.
  • Logging & Monitoring: Ungewöhnliche Abfragen (interne IP-Ranges, 169.254.169.254, localhost, IPv6-Loopback, numerische Hosts) alarmieren.

Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert