风控/隐私对抗中的浏览器指纹识别

Published: 2023年09月07日

In Auto.

协议指纹

这部分是最容易被忽略的,因此放在最前面。

TCP/IP指纹

就是检测IP层和TCP层的包头,不同系统可能会有差异,不过能提供的熵并不大:

packet

对于IP层,IHL(IP头长度)、TTL值可辨别,不过易受路由等影响。

对于TCP层,窗口大小、DF标志、选项字段(如MSS、窗口缩放、时间戳)等可用于辨识。

SSL/TLS指纹

TLS太复杂了,功能多,参数多,因此能提供大量的信息用于指纹识别。对TLS指纹研究可以追溯到Analysis of Googlebot's frugal cipher suite list - 2009TLS fingerprintingSmarter Defending & Stealthier Attacking - 2015,再后来就有了Open Sourcing JA3,JA3收集了Client Hello里的更多信息--TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurvePointFormats,最后通常会算一个JA3 Hash,不过在做风控时关注具体值就好。除了关注客户端指纹的JA3当然还有关注服务端的JA3S,不过这更多会在渗透测试中识别目标资产吧。

其实JA3面临一些问题,比如说RFC规定扩展顺序不影响握手,但它却可以改变JA3,另外它包括的参数不完全,所以还可以继续优化,比如peetprint就是分析更多信息,并将扩展顺序规范化后算出的。

总的来说这一块不应只满足于原始的JA3锁收集内容,每一个域都是可能的,而且不仅有静态信息,可通过协商进一步测试其能力,来识别一些粗糙的伪造。

对于这一部分指纹,可使用tls.peet.wsbrowserleaksscrapfly.io等工具检测

对抗简述

通常会用白名单和黑名单去做对抗,前者模拟真实的指纹而后者只是规避明显错误的指纹(如python原始发出的握手明显异常),所以成熟的攻击者都会使用前者,而ssl/tls太复杂了,要模拟好是个工程上的挑战。

例如chrome从110版开始默认启用GREASE机制(TLS中的GREASE机制),通过随机化扩展顺序(TLS RFC 4.2规定除pre_shared_key必须是最后一个外不同类型的扩展顺序任意)来尽快发现服务端TLS实施错误,另外它还急急忙忙自己实现了alps,这些都是openssl未实现的扩展能力,因此若要用openssl模拟相关行为需要自己实现对应扩展。而有些人也会通过直接改底层ssl库依赖去获取所需能力,例如将openssl替换为boringssl,亦或者自己实现一套,如Go的uTLS库,它专为对抗指纹而生,不过其实也就是能发一些fake包,实际并未实现功能,如果服务端再主动识别依然得露馅儿。这里简单说下怎么改openssl去模拟boringssl吧~

1.cipher suite一致:tls1.2和tls1.3的加密套件格式存在区别且完全互斥(tls1.2及之前与tls1.3的套件不能混用),如(ECDHE-RSA-AES256-SHA vs TLS_AES_256_GCM_SHA384),

参考Nginx with TLS 1.3(OpenSSL) SSL_CTX_set_ciphersuites

2.添加grease值,openssl上有个未被合并的prAdd RFC8701 GREASE Support #19646也已经实现了部分GREASE能力,可以在此基础上修改,注意boringssl的实现未完全按rfc来,模仿就得跟google学

3.extensions乱序

HTTP协议

HTTP指纹

有些信息不一致不影响整个通信,比如每个浏览器可以有自己的请求头,有自己的请求头顺序,请求头有自己的值,这些都不影响通信但可以作为指纹

HTTP2指纹

HTTP2协议上的一些特性可被用做指纹识别,HTTP2协议细节见RFC9113,这里简单描述下(也可见hpbn)。HTTP2在上层语义和之前的版本没什么差别,主要升级在传输上,它不再是明文传输,而是用二进制格式,数据分三层:

1.frame帧:最小通信单元,H2有多种不同类型的帧,如HEADER/DATA帧就是用于传递请求头和数据的,而为了实现其他功能(如流控)还存在多种功能帧

2.message消息:有一个或多个帧组成,如请求或响应消息

3.stream流:虚拟通道,消息在指定的流上传递,可通过打开多个流来进行多路复用等

它们关系可见下图:

Figure 12-2. HTTP/2 streams, messages, and frames

也可以在wireshark上下载几个sample体验下。这里涉及到的指纹分两点:请求头与akamai hash

在H2中cookie头可出现多次,也可以出现一次并用','分割多个值,前者是大多数实现的方式,但自己写代码很可能就无意识的改成后者的样子了。

akamai hash

这才是重点,原文可见AKAMAI WHITE PAPER - Passive Fingerprinting of HTTP/2 Clients,具体来说涉及到两个点:

1.伪头顺序:http头字段的顺序一直是个指纹识别点,而H2还额外加了4个伪头(:schema/:method/:authority/:path),它们的顺序也是个识别点

2.SETTING帧和WINDOW_UPDATE帧立大功:这些帧就相当于一个应用的默认配置,不同的应用可能不同,因此也能作为特征,具体语义看RFC吧

当然还有优先级帧和帧里的优先级字段,都得认真对比哦

应用级指纹

其实这个没有太多说的,因为它要采集的数据都得通过HTTP协议上报,分析这些数据即可,但这里还是列出一些常见的,只关注chromium哦。

通用静态数据

下面这些是必收集,又容易被伪造的静态数据:

navigator.userAgent // 浏览器、操作系统和其他相关信息
navigator.appVersion    // 浏览器的版本
navigator.platform  // 操作系统平台,如Win32
navigator.vendor/navigator.vendorSub // 供应商和子供应商信息,如Google Inc.,后者通常为空
navigator.product/navigator.productSub  // 浏览器产品名
navigator.oscpu // CPU架构
navigator.language/languages/userLanguage/browserLanguage/systemLanguage    // 当前语言和语言列表
navigator.hardwareConcurrency   // 处理器数
navigator.deviceMemory  // 内存大小 GB
navigator.plugins   // 浏览器插件
navigator.mimeTypes // 支持的mime类型
navigator.doNotTrack // 不要追我,通常没人改它 "1" (启用), "0" (禁用), 或 null (未设置)
navigator.maxTouchPoints    // 最大支持触控点数 
'ontouchstart' in window    // 是否支持触控
Error.stackTraceLimit  // 栈回溯层数
navigator.pdfViewerEnabled  // 是否支持pdf
performance.memory.jsHeapSizeLimit // 堆大小

下面再是和屏幕/窗口相关的:

screen.width/screen.height  // 屏幕(显示器)的宽和高
screen.availWidth/screen.availHeight
screen.colorDepth   // 屏幕的颜色深度
screen.pixelDepth  // 屏幕的像素深度

window.innerWidth/window.innerHeight    // 浏览器窗口的内部宽高(单位px),包含滚动条的可视内容区域
window.outerWidth/window.outerHeight    // 浏览器窗口的外部宽高(单位px),是整个浏览器窗口的宽度,包括工具栏、地址栏等
window.devicePixelRatio 

这些都是主屏的,如果要知道每个屏幕,需要window.getScreenDetails接口,它需要授权。

主动一致性检测

静态数据容易被伪造,所以需要多方验证数据一致性,最好的方式是主动测试,下面是一些例子:

1.检测窗口宽高是否异常:

function deviceWidthLie() {
    var windowWidth = window.innerWidth;
    var windowHeight = window.innerHeight;
    if (window.matchMedia) {
      var mediaQuery = '(max-width:' + windowWidth + 'px) and (max-height:' + windowHeight + 'px)';
      return !window.matchMedia(mediaQuery).matches;
    }
}

2.检测CPU是否是32位:

// 定义常量
const NOT_SUPPORT = "-1";
const UNKNOWN_TYPE = "0";
const ARM_TYPE = "1";
const X86_TYPE = "2";
const ARM_VAL = 127;
const X86_VAL = 255;

function isFloat32ArraySupported(): boolean {
    return typeof Float32Array === 'function';
}
function isUint8ArraySupported(): boolean {
    return typeof Uint8Array === 'function';
}

function getCPUType(): string {
    if (!isFloat32ArraySupported() || !isUint8ArraySupported()) { 
        return NOT_SUPPORT;
    }

    try {
        // 创建一个 Float32Array 和与其共享缓冲区的 Uint8Array
        const floatArray = new Float32Array(1);
        const byteArray = new Uint8Array(floatArray.buffer);

        // 将 Float32Array 的第一个元素设置为无穷大,然后减去自身
        floatArray[0] = 1 / 0;
        floatArray[0] = floatArray[0] - floatArray[0];

        // 根据 Uint8Array 的第四个字节判断 CPU 类型
        const value = byteArray[3];
        if (value === ARM_VAL) {
            return ARM_TYPE;
        } else if (value === X86_VAL) {
            return X86_TYPE;
        } else {
            return UNKNOWN_TYPE;
        }
    } catch (e) {
        // 如果发生异常,返回不支持
        return NOT_SUPPORT;
    }
}

const cpuType = getCPUType();

浏览器识别

去识别不同浏览器类型、同类型不同版本,方法就是依赖不同浏览器(版本间的差异):

  1. 独有的api
  2. 内建属性顺序差异
  3. api行为差异

这一块能检测的地方太多了,下面列出部分:

1.标志变量

'MSCSSMatrix', 'msSetImmediate', "msIndexedDB", 'msMaxTouchPoints', 'msPointerEnabled' // Trident标志
'addBehavior' // ie标志
'webkitPersistentStorage', 'webkitTemporaryStorage', 'webkitResolveLocalFileSystemURL', 'webkitMediaStream', 'webkitSpeechGrammar' // webkit标志

'InstallTrigger' // firefox标志

2.支持的媒体类型

function getSupportedMediaTypes() {
  const mediaTypes = [
    'audio/mpeg',
    'video/mp4; codecs="avc1.42E01E"',
    'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'
  ].sort();
  const videoElement = document.createElement('video');
  const audioElement = new Audio();
  return mediaTypes.reduce((supportedTypes, mimeType) => {
    const supportInfo = {
      mimeType: mimeType,
      audioPlayType: audioElement.canPlayType(mimeType),
      videoPlayType: videoElement.canPlayType(mimeType),
      mediaSource: MediaSource?.isTypeSupported(mimeType),
      mediaRecorder: MediaRecorder?.isTypeSupported(mimeType)
    };
    if (supportInfo.audioPlayType || supportInfo.videoPlayType || supportInfo.mediaSource || supportInfo.mediaRecorder) {
      supportedTypes.push(supportInfo);
    }
    return supportedTypes;
  }, []);
}

3.音频支持

function catchFunc(e) {
  try {
    return e
  } catch (e) {
    return 'error'
  }
}
// audioCodecs
function audioCodecs() {  // 还有很多,这里就列部分
    var r = document.createElement("audio");
    return r.canPlayType ? {
        ogg: catchFunc(r.canPlayType('audio/ogg; codecs="vorbis"')),
        mp3: catchFunc(r.canPlayType('audio/mpeg;')),
        wav: catchFunc(r.canPlayType('audio/wav; codecs="1"')),
        m4a: catchFunc(r.canPlayType('audio/x-m4a;')),
        aac: catchFunc(r.canPlayType('audio/aac;'))
    } : {}
}
// videoCodecs
function videoCodecs() {
  var r = document.createElement("video");
  return r.canPlayType ? {
    ogg: catchFunc(r.canPlayType('video/ogg; codecs="theora"')),
    h264: catchFunc(r.canPlayType('video/mp4; codecs="avc1.42E01E"')),
    webm: catchFunc(r.canPlayType('video/webm; codecs="vp8, vorbis"'))
  } : {}
}
// VoiceSys
function getVoiceSys() {
  const VOICE_SYSTEMS = {
    MAC: 'Mac',
    WINDOWS: 'Windows',
    CHROME_OS: 'Chrome OS',
    ANDROID: 'Android'
  };
  const TIMEOUT = 500;
  const ERROR_TYPES = {
    NOT_SUPPORTED: 'not-sup',
    TIMEOUT: 'time-out',
    GENERAL: 'thr-err'
  };
  function detectVoiceSystem(voices) {
    if (voices.some(voice => /lekha/i.test(voice.name))) return VOICE_SYSTEMS.MAC;
    if (voices.some(voice => /microsoft/i.test(voice.name))) return VOICE_SYSTEMS.WINDOWS;
    if (voices.some(voice => /chrome os/i.test(voice.name))) return VOICE_SYSTEMS.CHROME_OS;
    if (voices.some(voice => /android/i.test(voice.name))) return VOICE_SYSTEMS.ANDROID;
    return undefined;
  }
  return new Promise((resolve) => {
    if (!('speechSynthesis' in window)) {
      resolve(ERROR_TYPES.NOT_SUPPORTED);
      return;
    }
    let voicesLoaded = false;
    function handleVoicesChanged() {
      const voices = speechSynthesis.getVoices();
      if (voices.length) {
        voicesLoaded = true;
        const voiceSystem = detectVoiceSystem(voices);
        resolve(voiceSystem);
      }
    }
    handleVoicesChanged();
    speechSynthesis.onvoiceschanged = handleVoicesChanged;
    setTimeout(() => {
      if (!voicesLoaded) resolve(ERROR_TYPES.TIMEOUT);
    }, TIMEOUT);
  }).catch(() => ERROR_TYPES.GENERAL);
}

4.css/h5接口特性支持度,可参考Modernizr/css3test

function getEngine() {
  var  n = []["constructor"];
  try {
    (-1)["toFixed"](-1)
  } catch (t) {
    return t.message.length + (n+"").split(n.name).join("").length
  }
}
const Platform = {
  WINDOWS: 'Windows',
  MAC: 'Mac',
  LINUX: 'Linux',
  ANDROID: 'Android',
  CHROME_OS: 'Chrome OS'
};
function getPlatformDetected() {
  if (!(80 == getEngine())) return [];
    const supports = {
        colorScheme: CSS.supports("color-scheme: initial"),
        appearance: CSS.supports("appearance: initial"),
        aspectRatio: CSS.supports("aspect-ratio: initial"),
        borderEndEndRadius: CSS.supports("border-end-end-radius: initial"),
        videoPlaybackQuality: "getVideoPlaybackQuality" in HTMLVideoElement.prototype,
        randomUUID: "randomUUID" in Crypto.prototype,
        barcodeDetector: "BarcodeDetector" in window,
        downlinkMax: "downlinkMax" in navigator.connection,
        contentIndex: "ContentIndex" in window,
        contactsManager: "ContactsManager" in window,
        eyeDropper: "EyeDropper" in window,
        fileSystemWritableFileStream: "FileSystemWritableFileStream" in window,
        hid: "HID" in window,
        hidDevice: "HIDDevice" in window,
        serialPort: "SerialPort" in window,
        serial: "Serial" in window,
        sharedWorker: "SharedWorker" in window,
        touchEvent: "TouchEvent" in window,
        setAppBadge: "setAppBadge" in navigator,
    };
    const platformFeatures = {
        [Platform.ANDROID]: [
            supports.aspectRatio && supports.barcodeDetector,
            supports.appearance && supports.contentIndex,
            supports.videoPlaybackQuality && supports.contactsManager,
            supports.downlinkMax,
            supports.randomUUID && !supports.fileSystemWritableFileStream,
            supports.barcodeDetector && !supports.eyeDropper,
            supports.barcodeDetector && !supports.hidDevice,
            !supports.sharedWorker && supports.touchEvent,
            !supports.sharedWorker && supports.setAppBadge,
        ],
        [Platform.WINDOWS]: [
            supports.aspectRatio && !supports.barcodeDetector,
            supports.appearance && !supports.contentIndex,
            supports.videoPlaybackQuality && !supports.contactsManager,
            !supports.downlinkMax,
            supports.randomUUID && supports.fileSystemWritableFileStream,
            supports.randomUUID && supports.contactsManager,
            supports.borderEndEndRadius && supports.hidDevice,
            supports.borderEndEndRadius && supports.serial,
            !supports.sharedWorker && !supports.touchEvent,
            supports.colorScheme && !supports.setAppBadge,
        ],
        [Platform.MAC]: [
            supports.aspectRatio && supports.barcodeDetector,
            supports.appearance && !supports.contentIndex,
            supports.videoPlaybackQuality && !supports.contactsManager,
            !supports.downlinkMax,
            supports.randomUUID && !supports.fileSystemWritableFileStream,
            supports.randomUUID && supports.contactsManager,
            supports.borderEndEndRadius && supports.hidDevice,
            supports.borderEndEndRadius && supports.serial,
            supports.sharedWorker && !supports.touchEvent,
            supports.colorScheme && supports.setAppBadge,
        ],
        [Platform.LINUX]: [
            !supports.aspectRatio && !supports.barcodeDetector,
            !supports.appearance && !supports.contentIndex,
            supports.videoPlaybackQuality && !supports.contactsManager,
            !supports.downlinkMax,
            supports.randomUUID && supports.fileSystemWritableFileStream,
            supports.randomUUID && supports.contactsManager,
            supports.borderEndEndRadius && supports.hidDevice,
            supports.borderEndEndRadius && supports.serial,
            supports.sharedWorker && (!supports.touchEvent || !supports.touchEvent),
            supports.colorScheme && !supports.setAppBadge,
        ],
    };
    const platformScores = {};
    for (const platform in platformFeatures) {
        const features = platformFeatures[platform];
        const supportedFeatures = features.filter(feature => feature);
        const score = (supportedFeatures.length / features.length).toFixed(2);
        platformScores[platform] = score;
    }
    return platformScores;
}

5.对错误的的显示

function getErrors(e) {
    var t, n = [], o = e.length;
    for (t = 0; t < o; t++) try {
        e[t]()
    } catch (e) {
        n.push(e.message)
    }
    return n
}
var hashify = function (e) {
    var t = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : 'SHA-256',
        n = "" + JSON.stringify(e), o = (new TextEncoder).encode(n);
    return crypto.subtle.digest(t, o).then((function (e) {
        return Array.from(new Uint8Array(e)).map((function (e) {
            return ("00" + e.toString(16)).slice(-2)
        })).join("")
    })).catch((function () {
    }))
};
function getConsoleErrors() {
    try {
        var t = getErrors([function () {
            return new Function('alert(")')()
        }, function () {
            return new Function('const foo;foo.bar')()
        }, function () {
            return new Function("null.bar")()
        }, function () {
            return new Function('abc.xyz = 123')()
        }, function () {
            return new Function('const foo;foo.bar')()
        }, function () {
            return new Function('(1).toString(1000)')()
        }, function () {
            return new Function("[...undefined].length")()
        }, function () {
            return new Function("var x = new Array(-1)")()
        }, function () {
            return new Function('const a=1; const a=2;')()
        }]), r = {};
        r.errors = t;
        return r;
    } catch (e) {
        return
    }
}
function getErrorHash() {
    var e = hH;
    try {
        var t = getConsoleErrors();
        if (!t) return;
        var r = hashify(t.errors);
        if (!r) return;
        return r
    } catch (e) {
        return
    }
}

6.对数学计算的精度

7.对DOM元素大小差异(利用getClientRects)

8....

浏览器插件

早期的重灾区,有actionscript(flash)/java applet /plugins(npapi)等,但都因为安全性问题退出了,现在一般就检查下plugins:

navigator.plugins

扩展识别

Chrome里网页API(相对于扩展API)无法直接获取插件信息,但是可以利用多种技术去探测特定插件是否存在[13] [9] [0] [18] [19]

XX

WAS

CSS注入

DOM改变

PostMessage通信

不属于当前页面的JS

function getChromeExtensionScripts() {
  // 获取所有的 <script> 元素
  const scripts = document.getElementsByTagName('script');
  const chromeExtensionScripts = [];
  // 如果没有找到任何 <script> 元素,直接返回空数组
  if (!scripts || scripts.length === 0) return JSON.stringify(chromeExtensionScripts);
  // 遍历所有 <script> 元素
  for (let i = 0; i < scripts.length; i++) {
    const script = scripts[i];
    const src = script.getAttribute('src');
    // 检查 src 属性是否存在且包含 "chrome-extension://"
    if (src && src.indexOf("chrome-extension://") >= 0) {
      chromeExtensionScripts.push(src);
    }
  }
  // 返回包含 Chrome 扩展脚本源的 JSON 字符串
  return JSON.stringify(chromeExtensionScripts);
}
结构分析
行为分析

时间和时区

比如IP是美国,时区却是中国,露馅:

const now = new Date();
now.toISOString() // ISO 8601格式的时间
now.toLocaleString() // 本地时间


const getYear = (date) => date.getFullYear();   // 最大时区偏移
const getTimezoneOffset = (date) => date.getTimezoneOffset();
const maxTimezoneOffset = () => {
    const year = getYear(new Date());
    const januaryOffset = getTimezoneOffset(new Date(year, 0, 1));
    const julyOffset = getTimezoneOffset(new Date(year, 6, 1));
    return Math.max(januaryOffset, julyOffset);
};

Intl.DateTimeFormat().resolvedOptions().timeZone    // 时区

安全环境

有些会使用中间人强制ssl/tls降级(sslstrip)来获取数据,此时可检测当前环境是否还是https,方法太多了:

window.location/document.location   // 直接从url里看
window.isSecureContext // 安全上下文
navigator.clipboard  // 安全上下文才会出现的api,但是有些浏览器本身不支持...
document.querySelectorAll('a')  // 查找页面所有链接,看是不是正常的

行为相关

传感器数据收集

这主要针对手机(如浏览器/webview,native本身提供的不在讨论范围),它们支持多种传感器且浏览器可使用,主要是加速度计、陀螺仪、环境光传感器和磁力计,不过并不是所有浏览器都支持,使用前需判断[15]:

// 加速度
window.addEventListener('devicemotion', function(event) {
    console.log('加速度:', event.acceleration);
    console.log('加速度(包括重力):', event.accelerationIncludingGravity);
    console.log('旋转速率:', event.rotationRate);
}, true);
// 陀螺仪
window.addEventListener('deviceorientation', function(event) {
    console.log('Alpha:', event.alpha);
    console.log('Beta:', event.beta);
    console.log('Gamma:', event.gamma);
}, true);
// 环境光
const sensor = new AmbientLightSensor();
sensor.onreading = () => {
    console.log('光照强度:', sensor.illuminance);
};
sensor.start();
// 绝对方向 结合加速度计、陀螺仪和磁力计
const sensor = new AbsoluteOrientationSensor();
sensor.onreading = () => {
    console.log('四元数:', sensor.quaternion);
    console.log('欧拉角:', sensor.euler);
};
sensor.start();
// 磁力计
const sensor = new Magnetometer();
sensor.onreading = () => {
    console.log('磁场强度:', sensor.x, sensor.y, sensor.z);
};
sensor.start();

Clipboard

在浏览器里剪切板的权限是严格受限的,无法直接读取里面的内容,但可以通过监听剪切板事件来发现一些异常行为(谁没事疯狂复制粘贴呀!):

document.addEventListener('copy', () => {});
document.addEventListener('paste', (e) => {console.log(e.clipboardData.getData('text');)});

用户输入

这里就是鼠标/键盘/触屏/触摸板等用户输入设备产生的事件,这是风控中的重中之重,也是最难处理的之一,下面简单描述:

  1. 鼠标事件:包括 mousedown, mouseup, mousemove, mouseover, mouseout, click, dblclick 等,捕获鼠标操作。
  2. 触控事件:包括 touchstart, touchmove, touchend, 和 touchcancel,捕获触控操作。
  3. 键盘事件:包括 keydown, keyup, keypress,可捕获输入的数据。
  4. 其它事件:包括 scroll ,dragstart, drag, dragend, drop,blur等,它们是鼠标/触控板等发出的。

在对这些事件做收集时,通常包括单一信息和综合信息:

  1. 单一信息:比如一个点击操作,它的坐标是什么,距离当前元素的中心点有多远,该次的触摸点数量,大小和按压力度、倾斜角度,对键盘事件记录按下的键等
  2. 行为序列:对于鼠标和触控,它是可以采样行为轨迹的,还有点击前的悬停,按压的用时等,对于轨迹模拟,传统的方法是用贝塞尔曲线模拟,或者通过收集大量样本加偏重放,不过简单的用AI已经能识别咯,之后也得用AI对抗!像键盘输入,也是有规律的,需符合人/输入法的输入习惯!

这些都是事件,它们可以直接被JS创建,但是有个域会有问题,即Event.isTrustedisTrusted 是 JavaScript Event 接口的一个只读属性,它是一个布尔值。它的作用是判断一个事件是否是由用户代理(即浏览器本身)生成的,包括用户真实的操作(如点击鼠标、敲击键盘)以及通过浏览器内部机制触发的合法事件(如 HTMLElement.focus())。简而言之,只要是用JS生成的事件(EventTarget.dispatchEvent())它的该属性一定会是false,对此只能通过修改浏览器源码做对抗,不过通过CDP发出的事件是true哦!

自动化相关

如果浏览器正在被自动化(如群控),那会有些特征:

  1. navigator.webdriver:它是一个布尔值,默认为false,根据W3C WebDriver要求,在被自动化时webdriver需要将其设置为true以便让document知道正在被自动化。

  2. Page Visibility:这是个容易被忽略的点,JS能通过document.visbilityState去查看当前页面的可见性,也可通过document.visibilitychange监听它的改变,它有三种值visible/hidden/prerender,详见Page Visibility API 教程,需要关注的就是如果当前页面最小化/锁屏了/被完全遮挡,那么它就是不可见的!(另还有document.hidden效果类似)

  3. domAutomation:chrome开启自动化(如测试)时,会出现,可直接delete属性,也可在启动参数里禁用--disable-blink-features=AutomationControlled

  4. cdc_adoQpoasnfa76pfcZLmcfl_:chrome里硬编码的自动化属性,含三个cdc_adoQpoasnfa76pfcZLmcfl_Array/cdc_adoQpoasnfa76pfcZLmcfl_Promise/cdc_adoQpoasnfa76pfcZLmcfl_Symbol,直接delete可删除。

  5. performance.now():更详细内容可以看[10],这里主要关注常见的风控指纹点,它利用高精度时间performance.now(),现在浏览器也在主动降低精度

  6. window.webdriver:这是selenium标记的,不要和1搞混了,也是可以删

电池信息

看是否支持电池信息,如果有就收集电池信息的情况,比看变化是否正常(如群控可能会一直满电且冲着电,注意桌面端也会有哦,例如笔记本电脑):

if (navigator.getBattery) {
    navigator.getBattery().then(function(battery) {
        console.log('当前(初始电量):', battery.level);        // 0.0 到 1.0 之间的浮点数
        console.log('电池是否正在充电:', battery.charging);
        console.log('充满电所需的时间:', battery.chargingTime); // 单位秒
        console.log('放电到完全没电所需的时间:', battery.dischargingTime);
        battery.addEventListener('chargingchange', function() {
            console.log('充电状态改变:', battery.charging);
        });
        battery.addEventListener('levelchange', function() {
            console.log('电量改变:', battery.level);
        });
        battery.addEventListener('chargingtimechange', function() {
            console.log('充满时间改变:', battery.chargingTime);
        });
        battery.addEventListener('dischargingtimechange', function() {
            console.log('剩余可用时间变化:', battery.dischargingTime);
        });
    });
} else {
    console.log('不支持哦~');
}

网络信息

获取网络有关的信息,主要是看wifi/蜂窝网络等:

navigator.connection.type // 获取网络类型
navigator.connection.downlink // 获取下行速度
navigator.connection.effectiveType // 获取有效网络类型,不是实际类型
navigator.connection.rtt // 获取往返时间

授权

获取用户授予了哪些权限,如地理位置,通知等,(摄像头、麦克风太敏感了,小心):

if (navigator.permissions) {
    navigator.permissions.query({ name: 'geolocation' }).then(function(permissionStatus) {
        console.log('Geolocation permission:', permissionStatus.state); // 例如: "granted", "denied", "prompt"
    });
}

地理位置

位置信息显然是需要用户明确授权的,否则只能通过IP去拿,这里还是记一下吧:

navigator.geolocation.getCurrentPosition(
// navigator.geolocation.watchPosition 可持续监控变化
    (position) => {
        const { latitude, longitude } = position.coords;
    },
    (error) => {
        ...
    },
    {
        enableHighAccuracy: true, // 启用高精度定位
        timeout: 5000,           // 超时时间,单位ms
        maximumAge: 0            // 最大缓存时间,0表示不使用缓存
    }
)

canvas指纹

接下来几个是当前的核心指纹,它们的熵值都挺高,以canvas开始,它是html5新出的标准api,该api用于绘制各种图形,包括2D和3D等。此处说的canvas主要是指2D图形绘制,受软硬件栈影响(字体渲染、图像抗锯齿、颜色管理、GPU驱动和硬件等)[16 ],生成的图形会有细微的差别,利用它即可检测:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Canvas Fingerprint</title>
</head>
<body>
    <canvas id="fingerprintCanvas" width="200" height="100" style="display: none;"></canvas>
    <script>
        const canvas = document.getElementById('fingerprintCanvas');
        const ctx = canvas.getContext('2d');

        // 设置字体样式
        ctx.textBaseline = 'top';
        ctx.font = '14px Arial';

        // 绘制文本
        ctx.fillText('Hello, World!', 10, 10);

        // 绘制图形
        ctx.fillStyle = '#f60';
        ctx.beginPath();
        ctx.moveTo(120, 10);
        ctx.lineTo(120, 60);
        ctx.lineTo(160, 60);
        ctx.closePath();
        ctx.fill();

        // 获取Canvas内容
        const canvasDataUrl = canvas.toDataURL();

        // 计算哈希值
        function calculateHash(dataUrl) {
            const binaryString = atob(dataUrl.split(',')[1]);
            const bytes = new Uint8Array(binaryString.length);
            for (let i = 0; i < binaryString.length; i++) {
                bytes[i] = binaryString.charCodeAt(i);
            }
            const hashBuffer = crypto.subtle.digest('SHA-256', bytes);
            return hashBuffer.then(hash => {
                const hashArray = Array.from(new Uint8Array(hash));
                const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
                return hashHex;
            });
        }

        calculateHash(canvasDataUrl).then(fingerprint => {
            console.log('Canvas Fingerprint:', fingerprint);
        });
    </script>
</body>
</html>

字体指纹

以前的浏览器可通过插件直接获取系统存在的字体列表,甚至有字体间顺序,但是现代浏览器已经不支持该行为了,不过还是能通过一些其它方式获取系统存在哪些字体,具体来讲就是给一个完整的字体列表,通过一些办法去判断这个字体是否存在,判断方式常见的有两种[2]:

1.通过CSS指定特定字体去修饰块,以一定存在和一定不存在的字体为基准,通过检测元素宽度识别字体存在性[4]:

class FontDetector {
    private baseFonts: string[];
    private testString: string;
    private testSize: string;
    private body: HTMLElement;
    private span: HTMLSpanElement;
    private defaultWidth: { [key: string]: number };
    private defaultHeight: { [key: string]: number };

    constructor() {
        this.baseFonts = ['monospace', 'sans-serif', 'serif'];  // 这三个字体最常见,以此为基准

        this.testString = "mmmmmmmmmmlli";  // m/w宽,l/i窄
        this.testSize = '72px'; // 这里用大点的尺寸来放大差异
        this.body = document.getElementsByTagName("body")[0];

        this.span = document.createElement("span"); // 创建span来做容器
        this.span.style.fontSize = this.testSize;
        this.span.innerHTML = this.testString;

        this.defaultWidth = {};
        this.defaultHeight = {};

        for (const font of this.baseFonts) {
            this.span.style.fontFamily = font;
            this.body.appendChild(this.span);
            this.defaultWidth[font] = this.span.offsetWidth; 
            this.defaultHeight[font] = this.span.offsetHeight; 
            this.body.removeChild(this.span);
        }
    }

    public detect(font: string): boolean {
        let detected = false;
        for (const baseFont of this.baseFonts) {
            this.span.style.fontFamily = `${font}, ${baseFont}`; 
            this.body.appendChild(this.span);
            const matched = (this.span.offsetWidth !== this.defaultWidth[baseFont] || this.span.offsetHeight !== this.defaultHeight[baseFont]); // 如果与基准不同则认为存在
            this.body.removeChild(this.span);
            detected = detected || matched;
        }
        return detected;
    }
}

const detector = new FontDetector();
console.log(detector.detect('Arial'));

2.通过canvas的measureText去测量,同样以一定存在和一定不存在的字体为基准,通过分析生成的字体形状(如宽高、倾斜度等)即可推测字体是否存在[3]:

// 定义字体家族
const CSS_FONT_FAMILY = "\n\t'Segoe Fluent Icons',\n\t'Ink Free',\n\t'Bahnschrift',\n\t'Segoe MDL2 Assets',\n\t'HoloLens MDL2 Assets',\n\t'Leelawadee UI',\n\t'Javanese Text',\n\t'Segoe UI Emoji',\n\t'Aldhabi',\n\t'Gadugi',\n\t'Myanmar Text',\n\t'Nirmala UI',\n\t'Lucida Console',\n\t'Cambria Math',\n\t'Bai Jamjuree',\n\t'Chakra Petch',\n\t'Charmonman',\n\t'Fahkwang',\n\t'K2D',\n\t'Kodchasan',\n\t'KoHo',\n\t'Sarabun',\n\t'Srisakdi',\n\t'Galvji',\n\t'MuktaMahee Regular',\n\t'InaiMathi Bold',\n\t'American Typewriter Semibold',\n\t'Futura Bold',\n\t'SignPainter-HouseScript Semibold',\n\t'PingFang HK Light',\n\t'Kohinoor Devanagari Medium',\n\t'Luminari',\n\t'Geneva',\n\t'Helvetica Neue',\n\t'Droid Sans Mono',\n\t'Dancing Script',\n\t'Roboto',\n\t'Ubuntu',\n\t'Liberation Mono',\n\t'Source Code Pro',\n\t'DejaVu Sans',\n\t'OpenSymbol',\n\t'Chilanka',\n\t'Cousine',\n\t'Arimo',\n\t'Jomolhari',\n\t'MONO',\n\tsans-serif !important\n";

// 定义表情符号数组
const EMOJIS = [    
    [128512], [9786], [129333, 8205, 9794, 65039], [9832], [9784], [9895], [8265], [8505],
    [127987, 65039, 8205, 9895, 65039], [129394], [9785], [9760], [129489, 8205, 129456],
    [129487, 8205, 9794, 65039], [9975], [129489, 8205, 129309, 8205, 129489], [9752],
    [9968], [9961], [9972], [9992], [9201], [9928], [9730], [9969], [9731], [9732],
    [9976], [9823], [9937], [9e3], [9993], [9999], [128105, 8205, 10084, 65039, 8205, 128139, 8205, 128104],
    [128104, 8205, 128105, 8205, 128103, 8205, 128102], [128104, 8205, 128105, 8205, 128102],
    [128512], [169], [174], [8482], [128065, 65039, 8205, 128488, 65039], [10002],
    [9986], [9935], [9874], [9876], [9881], [9939], [9879], [9904], [9905], [9888],
    [9762], [9763], [11014], [8599], [10145], [11013], [9883], [10017], [10013], [9766],
    [9654], [9197], [9199], [9167], [9792], [9794], [10006], [12336], [9877], [9884],
    [10004], [10035], [10055], [9724], [9642], [10083], [10084], [9996], [9757], [9997],
    [10052], [9878], [8618], [9775], [9770], [9774], [9745], [10036], [127344], [127359]
].map((codes) => String.fromCodePoint(...codes));

/**
 * 获取 Canvas 2D 上下文并测量文本度量
 * @returns 包含文本度量总和的对象
 */
function getCanvas2d(): { textMetricsSystemSum: number } | undefined {
    try {
        // 创建一个 canvas 元素并获取 2D 上下文
        const canvas = document.createElement("canvas");
        const context = canvas.getContext("2d");
        if (!context) return;

        // 设置字体
        context.font = '10px ' + CSS_FONT_FAMILY.replace(/!important/gm, "");

        // 使用 Set 来存储唯一的度量值
        const uniqueMetrics = new Set<string>();
        const uniqueEmojis = EMOJIS.reduce((acc, emoji) => {
            const metrics = context.measureText(emoji) || {};
            const metricString = [
                metrics.actualBoundingBoxAscent,
                metrics.actualBoundingBoxDescent,
                metrics.actualBoundingBoxLeft,
                metrics.actualBoundingBoxRight,
                metrics.fontBoundingBoxAscent,
                metrics.fontBoundingBoxDescent,
                metrics.width
            ].join(",");

            if (!uniqueMetrics.has(metricString)) {
                uniqueMetrics.add(metricString);
                acc.add(emoji);
            }
            return acc;
        }, new Set<string>());

        // 计算所有唯一度量值的总和
        const sum = Array.from(uniqueMetrics).reduce((total, metricString) => {
            const metrics = metricString.split(",").map(Number);
            return total + metrics.reduce((sum, value) => sum + (value || 0), 0);
        }, 0);

        // 返回包含文本度量总和的对象
        return {
            textMetricsSystemSum: (1e-5) * sum
        };
    } catch (error) {
        console.log(error);
        return;
    }
}

// 使用示例
const result = getCanvas2d();
console.log(result); // 输出包含文本度量总和的对象

注1: 上面不仅探测了普通字体,还探测了emoji,这个在不同系统是有差异的,类似的还有数学符号!

注2: 其实还有个字体探测方式,它利用@font-face机制,无需JS也能探测,原理是它可指定字体的来源,优先使用本地的,若不存在再使用远端的,通过远端服务器的访问记录来探测,不过很少用,忽略~

WebGL指纹

Web图形库类似于OpenGL,它利用设备的图形硬件去高效渲染图像,主要用于3D场景,它的原理类似于Canvas指纹,可通过生成图像的细微差异做检测,另外它还提供接口直接访问硬件信息[16]!

// 检查canvas api和webgl上下文可用
const e = document.createElement('canvas');
if (typeof e.getContext !== 'function') return 'HTMLCanvasElement.getContext is not a function';
const n = e.getContext("webgl");
if (null === n) return 'WebGLRenderingContext is null';
if (typeof n.getParameter !== 'function') return 'WebGLRenderingContext.getParameter is not a function';
const r = n.getParameter(n.VENDOR),
      s = n.getParameter(n.RENDERER),
      a = n.getParameter(n.VERSION);
if (typeof window.InstallTrigger !== 'undefined') return JSON.stringify([r, s, a]);
const o = n.getExtension('WEBGL_debug_renderer_info');
if (null === o) return "WEBGL_debug_renderer_info extension is null";
const c = n.getParameter(o.UNMASKED_VENDOR_WEBGL),
      u = n.getParameter(o.UNMASKED_RENDERER_WEBGL);
return JSON.stringify([c, u, r, s, a]);

它能访问的信息特别多,这里只列出了常见的

WebGPU

这是新版本出的接口,相比WebGL它能操控更底层的硬件,因此它也能获取更多的信息,使用时同样也是获取静态数据和渲染图片的差异信息,不过它需要用户授权,用得没那么多:

const adapter = await navigator.gpu.requestAdapter();// 请求 WebGPU 适配器,要用户授权
const device = await adapter.requestDevice();
const adapterInfo = {       // 获取适配器的静态信息
    name: adapter.name,
    vendor: adapter.vendor,
    device: adapter.device,
    driver: adapter.driver,
    limits: adapter.limits,
};

const supportedTextureFormats = device.getSupportedTextureFormats();// 获取设备支持的纹理格式
const shaderCode = `
    [[stage(compute), workgroup_size(1)]]
    fn main([[builtin(global_invocation_id)]] global_id : vec3<u32>) {
        let i = global_id.x;
        var data = array<i32>(10);
        for (var j = 0u; j < 10u; j = j + 1u) {
            data[i] = i32(j);
        }
    }
`;

const shaderModule = device.createShaderModule({ code: shaderCode,}); // 创建着色器模块
const computePipeline = await device.createComputePipelineAsync({ // 创建计算管线
    layout: 'auto',
    compute: {
        module: shaderModule,
        entryPoint: 'main',
    },
});
const bufferSize = 40; // 创建缓冲区 10 * 4 字节
const buffer = device.createBuffer({
    size: bufferSize,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
});

const bindGroupLayout = device.createBindGroupLayout({ // 创建绑定组布局
    entries: [{
        binding: 0,
        visibility: GPUShaderStage.COMPUTE,
        buffer: { type: 'storage' },
    }],
});

const bindGroup = device.createBindGroup({ // 创建绑定组
    layout: bindGroupLayout,
    entries: [{
        binding: 0,
        resource: { buffer },
    }],
});

const commandEncoder = device.createCommandEncoder(); // 创建命令编码器
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(computePipeline);
passEncoder.setBindGroup(0, bindGroup);
passEncoder.dispatch(1);
passEncoder.end();

const gpuCommands = commandEncoder.finish(); // 提交命令
device.queue.submit([gpuCommands]);

const resultBuffer = new Uint8Array(bufferSize); // 读取缓冲区数据
await device.queue.onSubmittedWorkDone().then(() => {
    device.queue.writeBuffer(buffer, 0, resultBuffer, 0, bufferSize);
});

const deviceInfo = {
    ...adapterInfo,
    supportedTextureFormats,
    computeResult: Array.from(resultBuffer),
};

const performanceTest = async () => { // 性能测试
    const startTime = performance.now();
    for (let i = 0; i < 100; i++) {
        const commandEncoder = device.createCommandEncoder();
        const passEncoder = commandEncoder.beginComputePass();
        passEncoder.setPipeline(computePipeline);
        passEncoder.setBindGroup(0, bindGroup);
        passEncoder.dispatch(1);
        passEncoder.end();
        const gpuCommands = commandEncoder.finish();
        device.queue.submit([gpuCommands]);
    }
    await device.queue.onSubmittedWorkDone();
    const endTime = performance.now();
    return endTime - startTime;
};
const performanceTime = await performanceTest();
deviceInfo.performanceTime = performanceTime;

WebAudio指纹

它通过Web Audio API生成音频信号并提取其特征,基于不同设备在音频处理过程中的动态压缩和频率分析,利用设备音频处理的细微差异生成唯一标识符。在生成指纹时,它就像搭积木,选择一个容器,给输入源,使用管道对声音做各种变换,再输出看细微差异[12]:

function canUseFunction(func, properties, args, apply) {
    try {
        let result = func();
        for (let prop of properties) {
            result = result[prop];
        }
        if (apply && args.length > 0) {
            return result.apply(func(), args);
        } else if (apply) {
            return result.apply(func());
        } else {
            return result;
        }
    } catch (error) {
        return undefined;
    }
}

const hasOfflineAudioContext = typeof OfflineAudioContext !== 'undefined' || typeof webkitOfflineAudioContext !== 'undefined';
const hasFloat32Array = typeof Float32Array !== 'undefined';

function getOfflineAudioValues() {
    try {
        if (!hasOfflineAudioContext) return;

        const offlineContext = new (OfflineAudioContext || webkitOfflineAudioContext)(1, 5000, 44100);
        const analyser = offlineContext.createAnalyser();
        const oscillator = offlineContext.createOscillator();
        const dynamicsCompressor = offlineContext.createDynamicsCompressor();
        const biquadFilter = offlineContext.createBiquadFilter();

        if (!hasFloat32Array) return;

        const frequencyData = new Float32Array(analyser.frequencyBinCount);
        analyser.getFloatFrequencyData(frequencyData);
        const uniqueFrequencyCount = new Set(frequencyData).size > 1;

        const getPropertyValue = (property) => {
            try {
                return property();
            } catch (error) {
                return undefined;
            }
        };

        const values = {
            channelCount: getPropertyValue(() => analyser.channelCount),
            channelCountMode: getPropertyValue(() => analyser.channelCountMode),
            channelInterpretation: getPropertyValue(() => analyser.channelInterpretation),
            fftSize: getPropertyValue(() => analyser.fftSize),
            frequencyBinCount: getPropertyValue(() => analyser.frequencyBinCount),
            maxDecibels: getPropertyValue(() => analyser.maxDecibels),
            minDecibels: getPropertyValue(() => analyser.minDecibels),
            numberOfInputs: getPropertyValue(() => analyser.numberOfInputs),
            numberOfOutputs: getPropertyValue(() => analyser.numberOfOutputs),
            smoothingTimeConstant: getPropertyValue(() => analyser.smoothingTimeConstant),
            listenerForwardXMaxValue: getPropertyValue(() => canUseFunction(() => analyser.context.listener.forwardX, ['maxValue'])),
            biquadFilterGainMaxValue: getPropertyValue(() => biquadFilter.gain.maxValue),
            biquadFilterFrequencyDefaultValue: getPropertyValue(() => biquadFilter.frequency.defaultValue),
            biquadFilterFrequencyMaxValue: getPropertyValue(() => biquadFilter.frequency.maxValue),
            dynamicsCompressorAttackDefaultValue: getPropertyValue(() => dynamicsCompressor.attack.defaultValue),
            dynamicsCompressorKneeDefaultValue: getPropertyValue(() => dynamicsCompressor.knee.defaultValue),
            dynamicsCompressorKneeMaxValue: getPropertyValue(() => dynamicsCompressor.knee.maxValue),
            dynamicsCompressorRatioDefaultValue: getPropertyValue(() => dynamicsCompressor.ratio.defaultValue),
            dynamicsCompressorRatioMaxValue: getPropertyValue(() => dynamicsCompressor.ratio.maxValue),
            dynamicsCompressorReleaseDefaultValue: getPropertyValue(() => dynamicsCompressor.release.defaultValue),
            dynamicsCompressorReleaseMaxValue: getPropertyValue(() => dynamicsCompressor.release.maxValue),
            dynamicsCompressorThresholdDefaultValue: getPropertyValue(() => dynamicsCompressor.threshold.defaultValue),
            dynamicsCompressorThresholdMinValue: getPropertyValue(() => dynamicsCompressor.threshold.minValue),
            oscillatorDetuneMaxValue: getPropertyValue(() => oscillator.detune.maxValue),
            oscillatorDetuneMinValue: getPropertyValue(() => oscillator.detune.minValue),
            oscillatorFrequencyDefaultValue: getPropertyValue(() => oscillator.frequency.defaultValue),
            oscillatorFrequencyMaxValue: getPropertyValue(() => oscillator.frequency.maxValue),
            oscillatorFrequencyMinValue: getPropertyValue(() => oscillator.frequency.minValue)
        };

        const result = {
            values,
            uniqueFrequencyCount,
            bufferLength: 5000
        };

        return result;
    } catch (error) {
        return undefined;
    }
}

Web Worker

applicationCache

Worklet

GamePad

检测连接的游戏手柄的信息,直接调用navigator.getGamepads()不多说。

媒体设备

if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
    navigator.mediaDevices.enumerateDevices().then(function(devices) {
        devices.forEach(function(device) {
            console.log('Device:', device.kind, device.label, device.deviceId);
        });
    });
}

DOM树变化

这是检测协议指纹的方法,实际上效果一般,挺少见。

存储

现代浏览器有三类存储机制,如下:

  1. Cookie: 最常见,不多说;内容分为第一方、第三方和外部cookie,在用户追踪中lou见不止[6],且本地和三方组成联盟狼狈为奸相互同步/复活,可用navigator.cookieEnabled激活状态,或者通过写后读去检测可用性,不过它它重要了几乎没有禁用的。
  2. local storage/session storage:存储大点的键值对的(单域5M),local storage也经常被用于存储指纹/追踪数据。
  3. index db:存储大量结构化数据的,比如购物车/用户历史等信息。

还有个易被忽视的,ETAG,它是在其它地方存储的,本来用于优化性能(缓存)但是因为它不受cookie清空等的影响也可以用于追踪,还有的应用会起etag名字但是不用它的机制做指纹id,反正遇着需谨慎。

分析检测

JS也会上报异常环境,如果感知到当前正在被分析,那么一定是闪红色警报,这部分直接看另一篇JS动态分析的文章吧

WebRTC 真实IP泄漏

WebRTC 是一种让浏览器之间可以直接进行实时通信的技术,比如视频通话、语音聊天、文件传输等。它不需要通过服务器中转所有数据,因此效率更高、延迟更低。既然是P2P,就很容易出现真实IP泄漏的问题,WebRTC很不巧,有!关于WebRTC里P2P的原理,[14]做了很详细的描述,简单来讲它的ICE会报告本地的地址,便于P2P建立连接。建立连接有三种情况: 1. 没在NAT后面,能直接建立连接,此时使用的IP是chrome直接从本地网卡获取的。 2. 在NAT后面,使用STUN建立P2P连接,此时使用的IP是NAT转换后的公网IP,这个IP本机无法直接获取,它是先访问公网的一个服务器,由服务器返回源IP和端口,这个IP和端口就是NAT转换后的公网IP和端口。 3. 在NAT后面,但打不了洞,只能使用TURN建立中间转发连接

这里面提到的IP和端口信息都会存储于SDP,可被JS读取,对于1,在获取首选地址时,为了防止私有地址泄漏,chrome会使用mDNS匿名化域名(chrome://flags/#enable-webrtc-hide-local-ips-with-mdns 默认启用),具体来讲每次建立连接会随机生成一个域名(.local)并注册到系统里,于是它可被本地网络解析但不会被远端察觉真实私有IP,所以可以忽略。 现在需看2的情况,它坏就坏在访问公网服务器时,会使用UDP去请求,而这条请求chrome没有使用配置的常规代理而直接访问了,因此就暴露了真实IP。关于这个问题的检测方法可参考webrtc-ip-leaks-demo ,它的stun服务器已经无法用了但换一个即可,另外还有很多网站可直接检测,如What is my IP Address, WebRTC Test, ip leak, browserleaks

目前chrome没有启动参数能阻止这种泄漏,但可使用一些其他办法阻止

使用chrome 扩展

如WebRTC Network Limiter/WebRTC Leak Shield等

API HOOK

直接HOOK RTCPeerConnection等API,提供虚假的candidate值。

强制代理UDP

chrome.privacy 提供四种选项:

1. "default"chrome的默认值允许公网IP和私有IP私用
2. "default_public_and_private_interfaces"和上面一样名字写得更精确了
3. "default_public_interface_only"只允许公网IP
4. "disable_non_proxied_udp"禁用非代理的UDP连接如果代理支持UDP则走代理不支持则只使用UDP

如:

chrome.privacy.network.webRTCIPHandlingPolicy.set({
  value: 'disable_non_proxied_udp' // 强制代理 UDP
}, () => {});

网关处处理

由于ICE只有个简单的异或,没其他加密,因此可以在网关处直接修改数据包,返回错误的公网IP。[5]也是在网关搞事情,但是它是改的http/https里的JS....

其实WebRTC还有媒体接口,那个能拿到大量信息,但是需要用户授权,先忽略~

本地端口扫描

chrome可用http/websocket/webrtc去发请求,受限于同源策略它无法看到内容等,但是已经够扫端口了。

端口扫描有两种情况,是否有回显,有回显则看回显,没回显则看时间。

使用html标签

使用xhr

使用websocket

使用webrtc

根据[7],chrome可使用TURN去探测主机存活和端口开放状态,它利用icecandidateerror 事件,当主机不存活(或被防火墙拦截)则不会生成该事件,否则若端口开放则能显示主机和端口号,端口不开放则显示0.0.0.x:0

参考

[0] Unnecessarily Identifiable: Quantifying the fingerprintability of browser extensions due to bloat -- Oleksii Starov et al

[1] Browser Fingerprinting : Exploring Device Diversity to Augment Authentification and Build Client-Side Countermeasures -- Pierre Laperdrix

[2] Web browser fingerprinting : a framework for measuring the web browser -- Vanja Culafic

[3] privacycheck: fingerprintingCollection

[4] JavaScript/CSS Font Detector -- lalit.org

[5] Neither Denied nor Exposed: Fixing WebRTC Privacy Leaks -- Alexandros Fakis, Georgios Karopoulos, and Georgios Kambourakis

[6] The Web Never Forgets: Persistent Tracking Mechanisms in the Wild -- Gunes Acar, Christian Eubank et al

[7] Using WebRTC ICE Servers for Port Scanning in Chrome

[8] One Leak is Enough to Expose Them All From a WebRTC IP Leak to Web-based Network Scanning -- Mohammadreza Hazhirpasand, Mohammad Ghafari

[9] Fingerprinting in Style:Detecting Browser Extensions via Injected Style Sheets -- Pierre Laperdrix et al

[10] SoK: In Search of Lost Time: A Review of JavaScript Timers in Browsers -- Thomas Rokicki, Clémentine Maurice, Pierre Laperdrix

[11] FP-Radar: Longitudinal Measurement and Early Detection of Browser Fingerprinting -- Pouneh Nikkhah Bahrami, Umar Iqbal, and Zubair Shafiq

[12] Sounds of Silence: A Study of Stability and Diversity of Web Audio Fingerprints -- Shekhar Chalise

[13] Everyone is Different: Client-side Diversification for Defending Against Extension Fingerprinting -- Erik Trickel et al

[14] Peer-to-peer communication in web browsers using WebRTC: A detailed overview of WebRTC and what security and network concerns exists -- Christer Jakobsson

[15] The Web’s Sixth Sense:A Study of Scripts Accessing Smartphone Sensors -- Anupam Das et al

[16] Pixel Perfect: Fingerprinting Canvas in HTML5 -- Keaton Mowery and Hovav Shacham

[17] Poster: LockedApart: Faster GPU FingerprintingThrough the Compute API -- Tomer Laor and Yossi Oren

[18] Mystique: Uncovering Information Leakage from Browser Extensions -- Quan Chen et al

[19] Hulk: Eliciting Malicious Behavior in Browser Extensions -- Alexandros Kapravelos et al