深入理解 JavaScript 堆栈溢出检测
近年来,随着 Web 反爬与前端安全需求不断上升,网站常常会加入一系列反调试、反爬虫、防自动化检测逻辑。除了常见的 debugger 检测、toString 篡改检测、console 限制等,利用递归制造堆栈溢出的技术也逐渐成为前端反调试脚本的一种常见手段。
本文将深入分析某反爬脚本中的一段“看似无意义”的代码:
1 | (function(){ |
当我们在不同环境下执行这段代码,会得到截然不同的结果:
在nodejs下运行的结果:
1 | [44.02051282051282, 11412, 9657] |
在浏览器下运行的结果:
1 | [48.00540906017579, 10354, 8875] |
为什么同样的代码在不同环境下会产生不同结果?
为什么递归能用来检测是否是调试器?
这些数字又代表什么?
本文将从底层机制讲起,最终构建出一套可复用的“堆栈深度指纹模型”。
1. 看似“垃圾代码”的反调试逻辑
乍一看,这段 e() 与 r() 的内部结构非常可疑:
无限递归调用
try/catch 捕获异常
返回值是某种奇怪的计算
重复使用相同函数名进行隐藏
先看内部的函数:
1 | function e() { |
这是一个 故意制造堆栈溢出的函数:
- 正常情况下:递归会无止境进行,直到爆栈
- 发生异常后:catch 捕获后返回 1
- 关键点:上层递归不会再抛错,而是用 “1 + 下层返回值” 继续回溯
也就是说:
函数的返回值就是“最大递归深度”(maximum call stack size)
这意味着,这段代码实际上是在测量 JavaScript 引擎栈空间的大小。
2. 为什么两个函数的深度不同?
第二个函数 r():
1 | function r() { |
与 e() 的差异:
- 增加了一个局部变量
- 调用时传了参数 r(e)(虽然不会使用,但参数入栈也会占空间)
最终导致:
r() 每深一层就比 e() 多占一点栈空间
因此,在相同的栈大小限制下:
e() 理论上更深
r() 理论上更浅
3. 不同环境下为什么结果不同?
原因很简单:不同环境允许的最大调用栈深度不同。
浏览器版本的 V8 往往有更严格的堆栈限制,因为浏览器线程环境更复杂,有事件循环、UI 渲染、内存安全等考虑;Node.js 基于 Server V8,可接受更深的栈。
4. 栈深度差值为何可以用于“环境指纹识别”?
代码计算的是:
1 | n * 8 / (t - n) |
即:
1 | (更浅的栈深度 * 常数) / (两者差值) |
由于不同环境下:
- 栈大小不同
- 编译器优化不同
- 调试器开启时栈结构不同
- headless / puppeteer 中栈深度显著减少
- 沙箱、VM、Hook 会改变栈帧布局
使得这一数值对环境非常敏感。
换句话说:
这是一个“基于栈空间的环境指纹”(Stack Fingerprint)
可用于: - 识别是否是浏览器还是 Node
- 识别是否在 headless 模式
- 识别是否开启 devtools
- 识别是否处于沙箱/虚拟机
- 检测是否经过 Hook / Proxy
这就是它用于反爬/反调试的根本原因。
5. DevTools 影响堆栈深度?
是的。
打开 Chrome DevTools 后,最大递归深度会减少(调试器需要额外栈空间)。
你会发现:
普通模式:
1 | e(): 10354 |
打开 DevTools
1 | e(): 9600 |
DevTools 会减少栈空间,导致递归深度降低
这是因为 DevTools 会额外分配栈空间,用于调试器的功能。
有些反调试脚本正是利用这一点进行检测。
6. 使用此算法可生成“栈特征码”
可以将以下值合并,形成可用于识别浏览器类型、版本、环境的“特征指纹”:
- t:最大递归深度
- n:最大递归深度(带变量)
- (t - n):栈帧差值
- ratio = n * 8 / (t - n):引擎特征值
这些参数在不同浏览器、不同引擎下都有固定差异。
也就是说:
这是和 navigator.userAgent 等价但更隐蔽的“引擎识别方法”。
7. 结语:为什么反爬要用这种技巧?
因为:
- 栈深度无法被轻易伪造
- headless 浏览器栈更浅,很容易被检测
- DevTools 会改变栈
- Hook 或代理栈也会被破坏
- 沙箱、虚拟机对栈有独特迹象
- 不依赖任何 API,因此难以被屏蔽
它是目前最隐蔽的环境检测手段之一。