WebRTC泄露指的是网页JavaScript通过RTCPeerConnection的ICE候选收集过程,读到你本机的内网地址(192.168.x.x、10.x.x.x、172.16–31.x.x)和路由器NAT映射后的真实公网出口地址。你挂了代理,浏览器HTTP层出去的是代理IP,但UDP那一层可能根本没走代理,直接把真实出口地址塞进了SDP候选里交给网页。对跨境电商和出海团队来说,这意味着"IP隔离"这个前提在网页侧是失效的——你以为各环境互不相关,平台看到的却是同一个出口地址。
跨境和多账号运营者必须处理它,原因很直接:IP是平台风控图关系里权重很高的边,一个地址同时出现在多个环境上,等于把这些环境在图上连成了一个连通分量。而且WebRTC泄露是"静默"的,不报错、不影响功能,你不会有任何感知,只有被平台侧加权之后才反应过来。
三条修复路线,各自的适用场景不同:
路线A是浏览器层禁用WebRTC,一条策略或开关就能生效,成本极低,代价是音视频会议、在线客服语音、部分验证码的人机校验流程会受影响,适合纯运营后台、纯Listing维护这类不需要实时通信的场景。
路线B是强制UDP走代理,依赖SOCKS5的UDPASSOCIATE能力,改的是网络栈行为而不是浏览器行为,对网页功能无损,但要求代理服务商真的支持UDP中继,市面上一部分代理只做TCP,这条路就走不通。
路线C是内核层改写ICE候选,在候选上报给JS之前把host候选换成与代理出口自洽的地址,兼容性和功能保留度都好,实现难度也高,是走Chromium内核层改造路线的产品(比如MostLogin)采用的方案。
下面按"原理—危害—检测—方案—验证"的顺序展开,每一步都给可以直接跑的代码和可以照着打勾的清单。
一、WebRTC到底把什么泄露出去了
要理解泄露,得先理解WebRTC为什么要这么设计。它不是一个"可以被关掉的隐私开关",而是一套为了实时通信质量而生的网络机制,泄露是这套机制的副产品。
1.1为什么非要P2P直连
两个浏览器之间要传音视频,直觉上应该是A发给服务器、服务器转发给B。这条路叫中继,能通,但代价大:延迟翻倍,服务器带宽成本随用户量线性增长,一对720p通话按每秒几百KB算,几万并发就足以压垮一家小公司的带宽预算。
所以WebRTC的设计目标是能直连就直连。A和B各自知道自己的网络出口地址,互相交换之后尝试直接发包,通了就走直连,不通才退到中继。这个"尝试"的过程叫ICE(InteractiveConnectivityEstablishment),定义在RFC8445。
关键在于,要直连,双方就必须把自己的地址告诉对方。而"自己的地址"这件事,浏览器必须真的去问操作系统:我这台机器上有哪些网卡、每个网卡的IP是多少、我发包出去之后NAT会把我映射成什么地址。问完之后,这些地址会被写进SDP(SessionDescriptionProtocol)里,作为a=candidate:行交给对端——同时也交给了创建这个连接的网页JS。
泄露就发生在这里。注意,ICE候选收集不需要对方的配合,网页只要newRTCPeerConnection()再createOffer(),浏览器就会开始枚举本地接口和发起STUN探测。整个过程没有任何权限弹窗,不需要摄像头麦克风授权,甚至不需要真的建立连接。一个页面在后台默默跑几百毫秒,你的地址信息就已经在它的JS变量里了。
1.2 ICE候选的四种类型
ICE收集到的候选按来源分成几类,泄露风险各不相同。
类型 |
来源 |
暴露内容 |
风险 |
host |
本机网卡接口 |
内网IP、网卡数量 |
中,暴露内网结构 |
srflx |
STUN服务器回显 |
NAT映射后的公网IP |
高,等于真实出口 |
prflx |
连通性检查对端回包 |
对端看到的地址 |
高,等价于srflx |
relay |
TURN服务器分配 |
中继服务器地址 |
低,是代理地址 |
host候选是浏览器直接调本地接口枚举得到的,暴露的是内网地址。单看内网IP似乎不致命,但它可以做两件事:一是多家环境如果内网段完全一致(比如都是192.168.1.x),这一位信息本身就带了一致性;二是内网地址能反推网络拓扑,配合其他信号做聚类。
srflx(serverreflexive)是真正麻烦的那个。浏览器向STUN服务器发一个BindingRequest,STUN服务器收到后把"我看到你从哪个地址和端口来的"原样回给浏览器,这个地址就是NAT映射后的公网出口。如果你没走代理,它就是你的真实出口IP;如果UDP走了代理,它才是代理IP。
prflx(peerreflexive)通常出现在连通性检查阶段,是从对端回包里学到的地址,泄露效果与srflx类似,只是出现得晚一些,很多检测脚本不会等到这一步。
relay候选来自TURN服务器,是中继地址。使用TURN时,网页看到的是TURN的地址,反而不会暴露你的出口。这也是为什么有些团队会选择"强制所有候选走relay"作为治理手段之一。
1.3 STUN服务器是怎么把你的地址交出去的
STUN协议(RFC5389,WebRTC场景常用其子集RFC8489)本身极其简单。客户端发一个BindingRequest,服务器回一个BindingResponse,响应体里带一个MAPPED-ADDRESS或XOR-MAPPED-ADDRESS属性,内容就是服务器看到的客户端地址和端口。整个交互一次往返,几十字节。
浏览器拿到这个响应之后,构造一个srflx候选,把它放进候选列表。候选的SDP表示长这样:
a=candidate:14672500271udp2122260223192.168.1.10046243typhostgeneration0
a=candidate:14672500271udp1686052607203.0.113.746243typsrflxraddr192.168.1.100rport46243
第二行里的203.0.113.7就是公网出口,raddr和rport还顺带把内网地址和端口也带了出来。这两行对网页JS是可见的——通过pc.localDescription.sdp能拿到完整的SDP文本,通过onicecandidate事件能拿到结构化的RTCIceCandidate对象,对象的candidate字段就是上面那串字符串,早期规范里还有ip和port字段直接暴露地址。
还有一个容易被忽略的出口:RTCPeerConnection.getStats()。它的返回里包含local-candidate类型的统计项,字段里有ip、port、candidateType、networkType。很多方案只改了SDP和onicecandidate两条路,忘了getStats这条,检测侧一查就露馅。这一点后面讲路线C时会再提。
1.4 mDNS混淆:它解决了什么,又在什么情况下失效
Chrome从M75开始引入了一个缓解措施:如果页面没有获得摄像头或麦克风授权,host候选里的内网IP会被替换成一个mDNS主机名,形如a1b2c3d4-e5f6-7890-abcd-ef1234567890.local。浏览器在本地网络里通过组播DNS响应这个名字的解析请求,这样同一局域网内的对端仍能解析出真实内网IP完成直连,而网页JS拿到的只是一个无意义的UUID。
这个设计很聪明,但它只解决host,不解决srflx。公网出口地址照样通过STUN拿到,照样进srflx候选。也就是说,mDNS只堵住了四条路里的一条。
它在下面这些情况下会失效或被打回原形:
一、内核版本或构建配置差异。mDNS混淆是Chrome的行为,Firefox的实现路径不同,国内很多双核浏览器的极速模式与兼容模式表现也不一致。基于旧版Chromium分支的客户端,行为要具体版本具体分析。
二、被策略或启动参数关闭。Chrome提供了WebRtcLocalIpsAllowedUrls这类企业策略,允许指定名单里的站点看到本地IP;一旦在名单内,mDNS混淆对该站点不再生效。
三、页面拿到了媒体设备授权。授权后Chrome会放弃混淆,直接给出真实内网IP——因为此时浏览器认为通信是用户明确许可的。某些业务场景下页面会主动申请麦克风权限,一旦点了允许,混淆就没了。
四、mDNS本身可被本地反查。.local名字的解析依赖本地组播,在同一二层网络内的第三方可以监听组播流量并解析出真实地址。对远程网页来说这一点不构成直接风险,但在办公网、共享工位这类场景里,内网地址仍然会在本地网络内广播出去。
五、非浏览器环境。Electron应用、CEF容器、移动端WebView的行为由各自的构建参数决定,不能默认它们继承了Chrome的策略。
1.5挂上代理之后,UDP的三种命运
这是整篇文章的技术核心,也是绝大多数人配置错误的根源。
多数人对代理的心理模型是"配了代理,所有流量都走代理"。这个模型对TCP基本成立,对UDP完全不成立。WebRTC的ICE候选收集(STUN探测、连通性检查)走的是UDP,而UDP在代理体系里有三种截然不同的命运。
命运一:UDP真的走了代理。
只有SOCKS5且代理服务端实现了UDPASSOCIATE(RFC1928中CMD为0x03的请求)时才会发生。流程是:客户端先与代理建立TCP控制连接并完成认证握手,然后发一个UDPASSOCIATE请求,服务器回一个绑定地址和端口;后续客户端把每个UDP数据报前面加一层SOCKS5请求头(RSV、FRAG、ATYP、目标地址、目标端口)发到这个绑定端口,由代理服务器解封装后转发,回程再加头封装送回。
这条路走通之后,STUN探测的源地址就是代理出口,srflx候选等于代理IP,WebRTC层面自洽。代价是每次UDP收发多了封装和一次中转,延迟略增,且要求代理服务端真的支持。
命运二:UDP被静默丢弃。
HTTP代理只能处理CONNECT隧道,而CONNECT建立的是TCP隧道。浏览器要发UDP时,HTTP代理没有对应的通道,请求要么失败要么被丢弃。此时STUN探测收不到响应,srflx候选收集不到,看起来"没有泄露"——但这是假象。有些网络环境下浏览器会因为隧道内的TCP回退而继续尝试,另外host候选的本地接口枚举与代理无关,内网地址该暴露还是暴露。
命运三:UDP不理会代理,直接出去。
这是三种命运里代价大的一种。浏览器的UDPsocket直接绑定本机网卡,不理会代理配置,把包直接发了出去,STUN服务器看到的是你的真实出口地址,srflx候选里就是真实公网IP。HTTP代理在TCP层做得很干净,UDP层却把真实地址完整地交给了网页。
怎么判断自己属于哪一种?跑一遍后面第四节给出的检测代码,看srflx候选里的地址是不是你的代理IP,一目了然。
1.6为什么"HTTP代理+WebRTC"是高危组合
把上面三种命运和代理类型对应起来,结论就清楚了:
代理类型 |
UDP通道能力 |
WebRTC实际表现 |
HTTP/HTTPS代理 |
无,仅CONNECT隧道 |
丢弃或直连,地址不自洽 |
SOCKS5(服务端支持UDP) |
有,UDPASSOCIATE |
候选与代理IP一致 |
SOCKS5(服务端不支持UDP) |
无 |
退化为丢弃或直连 |
TURN强制中继 |
走应用层中继 |
暴露中继地址,不暴露本地 |
HTTP代理加上WebRTC,等于把特别需要隔离的那部分流量,交给了不具备隔离能力的通道。很多团队在环境隔离浏览器里给每个账号绑了干净的住宅IP,检测站点查HTTP出口IP一切正常,唯独WebRTC那一栏赫然写着真实宽带地址——这种"半隔离"比不隔离更危险,因为它会让人误以为已经处理好了。
这里还要提一个常见的误解:有人认为在浏览器里装一个WebRTC相关的扩展就能解决。扩展层能做到的事情有限——它可以HookRTCPeerConnection构造函数,但页面可以通过iframe、Worker、HTMLIFrameElement.contentWindow等多种路径拿到干净的构造函数;而且扩展注入时机晚于页面脚本执行时,容易形成竞态。扩展层方案不是不能做,只是它属于"应用层补丁",可靠度低于内核层方案。
二、泄露出去的那个地址,会被拿去做什么
2.1平台侧看到的是什么
平台拿到的不是"这个人挂了代理"这种结论,而是一组可能互相矛盾的事实:
· HTTP层:请求来自某个住宅代理IP,地理位置显示洛杉矶,时区America/Los_Angeles,`Accept-Language`是en-US。
· WebRTC层:同一个会话里,srflx候选显示出口在中国某省,host候选显示内网段为192.168.31.x。
这两组事实摆在一起,不需要任何高级算法,一个规则引擎就能判定为"地址不自洽"。在风控模型里,不自洽不是中性事件,它是加权项——因为真实用户不会产生这种矛盾,产生这种矛盾的原因只有一个:这个环境是被构造出来的。
2.2三个典型场景下的具体后果
平台场景 |
泄露信号 |
可能触发的后果 |
亚马逊卖家后台 |
多店铺同出口地址 |
触发关联审查,要求提交资料 |
亚马逊卖家后台 |
地址与注册地长期不符 |
二审、视频验证、账户审核 |
Meta广告账户 |
BM下多账户同出口 |
广告账户受限,投放中断 |
Meta广告账户 |
IP时区语言不匹配 |
触发安全验证,素材审核变慢 |
TikTok及TikTokShop |
移动网络IP与宽带地址混用 |
异常登录提醒,内容流量受限 |
独立站收款与登录 |
后台登录地址跳变 |
风控复核,结算延迟 |
需要强调的是,这些后果在平台的公开文档里通常不会写成"因为WebRTC泄露所以处理你"这样的对应关系。平台给出的是综合判定结果,WebRTC泄露只是其中一条输入。单条信号不足以定性,但它会显著提高整体风险评分,并且在人工审核阶段成为一条容易被抓住的把柄。
2.3地址、时区、语言、地理位置的四元不自洽
WebRTC泄露之所以权重高,是因为它很少孤立存在。它往往和下面几条一起被命中:
时区与IP地理位置不符——代理在美国,系统时区挂的是Asia/Shanghai。这一项通过Intl.DateTimeFormat().resolvedOptions().timeZone和newDate().getTimezoneOffset()就能采到。
语言与地理位置不符——navigator.language与navigator.languages是中文,而IP在德国。
UA与平台不符——Windows的UA配了一份macOS的字体列表。字体列表通过Canvas测量或document.fonts枚举可得,跨平台差异明显。
硬件参数与IP段不符——navigator.hardwareConcurrency是4,设备内存8GB,屏幕1440x900,看起来像一台旧笔记本,但IP段属于某数据中心。
当这四条里有两三条同时命中,风险模型基本不用挣扎就能给结论。WebRTC泄露的独特之处在于:它是四条里仅有的一条能直接给出"另一个真实出口地址"的,等于在图上又画了一条边。
2.4一次泄露对同IP下其他环境的连带影响
这一点尤其值得做多店铺、多账户的团队注意。
风控建模的常用手法是把账号、设备指纹、IP、支付凭证建成关系图,然后找连通分量。假设你有十个环境,其中九个做了完整的WebRTC处理,只有一个没做。这一个环境泄露出的真实出口地址,会把它自己和另外九个环境在图上连起来——只要那九个环境的代理IP曾经与这个真实地址出现在同一个会话上下文里,或者更糟,只要这个真实地址同时被其他环境在别的信号上命中过。
图关系的特点是"一条边就够"。九个环境做得再干净,被这一个邻居拖进同一个连通分量之后,它们在模型眼里的距离就是1。
这也是为什么环境治理必须当成整体工程来做,而不是"哪个账号出问题了就修哪个"。验收时要用同一套清单把全部环境跑一遍,而不是抽查。
三、三个层次的检测手段
知道原理之后,检测就变得很机械了。下面从"用现成的"到"自己搭"再到"批量跑",三层递进。
3.1在线检测站点在测什么
常见的检测站点(browserleaks、ipleak、whoer、iphey一类)做的事其实不复杂,拆开看就三步:
步骤一,创建一个带STUN服务器地址的RTCPeerConnection,多数站点会同时配1到3个公共STUN,比如Google或Twilio提供的公共实例。配STUN的目的是逼浏览器去收集srflx候选——不配STUN,只能拿到host。
第二步,触发候选收集。做法是pc.createDataChannel()建立数据通道,再setLocalDescription(awaitpc.createOffer()),然后监听onicecandidate或者等iceGatheringState变成complete。有些站点会同时解析pc.localDescription.sdp的全部a=candidate:行,作为交叉验证。
第三步,对比。站点自己的后端知道"你是从哪个IP访问我的",这是HTTP层的出口地址。把第二步拿到的候选地址和这个出口地址做比对,如果候选里出现了与出口地址不一致的公网地址,或者出现内网地址,就在页面标红。
理解这三步的意义在于:你可以完全复刻它。公共站点有隐私顾虑(你把地址信息交给了第三方),也有地域可达性问题(某些站点在部分网络下打不开)。自建检测页面不难,下面这份代码可以直接存成html文件用浏览器打开。
3.2自建检测页面:HTML+JavaScript
<!DOCTYPEhtml>
<htmllang="zh-CN">
<head>
<metacharset="utf-8">
<title>WebRTC泄露自检</title>
<style>
body{font-family:ui-monospace,Consolas,monospace;padding:24px;}
.ok{color:#16702a;}
.bad{color:#b3261e;}
.warn{color:#8a6100;}
table{border-collapse:collapse;margin-top:12px;}
td,th{border:1pxsolid#ccc;padding:4px10px;}
</style>
</head>
<body>
<h2>WebRTC泄露自检</h2>
<divid="status">正在收集ICE候选,请稍候...</div>
<preid="detail"></pre>
<script>
//公共STUN,用于逼出srflx候选;可替换为你自己的STUN/TURN
constSTUN_SERVERS=[
{urls:'stun:stun.l.google.com:19302'},
{urls:'stun:global.stun.twilio.com:3478'}
];
//判断是否为私网/保留地址
functionisPrivate(ip){
if(!ip)returntrue;
if(ip.indexOf(':')>=0){
constv=ip.toLowerCase();
returnv==='::1'||v.startsWith('fc')||v.startsWith('fd')||
v.startsWith('fe80');
}
constp=ip.split('.').map(Number);
if(p.length!==4||p.some(isNaN))returntrue;
returnp[0]===10||
(p[0]===172&&p[1]>=16&&p[1]<=31)||
(p[0]===192&&p[1]===168)||
(p[0]===169&&p[1]===254)||
p[0]===127;
}
//解析候选字符串,兼容SDP行与onicecandidate事件两种格式
functionparseCandidate(line){
letparts=String(line).trim().split(/\s+/);
if(parts[0].indexOf('candidate:')>=0)parts=parts.slice(1);
consttyp=parts.indexOf('typ');
if(typ<5)returnnull;
return{
address:parts[4],
port:parts[5],
type:parts[typ+1],
raw:String(line).trim()
};
}
//收集候选,同时从SDP与getStats两条路径交叉验证
asyncfunctioncollect(){
constseen=newSet();
constcandidates=[];
constpc=newRTCPeerConnection({iceServers:STUN_SERVERS,iceCandidatePoolSize:0});
pc.onicecandidate=(e)=>{
if(e.candidate&&e.candidate.candidate){
constc=parseCandidate(e.candidate.candidate);
if(c&&!seen.has(c.raw)){seen.add(c.raw);candidates.push(c);}
}
};
pc.createDataChannel('probe');
awaitpc.setLocalDescription(awaitpc.createOffer());
//等待候选收集完成,超时兜底
awaitnewPromise((resolve)=>{
if(pc.iceGatheringState==='complete')returnresolve();
pc.onicegatheringstatechange=()=>{
if(pc.iceGatheringState==='complete')resolve();
};
setTimeout(resolve,3000);
});
//路径二:直接解析SDP,防止onicecandidate被改写而SDP没改
constsdp=pc.localDescription?pc.localDescription.sdp:'';
sdp.split(/\r?\n/).forEach((line)=>{
if(line.indexOf('a=candidate:')===0){
constc=parseCandidate(line);
if(c&&!seen.has(c.raw)){seen.add(c.raw);candidates.push(c);}
}
});
//路径三:getStats里的local-candidate统计项
conststats=[];
try{
constreport=awaitpc.getStats();
report.forEach((s)=>{
if(s.type==='local-candidate'){
stats.push({
ip:s.ip||s.address,
port:s.port,
type:s.candidateType,
network:s.networkType
});
}
});
}catch(e){
stats.push({ip:'getStats调用失败:'+e.message,port:'',type:'',network:''});
}
pc.close();
return{candidates,sdp,stats};
}
(async()=>{
//网页侧看到的出口IP,用于与srflx候选比对
//这里用公共回显接口,生产环境建议换成你自建的接口
letexitIp=null;
try{
constres=awaitfetch('https://api.ipify.org?format=json');
exitIp=(awaitres.json()).ip;
}catch(e){
exitIp=null;
}
const{candidates,stats}=awaitcollect();
constpublicLeaks=candidates.filter((c)=>!isPrivate(c.address)&&c.address!==exitIp);
consthostLeaks=candidates.filter((c)=>c.type==='host'&&!isPrivate(c.address));
constmdns=candidates.filter((c)=>/\.local$/.test(c.address));
constsrflx=candidates.filter((c)=>c.type==='srflx');
constsrflxMatch=srflx.length>0&&srflx.every((c)=>c.address===exitIp);
conststatsLeak=stats.filter((s)=>s.ip&&!isPrivate(s.ip)&&s.ip!==exitIp);
constlines=[];
lines.push('网页出口IP:'+(exitIp||'获取失败(接口不可达)'));
lines.push('候选总数:'+candidates.length);
lines.push('候选明细:');
candidates.forEach((c)=>lines.push('['+c.type+']'+c.address+':'+c.port));
lines.push('getStats本地候选:');
stats.forEach((s)=>lines.push('['+s.type+']'+s.ip+'(network='+s.network+')'));
lines.push('');
lines.push('与出口IP不符的公网候选:'+(publicLeaks.length?publicLeaks.map((c)=>c.address).join(','):'无'));
lines.push('非私网host候选:'+(hostLeaks.length?hostLeaks.map((c)=>c.address).join(','):'无'));
lines.push('mDNS混淆:'+(mdns.length?'生效('+mdns.length+'条)':'未生效'));
lines.push('srflx与出口IP一致:'+(srflx.length?(srflxMatch?'是':'否'):'无srflx候选'));
lines.push('getStats泄露:'+(statsLeak.length?statsLeak.map((s)=>s.ip).join(','):'无'));
constpass=publicLeaks.length===0&&hostLeaks.length===0&&statsLeak.length===0;
constwarn=!pass?[]:(srflx.length&&!srflxMatch?['srflx与出口IP不一致']:[]);
document.getElementById('detail').textContent=lines.join('\n');
constel=document.getElementById('status');
if(!pass){
el.className='bad';
el.textContent='结论:存在泄露,见明细';
}elseif(warn.length){
el.className='warn';
el.textContent='结论:未见公网地址泄露,但存在不自洽项';
}else{
el.className='ok';
el.textContent='结论:未发现泄露,各条路径一致';
}
window.__webrtcProbeResult={pass,exitIp,candidates,stats};
})();
</script>
</body>
</html>
这份代码的价值在于它同时查了三条路径:onicecandidate事件、localDescription.sdp文本、getStats()统计。只查一条Path的检测是不完整的,很多半吊子处理方案就是栽在第二条或第三条上。
3.3用Node.js+Puppeteer批量检测多个环境
手工打开十几个环境逐个点检测页面不现实。下面这段脚本通过CDP连接到已经启动的环境,注入同一份探测逻辑,汇总输出。
//batch_webrtc_check.js
//依赖:npmipuppeteer-core
//用法:nodebatch_webrtc_check.js
constpuppeteer=require('puppeteer-core');
//每个环境一行,ws地址从环境管理工具的本地API获取
constENVIRONMENTS=[
{name:'环境-01',ws:'ws://127.0.0.1:9222/devtools/browser/aaaaaaaa'},
{name:'环境-02',ws:'ws://127.0.0.1:9223/devtools/browser/bbbbbbbb'},
{name:'环境-03',ws:'ws://127.0.0.1:9224/devtools/browser/cccccccc'}
];
//注入到页面里执行的探测逻辑,与3.2节同源,这里精简掉DOM部分
constPROBE=`async()=>{
constisPrivate=(ip)=>{
if(!ip)returntrue;
if(ip.indexOf(':')>=0){
constv=ip.toLowerCase();
returnv==='::1'||v.startsWith('fc')||v.startsWith('fd')||v.startsWith('fe80');
}
constp=ip.split('.').map(Number);
if(p.length!==4||p.some(isNaN))returntrue;
returnp[0]===10||(p[0]===172&&p[1]>=16&&p[1]<=31)||
(p[0]===192&&p[1]===168)||(p[0]===169&&p[1]===254)||p[0]===127;
};
constparse=(line)=>{
letparts=String(line).trim().split(/\\s+/);
if(parts[0].indexOf('candidate:')>=0)parts=parts.slice(1);
consttyp=parts.indexOf('typ');
if(typ<5)returnnull;
return{address:parts[4],port:parts[5],type:parts[typ+1],raw:String(line).trim()};
};
constseen=newSet();
constcandidates=[];
constpc=newRTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
pc.onicecandidate=(e)=>{
if(e.candidate&&e.candidate.candidate){
constc=parse(e.candidate.candidate);
if(c&&!seen.has(c.raw)){seen.add(c.raw);candidates.push(c);}
}
};
pc.createDataChannel('probe');
awaitpc.setLocalDescription(awaitpc.createOffer());
awaitnewPromise((resolve)=>{
if(pc.iceGatheringState==='complete')returnresolve();
pc.onicegatheringstatechange=()=>{if(pc.iceGatheringState==='complete')resolve();};
setTimeout(resolve,3000);
});
constsdp=pc.localDescription?pc.localDescription.sdp:'';
sdp.split(/\\r?\\n/).forEach((line)=>{
if(line.indexOf('a=candidate:')===0){
constc=parse(line);
if(c&&!seen.has(c.raw)){seen.add(c.raw);candidates.push(c);}
}
});
conststats=[];
constreport=awaitpc.getStats().catch(()=>null);
if(report){
report.forEach((s)=>{
if(s.type==='local-candidate')stats.push({ip:s.ip||s.address,type:s.candidateType});
});
}
pc.close();
letexitIp=null;
try{
constres=awaitfetch('https://api.ipify.org?format=json');
exitIp=(awaitres.json()).ip;
}catch(e){exitIp=null;}
return{exitIp,candidates,stats};
}`;
asyncfunctioncheckOne(env){
letbrowser=null;
try{
browser=awaitpuppeteer.connect({browserWSEndpoint:env.ws,defaultViewport:null});
constpage=awaitbrowser.newPage();
//用about:blank即可,探测逻辑不依赖具体页面
awaitpage.goto('about:blank');
constresult=awaitpage.evaluate(eval(PROBE));
constleaks=result.candidates.filter(
(c)=>!/^(10|192\.168|172\.(1[6-9]|2\d|3[01])|127)\./.test(c.address)&&c.address!==result.exitIp
);
conststatsLeak=result.stats.filter(
(s)=>s.ip&&!/^(10|192\.168|172\.(1[6-9]|2\d|3[01])|127)\./.test(s.ip)&&s.ip!==result.exitIp
);
return{
name:env.name,
pass:leaks.length===0&&statsLeak.length===0,
exitIp:result.exitIp,
leak:leaks.map((c)=>c.address+'('+c.type+')').join(',')||
statsLeak.map((s)=>s.ip+'(stats)').join(',')||'-',
candidateCount:result.candidates.length
};
}catch(e){
return{name:env.name,pass:false,exitIp:'-',leak:'执行失败:'+e.message,candidateCount:0};
}finally{
//只断开连接,不关闭浏览器进程,环境需要保持存活
if(browser)awaitbrowser.disconnect();
}
}
(async()=>{
constresults=[];
//简单串行,避免同时连太多环境触发本地API限速
for(constenvofENVIRONMENTS){
results.push(awaitcheckOne(env));
}
console.log('环境\t结果\t出口IP\t候选数\t泄露地址');
results.forEach((r)=>{
console.log([r.name,r.pass?'通过':'不通过',r.exitIp,r.candidateCount,r.leak].join('\t'));
});
constfailed=results.filter((r)=>!r.pass);
console.log('\n合计'+results.length+'个环境,不通过'+failed.length+'个');
//汇总结果落盘,便于归档比对
require('fs').writeFileSync('webrtc_report.json',JSON.stringify(results,null,2));
})();
几个实操要点:连接用connect而不是launch,因为环境通常由客户端拉起并需要保持存活;disconnect而不是close,避免把别人的环境关掉;并发数要压住,本地API有按套餐分级的速率限制,批量拉起时撞上限速会拿到一堆失败结果,误判成"配置有问题"。
3.4用Python调本地API批量拉起环境
多数团队的环境创建和启动是在客户端里点出来的,但在批量验收场景下,更合适的方式是让脚本去拉起环境、拿调试地址、注入探测、回收结果。MostLogin在2025年8月的2.0阶段开放了本地RESTAPI,支持通过CDP桥接控制环境,这类能力正是为批量巡检准备的。
下面这段是完整的编排示例。注意代码里的端点、字段名都是占位,实际字段以官方文档为准。
#-*-coding:utf-8-*-
"""
批量拉起环境并执行WebRTC泄露巡检(示例)
说明:
1.BASE/路径/字段名均为占位示例,请替换为官方本地API文档中的实际值。
2.本地API需在客户端设置中开启,且客户端保持运行状态。
3.本地API有按套餐分级的速率限制,批量场景务必限速与重试。
4.探测脚本probe.js为上节PROBE逻辑的独立文件版本。
"""
importjson
importtime
importrequests
fromwebsocketimportcreate_connection
#占位:客户端设置页里显示的本地API端口
BASE="http://127.0.0.1:<本地API端口>"
#占位:客户端生成的本地API令牌,等同于密码,请勿提交到代码仓库
HEADERS={"Authorization":"Bearer<你的本地API令牌>",
"Content-Type":"application/json"}
ENV_IDS=["env_001","env_002","env_003"]#占位:环境标识
RATE_LIMIT_DELAY=0.6#基础版限速约2次/秒,留出余量
defstart_env(env_id):
"""占位端点:启动指定环境,返回里应包含CDP调试地址"""
r=requests.post(f"{BASE}/api/v1/env/start",
headers=HEADERS,json={"envId":env_id},timeout=30)
r.raise_for_status()
data=r.json()
#占位字段名:实际可能是debuggerAddress/ws/cdpUrl等
returndata["data"]["debuggerAddress"]
defstop_env(env_id):
"""占位端点:关闭环境,回收资源"""
try:
requests.post(f"{BASE}/api/v1/env/stop",
headers=HEADERS,json={"envId":env_id},timeout=30)
exceptrequests.RequestExceptionase:
print(f"[warn]关闭环境{env_id}失败:{e}")
defrun_probe(ws_url,probe_js,timeout=20):
"""通过CDP的Runtime.evaluate在环境里执行探测脚本"""
ws=create_connection(ws_url,timeout=timeout)
try:
payload={
"id":1,
"method":"Runtime.evaluate",
"params":{
"expression":f"(async()=>{{{probe_js}returnawaitwindow.__probe();}})()",
"awaitPromise":True,
"returnByValue":True
}
}
ws.send(json.dumps(payload))
deadline=time.time()+timeout
whiletime.time()<deadline:
msg=json.loads(ws.recv())
ifmsg.get("id")==1:
returnmsg.get("result",{}).get("result",{}).get("value")
finally:
ws.close()
returnNone
defjudge(result):
"""按三条路径判定是否通过"""
ifnotresultor"candidates"notinresult:
returnFalse,"探测未返回结果"
exit_ip=result.get("exitIp")
bad=[]
forcinresult.get("candidates",[]):
addr=c.get("address","")
ifaddr.startswith(("10.","192.168.","127.")):
continue
ifaddr!=exit_ip:
bad.append(f"{addr}({c.get('type')})")
forsinresult.get("stats",[]):
ifs.get("ip")andnots["ip"].startswith(("10.","192.168.","127."))ands["ip"]!=exit_ip:
bad.append(f"{s['ip']}(stats)")
return(len(bad)==0),(",".join(bad)ifbadelse"-")
defmain():
withopen("probe.js","r",encoding="utf-8")asf:
probe_js=f.read()
rows=[]
forenv_idinENV_IDS:
time.sleep(RATE_LIMIT_DELAY)#限速,避免触发本地API限流
try:
ws_url=start_env(env_id)
time.sleep(1.5)#等环境完全就绪再注入
result=run_probe(ws_url,probe_js)
exceptExceptionase:
rows.append({"env":env_id,"pass":False,"leak":f"执行异常:{e}"})
continue
ok,detail=judge(result)
rows.append({"env":env_id,
"pass":ok,
"exitIp":(resultor{}).get("exitIp"),
"leak":detail})
stop_env(env_id)
withopen("webrtc_inspect.json","w",encoding="utf-8")asf:
json.dump(rows,f,ensure_ascii=False,indent=2)
failed=[rforrinrowsifnotr["pass"]]
print(f"巡检完成:共{len(rows)}个环境,不通过{len(failed)}个")
forrinfailed:
print(f"-{r['env']}:{r['leak']}")
if__name__=="__main__":
main()
把这套脚本挂到定时任务上,每天凌晨跑一遍全部环境,把结果写进日志,异常环境自动告警——这就从"一次性排查"变成了"持续巡检"。
四、三条技术路线的实现与取舍
前面说过有三条路,这一节把每条路的实现细节、副作用和坑点讲透。
4.1路线A:浏览器层禁用WebRTC
这是成本较低的一条路,本质上是不让RTCPeerConnection正常工作。
在Chromium系浏览器上,可以通过企业策略或命令行开关控制ICE候选的暴露范围。相关的策略项是WebRtcIPHandling,取值大致对应四种行为:默认行为、暴露公网与私网接口地址、只暴露公网接口地址、禁用非代理UDP。与之对应的命令行开关是--force-webrtc-ip-handling-policy=,可以在启动参数里强制指定,适合不想改注册表的场景。另外WebRtcLocalIpsAllowedUrls允许给特定站点开白名单,让内网的视频会议应用仍能拿到本地地址。
Firefox侧走的是另一套:在about:config里通过media.peerconnection.*系列开关控制,比如把media.peerconnection.enabled置为false会整体关闭WebRTC,另有与ICE相关的开关可以限制候选类型。具体开关名随版本有调整,落地时以对应版本的官方文档为准。
副作用要说清楚:
一是实时通信功能全部或大部失效。网页版的视频会议(GoogleMeet、TeamsWeb、ZoomWeb等)、浏览器内的语音客服、部分在线教育的直播连麦,底层都是WebRTC。禁用之后这些功能要么打不开,要么降级到需要插件的模式。
二是部分人机校验流程受影响。有些验证流程会读取媒体设备与网络能力作为信号的一部分,WebRTC被整体关闭后,这些信号变成缺失值。缺失本身也是异常——真实用户的浏览器里WebRTC默认是开着的。
三是"禁用"这个事实本身会构成特征。统计数据里,WebRTC被禁用的客户端占比很低。对纯后台运营类业务这不成问题,但如果你的业务形态与主流人群一致,一个被禁用了WebRTC的环境在人群分布里就是少数派。
所以路线A的适用边界很清楚:只在那些明确不需要实时通信、且流程里不含活体或媒体校验的后台场景里用。亚马逊卖家后台、广告后台的纯操作类工作,属于这一类;客服、直播、需要视频验证的场景,不属于。
4.2路线B:强制UDP走代理
这条路的思路是:不碰浏览器,只让UDP老实走代理。
技术上依赖SOCKS5的UDPASSOCIATE,定义在RFC1928,请求命令为0x03。完整流程是三步:
步骤一,客户端与代理服务端建立TCP控制连接并完成方法协商(无用户名密码为0x00,用户名密码为0x02)。
第二步,在控制连接上发送UDPASSOCIATE请求,RSV为0x0000,FRAG为0x00,ATYP与地址填客户端准备发UDP的源地址(可填全零,由服务端忽略)。服务端在响应里返回BND.ADDR与BND.PORT,这是客户端后续发UDP的中继入口。
第三步,客户端把每个要发的UDP数据报前面加一层SOCKS5请求头(RSV两字节、FRAG一字节、ATYP一字节、目标地址、目标端口两字节),发到BND指定的端口,服务端解封装后转发;回程数据报同样带头部封装送回客户端。客户端按头里的信息还原出真实来源。
这条路走通之后,STUN探测的源地址是代理出口,srflx候选等于代理IP,网页功能完全无损。
对代理服务商的要求,有四条必须提前确认:
一是服务端真的实现了UDPASSOCIATE。不少代理只做TCP转发,收到0x03命令直接拒绝或超时。检测方法很简单:让候选收集跑起来,看srflx候选是否等于代理IP,不等于就是没走通。
二是UDP出口与TCP出口必须是同一个IP。这一条特别容易被忽略。有些代理池的UDP中继与TCP出口不在同一节点,结果HTTP层查出来是IP-A,WebRTC的srflx是IP-B,两个都是代理IP,但互不自洽——这比泄露真实地址更容易被判定为异常,因为它明确指向"流量被拆分处理过"。
三是UDP有会话超时,需要保活。UDPASSOCIATE建立的是有状态的关联,代理服务端一般会设空闲超时(几十秒到几分钟不等)。WebRTC的连通性检查自身有保活机制,但如果候选收集结束后长时间没有UDP流量,关联被回收,后续会话又要重新建立。
四是UDP丢包率要能接受。STUN是单次往返,丢包就直接收集不到srflx;连通性检查丢包会导致候选配对失败,结果降级到TURN或连接失败。
失败时的排查顺序建议是:先用普通UDP工具确认代理的UDP通道本身通不通;再确认浏览器是否真的把UDP交给了代理(可以看候选里有没有srflx,以及srflx是否等于TCP出口);收尾时确认认证方式是否被正确处理。
浏览器侧的注意点:Chrome使用socks5://代理时,会把域名交给代理端解析(发ATYP为域名的请求),所以不需要额外配socks5h;Firefox侧由network.proxy.socks_remote_dns控制,默认行为需按版本确认。还有一点,如果配的是HTTP代理,这条路在起点上就不成立——HTTP代理没有UDP通道。
4.3路线C:内核层ICE候选改写
这条路是三条里实现难度高的,也是保留度好的。思路是:让WebRTC正常工作,但在候选上报给页面JS之前,把地址换成与代理出口自洽的值。
改写时机是关键。Chromium的WebRTC模块在收集到候选之后,会通过观察者接口把候选对象回调给上层,再由上层转成JS事件抛出。内核层要做的,就是在这个回调链路上做拦截与替换。如果改得太早(在候选还没生成完时),后续内部逻辑会用改后的值去做连通性计算,导致行为错乱;改得太晚(JS已经拿到对象),就没有意义了。
在页面脚本层面能看到三条出口,三条都必须改到:
一,onicecandidate事件对象。事件的candidate字段是RTCIceCandidate,其candidate属性是候选字符串。
二,RTCPeerConnection.getLocalDescription()返回的SDP文本,以及localDescription/pendingLocalDescription/currentLocalDescription属性。里面有完整的a=candidate:行。
三,getStats()返回的local-candidate统计项,字段里有ip、port、candidateType、networkType。
只改前两条、漏掉getStats属于常见疏漏。这也是为什么3.2节的检测代码要专门查getStats。
自洽性是这条路的生命线。改写不是随便填一个地址,要满足几个约束:
host候选必须填私网地址段。10.0.0.0/8、172.16.0.0/12、192.168.0.0/16都可,填公网地址等于主动暴露一个假的公网地址,比不改还糟。
srflx候选必须等于当前环境的代理出口IP,且raddr/rport要与改写后的host候选一致。srflx行里的raddr字段记录的是"这个映射对应的本地地址",如果host改了而raddr没改,两条信息对不上,一眼就看出来。
候选的priority字段要与类型匹配。不同类型候选有不同的优先级公式(host通常高于srflx高于relay),乱填会导致对端的连通性检查顺序错乱。
foundation字段可以保持稳定,它是候选的分组标识,不需要伪装。
改写之后,真实的P2P直连其实是不通的——因为host地址是假的,对端连不上。这在运营类场景里无所谓,因为根本不需要真的建立P2P连接;但对需要真实音视频的业务,路线C需要配合TURN中继,让实际流量走relay,而relay地址本来就是公开的,不需要改写。
从产品形态上看,走Chromium内核层定制改造的路线具备天然优势:在C++层面对候选上报接口做Hook,能让SDP、事件对象、getStats三条路径同时收敛,也避免了页面脚本层的注入竞态。MostLogin这类在内核层做参数一致化的产品,采用的正是候选改写方案,它把WebRTC与Canvas、WebGL、字体这些采集面放在同一套注入逻辑里管理,好处是各面之间天然自洽。
4.4三条路线怎么选
路线 |
实现成本 |
兼容性 |
主要副作用 |
适用场景 |
A禁用WebRTC |
低,一条策略 |
一般,各内核开关不同 |
实时通信失效,禁用本身成特征 |
纯后台操作类环境 |
BUDP走代理 |
中,依赖代理能力 |
好,不碰浏览器行为 |
依赖代理支持,出口需一致 |
有代理资源且业务含音视频 |
C候选改写 |
高,需内核层改造 |
好,业务无感 |
真实P2P不可用,需配TURN |
规模化环境的统一治理 |
补充两条判断原则:
一是如果团队里环境的数量在几十个以上,路线A的运维成本会反超路线C。因为路线A要在每个环境、每个内核版本上分别验证策略是否生效,而路线C一旦在内核层做对,所有环境自动继承。
二是路线B和路线C不互斥,可以叠加。UDP走代理解决的是"srflx候选的地址来源正确",候选改写解决的是"host候选与统计接口不漏私网结构"。两个都做,覆盖才完整。
五、可以照着打勾的验收清单
方案落地之后必须有验收,否则"我配过了"和"它真的生效了"之间隔着一整个运维周期。下面这张表可以直接当checklist用。
5.1验收清单
检测项 |
合格标准 |
检测方法 |
常见失败原因 |
公网候选一致性 |
srflx地址等于代理出口 |
3.2自检页比对 |
UDP未走代理,直连出去 |
host候选类型 |
全部为私网或mDNS |
解析候选type=host |
策略未生效或媒体授权已授予 |
SDP文本一致 |
SDP无异常公网地址 |
解析localDescription.sdp |
只改了事件,漏改SDP |
getStats一致 |
local-candidate无异常IP |
getStats遍历统计项 |
漏改统计接口,三条路不同步 |
raddr自洽 |
srflx的raddr等于host |
解析候选行raddr字段 |
只改host未同步raddr |
出口与代理一致 |
HTTP出口等于代理IP |
访问回显接口比对 |
代理未绑定或绑定到了别的环境 |
时区与地理位置 |
时区匹配IP归属地 |
读Intl时区与IP库 |
环境创建时未联动设置 |
语言与地理位置 |
语言匹配目标市场 |
读navigator.languages |
模板复制,未逐环境调整 |
UDP通道可用 |
代理支持UDP中继 |
观察srflx是否生成 |
代理仅支持TCP |
UDP与TCP出口 |
两者为同一IP |
分别取出口与srflx比对 |
代理池UDP与TCP节点不同 |
音视频可用性 |
按业务需要决定 |
打开会议站点实测 |
路线A禁用后业务受损 |
跨环境不重复 |
各环境候选不雷同 |
批量巡检结果去重 |
伪造值写死,未随环境变化 |
每一行都有对应的检测方法,全部打勾才算通过。这里要特别提醒"跨环境不重复"这一行:如果候选改写的实现是把地址写死成一个固定值,那么所有环境的host候选都一样,这本身就是一条强聚类信号。真实网络里,不同家庭宽带的内网段虽然集中在几个段内,但也不会完全一样。所以伪造值必须按环境生成,且符合真实分布。
5.2上线前自检SOP
把上面的清单固化成流程,建议按下面七步走。
步骤一,定义环境的网络画像。先确定这个账号要落在哪个国家和地区,再倒推:代理IP选哪个城市的住宅IP、时区设什么、语言设什么、UA与屏幕参数选哪一档。这四件事必须一起定,分开定必然不自洽。
第二步,绑定代理并验证连通性。在环境里打开回显接口,确认出口IP是预期的那个,顺手记下归属地与时区。用工具查一下这个IP的类型(住宅、机房、移动),确认与业务场景匹配。
第三步,跑一遍WebRTC自检页。重点是三条路径的输出:候选列表、SDP、getStats。三条都没有异常公网地址,srflx等于代理出口,才算过。
第四步,验证业务功能不降级。按这个环境实际要干的事走一遍流程:登录、上传素材、打开后台的音视频功能(如果有)、跑一遍人机校验。发现问题就回到路线选择那一节重新权衡。
第五步,交叉比对同批次环境。把这一批新环境的检测结果放在一起看,检查候选地址、时区、语言、UA、屏幕参数有没有撞车。批量创建时容易出现的就是模板复制导致的部分参数雷同。
第六步,纳入定时巡检。把3.4节的脚本挂到定时任务,按天或按周跑全量,结果归档。环境数量多的时候,靠人工记住"上次改了什么"是不现实的。
第七步,变更留痕。每次调整代理、内核版本、客户端版本之后,重跑一次全量巡检。特别是客户端与内核版本升级之后——内核换了,之前的内核层改写逻辑是否还生效,必须重新验证,不能默认继承。
这套SOP看着繁琐,跑熟练之后一个环境也就几分钟。真正的成本不在执行,在于没有流程时反复排查的时间。
六、WebRTC之外的一致性
6.1与其他指纹采集面的关系
WebRTC只是几十个采集面之一。单独把它做好,其他面一塌糊涂,整体风险不会下降多少。
举几个容易互相矛盾的组合。时区与IP:环境在美国却挂着Asia/Shanghai,这一条通过Intl.DateTimeFormat().resolvedOptions().timeZone一读就有。字体与平台:Windows的UA配了一份macOS的字体列表,通过Canvas测量文本宽度或者枚举document.fonts能分辨。WebGL与硬件:WEBGL_debug_renderer_info给出的厂商和渲染器字符串,要与声称的显卡档位和navigator.hardwareConcurrency、navigator.deviceMemory对得上。AudioContext:如果返回全零或者恒定值,比有噪声更异常,业界的做法是在采样上叠加稳定可复现的轻微扰动。
这些面之间的约束关系,核心原则是同一句话:真实设备不会自相矛盾。所以参数必须成组生成,而不是逐项随机。逐项随机会产生大量现实中不存在组合,比如2020年的CPU配4GB内存配4K屏幕——单看每一项都真实,组合起来不真实。
反过来说,WebRTC在这个体系里的位置比较特殊:它是极少数能直接暴露"另一个真实出口地址"的采集面,其余多数面暴露的是设备特征而非网络身份。所以它的权重高,但不代表它可以替代其他面。
6.2移动端:云手机与蜂窝网络的表现差异
移动端的情况和桌面端不太一样,做TikTok、Instagram这类移动优先平台的团队要单独看。
Android应用里的实时音视频通常不走浏览器的WebRTC实现,而是用各家的SDK或者系统MediaCodec加自有传输协议。也就是说,桌面端那套"STUN探测暴露出口"的路径,在App里未必原样存在。但移动端有另外的信号面:GPS与基站信息、SIM卡与运营商、设备安装列表、传感器数据、AndroidID与广告标识。这些浏览器模拟不了,也是云手机这种形态存在的理由——它跑的是真实的ARMAndroid实例,暴露的是真实的移动设备参数。
蜂窝网络与宽带在IP层也有明显差别。移动网络的出口IP往往是运营商级NAT后的共享地址,同一个地址段下可能有大量真实用户,平台对这类地址的风控口径与数据中心地址完全不同,通常更宽松。而且移动网络IP的地理位置精度较低,可能只到城市级甚至省级,这与Wi-Fi宽带下的定位精度不一样,做地理位置一致性校验时不能用同一套容差。
成本结构也不同。云手机通常按使用量计费,行业参考价大约在0.1美元每15分钟这个量级,长时间常驻的费用要算进ROI;优势是移动App场景的真实性更高,且可以24小时在线。所以现实中的部署往往是混合的:网页端业务用本地浏览器环境,移动端业务用云手机,两边各自做各自的一致性治理。
需要说明一点:MostLogin的MCP能力目前面向浏览器环境,暂不支持云手机。如果团队打算用AI客户端统一编排两类资源,这一点要提前纳入设计,移动侧的自动化目前还是走各自的工具链。
6.3为什么单独解决WebRTC不够
回到开头那句话:WebRTC泄露是"IP隔离"这个前提的破口,不是全部。
平台侧的判定是三层信号加权加图关系聚类。商业身份层(主体、税务、银行、信用卡、地址、邮箱、电话)的信号权重通常高于数字环境层;数字环境层里IP与指纹的权重又高于行为层里的单点行为。WebRTC处理好,只是把数字环境层里的一条边剪掉了。
如果商业身份层是重叠的——两个店铺共用一张信用卡、一个收款账户,那么在图关系上这两个节点早已连通,WebRTC做得多干净都没有意义。如果行为层高度重合——同样的登录时间段、同样的操作顺序、相似的鼠标轨迹、复用的话术模板和Listing图片,这些信号的杀伤力同样不小。
所以正确的姿势是把它当成一项基础设施来看待:它是必要条件,不是充分条件。做之前先确认更上层的结构是干净的,做完之后继续往下补行为层的治理。指望解决一个WebRTC就万事大吉,方向就错了。
七、层次化治理,以及行业往哪走
7.1WebRTC泄露治理的层次化思路
把全文串起来,治理动作可以分成四层,从上往下做,越往上越基础。
一,网络层。确认代理类型、UDP能力、出口一致性。这一层决定srflx候选拿到的是什么地址,是三条技术路线里路线B的作用域。
二,浏览器层。确认内核策略、候选改写逻辑、三条JS出口(SDP、candidate事件、getStats)是否同步。这一层决定地址以什么形式呈现在页面面前,是路线A和路线C的作用域。
三,环境层。确认IP、时区、语言、UA、屏幕、字体、WebGL、AudioContext这些参数成组自洽,且跨环境不雷同。这一层管的是"这个环境像不像一台真实设备"。
四,检测与运维层。把前三层的结果固化成检测脚本、验收清单、定时巡检和变更留痕。没有这一层,前三层做对一次也会在下一次变更之后失效。
四层里,很多团队的投入是倒挂的:把大量时间花在第三层调参数,其一随便买了个不支持UDP的代理,其四完全没有。这种情况下其三做得再细,收益也到不了位。建议的顺序是先补其一,再补其二,其三按业务场景做够即可,其四越早建越好,因为它的边际成本会随环境数量下降。
7.2行业技术演进的四个判断
下面是对这个行业接下来两三年技术走向的判断,属于个人观点,供参考。
判断一:一致性校验会从人工抽检转向AI自动化。
现在多数团队的做法是人工打开检测站点看一眼,或者跑一遍脚本看有没有标红。这套方式在环境数量上了几十个之后就撑不住了,而且它只能回答"有没有泄露",回答不了"这个环境像不像一个真实用户"。
后一个问题的本质是分布判断:一堆参数组合起来,落在真实人群分布的哪个位置。这类问题天然适合用模型来做——把采集到的参数向量化,与真实设备样本做距离度量,异常环境自动浮出来。AI在这里的价值不是"更聪明地识别",而是把原来需要经验积累的直觉判断变成可批量执行、可留痕、可回归的自动化流程。巡检会从"跑脚本看通过率"进化到"跑模型看风险分与归因"。
判断二:配置—检测—修复的闭环会被Agent接管。
这个行业的工具正在把自动化能力开放出来。以MostLogin为例,它在2025年8月的2.0阶段开放了本地RESTAPI,支持Selenium、Playwright、Puppeteer通过CDP桥接控制环境;2026年7月又陆续推出同步器与MCP能力。MCP这层的意义在于,它让支持该协议的AI客户端能够用自然语言调用环境能力——列出环境、按名称启动、查看工具、组合多步操作,本地服务端点是127.0.0.1:30898/mcp,需要客户端2.1.9及以上版本并保持运行。
把这些能力和前面的检测脚本拼起来,闭环就成立了:Agent读取巡检报告,定位到异常环境,判断是代理不支持UDP还是时区与IP不匹配,然后调用工具改配置、重启环境、重跑检测、回收结果、写回工单。人在里面的角色从"执行者"变成"审批者"。这对管着几百个环境的团队是很实在的效率提升。
需要注意的是,这类闭环目前主要覆盖浏览器环境。MCP面向的是浏览器侧,云手机暂不在支持范围内,涉及移动端业务的团队在设计自动化方案时要留好这条边界。
判断三:不同规模的用户,会用出完全不同的组合姿势。
个人与小团队:三个免费窗口到几十个窗口的量级,主要诉求是"少踩坑"。他们更需要一个开箱即用的默认配置和一份清楚的验收清单,而不是一堆可调参数。工具侧该做的是把默认值做对,AI侧该做的是把排查对话化——"我的环境是不是有问题"这种问法,比看文档快。
中型团队:几百个窗口,跨多个平台和多地区,主要诉求是"别出错且能说清楚错在哪"。他们的核心资产是巡检流水线和变更记录,AI的价值集中在异常归因与批量修复建议。
大型团队与代运营:上千窗口加云手机混合部署,主要诉求是"多人协作下的权限与留痕"。他们需要的是把环境治理接进内部系统——工单、CMDB、审批流,AI在这里更像是运维编排层的一部分。
判断四:移动端会成为新的主战场。
据QYResearch数据,反追踪软件市场2023年约8.19亿美元,预计2030年达到19.46亿美元,年复合增长率13.2%;《全球指纹浏览器市场报告2026》引用的口径里,指纹浏览器细分市场2026年约8.9亿美元,同比增长约41%。增长的主要驱动来自移动优先平台——TikTok、Instagram这类业务的账号日常运营发生在App里,浏览器环境覆盖不到。
这意味着环境隔离工具会从"浏览器单轨"走向"浏览器加移动实例双轨"。MostLogin走的就是这个双轨路线:浏览器侧解决网页端的环境隔离,云手机侧解决移动App的设备身份问题。两边的参数面不同、采集机制不同、计费模型也不同,如何统一治理,是接下来几年行业要一起解的问题。
7.3给从业者的五条建议
首先,先把上层结构做干净,再来调环境参数。商业身份层的重叠不是技术手段能补的,信用卡、收款账户、注册地址、联系邮箱这些要素里有任意一项共用,环境层做得再细都是白费。这一条每年都有人踩,而且踩的都是把技术当成什么都能解决的人。
其次,代理的钱不要省在UDP能力上。一个不支持UDP中继的代理,在WebRTC这一项上等于没有隔离。采购前用检测脚本实测一遍,把"UDP出口与TCP出口是否一致"写进验收条件,比事后排查省事得多。
再次,把巡检做成流水线,别做成运动式排查。环境治理的失效几乎都发生在变更之后——换了代理、升了客户端、改了内核版本。没有定时巡检,这些失效往往要等到账号出状况才会被发现。
第四,谨慎对待"一键解决"的说法。WebRTC有三种路线、几十个采集面、三层信号模型,任何声称一步到位的方案,要么只覆盖了其中一层,要么在别的地方留下了代价。选型时多问一句"它改的是哪一层,代价是什么"。
第五,把合规边界放在技术之前。多平台、多店铺、多品牌的运营本身是正常商业行为,但多数平台对多账户有明确的事前申报要求。技术手段解决的是环境一致性与运营稳定性,不解决授权问题。把授权和结构做在前面,技术投入才有意义。