BrowserScan WebRTC 采集与检测逻辑
目标站点:https://www.browserscan.net/zh
本 README 只记录该站点 WebRTC 模块的 JS 采集代码、候选解析、结果写出和检测逻辑。
JS 入口
WebRTC 检测代码位于:
- 页面预加载资源:
/dist/BFntJwDg.js - 本地落盘文件:
runs/20260615_181657/resources/0017_cbcbaddb85_BFntJwDg.js - HTTP 落盘包:
runs/20260615_181657/firefox/http_packet/000016_GET_200_www_browserscan_net_dist_BFntJwDg_js_pid8804_30000002e.http_packet.json
BFntJwDg.js 导出两个函数:
js
export { R as a, D as w };
R():站点首页 WebRTC 检测主入口,采集 STUN 与 TURN 结果。D(iceServer):传入任意iceServer,返回一次 WebRTC candidate 采集结果。
浏览器 API 选择
站点优先取标准 WebRTC API,兼容旧别名:
js
const PC =
window.RTCPeerConnection ||
window.mozRTCPeerConnection ||
window.webkitRTCPeerConnection;
if (!PC) {
return {
stun: "disabled",
turn: "disabled"
};
}
检测点:
window.RTCPeerConnectionwindow.mozRTCPeerConnectionwindow.webkitRTCPeerConnection
没有可用构造器时,页面直接认为 WebRTC STUN/TURN 不可用。
ICE Server 配置
主入口 R() 固定创建两组配置:
js
const configs = [
{
iceServers: [
{ urls: "stun:stun.l.google.com:19302" }
]
},
{
iceServers: [
{
urls: "turn:aa.online-metrix.net?transport=udp",
username: "1:null:null",
credential: "null"
}
]
}
];
第一组用于取 srflx,作为页面显示的 WebRTC STUN IP。
第二组用于取 relay,作为页面显示的 TURN/UDP 结果。
候选采集函数
BFntJwDg.js 中的内部函数 g(PC, config) 是核心采集器。还原后的逻辑如下:
js
function collectCandidates(PC, config) {
return new Promise((resolve) => {
let pc;
let timer = 0;
let done = false;
const candidates = [];
const rawEvents = [];
pc = new PC(config);
function close() {
if (timer > 0) {
clearTimeout(timer);
timer = 0;
}
if (pc) {
pc.onicecandidate = null;
pc.close();
}
}
function finish(result) {
if (done) return;
done = true;
close();
resolve(result);
}
if (!("iceGatheringState" in pc)) {
finish({ sdp: "" });
return;
}
timer = setTimeout(() => {
timer = 0;
if (candidates.length > 0) {
const grouped = parseCandidateGroups(candidates, rawEvents);
grouped.sdp = pc.localDescription.sdp;
finish(grouped);
} else {
finish({ sdp: pc.localDescription.sdp });
}
}, 5000);
pc.onicecandidate = (event) => {
if (event.candidate) {
candidates.push(event.candidate);
}
if (event) {
rawEvents.push(event);
}
if (pc.iceGatheringState === "complete") {
const grouped = parseCandidateGroups(candidates, rawEvents);
grouped.sdp = pc.localDescription.sdp;
finish(grouped);
}
};
pc.createDataChannel("line-metric");
try {
pc.createOffer({
offerToReceiveAudio: 1,
offerToReceiveVideo: 1
})
.then((offer) => pc.setLocalDescription(offer))
.catch(() => {
return pc.createOffer()
.then((offer) => pc.setLocalDescription(offer));
})
.catch(() => {
finish({
sdp: pc.localDescription?.sdp || ""
});
});
} catch (err) {
if (err instanceof TypeError) {
pc.createOffer(
(offer) => pc.setLocalDescription(offer, function noop() {}, function noop() {}),
function noop() {}
);
finish({ sdp: pc.localDescription.sdp });
}
}
});
}
核心行为:
- 构造
new RTCPeerConnection(config)。 - 创建
createDataChannel("line-metric"),触发 data channel 场景下的 ICE gathering。 - 调用
createOffer({ offerToReceiveAudio: 1, offerToReceiveVideo: 1 })。 - 调用
setLocalDescription(offer)。 - 监听
pc.onicecandidate。 - 等
iceGatheringState === "complete"结束。 - 最长等待 5 秒,超时后用已收集到的 candidate 或 SDP 兜底。
- 结束前关闭 peer connection。
Candidate 解析逻辑
站点解析函数会深拷贝 candidate 数组,然后逐条读取 candidate.candidate:
js
function parseCandidateGroups(candidates, rawEvents) {
candidates = JSON.parse(JSON.stringify(candidates));
rawEvents = JSON.parse(JSON.stringify(rawEvents));
if (candidates.length === 0 && rawEvents.length > 0) {
return {};
}
const groups = {};
for (let i = 0; i < candidates.length; i++) {
try {
const tokens = candidates[i].candidate.split(" ");
if (tokens.length >= 8 && (isIPv4(tokens[4]) || isIPv6(tokens[4]))) {
const address = tokens[4];
const type = tokens[7];
if (type in groups) {
groups[type].push(address);
} else {
groups[type] = [address];
}
}
} catch (_) {}
}
return groups;
}
关键字段:
tokens[4]:candidate 地址。tokens[7]:candidate 类型。typ host:本机或 mDNS host candidate。typ srflx:STUN server-reflexive candidate。typ relay:TURN relay candidate。
站点只把 tokens[4] 是 IPv4 或 IPv6 的 candidate 放入结果分组。.local mDNS host candidate 不会进入 stun/turn 最终值。
STUN/TURN 结果选择
主入口同时跑 STUN 和 TURN 两个采集器:
js
const [stunResult, turnResult] = await Promise.all([
collectCandidates(PC, configs[0]),
collectCandidates(PC, configs[1])
]);
let turn = "disabled";
if (turnResult.relay && turnResult.relay.length) {
turn = turnResult.relay[0];
}
let stun = "disabled";
if (stunResult.srflx && stunResult.srflx.length) {
stun = stunResult.srflx[0];
}
return { stun, turn };
页面最终只取每类数组的第一个值:
stun = stunResult.srflx[0] || "disabled"turn = turnResult.relay[0] || "disabled"
在本次不带 fpfile 的采集中,页面拿到:
json
{
"stun": "183.157.85.95",
"turn": "disabled"
}
页面 UI 将 stun 和内部 udp 状态写成同一个 WebRTC IP:183.157.85.95。
页面写出逻辑
DOM trace 复核到页面状态写入:
text
Reflect.set(..., "stun", "183.157.85.95", ...)
Reflect.set(..., "udp", "183.157.85.95", ...)
对应证据:
runs/20260615_181657/firefox/domtrace/trace_process_11132.jsonl:71961runs/20260615_181657/firefox/domtrace/trace_process_11132.jsonl:71963
写入后,页面展示:
WebRTCWebRTC STUN
两项都显示 183.157.85.95。
WebRTC IP 地理位置查询
页面拿到 WebRTC IP 后,会调用 BrowserScan 的 IP 查询接口:
text
https://ip-scan.browserscan.net/sys/config/ip/get-visitor-ip
带 WebRTC IP 的请求形态:
text
https://ip-scan.browserscan.net/sys/config/ip/get-visitor-ip
?_t=<timestamp>
&_from=browserscan
&_sign=<sign>
&ip=183.157.85.95
&type=ip-api
对应 trace:
runs/20260615_181657/firefox/domtrace/trace_process_11132.jsonl:72979runs/20260615_181657/firefox/domtrace/trace_process_11132.jsonl:77559
该接口用于给 WebRTC IP 补国家、地区、城市、ISP 等展示信息,并与页面出口 IP 进行对比。
检测表面
BrowserScan 这个 WebRTC 模块实际读取或依赖的表面包括:
RTCPeerConnection构造器。RTCPeerConnection.prototype.createDataChannel。RTCPeerConnection.prototype.createOffer。RTCPeerConnection.prototype.setLocalDescription。RTCPeerConnection.prototype.close。pc.onicecandidate。pc.iceGatheringState。pc.localDescription.sdp。RTCPeerConnectionIceEvent.candidate。RTCIceCandidate.candidate。
本次页面侧 hook 额外记录了 RTCIceCandidate.address、toJSON() 和 getStats(),这些不是 BFntJwDg.js 最终选择 stun/turn 的必要字段,但属于 WebRTC 指纹一致性检查中容易被站点扩展读取的表面。
Trace 证明点
http_packet 证明源码:
runs/20260615_181657/firefox/http_packet/000016_GET_200_www_browserscan_net_dist_BFntJwDg_js_pid8804_30000002e.http_packet.json:2runs/20260615_181657/firefox/http_packet/000016_GET_200_www_browserscan_net_dist_BFntJwDg_js_pid8804_30000002e.http_packet.json:137
jscall 证明 BFntJwDg.js 构造 WebRTC:
runs/20260615_181657/firefox/jscall/trace_jscall_process_11132.jsonl:70599runs/20260615_181657/firefox/jscall/trace_jscall_process_11132.jsonl:70630runs/20260615_181657/firefox/jscall/trace_jscall_process_11132.jsonl:70653runs/20260615_181657/firefox/jscall/trace_jscall_process_11132.jsonl:70672
domtrace 证明 WebRTC API 调用链:
runs/20260615_181657/firefox/domtrace/trace_process_11132.jsonl:57344:构造 STUNRTCPeerConnection。runs/20260615_181657/firefox/domtrace/trace_process_11132.jsonl:57394:构造 TURNRTCPeerConnection。runs/20260615_181657/firefox/domtrace/trace_process_11132.jsonl:57387:调用createOffer。runs/20260615_181657/firefox/domtrace/trace_process_11132.jsonl:57430:调用第二路createOffer。runs/20260615_181657/firefox/domtrace/trace_process_11132.jsonl:60498:调用setLocalDescription。runs/20260615_181657/firefox/domtrace/trace_process_11132.jsonl:60507:调用第二路setLocalDescription。runs/20260615_181657/firefox/domtrace/trace_process_11132.jsonl:62593到:62608:读取candidate并进入BFntJwDg.jshandler。
fpfile 改写后的站点可见结果
带 WebRTC IP 改写时,站点可见 candidate 地址统一为:
text
104.251.229.181
DOM trace 证明:
runs/20260615_181513/firefox/domtrace/trace_process_8972.jsonl:663runs/20260615_181513/firefox/domtrace/trace_process_8972.jsonl:664
对应值:
text
RTCIceCandidate.candidate = candidate:0 1 UDP 2122252543 104.251.229.181 52948 typ host
RTCIceCandidate.address = 104.251.229.181
这说明对该站点来说,WebRTC 指纹伪装必须至少保证 candidate 字符串与 candidate 地址字段一致;如果后续站点扩展读取 toJSON()、SDP 或 getStats(),这些表面也需要保持同一 IP 语义。