栈深度指纹识别:递归限制追踪
JavaScript 栈深度和递归限制如何因浏览器和平台不同而创建指纹,以及如何控制栈行为。
简介
每个 JavaScript 引擎都有函数递归深度的限制。当函数自调用次数过多时,引擎抛出 "Maximum call stack size exceeded" 错误。这个限制没有标准化。它在浏览器、浏览器版本、操作系统甚至同一浏览器的主线程和 Web Workers 之间都不同。追踪系统利用这种变化,通过测量栈溢出前可以进行多少次递归调用来精确计算。得到的数字(最大递归深度)成为指纹信号。因为这个值取决于低级引擎内部和平台特定的栈分配,从 JavaScript 很难控制。本文解释栈深度指纹识别的工作原理,以及 BotBrowser 如何通过 --bot-stack-seed 标志提供精确控制。
隐私影响
栈深度指纹识别是较为冷门的指纹技术之一,但正因为大多数隐私工具不解决它,它提供了有用的熵。学术研究表明,递归限制可以区分浏览器版本、操作系统甚至同一浏览器的 32 位与 64 位版本。
该技术有效因为:
- 它在浏览器间变化。 Chrome、Firefox 和 Safari 各自分配不同的默认栈大小,产生不同的最大递归深度。
- 它在平台间变化。 同一浏览器版本在 Windows、macOS 和 Linux 上可能因操作系统级栈分配差异而报告不同深度。
- 它在上下文间变化。 主线程、专用 workers、共享 workers 和 WASM 模块在同一浏览器内各有不同的栈限制。
- 它是稳定的。 在相同的浏览器/操作系统/硬件组合上,递归限制在会话间保持一致。
Brave 浏览器团队的研究指出,栈深度提供约 2-3 比特的熵。虽然这很适度,但在复合指纹中很有价值,因为它与其他信号不捕获的浏览器引擎内部相关。报告的用户代理和观察到的栈深度之间的不匹配是强不一致信号。
技术背景
JavaScript 栈深度如何工作
当 JavaScript 函数被调用时,引擎将新帧推送到调用栈上。每个帧包含函数的局部变量、参数和返回地址。调用栈占用固定大小的内存区域。当栈满时,引擎抛出 RangeError。
最大深度取决于:
- 栈大小分配。 操作系统为栈分配一定量的内存。Linux 默认 8 MB(可通过
ulimit -s配置),macOS 为主线程分配 8 MB,辅助线程 512 KB,Windows 默认 1 MB。 - 帧大小。 每个调用帧的大小不同,取决于局部变量数量和引擎的内部记账。
- 引擎优化。 V8(Chrome)、SpiderMonkey(Firefox)和 JavaScriptCore(Safari)各自以不同的头大小、对齐要求和优化实现调用栈。
- 执行上下文。 主线程通常比 Web Workers 有更大的栈。WASM 模块可能有自己的栈配置。
测量栈深度
追踪脚本通过计数递归调用直到异常发生来测量栈深度:
function measureDepth() {
let depth = 0;
function recurse() {
depth++;
recurse();
}
try {
recurse();
} catch (e) {
return depth;
}
}
结果取决于函数的帧大小。最小函数(无局部变量、无参数)产生比有许多变量的函数更高的计数,但两个值对给定的浏览器/操作系统组合都是稳定的。
上下文间的变化
栈深度测量在执行上下文间不同:
- 主线程通常有最大的栈(通常 8 MB 或更多)。
- 专用 Workers 有较小的栈(通常 1 MB 或 512 KB)。
- WASM 模块可能有独立于 JavaScript 的栈限制。
这些差异在浏览器/操作系统组合内一致但在组合间变化。主线程深度和 worker 深度之间的比率本身是区分信号。
为什么现有保护无效
栈深度由 JavaScript 引擎的内部栈管理决定。它无法从 JavaScript 修改,因为:
- 异常由引擎抛出,而非 JavaScript 代码。
- 栈大小在线程创建时由操作系统分配。
- 拦截 RangeError 不会改变它发生的深度。
- 浏览器扩展在同一引擎中运行,无法访问栈配置。
常见保护方法及其局限性
JavaScript 级别基本没有保护栈深度指纹识别。值由引擎和操作系统决定,没有扩展或脚本可以改变它。
用户代理伪装改变报告的浏览器但不改变实际引擎行为。声称是 Firefox 但在 Chrome 中运行不改变 Chrome 特定的栈深度,创建可检测的不一致。
虚拟机可能改变栈深度(由于不同的操作系统配置)但引入其他指纹信号。
编译自定义浏览器构建并修改栈大小是可能的,但对大多数用户来说不切实际。它还要求匹配所有其他引擎级信号以保持一致性。
随机化测量(通过提前捕获异常)没有帮助。追踪脚本可以多次调用测量函数并取最大值,或使用不同的函数形状来三角定位实际限制。
唯一有效的方法是控制引擎的实际栈行为,这需要在浏览器级别修改。
BotBrowser 的引擎级方法
BotBrowser 通过 --bot-stack-seed 标志提供对 JavaScript 栈深度行为的直接控制。
三种控制模式
--bot-stack-seed 标志接受三种类型的值:
profile - 匹配配置文件的精确栈深度。递归限制匹配配置文件设备会产生的。这是维护一致设备身份的最准确选项。
real - 使用原生栈深度。不应用修改。递归限制反映你的实际硬件和操作系统配置。
整数种子(1-UINT32_MAX) - 生成确定性的每会话栈深度变化。每个种子产生不同但稳定的深度值。
多上下文覆盖
BotBrowser 的栈深度控制适用于所有 JavaScript 执行上下文:
- 主线程递归限制受控。
- Web Workers(专用和共享)的栈深度独立但与配置文件一致地受控。
- WASM 模块栈行为也受控。
主线程和 worker 深度之间的比率匹配配置文件设备会表现的,确保跨上下文测量产生一致结果。
与其他信号的一致性
受控栈深度与配置文件的用户代理、平台和浏览器版本对齐。如果配置文件指定 Windows 10 上的 Chrome 120,栈深度就匹配 Chrome 120 在 Windows 10 上实际产生的值。用户代理声明和可观察的引擎行为之间不会有不匹配。
基于种子的变化
使用整数种子时,栈深度从种子值确定性地派生。相同的种子始终产生相同的深度。不同的种子在配置文件的浏览器/操作系统组合的真实范围内产生不同的深度。这允许你创建多个不同的身份,每个都有合理的栈深度。
配置和使用
配置文件匹配的栈深度
chrome --bot-profile="/path/to/profile.enc" \
--bot-stack-seed=profile \
--user-data-dir="$(mktemp -d)"
基于种子的变化
# 从种子的确定性栈深度
chrome --bot-profile="/path/to/profile.enc" \
--bot-stack-seed=42 \
--user-data-dir="$(mktemp -d)"
# 不同种子产生不同深度
chrome --bot-profile="/path/to/profile.enc" \
--bot-stack-seed=43 \
--user-data-dir="$(mktemp -d)"
原生栈深度
chrome --bot-profile="/path/to/profile.enc" \
--bot-stack-seed=real \
--user-data-dir="$(mktemp -d)"
与其他确定性标志结合
chrome --bot-profile="/path/to/profile.enc" \
--bot-stack-seed=profile \
--bot-noise-seed=42 \
--bot-time-seed=42 \
--user-data-dir="$(mktemp -d)"
Playwright 集成
const { chromium } = require('playwright');
const browser = await chromium.launch({
executablePath: '/path/to/botbrowser/chrome',
args: [
'--bot-profile=/path/to/profile.enc',
'--bot-stack-seed=profile'
]
});
const page = await browser.newPage();
await page.goto('https://example.com');
const depth = await page.evaluate(() => {
let d = 0;
function r() { d++; r(); }
try { r(); } catch(e) {}
return d;
});
console.log(`Stack depth: ${depth}`);
Puppeteer 集成
const puppeteer = require('puppeteer');
const browser = await puppeteer.launch({
executablePath: '/path/to/botbrowser/chrome',
defaultViewport: null,
args: [
'--bot-profile=/path/to/profile.enc',
'--bot-stack-seed=profile'
]
});
const page = await browser.newPage();
await page.goto('https://example.com');
验证
深度测量。 在浏览器控制台中运行递归函数并记录深度。使用相同配置文件和栈种子跨会话比较。深度应完全相同。
Worker 深度测量。 在 Web Worker 中运行相同的递归测试。深度应与主线程不同(workers 有更小的栈)但跨会话一致。
跨机器验证。 在不同机器上使用相同配置文件和种子运行相同测试。栈深度应匹配。
种子变化。 改变种子并验证深度变化。这确认种子正在积极控制行为。
最佳实践
- 使用
--bot-stack-seed=profile获得最大准确性。 这匹配配置文件设备的精确栈深度,确保与用户代理和平台一致。 - 与
--bot-noise-seed和--bot-time-seed结合。 栈深度、渲染噪声和时间行为都是整体浏览器指纹的一部分。控制所有三者以获得全面保护。 - 不要设置不切实际的深度值。 如果使用种子,生成的深度会落在配置文件浏览器的真实范围内。手动指定极端值可能与其他信号不一致。
- 在 worker 上下文中测试。 栈深度在主线程和 workers 间不同。验证两者。
- 理解范围。 栈深度是众多信号之一。它对整体指纹有贡献但本身不够充分。始终使用完整配置文件。
常见问题
Q: 栈深度提供多少熵? A: 大约 2-3 比特。该值区分浏览器系列、平台类型,有时还有特定版本。其价值在其他信号已被控制的复合指纹中增加。
Q: 栈深度会随浏览器更新而变化吗? A: 有时会。主要的 V8 引擎更改可以改变帧大小或栈分配,这改变最大递归深度。这是配置文件应更新以匹配当前浏览器版本的原因之一。
Q: 网站能在我不知情的情况下测量我的栈深度吗? A: 可以。测量是一个简单的递归函数调用,在毫秒内执行且不产生可见效果。不需要权限。
Q: 栈深度影响正常 JavaScript 执行吗? A: 受控栈深度只适用于测量的限制。正常的 Web 应用代码很少接近递归限制。
Q: WASM 栈深度是否单独控制?
A: 是的。WASM 模块有自己的栈配置。BotBrowser 的 --bot-stack-seed 标志同时控制 WASM 和 JavaScript 栈行为。
Q: 如果我需要运行深度递归算法怎么办?
A: 栈深度控制设置的是测量限制,而非人为上限。如果你的应用需要深度递归,使用 --bot-stack-seed=real 可以获得原生栈深度。
Q: headless 和 headed 模式下栈深度不同吗? A: 在标准 Chromium 中,栈深度无论显示模式如何都相同。BotBrowser 用其受控值维持这种一致性。
总结
JavaScript 栈深度是一个冷门但有效的指纹信号,在浏览器、平台和执行上下文间变化。因为它由引擎内部和操作系统级栈分配决定,无法从 JavaScript 控制。BotBrowser 的 --bot-stack-seed 标志在引擎级别提供对递归限制的直接控制,有匹配配置文件精确深度、使用原生深度或从种子生成确定性变化的选项。结合噪声和时间种子,栈深度控制完善了 BotBrowser 对确定性浏览器行为的全面方法。
相关主题请参阅什么是浏览器指纹识别、确定性浏览器行为、Navigator 属性保护和性能时间指纹识别。