前端反调试详解 - 道高一尺,魔高一丈

226 阅读14分钟

⚠️ 免责声明

本文旨在从技术角度探讨前端反调试机制的实现与绕过方法,仅供安全研究和学习参考。请读者遵守相关法律法规,不要将本文技术用于未经授权的系统访问。作者对技术滥用行为不承担任何责任。

什么是"前端反调试"?

当我们以"逆向"、"爬虫"为关键字进行搜索时,很容易发现结果中通常都带有"JS"字样。黑灰产人员以前端网页作为切入点,分析接口、构造请求、爬取数据,最终通过出售数据或接口破解方法获利。电商购物、票务、金融证券类网站是此类攻击的重灾区。

image.png

攻击者在进行逆向时,几乎都会使用浏览器开发者工具中的 Source(源代码)面板和断点功能来分析代码执行逻辑。为了保护网站的代码和数据安全,我们需要设法阻止攻击者调试线上代码。

简单来说,前端反调试是一种通过各种技术手段,增加攻击者调试和分析代码的难度,保护前端代码和商业机密不被轻易窃取或逆向的防御技术。

下文将介绍一些常见的反调试手段,并结合实际案例,帮助读者更好地理解和应用这些技术。

禁止访问开发者工具 - 简单但无用

禁止用户访问开发者工具是最容易想到的防御手段。通过 JS 代码禁用鼠标右键和常用快捷键(如 F12、Ctrl + Shift + I),看似可以阻止攻击者打开开发者工具。

实际上,这种方式完全是掩耳盗铃。攻击者有多种绕过方法:

  • 先在任意网页打开开发者工具,再跳转到目标网站,工具会保持打开状态
  • 直接通过浏览器菜单(而非快捷键)打开开发者工具

因此,这种方法对真正的攻击者毫无防御作用。

如何检测开发者工具是否打开?

既然无法阻止攻击者访问开发者工具,那么我们可以退而求其次:检测开发者工具是否打开,并在其打开时触发防御机制,同时保证不影响正常用户的访问。那么如何实现检测呢?下面介绍几种常用的方法。

窗口尺寸差异检查

window.innerHeight 是一个常见的BOM只读属性,用于获取浏览器窗口的视口(viewport)高度,包括网页内容显示区域和滚动条的高度。而 window.outerHeight 则表示整个浏览器窗口的高度,包含了地址栏、书签栏、工具栏以及窗口边框。

正常情况下,这两个属性的差值相对固定(通常在100-200px左右)。但当开发者工具以 dock 模式在底部打开时,innerHeight 会明显减小,导致与 outerHeight 的差值显著增大。通过监测这个差值的变化,我们就能判断开发者工具是否被打开。

代码示例:

// 作为SetInterval或者resize事件的回调,检测开发者工具是否打开
function isDevToolsOpen() {
  const threshold = 160; // 阈值:正常情况下的高度差
  const heightDiff = window.outerHeight - window.innerHeight;
  const widthDiff = window.outerWidth - window.innerWidth;
  
  // 高度差或宽度差超过阈值,说明开发者工具打开了
  return heightDiff > threshold || widthDiff > threshold;
}

但是,开发者工具可以作为独立窗口打开,这种情况下,窗口尺寸差异检测就会失效。

时间差异检测

我们知道,debugger 语句只有在开发者工具打开时才会暂停代码执行。借助这个特性,我们可以通过检测代码执行时间的异常来判断开发者工具是否打开。

实现原理:

  • 开发者工具关闭:debugger 语句不生效,执行时间极短(< 1ms)
  • 开发者工具打开:debugger 暂停执行,等待用户手动继续,耗时显著增加(> 100ms)

当开发者工具打开时,程序会在 debugger 处暂停执行。用户手动继续执行或跳过断点的操作会产生时间延迟,导致实际时间差远大于预期的 100ms。通过监测这个异常的时间差,就能判断开发者工具是否打开。

代码示例:

let lastTime = Date.now();

setInterval(() => {
  const startTime = Date.now();
  
  debugger; // 开发者工具打开时会暂停在这里
  
  const endTime = Date.now();
  const timeDiff = endTime - startTime;
  
  if (timeDiff > 100) { // 如果 debugger 暂停时间超过 100ms
    console.warn('检测到异常延迟,开发者工具可能已打开');
    // 在这里执行防御措施
  }
  
  lastTime = endTime;
}, 1000);

但是这个方案也有很明显的缺点

  1. 误报率高:系统卡顿、CPU 占用过高、浏览器标签页切换、电脑休眠等情况都可能导致时间差异超过阈值,产生误报

  2. 容易被禁用:攻击者可以在开发者工具中禁用所有断点(Deactivate breakpoints)或右键点击断点选择 "Never pause here",这样 debugger 就不会触发暂停

image.png

Console 性能检测

除了前面提到的窗口尺寸和时间差异检测,还有一种巧妙的检测方式:利用控制台打开时,某些 Console API 的性能表现会发生显著变化的特性。

原理说明:

浏览器对 console 方法的执行策略存在差异:当开发者工具的控制台打开时,console.table() 等方法需要将数据格式化、序列化并渲染成表格显示在控制台中,这个过程需要消耗额外的计算和渲染时间。相反,当控制台关闭时,浏览器会跳过这些渲染操作,使得方法执行速度大幅提升。基于这一特性,我们可以通过测量 console.table() 的执行耗时来推断控制台是否打开。

代码示例:

// 准备大量测试数据
const data = [];
for (let i = 0; i < 500; i++) {
  data.push({
    id: i,
    name: `User${i}`,
    email: `user${i}@example.com`,
    age: Math.floor(Math.random() * 50) + 18,
    department: `Dept${Math.floor(Math.random() * 10)}`
  });
}

// 测量执行时间
const start = performance.now();
console.table(data);
const timeCost = performance.now() - start;

// 控制台关闭时,timeCost 通常 < 1ms
// 控制台打开时,timeCost 可能 > 10ms
if (timeCost > 10) {
  console.warn('检测到控制台已打开!');
  // 执行防御措施
}

方案缺点:

  1. 阈值设置困难:不同设备的性能差异很大,低性能设备即使在正常情况下也可能因为 console 方法的渲染而产生较长耗时,导致误报。需要根据实际情况动态调整阈值,增加了实现复杂度。

  2. 容易被绕过:攻击者可以通过重写 console 对象的方法(如 console.table = () => {})来绕过检测。

使用 disable-devtool 库实现防护

前面介绍的三种检测方法需要开发者自己实现,为了简化开发,社区中出现了一些成熟的反调试库,其中 disable-devtool 是一个轻量级的前端反调试 JavaScript 库。引入该库并通过配置初始化后,即可使用它内置的更多检测方法。

如何反制攻击者

上一节介绍了检测开发者工具打开状态的几种方法。现在,假设检测到开发者工具已经被打开,该如何应对呢?目前主流的方案有如下几种:

中断接口调用,显示错误数据

应用初始化时就设置好劫持逻辑,检测到开发者工具打开时,劫持请求方法并返回假数据或中断请求,防止敏感数据泄露。

循环执行 debugger,阻碍用户调试

function onDevToolOpen() {
  // 原始版,每次都在同一位置暂停,会被之前提到过的"Never pause here"轻松绕过
  setInterval(() => {
    debugger;
  }, 100); // 每 100ms 触发一次
  
  // 增强版,动态创建 debugger,会不断在调试器中打开新Tab
  setInterval(() => {
    Function('debugger')(); // 动态创建函数
  }, 100);
}

重定向到其他页面

离开当前页面,攻击者自然就无法继续调试了

// 有多种方法可以重定向
window.location.replace('https://example.com');
window.location.href = 'https://example.com';
window.open('https://example.com', '_self');

攻击者视角

前面介绍了开发者工具的检测方法和相应的防御措施。那么,这些手段是否真的能够有效防御?本节将从攻击者的角度分析这些防御机制的绕过方法。

无效的debugger

基于 debugger 的检测和防御手段都可以被调试器自身功能轻松化解:

  • Never pause here:右键点击断点,选择永不暂停
  • Deactivate breakpoints:一键禁用所有断点
  • Add all anonymous scripts to ignore list:即使是动态创建的 debugger(如 Function('debugger')()),也能通过此选项批量忽略

image.png

甚至网上还有人通过修改 Chromium 源码并重新编译,制造完全禁用 debugger 关键字的定制浏览器,使用这种浏览器作为攻击平台来绕过反调试检测。

可绕过的重定向

重定向防护看似滴水不漏——从检测到调试器打开再到页面跳转,几乎不留任何操作空间。但借助特定工具,这一机制依然可以被突破。下面就介绍一种方法:

使用tampermonkey拦截重定向

首先要用到 Tampermonkey(油猴),它是一款浏览器扩展,允许用户通过运行自定义 JavaScript 脚本来修改网页行为、增强功能或绕过限制。网上有很多安装教程和功能介绍文档,请自行搜索了解。

通过 Tampermonkey 注入一段自定义脚本,可以在页面发生重定向前拦截并弹出确认对话框。只要不点击确认,页面就会保持在当前状态。这样我们就获得了充足的时间,可以在开发者工具中搜索重定向相关的关键字(如 location.hrefwindow.locationredirect 等),从而精准定位到重定向代码所在的文件和具体行数。

// ==UserScript==
// @name         页面跳转拦截器
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  拦截所有页面跳转
// @author       Your Name
// @match        http://*/*
// @match        https://*/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    // Your code here...
    // 拦截所有页面跳转
    window.addEventListener('beforeunload', function(e) {
        e.preventDefault();
        e.returnValue = ''; // Chrome 需要返回值
        return '确定要离开此页面吗?';
    });

    window.addEventListener('popstate', function(e) {
        e.preventDefault();
        console.log('URL 变化 (浏览器前进后退):', window.location.href);
        e.returnValue = ''; // Chrome 需要返回值
        return '确定要离开此页面吗?';
    });
})();

效果:使用油猴脚本在本地为百度添加了一个重定向确认弹窗 使用油猴脚本在本地为百度添加了一个重定向确认弹窗

浏览器本地代理 JS

利用开发者工具的 Local Overrides 功能,我们可以将远程 JavaScript 文件代理到本地进行修改。

操作步骤:

  1. 在 Network 面板右键点击目标 JavaScript 文件
  2. 选择 Override content(覆盖内容)
  3. 首次使用需要选择本地存储目录并授权
  4. 启用该功能后,浏览器会在本地创建一个工作目录,并按照原始请求的完整 URL 路径自动生成相应的目录结构
  5. 然后每次选择Override content就会将远程文件复制到本地
  6. 此后,浏览器加载的将是本地版本而非远程文件

现在我们可以删除本地文件中重定向相关的代码,从而绕过重定向防护。

image.png

实践案例

上面介绍了理论方法,现在我们以某网站为例,展示如何绕过重定向防御。这个网站在打开开发者工具后,会在当前窗口重定向到首页。

首先通过上面介绍的方法,将网站中所有的JS代码代理到本地,并搜索重定向相关的关键字,很快就找到了相关代码,清除ondevtoolopen方法中的内容即可。 image.png

其实对于这个网站,还有更简单的方法可以破解。在 Console 面板中找到警告提示: You don't have permission to use DEVTOOL! image.png

以此为关键字搜索,很容易定位到相关代码。 image.png

继续向上溯源后发现,该网站使用的是之前介绍过的 disable-devtool 第三方库进行防御,删除相关的初始化代码即可破解。 image.png

高级防御手段

高阶代码混淆

从上述案例可以看出,关键字是攻击者定位防御代码的重要线索。常用的打包工具(如 UglifyJS)通常只进行代码压缩和变量名丑化,而逻辑结构和字符串内容依然清晰可读,因此防御效果有限。要真正提高安全性,需要采用更高级的代码混淆技术,包括控制流平坦化、字符串加密、死代码注入等手段来显著降低代码可读性。

通常为了达到比较好的效果,我们会结合多种混淆手段,比如变量名混淆、字符串编码、表达式拆分等,以规避关键字搜索,制造可读性极低且难以理解的代码。比如,你能理解下面这段代码吗?

// 变量名混淆 + 字符串编码 + 表达式拆分 + window对象隐藏(优化版)
var _0x3c4d = [0x6f, 0x70, 0x65, 0x6e];  // 'open'
var _0x5e6f = [0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x63, 0x6f, 0x6d];
var _0x7g8h = [0x5f, 0x73, 0x65, 0x6c, 0x66];  // '_self'

// 表达式拆分:将每个操作都拆分为独立步骤
var _0x1k2l = String.fromCharCode.apply(null, _0x3c4d);  // 解码'open'
var _0x3m4n = String.fromCharCode.apply(null, _0x5e6f);  // 解码URL
var _0x5o6p = String.fromCharCode.apply(null, _0x7g8h);  // 解码'_self'

// 获取window对象和open方法
var _0x7q8r = (function() { return this; })();  // 获取window对象
var _0x9s0t = _0x7q8r[_0x1k2l];  // 获取window.open方法
var _0x1u2v = _0x7q8r;  // window对象作为上下文
var _0x3w4x = _0x3m4n;  // 参数1:URL
var _0x5y6z = _0x5o6p;  // 参数2:'_self'

// 直接调用方法
var _0x7a8b = _0x9s0t(_0x3w4x, _0x5y6z);  // 调用window.open(url, '_self')

SourcemappingURL

原文链接(英文)

SourceMap 作为开发调试工具,通常以注释形式出现在文件末尾,用于将压缩混淆后的代码映射回原始源码。同时它具有以下关键特性:

  • SourceMappingURL请求只在开发者工具打开时才会触发。
  • SourceMappingURL是完全静默的隐藏请求,不会在开发者工具的 Network 面板或 Console 中显示,只能通过网络调试代理才能观察到。
  • SourceMappingURL可以动态构造请求URL并传递任意数据作为查询参数,脚本移除后请求仍然会发送。
  • 即使页面设置了严格的 CSP 规则,SourceMappingURL 请求也能完全绕过所有限制。

利用上面的特性,我们可以构建这样的防御流程: 将 SourceMappingURL 指向非 SourceMap 的接口,当开发者工具打开时自动调用该接口并携带用户标识。服务器可以通过Set-Cookie标记调试用户,或者在后续请求中返回虚假数据,实现精准的反调试防护。同时整个流程在开发者工具内是不可见的,有较高的隐蔽性。

PolyGlot + WASM

原文链接(英文)

作者巧妙运用 PolyGlot 编程概念,结合 WebAssembly 技术构建了一套极其精妙的防御机制。有兴趣的朋友可以访问 https://remyhax.xyz/antidebug/wasm.html 挑战一下,尝试追踪网页打开时弹出的 alert 函数调用来源。

结语

正如前文所展示的,前端反调试本质上是一场不对称的攻防战。由于JavaScript代码必须下载到客户端并在用户浏览器环境中执行,防御者天然处于劣势——当代码的执行权交到用户手中,防御者就失去了最后的控制权。攻击者拥有完整的代码、完全的环境控制权,可以随意修改运行环境、拦截函数调用,甚至重编译浏览器内核。在这样的前提下,任何反调试手段都终将被绕过。

因此,前端反调试更像是提高攻击成本的"减速带",而非牢不可破的"城墙"。道高一尺,魔高一丈,理解这一本质,才能在安全防护中做出正确的决策。