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 bauenftp://
,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 verwundbarinternal_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):
- Starte
internal_api.py
(Port 5001, localhost). - Starte
public_app_vuln.py
(Port 5000). - 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. - Prüfe die gespeicherten Notizen:
http://127.0.0.1:5001/notes
→ Ein Eintrag mittitle="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.
Schreibe einen Kommentar