指纹浏览器检测之BrowserScan的webrtc指纹检测和反检测

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.RTCPeerConnection
  • window.mozRTCPeerConnection
  • window.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:71961
  • runs/20260615_181657/firefox/domtrace/trace_process_11132.jsonl:71963

写入后,页面展示:

  • WebRTC
  • WebRTC 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:72979
  • runs/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.addresstoJSON()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:2
  • runs/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:70599
  • runs/20260615_181657/firefox/jscall/trace_jscall_process_11132.jsonl:70630
  • runs/20260615_181657/firefox/jscall/trace_jscall_process_11132.jsonl:70653
  • runs/20260615_181657/firefox/jscall/trace_jscall_process_11132.jsonl:70672

domtrace 证明 WebRTC API 调用链:

  • runs/20260615_181657/firefox/domtrace/trace_process_11132.jsonl:57344:构造 STUN RTCPeerConnection
  • runs/20260615_181657/firefox/domtrace/trace_process_11132.jsonl:57394:构造 TURN RTCPeerConnection
  • 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.js handler。

fpfile 改写后的站点可见结果

带 WebRTC IP 改写时,站点可见 candidate 地址统一为:

text 复制代码
104.251.229.181

DOM trace 证明:

  • runs/20260615_181513/firefox/domtrace/trace_process_8972.jsonl:663
  • runs/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 语义。

相关推荐
iPad协议个微协议1 天前
企业微信文件上传下载在自动化系统中的处理方式
java·运维·人工智能·机器人·自动化·企业微信
Profile排查笔记1 天前
指纹浏览器环境异常排查:Fingerprint、Profile、Proxy、Session 和 Task Log 怎么看
前端·人工智能·后端·自动化
想你依然心痛1 天前
持续集成在嵌入式开发中的实践:GitLab CI与交叉编译——自动化构建、固件生成
ci/cd·自动化·gitlab
xcLeigh1 天前
KES运维自动化与脚本体系实战
运维·数据库·自动化·脚本·数据迁移·kes
许彰午1 天前
75_Python自动化办公之Word与PDF
python·自动化·word
测试工程师成长之路1 天前
软件测试智能化升级与落地实践
自动化
开开心心_Every1 天前
带OCR识别的电子发票打印工具
运维·自动化·ocr·电脑·powerpoint·音视频·lua
风信子IT人1 天前
【5天实战】从零构建AI-Native组织:飞书+Bot+Gitee全链路自动化实战指南—Day 5:完整场景实操验证
gitee·自动化·飞书·ai-native·bot开发
搬砖柯1 天前
系列11-测试平台 MCP Server 实践:用 Kimi Code 自然语言查项目、跑 API 回归
人工智能·python·ai·开源·自动化
微信开发api-视频号协议1 天前
企业微信二次开发实战:API、外部群与自动化应用指南
运维·自动化·企业微信