Created
March 17, 2026 13:27
-
-
Save Kr1tos/26585898c08e5222d5edc3d6fad546be to your computer and use it in GitHub Desktop.
webrtc-detector.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name WebRTC detector + selected endpoint + network hooks | |
| // @namespace https://proxyshard.com/ | |
| // @version 1.0 | |
| // @description WebRTC + network inspection hooks | |
| // @author Kritos | |
| // ==/UserScript== | |
| (function () { | |
| const w = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; | |
| function shortText(text, max = 1200) { | |
| try { | |
| const s = String(text); | |
| return s.length > max ? s.slice(0, max) + "...[truncated]" : s; | |
| } catch (e) { | |
| return text; | |
| } | |
| } | |
| function safeJsonStringify(obj) { | |
| try { | |
| return JSON.stringify(obj); | |
| } catch (e) { | |
| return "[unserializable]"; | |
| } | |
| } | |
| function looksLikeSDP(value) { | |
| if (typeof value !== "string") return false; | |
| return value.includes("v=0") && | |
| ( | |
| value.includes("m=audio") || | |
| value.includes("m=video") || | |
| value.includes("webrtc-datachannel") || | |
| value.includes("a=group:BUNDLE") | |
| ); | |
| } | |
| function looksLikeICE(value) { | |
| if (typeof value !== "string") return false; | |
| return value.includes("candidate:") || | |
| value.includes('"candidate"') || | |
| value.includes('"ice"') || | |
| value.includes('"sdpMid"') || | |
| value.includes('"sdpMLineIndex"'); | |
| } | |
| function looksWebRTCRelated(value) { | |
| if (typeof value !== "string") return false; | |
| return looksLikeSDP(value) || | |
| looksLikeICE(value) || | |
| value.includes('"type":"offer"') || | |
| value.includes('"type":"answer"') || | |
| value.includes('"webrtc"') || | |
| value.includes('"rtc"'); | |
| } | |
| async function payloadToText(payload) { | |
| try { | |
| if (payload == null) return null; | |
| if (typeof payload === "string") return payload; | |
| if (payload instanceof Blob) { | |
| return await payload.text(); | |
| } | |
| if (payload instanceof ArrayBuffer) { | |
| return new TextDecoder().decode(payload); | |
| } | |
| if (ArrayBuffer.isView(payload)) { | |
| return new TextDecoder().decode(payload); | |
| } | |
| if (typeof URLSearchParams !== "undefined" && payload instanceof URLSearchParams) { | |
| return payload.toString(); | |
| } | |
| if (typeof FormData !== "undefined" && payload instanceof FormData) { | |
| const obj = {}; | |
| for (const [k, v] of payload.entries()) { | |
| obj[k] = typeof v === "string" ? v : `[binary:${v && v.name ? v.name : "blob"}]`; | |
| } | |
| return safeJsonStringify(obj); | |
| } | |
| if (typeof payload === "object") { | |
| return safeJsonStringify(payload); | |
| } | |
| return String(payload); | |
| } catch (e) { | |
| return "[payload parse failed]"; | |
| } | |
| } | |
| function logInterestingNetwork(prefix, url, text) { | |
| if (!text) return; | |
| if (!looksWebRTCRelated(text)) return; | |
| console.group(`${prefix} ${url || ""}`); | |
| if (looksLikeSDP(text)) { | |
| console.warn("[NET] SDP detected"); | |
| } | |
| if (looksLikeICE(text)) { | |
| console.warn("[NET] ICE detected"); | |
| } | |
| console.log(shortText(text, 4000)); | |
| console.groupEnd(); | |
| } | |
| function installNetworkHooks() { | |
| if (w.__webrtc_net_hooks_installed__) return; | |
| w.__webrtc_net_hooks_installed__ = true; | |
| const origFetch = w.fetch; | |
| if (origFetch) { | |
| w.fetch = async function (...args) { | |
| try { | |
| const [input, init] = args; | |
| const url = typeof input === "string" ? input : (input && input.url) ? input.url : ""; | |
| if (init && "body" in init) { | |
| payloadToText(init.body).then(text => { | |
| logInterestingNetwork("[NET][fetch]", url, text); | |
| }).catch(() => {}); | |
| } | |
| } catch (e) {} | |
| return origFetch.apply(this, args); | |
| }; | |
| } | |
| const origXHROpen = XMLHttpRequest.prototype.open; | |
| const origXHRSend = XMLHttpRequest.prototype.send; | |
| XMLHttpRequest.prototype.open = function (method, url, ...rest) { | |
| this.__webrtc_hook_method = method; | |
| this.__webrtc_hook_url = url; | |
| return origXHROpen.call(this, method, url, ...rest); | |
| }; | |
| XMLHttpRequest.prototype.send = function (body) { | |
| try { | |
| const url = this.__webrtc_hook_url || ""; | |
| payloadToText(body).then(text => { | |
| logInterestingNetwork("[NET][xhr]", url, text); | |
| }).catch(() => {}); | |
| } catch (e) {} | |
| return origXHRSend.call(this, body); | |
| }; | |
| const OrigWS = w.WebSocket; | |
| if (OrigWS && OrigWS.prototype && OrigWS.prototype.send) { | |
| const origWSSend = OrigWS.prototype.send; | |
| OrigWS.prototype.send = function (data) { | |
| try { | |
| const url = this.url || ""; | |
| payloadToText(data).then(text => { | |
| logInterestingNetwork("[NET][ws]", url, text); | |
| }).catch(() => {}); | |
| } catch (e) {} | |
| return origWSSend.call(this, data); | |
| }; | |
| } | |
| if (navigator.sendBeacon) { | |
| const origBeacon = navigator.sendBeacon.bind(navigator); | |
| navigator.sendBeacon = function (url, data) { | |
| try { | |
| payloadToText(data).then(text => { | |
| logInterestingNetwork("[NET][beacon]", url, text); | |
| }).catch(() => {}); | |
| } catch (e) {} | |
| return origBeacon(url, data); | |
| }; | |
| } | |
| console.log("[TM] Network hooks installed"); | |
| } | |
| function installHook() { | |
| const Orig = | |
| w.RTCPeerConnection || | |
| w.webkitRTCPeerConnection || | |
| w.mozRTCPeerConnection; | |
| if (!Orig) { | |
| console.log("[TM] RTCPeerConnection not found yet"); | |
| return false; | |
| } | |
| if (w.__webrtc_hook_installed__) { | |
| console.log("[TM] hook already installed"); | |
| return true; | |
| } | |
| w.__webrtc_hook_installed__ = true; | |
| const peers = new Set(); | |
| function parseCandidate(str) { | |
| if (!str || typeof str !== "string") return null; | |
| const parts = str.trim().split(/\s+/); | |
| if (parts.length < 8) return null; | |
| const out = { | |
| foundation: parts[0].replace(/^candidate:/, ""), | |
| component: parts[1], | |
| protocol: parts[2], | |
| priority: parts[3], | |
| ip: parts[4], | |
| port: parts[5], | |
| type: parts[7], | |
| raw: str | |
| }; | |
| const raddrIndex = parts.indexOf("raddr"); | |
| const rportIndex = parts.indexOf("rport"); | |
| const tcptypeIndex = parts.indexOf("tcptype"); | |
| const generationIndex = parts.indexOf("generation"); | |
| const networkIdIndex = parts.indexOf("network-id"); | |
| const networkCostIndex = parts.indexOf("network-cost"); | |
| if (raddrIndex !== -1 && parts[raddrIndex + 1]) out.raddr = parts[raddrIndex + 1]; | |
| if (rportIndex !== -1 && parts[rportIndex + 1]) out.rport = parts[rportIndex + 1]; | |
| if (tcptypeIndex !== -1 && parts[tcptypeIndex + 1]) out.tcptype = parts[tcptypeIndex + 1]; | |
| if (generationIndex !== -1 && parts[generationIndex + 1]) out.generation = parts[generationIndex + 1]; | |
| if (networkIdIndex !== -1 && parts[networkIdIndex + 1]) out.networkId = parts[networkIdIndex + 1]; | |
| if (networkCostIndex !== -1 && parts[networkCostIndex + 1]) out.networkCost = parts[networkCostIndex + 1]; | |
| return out; | |
| } | |
| function summarizeSDP(desc) { | |
| try { | |
| if (!desc || !desc.sdp) return desc; | |
| const sdp = String(desc.sdp); | |
| const summary = { | |
| type: desc.type || null, | |
| hasAudio: sdp.includes("m=audio"), | |
| hasVideo: sdp.includes("m=video"), | |
| hasDataChannel: sdp.includes("webrtc-datachannel"), | |
| recvonlyCount: (sdp.match(/\ba=recvonly\b/g) || []).length, | |
| sendrecvCount: (sdp.match(/\ba=sendrecv\b/g) || []).length, | |
| sendonlyCount: (sdp.match(/\ba=sendonly\b/g) || []).length, | |
| inactiveCount: (sdp.match(/\ba=inactive\b/g) || []).length, | |
| candidatesInSDP: (sdp.match(/\ba=candidate:/g) || []).length, | |
| iceUfrag: (sdp.match(/a=ice-ufrag:([^\r\n]+)/) || [])[1] || null, | |
| bundle: (sdp.match(/a=group:BUNDLE ([^\r\n]+)/) || [])[1] || null | |
| }; | |
| return { | |
| summary, | |
| sdp: shortText(sdp, 4000) | |
| }; | |
| } catch (e) { | |
| return desc; | |
| } | |
| } | |
| async function dumpSelectedPair(pc, reason) { | |
| try { | |
| const stats = await pc.getStats(); | |
| let selectedPair = null; | |
| let localCandidate = null; | |
| let remoteCandidate = null; | |
| stats.forEach((report) => { | |
| if ( | |
| report.type === "transport" && | |
| report.selectedCandidatePairId && | |
| !selectedPair | |
| ) { | |
| selectedPair = stats.get(report.selectedCandidatePairId); | |
| } | |
| }); | |
| if (!selectedPair) { | |
| stats.forEach((report) => { | |
| if ( | |
| report.type === "candidate-pair" && | |
| (report.selected === true || report.nominated === true) | |
| ) { | |
| selectedPair = report; | |
| } | |
| }); | |
| } | |
| if (!selectedPair) { | |
| return; | |
| } | |
| if (selectedPair.localCandidateId) { | |
| localCandidate = stats.get(selectedPair.localCandidateId); | |
| } | |
| if (selectedPair.remoteCandidateId) { | |
| remoteCandidate = stats.get(selectedPair.remoteCandidateId); | |
| } | |
| console.group("[WebRTC] SELECTED PATH" + (reason ? ` (${reason})` : "")); | |
| console.log("candidate-pair:", selectedPair); | |
| if (localCandidate) { | |
| console.log("local-candidate:", { | |
| id: localCandidate.id, | |
| address: localCandidate.address || localCandidate.ip, | |
| ip: localCandidate.ip || localCandidate.address, | |
| port: localCandidate.port, | |
| protocol: localCandidate.protocol, | |
| candidateType: localCandidate.candidateType, | |
| networkType: localCandidate.networkType, | |
| relayProtocol: localCandidate.relayProtocol || null, | |
| url: localCandidate.url || null | |
| }); | |
| } | |
| if (remoteCandidate) { | |
| const endpoint = { | |
| id: remoteCandidate.id, | |
| address: remoteCandidate.address || remoteCandidate.ip, | |
| ip: remoteCandidate.ip || remoteCandidate.address, | |
| port: remoteCandidate.port, | |
| protocol: remoteCandidate.protocol, | |
| candidateType: remoteCandidate.candidateType, | |
| relayProtocol: remoteCandidate.relayProtocol || null, | |
| url: remoteCandidate.url || null | |
| }; | |
| console.log("remote-candidate:", endpoint); | |
| console.warn("[WebRTC] REAL REMOTE ENDPOINT:", endpoint); | |
| } | |
| console.groupEnd(); | |
| } catch (e) { | |
| console.log("[WebRTC] getStats failed:", e); | |
| } | |
| } | |
| function dumpAllStats(pc, reason) { | |
| pc.getStats().then(stats => { | |
| const interesting = []; | |
| stats.forEach(report => { | |
| if ( | |
| report.type === "local-candidate" || | |
| report.type === "remote-candidate" || | |
| report.type === "candidate-pair" || | |
| report.type === "transport" | |
| ) { | |
| interesting.push(report); | |
| } | |
| }); | |
| if (interesting.length) { | |
| console.group("[WebRTC] STATS SNAPSHOT" + (reason ? ` (${reason})` : "")); | |
| for (const item of interesting) { | |
| console.log(item.type, item); | |
| } | |
| console.groupEnd(); | |
| } | |
| }).catch(e => { | |
| console.log("[WebRTC] getStats snapshot failed:", e); | |
| }); | |
| } | |
| function startStatsWatcher(pc) { | |
| if (pc.__stats_watcher_started__) return; | |
| pc.__stats_watcher_started__ = true; | |
| const interval = setInterval(() => { | |
| if ( | |
| pc.connectionState === "closed" || | |
| pc.iceConnectionState === "closed" || | |
| pc.signalingState === "closed" | |
| ) { | |
| clearInterval(interval); | |
| return; | |
| } | |
| dumpSelectedPair(pc, "periodic"); | |
| }, 2000); | |
| pc.__stats_interval__ = interval; | |
| } | |
| function WrappedRTCPeerConnection(...args) { | |
| const config = args[0]; | |
| console.group("[WebRTC] created"); | |
| console.log("config:", config); | |
| if (config && Array.isArray(config.iceServers)) { | |
| console.log("ICE servers:", config.iceServers); | |
| } else { | |
| console.log("ICE servers: none passed explicitly"); | |
| } | |
| console.groupEnd(); | |
| const pc = new Orig(...args); | |
| peers.add(pc); | |
| startStatsWatcher(pc); | |
| pc.addEventListener("icecandidate", (e) => { | |
| if (!e.candidate || !e.candidate.candidate) { | |
| console.log("[WebRTC] local candidate gathering finished"); | |
| dumpAllStats(pc, "after local gathering finished"); | |
| return; | |
| } | |
| console.log("[WebRTC] local candidate raw:", e.candidate.candidate); | |
| const parsed = parseCandidate(e.candidate.candidate); | |
| if (parsed) { | |
| console.log("[WebRTC] local candidate parsed:", parsed); | |
| } | |
| }); | |
| pc.addEventListener("icecandidateerror", (e) => { | |
| console.warn("[WebRTC] icecandidateerror:", { | |
| url: e.url, | |
| address: e.address, | |
| port: e.port, | |
| errorCode: e.errorCode, | |
| errorText: e.errorText | |
| }); | |
| }); | |
| pc.addEventListener("icegatheringstatechange", () => { | |
| console.log("[WebRTC] iceGatheringState:", pc.iceGatheringState); | |
| if (pc.iceGatheringState === "complete") { | |
| dumpAllStats(pc, "iceGatheringState complete"); | |
| } | |
| }); | |
| pc.addEventListener("connectionstatechange", () => { | |
| console.log("[WebRTC] connectionState:", pc.connectionState); | |
| if (pc.connectionState === "connecting" || pc.connectionState === "connected") { | |
| dumpSelectedPair(pc, "connectionstatechange"); | |
| } | |
| }); | |
| pc.addEventListener("iceconnectionstatechange", () => { | |
| console.log("[WebRTC] iceConnectionState:", pc.iceConnectionState); | |
| if ( | |
| pc.iceConnectionState === "checking" || | |
| pc.iceConnectionState === "connected" || | |
| pc.iceConnectionState === "completed" || | |
| pc.iceConnectionState === "failed" | |
| ) { | |
| dumpSelectedPair(pc, "iceconnectionstatechange"); | |
| dumpAllStats(pc, "iceconnectionstatechange"); | |
| } | |
| }); | |
| pc.addEventListener("signalingstatechange", () => { | |
| console.log("[WebRTC] signalingState:", pc.signalingState); | |
| }); | |
| const origSetLocalDescription = pc.setLocalDescription; | |
| pc.setLocalDescription = function (...a) { | |
| console.group("[WebRTC] setLocalDescription"); | |
| console.log(summarizeSDP(a[0])); | |
| console.groupEnd(); | |
| const result = origSetLocalDescription.apply(this, a); | |
| Promise.resolve(result).then(() => { | |
| setTimeout(() => dumpAllStats(pc, "after setLocalDescription"), 300); | |
| }).catch(() => {}); | |
| return result; | |
| }; | |
| const origSetRemoteDescription = pc.setRemoteDescription; | |
| pc.setRemoteDescription = function (...a) { | |
| console.group("[WebRTC] setRemoteDescription"); | |
| console.log(summarizeSDP(a[0])); | |
| console.groupEnd(); | |
| const result = origSetRemoteDescription.apply(this, a); | |
| Promise.resolve(result).then(() => { | |
| setTimeout(() => { | |
| dumpSelectedPair(pc, "after setRemoteDescription"); | |
| dumpAllStats(pc, "after setRemoteDescription"); | |
| }, 500); | |
| }).catch(() => {}); | |
| return result; | |
| }; | |
| const origAddIceCandidate = pc.addIceCandidate; | |
| pc.addIceCandidate = function (...a) { | |
| if (a[0] && a[0].candidate) { | |
| console.log("[WebRTC] remote candidate raw:", a[0].candidate); | |
| const parsed = parseCandidate(a[0].candidate); | |
| if (parsed) { | |
| console.log("[WebRTC] remote candidate parsed:", parsed); | |
| } | |
| } else { | |
| console.log("[WebRTC] addIceCandidate called:", a[0]); | |
| } | |
| const result = origAddIceCandidate.apply(this, a); | |
| Promise.resolve(result).then(() => { | |
| setTimeout(() => { | |
| dumpSelectedPair(pc, "after addIceCandidate"); | |
| dumpAllStats(pc, "after addIceCandidate"); | |
| }, 300); | |
| }).catch(() => {}); | |
| return result; | |
| }; | |
| const origCreateDataChannel = pc.createDataChannel; | |
| if (origCreateDataChannel) { | |
| pc.createDataChannel = function (...a) { | |
| console.log("[WebRTC] createDataChannel:", a[0], a[1] || {}); | |
| return origCreateDataChannel.apply(this, a); | |
| }; | |
| } | |
| const origCreateOffer = pc.createOffer; | |
| if (origCreateOffer) { | |
| pc.createOffer = function (...a) { | |
| console.log("[WebRTC] createOffer called", a); | |
| return origCreateOffer.apply(this, a).then(desc => { | |
| console.group("[WebRTC] createOffer result"); | |
| console.log(summarizeSDP(desc)); | |
| console.groupEnd(); | |
| return desc; | |
| }); | |
| }; | |
| } | |
| const origCreateAnswer = pc.createAnswer; | |
| if (origCreateAnswer) { | |
| pc.createAnswer = function (...a) { | |
| console.log("[WebRTC] createAnswer called", a); | |
| return origCreateAnswer.apply(this, a).then(desc => { | |
| console.group("[WebRTC] createAnswer result"); | |
| console.log(summarizeSDP(desc)); | |
| console.groupEnd(); | |
| return desc; | |
| }); | |
| }; | |
| } | |
| return pc; | |
| } | |
| WrappedRTCPeerConnection.prototype = Orig.prototype; | |
| Object.setPrototypeOf(WrappedRTCPeerConnection, Orig); | |
| w.RTCPeerConnection = WrappedRTCPeerConnection; | |
| if (w.webkitRTCPeerConnection) w.webkitRTCPeerConnection = WrappedRTCPeerConnection; | |
| if (w.mozRTCPeerConnection) w.mozRTCPeerConnection = WrappedRTCPeerConnection; | |
| w.__dumpAllWebRTCSelectedPaths = async function () { | |
| for (const pc of peers) { | |
| await dumpSelectedPair(pc, "manual dump"); | |
| } | |
| }; | |
| w.__dumpAllWebRTCStats = async function () { | |
| for (const pc of peers) { | |
| dumpAllStats(pc, "manual dump"); | |
| } | |
| }; | |
| console.log("[TM] WebRTC hook installed"); | |
| return true; | |
| } | |
| installNetworkHooks(); | |
| if (!installHook()) { | |
| const timer = setInterval(() => { | |
| if (installHook()) clearInterval(timer); | |
| }, 50); | |
| setTimeout(() => clearInterval(timer), 10000); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment