Kas iš tikrųjų yra SSRF ir kodėl turėtumėte juo rūpintis
Įsivaizduokite situaciją: jūsų aplikacija leidžia vartotojams įkelti nuotraukas iš URL adresų. Atrodo nekalta funkcija, tiesa? Bet štai problema – kažkas gali paprašyti jūsų serverio pasiekti vidinius resursus, kurių niekas neturėtų matyti. Pavyzdžiui, http://localhost:8080/admin arba dar blogiau – http://169.254.169.254/latest/meta-data/, kuris AWS aplinkoje gali atskleisti slaptas prieigos raktas.
Server-Side Request Forgery (SSRF) yra pažeidžiamumas, kai užpuolikas gali priversti serverį siųsti užklausas į netyčia pasirinktus adresus. Tai tarsi leistumėte nepažįstamam žmogui naudoti jūsų telefoną skambinti bet kam – tik čia kalbame apie serverį ir tinklo užklausas.
Problema yra rimtesnė nei daugelis galvoja. 2019 metais Capital One duomenų nutekėjimas, kuris paveikė per 100 milijonų žmonių, įvyko būtent dėl SSRF pažeidžiamumo. Užpuolikas panaudojo SSRF, kad pasiektų AWS metadata servisą ir gautų laikinų kredencialų. Tai kainavo kompanijai apie 80 milijonų dolerių baudų.
Kaip atpažinti rizikingą kodą
Pirmiausia reikia suprasti, kur jūsų aplikacijoje gali slypėti SSRF rizika. Dažniausiai tai vietos, kur aplikacija priima URL ar IP adresus iš vartotojo ir su jais kažką daro.
Štai keletas klasikinių scenarijų:
Nuotraukų įkėlimas iš URL: Funkcionalumas, leidžiantis vartotojui nurodyti nuotraukos URL, kurį serveris parsisiunčia ir apdoroja. Tai viena dažniausių SSRF vietų.
Webhook’ai ir integracijos: Kai leidžiate vartotojams nurodyti callback URL, į kurį jūsų sistema siųs pranešimus. Užpuolikas gali nurodyti vidinį adresą.
PDF generavimas iš HTML: Jei jūsų sistema generuoja PDF iš vartotojo pateikto HTML, o tas HTML gali turėti nuorodų į išorinius resursus – turite SSRF riziką.
API proxy funkcionalumas: Kai jūsų aplikacija veikia kaip tarpininkas tarp kliento ir kitos API – klasikinis SSRF vektorius.
Štai kaip gali atrodyti pažeidžiamas kodas Python’e:
import requests
def fetch_image(url):
# BLOGAI - nėra jokios validacijos!
response = requests.get(url)
return response.content
Arba Node.js pavyzdys:
const axios = require('axios');
app.get('/fetch', async (req, res) => {
// BLOGAI - tiesiog priimame bet kokį URL
const data = await axios.get(req.query.url);
res.send(data.data);
});
Gynybos strategijos: nuo paprasčiausių iki pažangiausių
Dabar pereikime prie to, kaip iš tikrųjų apsisaugoti. Yra keletas gynybos linijų, ir geriausia strategija – naudoti jų kombinaciją.
Whitelist požiūris – jūsų pirmoji gynybos linija
Paprasčiausias ir efektyviausias būdas – leisti tik konkrečius domenus ar IP adresus. Jei jūsų aplikacija turi dirbti tik su keliais žinomais servisais, kodėl leisti bet ką kita?
ALLOWED_DOMAINS = ['api.example.com', 'images.example.com']
def is_url_allowed(url):
from urllib.parse import urlparse
parsed = urlparse(url)
return parsed.netloc in ALLOWED_DOMAINS
Bet dažnai realybė sudėtingesnė – jums gali reikėti leisti bet kokius išorinius URL. Tada reikia blacklist požiūrio.
Blokuokite privačius IP adresus ir specialius domenus
Jei negalite naudoti whitelist, bent jau užblokuokite akivaizdžiai pavojingus adresus:
import ipaddress
from urllib.parse import urlparse
import socket
def is_safe_url(url):
parsed = urlparse(url)
# Blokuojame specialius protokolus
if parsed.scheme not in ['http', 'https']:
return False
# Gauname IP adresą
try:
ip = socket.gethostbyname(parsed.hostname)
ip_obj = ipaddress.ip_address(ip)
# Blokuojame privačius IP diapazonus
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local:
return False
# Blokuojame AWS metadata servisą
if ip == '169.254.169.254':
return False
except (socket.gaierror, ValueError):
return False
return True
Bet čia yra problema – DNS rebinding atakos. Užpuolikas gali sukurti domeną, kuris pirmą kartą grąžina normalų IP, o antrą kartą – vidinį. Todėl reikia papildomų apsaugų.
DNS rebinding ir kaip su juo kovoti
DNS rebinding yra gudresnė atakos forma. Štai kaip ji veikia:
1. Užpuolikas kontroliuoja domeną evil.com
2. Pirmą kartą jūsų serveris patikrina evil.com – grąžina normalų IP (pvz., 1.2.3.4)
3. Jūsų validacija praeina
4. Bet kai faktiškai darote užklausą, DNS atsakymas pasikeitęs – dabar grąžina 127.0.0.1
Kaip apsisaugoti? Patikrinkite IP adresą ne tik prieš užklausą, bet ir po DNS resolution:
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.connection import create_connection
class SafeHTTPAdapter(HTTPAdapter):
def init_poolmanager(self, *args, **kwargs):
# Užtikriname, kad tikrinimas vyksta prieš kiekvieną connection
kwargs['socket_options'] = [
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
]
super().init_poolmanager(*args, **kwargs)
def safe_request(url):
# Pirmiausia patikriname URL
if not is_safe_url(url):
raise ValueError("Unsafe URL")
# Naudojame custom adapter
session = requests.Session()
session.mount('http://', SafeHTTPAdapter())
session.mount('https://', SafeHTTPAdapter())
# Dar kartą patikriname po DNS resolution
parsed = urlparse(url)
ip = socket.gethostbyname(parsed.hostname)
if not is_safe_ip(ip):
raise ValueError("DNS resolved to unsafe IP")
return session.get(url, timeout=5)
Tinklo lygio apsaugos
Kodas niekada nėra tobulas, todėl reikia gynybos gyliu (defense in depth). Tinklo konfigūracija gali būti jūsų paskutinė gynybos linija.
Atskirti tinklai: Jūsų web serveriai neturėtų turėti tiesioginės prieigos prie vidinių servisų. Naudokite atskirus subnet’us ir griežtas firewall taisykles.
Egress filtering: Daugelis organizacijų sutelkia dėmesį į ingress (įeinančią) saugą, bet pamiršta egress (išeinančią). Jūsų web serveriai tikrai neturėtų galėti jungtis prie visų vidinių portų.
Štai pavyzdinis AWS Security Group setup:
# Web serverių security group
resource "aws_security_group" "web" {
# Leidžiame tik HTTPS į išorę per NAT gateway
egress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# Blokuojame prieigą prie metadata serviso
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["169.254.169.254/32"]
action = "deny"
}
}
IMDSv2 AWS aplinkoje: Jei naudojate AWS, įjunkite IMDSv2 (Instance Metadata Service Version 2). Tai reikalauja, kad aplikacija pirmiausia gautų session token, kurį SSRF atakos negali lengvai gauti:
# Terraform pavyzdys
resource "aws_launch_template" "app" {
metadata_options {
http_endpoint = "enabled"
http_tokens = "required" # Reikalauja IMDSv2
http_put_response_hop_limit = 1
}
}
Realūs pavyzdžiai ir edge case’ai
Teorija yra gera, bet praktikoje visada atsiranda niuansų. Štai keletas realių situacijų, su kuriomis teko susidurti.
URL encoding triukai: Užpuolikai mėgsta naudoti įvairias URL encoding schemas, kad apeitų validaciją:
# Visi šie gali reikšti localhost:
http://127.0.0.1
http://127.1
http://0x7f.0x00.0x00.0x01
http://2130706433 # Decimal reprezentacija
http://0177.0000.0000.0001 # Octal
http://[::1] # IPv6 localhost
http://localhost
http://127.0.0.1.nip.io # Wildcard DNS servisas
Jūsų validacija turi sugauti visas šias formas. Geriausia strategija – visada konvertuoti į standartinį formatą prieš tikrinant:
def normalize_and_validate(url):
parsed = urlparse(url)
# Gauname canonical IP adresą
try:
ip = socket.gethostbyname(parsed.hostname)
ip_obj = ipaddress.ip_address(ip)
# Dabar tikriname normalized IP
if ip_obj.is_private or ip_obj.is_loopback:
return False
except Exception:
return False
return True
Redirect chain’ai: Dar viena klasikinė problema – jūsų kodas patikrina pradinį URL, bet paskui serveris atlieka redirect į vidinį adresą. Būtinai išjunkite automatinį redirect sekimą arba validuokite kiekvieną redirect:
def safe_fetch(url, max_redirects=0):
response = requests.get(
url,
allow_redirects=False,
timeout=5
)
if response.is_redirect and max_redirects > 0:
redirect_url = response.headers.get('Location')
if not is_safe_url(redirect_url):
raise ValueError("Unsafe redirect")
return safe_fetch(redirect_url, max_redirects - 1)
return response
Testavimas ir monitoringas
Jūs įdiegėte apsaugas, bet kaip žinoti, ar jos veikia? Testavimas yra kritinis.
Automatizuoti testai: Turėtumėte turėti testų suite, kuris bando įvairius SSRF vektorius:
def test_ssrf_protection():
dangerous_urls = [
'http://127.0.0.1',
'http://localhost',
'http://169.254.169.254',
'http://[::1]',
'http://0x7f000001',
'http://metadata.google.internal', # GCP metadata
'http://10.0.0.1', # Private IP
]
for url in dangerous_urls:
with pytest.raises(ValueError):
fetch_url(url)
Penetration testing: Reguliariai turėtumėte atlikti pentest’us. Yra puikių įrankių kaip SSRFmap ar Burp Suite, kurie gali automatizuoti SSRF paiešką.
Logging ir alerting: Loginkite visas užklausas į nestandartinius adresus. Jei matote bandymus pasiekti metadata servisus ar privačius IP – tai red flag:
import logging
def fetch_url(url):
logger = logging.getLogger(__name__)
if not is_safe_url(url):
logger.warning(
f"SSRF attempt blocked: {url}",
extra={'security_event': True}
)
raise ValueError("Unsafe URL")
logger.info(f"Fetching URL: {url}")
return requests.get(url)
Kada paprastos apsaugos nepakanka
Kartais jūsų aplikacijos reikalavimai tokie, kad standartinės apsaugos nepakanka. Pavyzdžiui, jei kuriate web scraping servisą, kuris turi galėti pasiekti bet kokius URL – kaip tada apsisaugoti?
Vienas sprendimas – naudoti atskirą, izoliuotą aplinką užklausoms vykdyti. Tai gali būti:
Serverless funkcijos: Kiekviena užklausa vykdoma atskiroje Lambda funkcijoje be prieigos prie jūsų VPC.
Konteineriai su griežtais apribojimais: Docker konteineriai su network policies, kurie leidžia tik išorines užklausas:
# Docker compose pavyzdys
services:
fetcher:
image: url-fetcher
networks:
- external_only
cap_drop:
- ALL
read_only: true
networks:
external_only:
driver: bridge
internal: false
ipam:
config:
- subnet: 172.28.0.0/16
Proxy serveriai: Visos išorinės užklausos eina per specialų proxy, kuris turi whitelist arba blacklist taisykles:
# Squid proxy konfigūracijos pavyzdys
acl private_networks dst 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
acl localhost dst 127.0.0.0/8
acl metadata dst 169.254.169.254
http_access deny private_networks
http_access deny localhost
http_access deny metadata
Dar vienas įdomus požiūris – naudoti headless browser su griežtais security policy. Pavyzdžiui, Puppeteer su Content Security Policy gali būti saugesnis nei tiesiog daryti HTTP užklausas:
const puppeteer = require('puppeteer');
async function safeFetch(url) {
const browser = await puppeteer.launch({
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-web-security=false'
]
});
const page = await browser.newPage();
// Blokuojame privačius IP
await page.setRequestInterception(true);
page.on('request', (request) => {
const url = new URL(request.url());
if (isPrivateIP(url.hostname)) {
request.abort();
} else {
request.continue();
}
});
await page.goto(url, {
waitUntil: 'networkidle0',
timeout: 30000
});
const content = await page.content();
await browser.close();
return content;
}
Praktiniai patarimai ir geriausia praktika
Apibendrinkime viską į konkrečius veiksmus, kuriuos galite įgyvendinti jau šiandien.
Pirma, auditinkite savo kodą. Ieškokite visų vietų, kur priimate URL ar IP adresus iš vartotojo. Naudokite grep ar IDE search:
– requests.get, urllib.request, axios, fetch
– file_get_contents, curl_exec PHP
– HttpClient, WebClient .NET
Antra, implementuokite validaciją sluoksniais:
1. Protokolo validacija (tik http/https)
2. Hostname validacija (ne localhost, ne private IP)
3. DNS resolution validacija
4. Redirect validacija
5. Timeout’ai (visada!)
Trečia, naudokite bibliotekų apsaugas. Daugelis modernių HTTP bibliotekų turi įtaisytas SSRF apsaugas:
# Python requests su ssrf-protect
from ssrf_protect import SSRFProtect
protector = SSRFProtect()
safe_url = protector.validate(user_url)
response = requests.get(safe_url)
Ketvirta, apribokite timeout’us. Net jei užpuolikas negali pasiekti vidinių resursų, jis gali bandyti DDoS jūsų serverį, versdamas jį daryti lėtas užklausas:
requests.get(url, timeout=(3, 10)) # 3s connect, 10s read
Penkta, rate limiting. Apribokite, kiek užklausų vienas vartotojas gali padaryti per laiko vienetą. Tai apsaugo nuo automatizuotų atakų.
Šešta, monitorinkite ir reaguokite. Turėkite alertus bet kokiems bandymams pasiekti:
– AWS metadata servisą (169.254.169.254)
– GCP metadata (metadata.google.internal)
– Azure metadata (169.254.169.254)
– Privačius IP diapazonus
– Localhost variantus
Septinta, dokumentuokite savo apsaugas. Kai ateina naujas developeris į komandą, jis turi žinoti, kodėl tam tikri dalykai daroma būtent taip. Code review procesas turi įtraukti security patikrinimus.
Aštunta, naudokite Content Security Policy frontend’e. Nors tai ne tiesiogiai SSRF apsauga, CSP gali užkirsti kelią kai kuriems atakų vektoriams.
Devinta, reguliariai atnaujinkite priklausomybes. SSRF pažeidžiamumai dažnai randami populiariose bibliotekose. Naudokite įrankius kaip Dependabot ar Snyk.
Dešimta, turėkite incident response planą. Kas nutiks, jei vis tiek įvyks SSRF ataką? Kaip greitai galite reaguoti? Kas turi būti informuotas?
SSRF prevencija nėra vienkartinis darbas – tai nuolatinis procesas. Technologijos keičiasi, atakų metodai tobulėja, ir jūsų apsaugos turi tobulėti kartu. Bet su teisingais principais, įrankiais ir budria komanda, galite žymiai sumažinti riziką. Svarbu prisiminti, kad saugumas – tai ne tik technologijos, bet ir kultūra. Kiekvienas komandos narys turi suprasti SSRF riziką ir žinoti, kaip rašyti saugų kodą. Investuokite į mokymą, code review, ir automatizuotus testus – tai atsipirks daug kartų.
