使用浏览器指纹实现免填邀请码绑定

最近产品经理提了一个优化需求希望实现“App 免填邀请码安装”、“免填邀请码绑定邀请关系”,简化拓客拉新的流程。本文讨论如何借助浏览器指纹实现该需求。

现有方案

经过调研,市面上已经有不少商业化产品:

整体流程都差不多,大致分为以下几步:

  1. 推荐人分享链接或者二维码
  2. 用户打开分享链接,进入 App 下载页
  3. 用户通过点击下载安装按钮,上报设备指纹和携带参数,同时打开应用商店或者直接下载
  4. 用户安装 App,打开后再次上报设备指纹,获取携带参数和推荐人信息

以上几种商业产品在使用时需要各端分别接入 JS/iOS/Android SDK。

设备指纹

“免填邀请码”实现的关键在于设备指纹,所谓设备指纹就是可以用来标识设备唯一性的特征。

下面以手机为例,列举一些常见的设备特征:

  1. 国际移动设备识别码(International Mobile Equipment Identity,IMEI),用于区分不同的移动通信设备
  2. 移动设备识别码(Mobile Equipment Identifier,MEID),CDMA 制式手机识别码,类似 IMEI
  3. 电子序列号(Electronic Serial Number,ESN),GSM 制式手机识别码
  4. 国际移动用户识别码(International Mobile Subscriber Identity,IMSI),用于区分蜂窝网络中的不同用户
  5. iPhone 设备的 Identifier for Advertisers (IDFA),用于追踪用户的广告标识符,每台设备只有一个 IDFA,可以重置
  6. iPhone 设备的 Identifier for Vendors (IDFV),用于区分同一台设备上的应用开发商
  7. MAC 地址,全称 Media Access Control Address,也被称为“物理地址“,用于标识网络中的不同网卡,iPhone 允许用户为 WiFi 设置单独的私有地址,减少追踪

遗憾的是在浏览器中无法获取到这些设备标识,那么浏览器有哪些特征可以用来标识唯一性呢?

浏览器指纹

浏览器指纹生成来源主要包括浏览器和系统的一些特性、个人偏好设置:

  • 浏览器的用户代理字符串(User Agent,简称 UA)
  • 浏览器 Cookie
  • 浏览器安装的插件、扩展
  • 浏览器语言设置
  • 通过 Canvas 生成的图像 Hash
  • 通过 WebGL 生成的图像 Hash
  • 通过 AudioContext 生成的音频 Hash
  • WebRTC
  • 系统平台
  • 系统时区
  • 系统字体列表
  • 系统深色、浅色主题
  • 屏幕分辨率
  • 屏幕色彩深度
  • 公网 IP(依赖外部接口调用)
  • 地理位置(依赖定位授权)
  • 等等

业界已经有成熟的 JS 库用于生成浏览器指纹,比如 fingerprintjs

@fingerprintjs/fingerprintjs 开源版支持以下特征源:

const components = [
  'audio', // 音频
  'canvas', // 图形
  'colorDepth', // 色度
  'colorGamut', // 色域
  'contrast', // 对比度
  'cookiesEnabled', // 是否启用 Cookie
  'cpuClass', // CPU 类型
  'deviceMemory', // 设备内存
  'domBlockers', // 是否开启 adBlock
  'fontPreferences', // 字体偏好
  'fonts', // 字体列表
  'forcedColors', // 浏览器是否开启强制颜色模式 `forced-colors`
  'hardwareConcurrency', // CPU核心数
  'hdr', // 是否支持动态范围 `dynamic-range`
  'indexedDB', // `indexedDB`
  'invertedColors', // 反色
  'languages', // 浏览器语言
  'localStorage', // localStorage
  'math', // 数学函数
  'monochrome', // CSS `monochrome`
  'openDatabase', // `openDatabase`
  'osCpu', // 操作系统类型
  'platform', // 系统平台,Win32/MacIntel/iOS/iPad/Linux armv8l
  'plugins', // 浏览器插件
  'reducedMotion', // 用户的系统是否开启 Reduce Motion `prefers-reduced-motion`
  'screenFrame', // 屏幕可用区域外边距
  'screenResolution', // 屏幕分辨率
  'sessionStorage', // `sessionStorage`
  'timezone', // 时区
  'touchSupport', // 是否支持触屏
  'vendor', // 浏览器供应商 `navigator.vendor`
  'vendorFlavors' // 浏览器特定标识
];

除此以外,收费版 @fingerprintjs/fingerprintjs-pro 支持更多的特征源:

  • WebGL
  • RTCPeerConnection WebRTC
  • SpeechSynthesis 声音合成
  • navigator.mediaDevices 媒体设备
  • crypto 加密
  • Notification 消息通知
  • navigator.webdriver
  • Error.prototype.toSource()
  • WebAssembly
  • navigator.doNotTrack 是否开启 Do Not Track
  • prefers-color-scheme 系统主题色(亮色或者暗色)
  • performance 性能
  • devicePixelRatio 设备像素比
  • document.cookie

看下 @fingerprintjs/fingerprintjs 的一个简单使用示例:

import { load } from '@fingerprintjs/fingerprintjs';

const agentPromise = load();
const agent = await agentPromise;
const result = await agent.get();
const { visitorId } = result;
console.log(visitorId);

visitorId Hash 值的生成过程:对采集到的数据使用 JSON.stringify() 序列化,再使用 MurmurHash3 算法生成 128 位 Hash(32 位 16 进制字符串)。

经测试,不同设备不同浏览器生成的 visitorId 均不相同。

@fingerprintjs/fingerprintjs 很不错,但并不能直接满足我们的需求。因为我们实际上不需要那么精确的区分浏览器,真正期望区分的是设备,同一台设备上的不同浏览器应该拥有相同的指纹,即“跨浏览器指纹”。在选择特征源时不依赖于浏览器特性,而依赖于硬件差异。

跨浏览器指纹

参照下图,让我们重新梳理一下哪些特征可以跨浏览器。

+------------------+
| 应用程序(如浏览器)|
+------------------+
        ↑
+------------------+
| 操作系统(驱动程序)|
+------------------+
        ↑
+------------------+
|       硬件        |
+------------------+

经调研已知:

  • iOS 系统只能使用内置的 WebView,即 WKWebView,旧的 UIWebView 已经废弃
  • iOS 默认 Safari 浏览器跟随 iOS 系统升级更新,类似 IE 之于 Windows。
  • Android 系统允许 App 自定义 WebView 实现,比如腾讯的 TBS (X5) 内核。
  • Android 和 iOS 的 WebView 组件都支持自定义 UA。
  • 国产 Android 系统允许用户自定义系统字体,iOS 不支持(相同版本字体列表相同)。

初步筛选出可用的最少特征包括:

  • 系统类型(区分 iOS/Android),对应 UA
  • 系统版本号(区分不同版本的 iOS/Android),对应 UA
  • 分辨率(区分不同尺寸的设备),对应 screen
  • 图形(区分不同设备),对应 Canvas、WebGL
  • 音频(区分不同设备),对应 AudioContext

理论上虽然用户的硬件和软件看上去一样,但终归是不同设备,图形渲染难免有差异。

下面进行验证,@fingerprintjs/fingerprintjs 已经包含 Canvas 和 Audio 指纹(Canvas 指纹受 WebView 字体设置影响,这里加上了剔除文本渲染的“稳定” Canvas 指纹)。

import { load } from '@fingerprintjs/fingerprintjs';

const agentPromise = load();
const agent = await agentPromise;
const result = await agent.get();
const { visitorId, components } = result;
const canvasHash = hashComponents({
  canvas: components.canvas
});
const canvasHashStable = hashComponents({
  canvas: {
    ...components.canvas,
    value: {
      ...components.canvas?.value,
      text: ''
    }
  }
});
const audioHash = hashComponents({
  audio: components.audio
});
const print = (name, value) => {
  console.log(name, value);
  const el = document.createElement('pre');
  el.textContent = `${value} (${name})`;
  document.body.appendChild(el);
};

print('visitorId', visitorId);
print('canvasHash', canvasHash);
print('canvasHashStable', canvasStableHash);
print('audioHash', audioHash);
print('UA', navigator.userAgent);

验证结果:

ios-fingprints.png

这里只给出 iPhone 的测试数据截图,详细数据见 https://github.com/keqingrong/fingerprint/tree/master/docs

总结:

  • 不同 Android 手机 canvasHash/canvasHashstable/audioHash 可能相同。
  • 同一台 Android 手机
    • canvasHash 9/13 概率相同,不同的是 Chrome、Edge、火狐和微信内置浏览器。
    • canvasHashStable 9/13 概率相同,不同的是 Chrome、Edge、火狐和微信内置浏览器。
    • audioHash 8/13 或 4/13 概率相同,其中猎豹浏览器、百度浏览器、手机 QQ 和苏宁易购内置浏览器相同,火狐独一无二。
      • Chrome/77: Edge、QQ 浏览器、微信内置浏览器
      • Chrome/78: UC 浏览器、夸克浏览器
      • Chrome/79: 小米浏览器
      • Chrome/80: Chrome
      • Chrome/83: 华为浏览器
      • Chrome/87: 猎豹浏览器、百度浏览器、手机 QQ 内置浏览器、苏宁易购内置浏览器,对应 Android System WebView 87.0.4280.101
  • 不同 iPhone 相同版本系统中 Safari 的 UA 相同。
  • 不同 iPhone 不同版本系统,canvasHash/canvasHashStable/audioHash 可能相同。
  • 同一台 iPhone 上不同 App 的 canvasHash/canvasHashStable/audioHash 相同。对应 iOS 系统只能使用内置的 WebView。
  • iOS App 内置 WebView UA 中的 iOS 版本号不一定和 Safari 一致,比如异类 Chrome。

可以进一步发现:

  • Canvas 指纹、Audio 指纹看似受硬件设备影响,但实际取决于浏览器内核。
  • 微信 Android 版内置了 WebView。为了识别设备需要提示用户使用系统浏览器打开页面,可能还要排除掉其他特征不一致的浏览器。
  • Android 设备 UA 中会包含 Android 版本号(Android 10)、手机型号(Redmi 8),不一定包含定制版系统版本号(Build/QKQ1.191014.001)。Chrome 和 Edge 一般不带定制版系统的版本号。
  • 识别 iOS 需要考虑 iOS 版本,即 /iPhone OS \d+_\d+(_\d+)?/

最后结论令人沮丧,Android 因支持自定义 WebView 而导致相同设备不同 App 的浏览器指纹互不相同,iOS 因不同手机指纹相同而没有区分度,跨浏览器指纹很难有稳定生成方案。

如何改进

如果可以获取到设备的唯一标识,那么只要匹配这个标识就能确定用户。但如果缺少这样的标识,只能尽可能多的搜集客户端信息,交给服务端来决策。

在浏览器指纹的基础上,可以增加以下辅助手段:

IP 地址

在 WebView 的基础上,调用外部接口获取当前公网 IP 地址,辅助判断。

当然也有相应风险点:

同一个局域网中的不同设备公网 IP 地址一样。具体场景:多台设备连接同一个 WiFi,或者你的网络运营商为了解决 IPv4 地址短缺问题,使用了 NAT 技术(比如中国移动),会存在多个手机号上网时的公网 IP 地址相同。

存在一部分用户使用相同的网络,相同品牌型号、相同系统版本、相同系统设置的手机,使用相同浏览器。

还存在一部分用户,使用蜂窝网络(数据流量)打开 App 下载页面,但切换到 WiFi 下载 App。此时的 IP 地址不同,但可以通过 IP 地址位置库分析两个 IP 所在城市是否一致。更推荐在下载页提示用户连接到 WiFi 以后再下载 App,避免前后 IP 地址变化。

时间范围

统计 App 成功安装注册前打开网页的用户浏览器特征,缩小判断范围,虽然使用某台设备的人数多,但下载 App 前打开下载页面的人是有限的。甚至可以根据 App 安装包体积,估算下载时长,倒推下载时间、确定信息搜集上报的时间点。

系统剪切板

openinstall 之类的商业产品在 App 下载页会尝试将推荐人邀请码写入剪切板,安装注册 App 后读取剪切板,查询服务器获取推荐人信息。

相关链接