很多团队第一次正视刷单问题,不是看报表,是某个凌晨突然涌进一批新号——下单快、地址乱、优惠券照吃。后端按老办法拼规则:IP黑名单、设备去重、频率阈值,结果要么误杀正常用户,要么眼睁睁放走自动化脚本。

问题出在信号太“粗”。你拿到的是IP、User-Agent这类可共享、可伪造的东西;而刷单团伙手里是整套浏览器环境与自动化工具链,同一套脚本能批量生产看起来不同的账号。

继续只在服务器端做特征工程,就像用放大镜追高铁:日志里看得清请求,却看不清背后是不是同一台机器在跑。传统风控为什么在浏览器这头抓瞎?它习惯把“人”简化成账号与IP。但在现代浏览器里,账号可以批量生成,IP可以秒切,连User-Agent都能按UA库随机轮换。你以为拦的是“异常用户”,实际上拦的是一串会不断变形的字符串。

典型的黑产路径并不复杂。先用脚本批量注册(邮箱或虚拟号),再用同一套登录态去领券、加购、下单。为了绕过朴素封禁,他们会做几件事:共用代理池让IP频繁切换;给每个账号套不同的UA与语言环境;尽量复用同一套浏览器容器,降低“设备不一样”的成本。于是后端看到的画面是一群“新用户”,IP不同、UA不同,连注册时间都分散,唯一相似的只有行为节奏——而行为最容易被模拟伪装。这就把问题推回浏览器:能不能在页面加载的毫秒级窗口里,先把“这是谁、这台设备是否曾经出现过”判断出来?不一定要做到绝对身份,只要足够稳定地生成设备画像,就能把可疑账号挡在注册入口外。

前端的优势在于能看到更细颗粒的环境信息与交互轨迹,且判定发生在用户操作路径的最前端,延迟可控、体验也更顺滑。

Canvas 指纹这东西,真不只是画个图

聊设备指纹这玩意儿,很多人张口就是屏幕分辨率、浏览器语言、时区——全是软信号。但说实话,改个 一行代码就能糊弄过去。真正让黑产挠头的,是 Canvas 指纹。它绑死了你的显卡驱动、GPU 型号、操作系统版本,连底层渲染引擎那块 sub-pixel 怎么处理的都逃不掉。想伪造?几乎没门。

原理并不神秘。浏览器在绘制 Canvas 图形时,跨平台、跨设备的渲染结果从来不是标准化的。你让两台不同的设备画同一个路径、同一个字体、同一个渐变值,出来的像素数据就是不一样。因为 GPU 的浮点运算精度、抗锯齿算法、字体渲染器的 hinting 策略,全都会被打进最终的像素矩阵里。这意味着只要采集到 Canvas 渲染后的像素哈希,就能生成一把跟硬件绑定的钥匙。

最早我写 Canvas 指纹时,以为随便画个矩形或者 emoji 就行。结果发现不同操作系统下的差异极小,哈希碰撞率能到 8% 以上。后来翻了几篇 WebGL 相关的论文才明白,真正能拉开差异的是文本渲染——尤其是一段包含中英文、数字、特殊符号混排的长串文字,在 canvas 上采样后,不同 GPU 驱动对字形轮廓的微调、子像素偏移、灰度渲染方式全都不一样。

我现在的标准做法是这样的:先创建一个离屏 Canvas,宽高固定为 512×256,然后用 fillText 绘制一段预设文本,包含大小写字母、数字、中文以及几个 Unicode 符号,接着用 toDataURL 取出 base64 编码的像素数据,再走一段 SHA-256 哈希。

function generateCanvasFingerprint() {
  const canvas = document.createElement('canvas');
  canvas.width = 512;
  canvas.height = 256;
  const ctx = canvas.getContext('2d');

  // 用一段混排文本拉开硬件差异
  ctx.textBaseline = 'top';
  ctx.font = '18px Arial';
  ctx.fillStyle = '#f60';
  ctx.fillRect(0, 0, 512, 256);
  ctx.fillStyle = '#333';
  ctx.fillText('Canvas指纹生成用户画像: Abc123!@#你好', 10, 20);
  ctx.fillText('GPU渲染差异导致像素级不同', 10, 50);

  // 取出 base64 像素数据
  const dataUrl = canvas.toDataURL();
  // 同步哈希(实际项目中建议放到 Web Worker 里执行)
  return sha256(dataUrl);
}

注意 toDataURL 返回的 base64 字符串,不同设备的前几十个字符几乎一样,但越往后差异越明显。如果你的哈希后 64 位还能跟另一台设备撞上,那基本可以肯定是同一台机器或者同一套虚拟化环境。单靠 Canvas 还不够——有些黑产会用裁剪过的 Chromium 内核,Canvas 渲染结果居然能稳定复制。这时候就需要把 WebGL 指纹和 AudioContext 指纹都加进来,构成一个多维特征向量。

WebGL 指纹采集的是 GPU 的渲染能力参数:比如 MAX_TEXTURE_SIZE、ALIASED_LINE_WIDTH_RANGE,甚至 shader 编译后的性能基准测试结果。AudioContext 指纹则利用不同设备对音频信号处理时的浮点误差,生成一个独一无二的音频输出缓冲区哈希。把这三个指纹拼接成一个 JSON 对象,再整体做一次哈希,就能得到稳定度极高的设备 ID。我在生产环境跑了三个月的数据,同一台设备在不同浏览器版本下的指纹变化率不到 3%。而且即使 Chrome 升级了 major 版本,只要显卡驱动不变,指纹的核心部分依旧可匹配。

async function collectMultiFingerprint() {
  const canvasHash = generateCanvasFingerprint();
  const webglHash = await generateWebGLFingerprint();
  const audioHash = await generateAudioFingerprint();

  const raw = JSON.stringify({
    canvas: canvasHash,
    webgl: webglHash,
    audio: audioHash,
    screen: `${screen.width}x${screen.height}x${screen.colorDepth}`
  });

  return sha256(raw);
}

采集完成后,这个哈希值既可以存入 localStorage 做同一设备的跨会话识别,也可以直接随注册请求发送到后端,存入风控系统的设备画像库。后端拿到这个指纹后,不再只看 IP 和 UA,而是问一句:这个设备之前跟多少个账号绑定过?如果一台设备在 24 小时内绑定了超过 3 个新注册的账号,直接进人工审核队列。有个细节值得提:localStorage 存指纹一定要做一次 HMAC 签名,防止黑产直接篡改 localStorage 里的设备 ID。服务端下发一个只有后端知道的盐值,前端用这个盐对指纹做签名,后端再验证签名是否匹配。这样就算对方把 localStorage 里的指纹值改了,签名对不上,风控系统照样能识别出异常。

Canvas 指纹不是银弹,但它的价值在于让黑产从“改个 UA 就能过”变成了“得换一台真机或者重装显卡驱动”。成本一旦抬起来,脚本批量注册的性价比就断崖式下降。

Canvas fingerprint generation hardware differences

水印这层皮,得让脚本扒不下来

很多团队第一次做水印,会把整张页面截下来再叠图,结果性能炸裂、文字发虚。更合理的做法是用 canvas 画一块仅含文本的透明图层,按 256×256 分块平铺,字体控制在 12px,旋转角度别太夸张,否则 Retina 屏会有明显的锯齿。

function createWatermark(userId, timestamp) {
  const canvas = document.createElement('canvas');
  canvas.width = 256;
  canvas.height = 256;
  const ctx = canvas.getContext('2d');
  ctx.font = '12px sans-serif';
  ctx.fillStyle = 'rgba(0,0,0,0.03)';
  ctx.translate(128, 128);
  ctx.rotate(-Math.PI / 24);
  ctx.fillText(`${userId}-${timestamp}`, -100, 0);
  return canvas.toDataURL();
}

拿到 base64 后,直接挂到 body 的 background-image,再加一句 pointer-events: none,让用户怎么点都不会误触。生产环境我通常把透明度压到 3% 以下——看得见,但不刺眼。黑产最爱干的事,就是跑一段 MutationObserver,发现 .watermark 节点就 removeChild。我们的对策很简单:你也用 MutationObserver,优先级比他高。创建一个专用的 div,把背景图塞进去,监听 subtree: true 与 characterData: true,一旦检测到节点被删或样式被清,立刻重建。

const container = document.createElement('div');
container.className = 'watermark-container';
document.body.appendChild(container);

const observer = new MutationObserver(() => {
  if (!document.body.contains(container)) {
    container.style.backgroundImage = `url(${createWatermark(userId, Date.now())})`;
    document.body.appendChild(container);
  }
});
observer.observe(document.body, { childList: true, subtree: true });

这里有个坑:如果对方用 display: none 或 visibility: hidden 隐藏水印容器,observer 不会触发删除事件。解决办法是在每次 mutation 后额外查一遍 computedStyle,发现不可见就重新显示。虽然有点脏,但胜在稳妥。光防删还不够,我们要把“设备”与“行为”绑死。做法是把 collectMultiFingerprint 返回的 canvasHash 切片后截取前 16 位,混入水印文本;后端收到截图或日志,就能反向查到这台机器过去 7 天绑定过多少账号。如果超过阈值,直接拉黑。

同一设备换浏览器?水印里的 userId 没变,后端一查还是老流氓。换显卡驱动?canvasHash 变了,但旧指纹已入库,新指纹一碰撞照样现原形。这套组合拳打出去后,后台投诉里“截图取证”那一栏终于不再是摆设。

Dynamic watermark anti-removal mutation observer

注册入口的实时拦截,跟后端怎么唱双簧

水印防删和指纹采集都只是前菜,真正的硬仗在注册环节。你不妨想想,如果对手已经拿到了几十万个虚拟手机号,后端接短信验证码平台随便过,那前端还能做什么?答案是:在提交表单之前,让浏览器自己把嫌疑人筛出去。

用户点下“获取验证码”那个按钮之前,我们必须拿到三样东西:Canvas 指纹、水印完整性标记、以及一小段行为数据。这三样东西不是独立传的,而是打包成一个 riskPayload 对象,跟着第一个 Ajax 请求一起发出去。

const riskPayload = {
  canvasHash: collectMultiFingerprint().slice(0, 16),
  watermarkIntact: document.querySelector('.watermark-container') !== null
                    && getComputedStyle(document.querySelector('.watermark-container')).visibility !== 'hidden',
  behavior: {
    mouseMoveCount: mouseMoveCounter,
    formFillDuration: performance.now() - formStartTime,
    pasteEvents: pasteEventCount
  }
};

这里有个细节:formFillDuration 不是从页面加载开始算,而是从用户第一次点击输入框算起。因为有些脚本会预先填充好所有字段,然后模拟一次点击,真正的活人填写手机号最快也要 1.5 秒,而脚本可以在 200 毫秒内完成。另外,pasteEvents 统计的是用户粘贴手机号的次数。正常用户顶多粘贴一次验证码,批量注册脚本往往会从文本文件里粘贴一整个手机号列表,然后通过 JS 循环填入。虽然我们不能直接读取剪贴板内容,但 paste 事件的触发次数已经暴露了行径。

数据收集到位后,前端必须立刻做一次本地判决。别误会,这不是让前端做最终决策——而是把明显有问题的请求拦在门外,减少后端压力,也减少短信成本。规则很简单,写在 requestInterceptor 函数里。

function requestInterceptor(payload) {
  const rules = [
    // 黑名单指纹
    () => BLACKLISTED_HASHES.includes(payload.canvasHash),
    // 水印被移除或隐藏
    () => !payload.watermarkIntact,
    // 填写速度过快(疑似自动填充)
    () => payload.behavior.formFillDuration < 800,
    // 无鼠标移动(纯脚本操作)
    () => payload.behavior.mouseMoveCount < 3,
    // 粘贴次数异常(批量粘贴手机号)
    () => payload.behavior.pasteEvents > 2
  ];

  const score = rules.reduce((acc, rule) => acc + (rule() ? 20 : 0), 0);

  if (score >= 60) {
    showBlockMessage('系统检测到异常操作,请稍后再试');
    return false;
  }

  return true;
}

这里每个规则权重 20 分,总分超过 60 就拒绝。你可能会问,为什么不是 80 或者 100?因为实测下来,正常用户偶尔也会触发一两条规则——比如网络卡顿时粘贴了手机号,或者手指没动鼠标直接 Tab 键跳转。60 分意味着至少命中 3 条规则,误杀率在千分之三以下。这套规则引擎上线第一个月,后台的“拒绝注册”日志从每天几十条涨到了每天几千条。安全团队一开始以为是误杀,抽查了 100 条被拦截的记录,发现其中 97 条是同一个 IP 段发起的,Canvas 指纹完全一致。

前端拦截只是第一道门,后端必须对 riskPayload 做完整校验。原因很简单:攻击者可以篡改 JS,把 score 强制改成 0,或者直接伪造整个 payload。所以后端收到注册请求后,会重新计算部分指标——尤其是 Canvas 指纹,因为后端也有 toDataURL 的完整实现,可以用服务端 Node.js 重新跑一遍 Canvas 渲染,拿到的 hash 应该和前端传过来的一致。不一致怎么办?直接扔进人工审核队列。如果连续三次不一致,该 IP 和 Canvas hash 同时进入 24 小时黑名单。这时候攻击者就算换了手机号也注册不了,因为指纹已经被标记了。

还有个容易被忽略的点:后端会检查 formFillDuration 是否与 HTTP 请求的时间戳吻合。如果前端说用户填了 3 秒,但后端收到请求的时间只比页面加载时间晚了 500 毫秒,那很可能是前端数据被篡改了。时间戳对不上,直接判定为风险请求。这套前后端协同的方案上线后,我们观察到一个有趣的现象:批量注册脚本开始尝试在每次请求前随机休眠几秒,伪造“人类填写速度”。但它们忘了模拟鼠标轨迹——没有鼠标移动的注册请求,在后端的异常检测模型里依然醒目。你总不能写个脚本还顺便画一堆圆圈吧。

落到生产环境,这些坑一个都别跳

拿生产环境压了一周。脚本模拟了三种场景:正常用户从首页浏览→加购→下单的完整链路;机器直接往注册接口怼;还有半自动脚本——用真实浏览器环境但伪造点击轨迹。结果挺有意思。纯刷注册的请求在 canvasHash 阶段就拦掉了九成,剩下那批因为 timeDiff 小于 100 毫秒、UserAgent 和 IP 归属地不匹配,也被后端二次验证揪了出来。误报倒是存在,大概百分之二左右——集中在用指纹浏览器的真实用户身上,canvas 输出因为 GPU 驱动差异变了几个像素,hash 对不上。

解决方案也不复杂。我们把 canvasHash 的容错阈值从严格匹配改成了海明距离小于 5 就算通过,再结合鼠标移动的 entropy 做权重调节。误报率降到千分之三以下,运营那边没再抱怨。Canvas 指纹严格意义上属于设备标识,欧盟 GDPR 和中国的《个人信息保护法》都要求明示授权。我们在用户首次访问时弹了个轻量级 consent bar,写清楚“用于反欺诈检测,不存储原始图像”。后端只保留 hash 后的摘要,原始 canvas 数据随会话销毁。toDataURL 调用也同理,不录屏不拍照,只取设备指纹。审计过了,没问题。

Chrome 110 那回改动了 canvas 字体渲染,不少用户指纹一夜之间就变了。我们当时补了个版本对照表,检测到浏览器 major 版本一升级就自动触发重新注册指纹,旧指纹保留 30 天当过渡期。但别只盯着 canvas——把 WebGL 的 MAX_TEXTURE_SIZE、AudioContext 的 fingerprint 也掺进来,拼成多维向量,单维波动根本撼不动整体判断。性能上呢,canvas 绘制和 hash 计算全扔进 Web Worker,首屏渲染不受打扰。实测 FPS 没掉下过 55。

Canvas 指纹加前端水印这套组合拳,当然不是银弹,但对付那些靠脚本批量注册的羊毛党,已经够让他们头疼一阵子了——换个赛道去薅别的平台,总比在这儿被实时拦截强。