1. 反调试这件事,先想清楚到底在防谁
“js 检测开发者工具是否打开”这个需求,我在前端安全加固这块被人问过太多次了。绝大多数人来问的时候,诉求都写得很直白——防止别人调试代码。但真正落地之前,得先把一个前提摆在桌面上:浏览器把代码发到你手里,它就已经不属于你了。JavaScript 是解释执行的,源码(哪怕是压缩混淆过的)必然躺在浏览器里,任何人只要愿意花时间,都能把它捞出来看。
所以反调试的目标从来不是“让别人看不到代码”,而是“让随手 F12 看一眼的人觉得麻烦,转身走掉”。它是一道门槛,不是一堵墙。想通这一点,后面的方案选型才不会走偏——你不会再纠结于“怎么做到绝对不可破解”,而是会去思考“怎么用最小的性能代价,拦掉 80% 的低成本窥探”。
我在实际项目里见过两种极端。一种是完全不做,前端把签名密钥、风控规则、接口地址全写在明面上,改个参数就能刷活动;另一种是走火入魔,每 50 毫秒跑一次debugger,结果开着自己家 DevTools 的开发同学天天被卡死,最后在群里骂娘。这两种都不对。
下面我把这几年试过、踩过、也推翻过的方案整理一遍。内容偏实战,会带上完整的代码、阈值计算过程和误报控制策略,你可以直接抄,也可以按自己的场景裁剪。读者门槛不高,懂基本的 DOM 和事件循环就能跟着走;如果你已经在做风控或前端安全,第 3、4 节的参数部分应该对你更有用。
1.1 前端代码天生可见,检测只能抬高成本
先建立一个认知:浏览器 DevTools 的打开状态,本质上是一个“客户端本地状态”。它不在你的服务器上,不在你的数据库里,你只能通过一些间接信号去猜。猜就有误差,有误差就有误报,这是所有反调试方案的共同宿命。
那为什么还要做?因为成本不对称。攻击者写一个自动化解密脚本可能要花两天,你的检测代码只花两小时;攻击者绕过你的检测可能只需要在控制台敲一行覆盖代码,但他得先知道你有检测、知道检测点在哪、知道怎么覆盖。反调试的价值在于增加“发现成本”和“维持成本”,而不是制造不可能。
我个人的经验是:一套设计良好的检测,能让 90% 的爬虫脚本和“脚本小子”直接放弃,让 10% 的专业选手多花 30 分钟到几小时。这个投入产出比对绝大多数业务来说已经够了。
1.2 三类真实场景:从防抄到防刷
别为了技术而技术,先看看自己是哪一类需求,方案差别很大。
第一类是内容保护型。比如付费课程、付费报告的页面,正文是前端渲染的。做这个的人最怕的就是用户打开 DevTools 复制 DOM 结构,或者干脆把接口揪出来批量拉数据。这类场景的重点不在“阻止调试”,而在“检测到异常后让内容不可用”,比如直接遮罩、清空容器、跳转提示页。
第二类是逻辑保护型。前端有一堆校验逻辑,比如优惠券叠加规则、积分计算、抽奖概率。攻击者打开 DevTools 改几个变量就能白拿。这类场景关注的是“防止运行时篡改”,除了检测 DevTools,还要做变量冻结、函数完整性校验、关键计算下沉到后端。
第三类是自动化对抗型。主要是挡无头浏览器和自动化脚本。这类场景里 DevTools 检测只是拼图的一小块,更多要靠行为特征、鼠标轨迹、环境指纹。
我做过一个抽奖活动页,属于第三类。当时的做法是:入口处跑 DevTools 检测,命中就直接不初始化抽奖组件,静默上报。上线后异常请求量掉了大概七成,剩下三成大多是真人用户在多开窗口时因为窗口尺寸异常被误判,后来调阈值解决了。
1.3 别把检测当安全边界
这是我最想说的一条。前端检测永远不能作为安全边界,它只是一个信号源。所有真正的判定必须回到服务端。
举个反例。有团队做了个“检测到 DevTools 就禁止提交表单”的逻辑,纯前端。结果攻击者用 curl 直接打接口,绕过前端一百次。检测代码写得再花哨,也没有意义。
正确的姿势是:前端检测 → 生成一个欺诈分或标记 → 随请求带上 → 后端结合行为数据综合判定。后端才是那个说了算的人。前端检测提供的价值是“我帮你提前筛掉一部分脏流量,减轻后端压力”,而不是“我保证干净”。
想清楚这个定位,你就不会在检测代码上过度投入,也不会因为“被绕过了”而觉得白干。
2. 主流检测方案的原理与可靠性排序
市面上流通的 DevTools 检测手段大概有七八种,我按可靠性从高到低排一下,并解释每种背后的原理和失效条件。这一段是全文的技术底座,看懂了后面写代码就是拼装。
2.1 时间差检测:利用 debugger 语句的停顿特性
这是目前最稳、兼容性最好的一招。原理很简单:debugger语句只在 DevTools 处于打开状态时才会触发断点暂停。DevTools 关闭时,这行语句会被引擎直接跳过,执行时间几乎为零。
所以检测逻辑就是:记录执行前的时间戳,执行debugger,再记录执行后的时间戳,如果差值超过某个阈值,说明中间被暂停了,那 DevTools 就是开着的。
function detectByDebugger() { const start = performance.now(); // eslint-disable-next-line no-debugger debugger; const end = performance.now(); return end - start > 100; }为什么用performance.now()而不是Date.now()?因为Date.now()的精度受系统时间调整影响,而且最小粒度在某些老浏览器上是 15.6 毫秒(跟刷新率有关),用来测百毫秒级的差异不够细。performance.now()是单调时钟,精度到微秒级,更适合。
失效条件有三个,得说清楚:一是用户可以在 Sources 面板里点“Deactivate breakpoints”(快捷键Ctrl+F8),这一按所有debugger语句就全废了;二是如果用户把 DevTools 开着但没启用脚本调试(比如只开着 Console),断点仍然生效,检测依然准;三是在某些无头浏览器里debugger根本不生效,检测会返回 false,这反而成了识别无头环境的一个反向特征。
2.2 console 对象特性检测:getter 与 toString
这一类方案流传最广,但可靠性在逐年下降,必须配合版本实测。
最经典的是对象 getter 触发法。思路是:创建一个对象,用Object.defineProperty给它定义一个带 getter 的属性,然后用console.log把这个对象打印出来。当 DevTools 打开时,控制台为了渲染这个对象,会去读取它的属性,从而触发 getter;DevTools 关闭时,console.log基本是空操作,不会读取属性。
let triggered = false; const probe = document.createElement('img'); Object.defineProperty(probe, 'id', { get() { triggered = true; return 'probe'; } }); console.log(probe);注意这里用document.createElement('img')而不是{},是因为浏览器对 DOM 元素的控制台渲染策略和普通对象不同,DOM 元素更容易触发属性读取。但即便如此,Chrome 从 70 多个版本之后就开始对控制台参数做惰性求值,很多时候你直接console.log(obj)不会立刻触发,得手动点开那个三角才触发。这一改,命中率就掉了一半。
另一个变种是正则 toString 法:
let opened = false; const re = /./; re.toString = function () { opened = true; return ''; }; console.log('%c', re);这招利用的是%c格式符会去调用后续参数的toString。同理,DevTools 关着的时候可能不会被调用。可靠性同样受版本影响。
还有一个是console 方法 toString 比对。正常情况下console.log.toString()返回"function log() { [native code] }",而某些浏览器在 DevTools 打开时,控制台内部会替换这些方法,导致返回的字符串不一样。这个差异以前在 Firebug 时代很明显,现在的 Chrome 上基本测不出来。
我的结论是:console 系列可以作为辅助信号,权重给低一点,绝不能作为唯一判据。
2.3 视口尺寸差检测:最省事但也最容易误报
原理是:DevTools 停靠在浏览器窗口内时,会占据一部分空间,导致window.innerWidth和window.outerWidth之间出现明显差值。停靠在右侧,宽度差变大;停靠在底部,高度差变大;如果是以独立窗口模式打开(undocked),这个方案直接失效。
const widthGap = window.outerWidth - window.innerWidth; const heightGap = window.outerHeight - window.innerHeight;这个方案的优点是不耗性能(读属性是同步的,微秒级),缺点是正常浏览器 UI 本身就会吃掉一部分空间。标题栏、标签栏、地址栏、书签栏加起来,在 Windows 上通常吃掉 90 到 130 像素的高度;Mac 上少一些,大概 60 到 90 像素。所以你不能简单判断heightGap > 0,必须设一个阈值。
阈值怎么算,我在第 4 节会详细展开。这里先记住:阈值不能凭感觉拍,要按平台和浏览器分别统计。
2.4 原生函数完整性校验
这一招防的不是“打开 DevTools”,而是“已经打开了 DevTools 并开始动手脚”。因为一旦有人在控制台里 Hook 了Function.prototype.toString或者重写了console.log,你的其他检测函数就可能被骗过去。
核心思路是:在页面最开始(越早越好,最好在业务代码之前)把一批原生函数的toString结果存下来,之后定期比对。
const nativeSnapshot = { FunctionToString: Function.prototype.toString, ObjectDefineProperty: Object.defineProperty, performanceNow: performance.now.bind(performance) }; function isTampered(fn) { return Function.prototype.toString.call(fn).indexOf('[native code]') === -1; }但这里有个鸡生蛋问题:如果Function.prototype.toString本身被换了,isTampered就失效了。所以更稳的做法是把原始引用提前存到闭包里,永远用存下来的那个版本去校验。
(function () { const rawToString = Function.prototype.toString; const rawDefineProperty = Object.defineProperty; function checkNative(fn) { try { return rawToString.call(fn).indexOf('[native code]') !== -1; } catch (e) { return false; } } window.__checkNative = checkNative; window.__rawToString = rawToString; })();注意:这段代码必须放在所有第三方脚本之前执行。如果你的页面接了统计 SDK、客服 SDK、广告 SDK,它们里面有不少会重写原生方法。你先跑校验,再让它们加载,避免自己人误伤自己人。
2.5 各方案横向对比
把上面几种放在一张表里看清楚,选型的时候直接对照。
| 方案 | 原理 | 可靠性 | 性能开销 | 主要误报来源 |
|---|---|---|---|---|
| debugger 时间差 | 断点暂停导致耗时突增 | 高 | 低(关闭时几乎为零) | 主线程卡顿、长任务 |
| 尺寸差检测 | DevTools 挤占视口 | 中高 | 极低 | 书签栏、侧边栏、多屏、缩放 |
| console getter | 控制台渲染触发属性读取 | 中(版本相关) | 低 | 浏览器惰性求值导致漏报 |
| console toString | 控制台方法被替换 | 低 | 低 | 浏览器版本差异大 |
| 原生函数校验 | 比对 toString 快照 | 高 | 低 | 第三方 SDK 重写 |
| 键盘/右键拦截 | 拦截 F12、Ctrl+Shift+I | 极低 | 极低 | 菜单栏打开、快捷键自定义 |
最后一行那个键盘拦截,我特意放进来是想说:别做这个。它拦不住任何人,却实打实地影响正常用户的复制粘贴和右键操作,还会让你在无障碍审核里扣分。我见过有站点把右键菜单禁了,结果用户想复制订单号都复制不了,客服电话打爆。
3. 手把手写一套可用的检测模块
理论讲完了,开始动手。我要写的不是一个孤立的检测函数,而是一个带状态管理、加权评分、防抖触发的完整模块。为什么不写孤立的?因为单一信号误报率太高,你必须做多信号融合。
3.1 模块骨架与状态机设计
先定状态。我设计三个状态:
clean:所有信号都正常suspicious:有信号命中,但样本不足flagged:多个信号持续命中,判定为已打开
从suspicious到flagged需要连续 N 次命中,这个 N 是我用来压误报的关键旋钮。为什么用“连续”而不是“累计”?因为偶发的尺寸抖动(比如用户临时打开书签栏)是孤立事件,连续命中说明状态是稳定的。
const Detector = (function () { const config = { interval: 1000, // 轮询间隔,毫秒 hitThreshold: 3, // 连续命中多少次判定为打开 debuggerTimeGap: 100, // 时间差阈值,毫秒 widthGap: 160, // 宽度差阈值,像素 heightGap: 180, // 高度差阈值,像素 scoreThreshold: 2 // 单轮评分达到多少算命中 }; const state = { level: 'clean', streak: 0, timer: null, reported: false }; // ... 后续填充 return { config, state, start, stop }; })();为什么默认间隔是 1000 毫秒?因为更快的轮询没有收益——用户打开 DevTools 到真正开始调试,中间至少有几秒的间隔,你 1 秒探测一次完全来得及。而 1 秒一次对 CPU 的影响基本可以忽略。我实测过 200 毫秒间隔,在低端安卓机上温度会明显升高,得不偿失。
3.2 四个探测器的具体实现
现在把四个探测器填进去。每个探测器返回 0 或 1,代表是否命中。
探测器一:debugger 时间差。
function probeDebugger() { const t0 = performance.now(); // eslint-disable-next-line no-debugger debugger; const t1 = performance.now(); return t1 - t0 > config.debuggerTimeGap ? 1 : 0; }这里有个坑要提醒:如果主线程正在执行一个耗时 300 毫秒的复杂计算,这个探测器会误判。所以我在评分环节给它单独设了权重,并且要求必须和其他信号同时命中。
探测器二:视口尺寸差。
function probeViewport() { // 移动端直接跳过,outerWidth 在部分移动浏览器上等于 0 或等于 innerWidth if (/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)) return 0; const widthGap = window.outerWidth - window.innerWidth; const heightGap = window.outerHeight - window.innerHeight; // 全屏模式(F11)下 outerHeight 等于屏幕高度,会产生巨大差值,必须排除 const isFullscreen = !!(document.fullscreenElement || window.fullScreen || (window.screen.height - window.outerHeight) < 10); if (isFullscreen) return 0; const hitW = widthGap > config.widthGap ? 1 : 0; const hitH = heightGap > config.heightGap ? 1 : 0; return hitW || hitH; }全屏模式必须排除,这是最容易踩的坑。用户按 F11 全屏后,浏览器把所有 UI 都藏了起来,outerHeight直接等于屏幕物理高度,innerHeight等于视口高度,差值可能上百像素,一检测一个准,全是误报。我第一次上线这个方案的时候,第二天就收到一堆反馈说“我全屏看视频也被拦了”,丢人。
探测器三:console getter。
let consoleHit = false; function installConsoleProbe() { const probe = document.createElement('div'); Object.defineProperty(probe, 'id', { get() { consoleHit = true; return ''; }, configurable: true }); // 每轮探测时打印一次 return function () { consoleHit = false; try { // eslint-disable-next-line no-console console.log(probe); // eslint-disable-next-line no-console console.clear(); } catch (e) { // 有些环境 console 被重写,忽略 } return consoleHit ? 1 : 0; }; }console.clear()是为了不让控制台被刷屏。但要注意,console.clear在 DevTools 关闭时也是空操作,不会有副作用。另外,如果你的页面是嵌入在 iframe 里,控制台的输出会归属到父页面,这个小技巧可能不生效,所以 iframe 场景要降权。
探测器四:原生函数校验。
const rawToString = Function.prototype.toString; const rawDefineProperty = Object.defineProperty; function probeNative() { let hit = 0; try { if (rawToString.call(rawDefineProperty).indexOf('[native code]') === -1) hit++; if (rawToString.call(Function.prototype.toString).indexOf('[native code]') === -1) hit++; if (rawToString.call(JSON.stringify).indexOf('[native code]') === -1) hit++; } catch (e) { hit++; } return hit > 0 ? 1 : 0; }用rawToString而不是Function.prototype.toString,就是为了防止被 Hook 之后自证清白。这个细节很多人会漏。
3.3 加权评分与触发策略
四个探测器都写好了,现在做融合。我给每个探测器分配权重:
| 探测器 | 权重 | 权重理由 |
|---|---|---|
| debugger 时间差 | 2 | 可靠性高,但受主线程卡顿影响 |
| 视口尺寸差 | 2 | 稳定性好,但受窗口布局影响 |
| console getter | 1 | 版本差异大,只作辅助 |
| 原生函数校验 | 2 | 一旦命中基本可以确定有人在动手脚 |
单轮得分达到scoreThreshold(默认 2)算一次命中,连续命中hitThreshold(默认 3)次才升级状态。
function evaluate() { let score = 0; score += probeDebugger() * 2; score += probeViewport() * 2; score += consoleProbe() * 1; score += probeNative() * 2; const roundHit = score >= config.scoreThreshold; state.streak = roundHit ? state.streak + 1 : 0; if (state.streak >= config.hitThreshold && state.level !== 'flagged') { state.level = 'flagged'; onFlagged(); } else if (state.streak > 0 && state.level === 'clean') { state.level = 'suspicious'; } }为什么 debugger 和尺寸检测权重都给 2,而要求阈值也是 2?因为这两个信号独立性强——主线程卡顿不会让窗口尺寸变化,窗口布局变化不会让主线程暂停。任意一个命中就已经很有说服力了。而 console 只能给 1,因为它自己就能触发阈值的话误报率会飙升。
3.4 触发后的动作设计:软提示 vs 硬阻断
判定为flagged之后干什么,这个决策比检测本身更重要。我的建议是分级处置,给用户留退路。
第一种是软提示。页面顶部弹一条横幅,写“检测到调试行为,部分功能可能受限”。这个适合内容型站点,用户体验友好,也有威慑作用。
第二种是功能降级。不阻止访问,但把敏感功能关掉。比如抽奖按钮变成灰色,视频分辨率降到最低,导出功能提示“当前环境不支持”。
第三种是硬阻断。直接覆盖一个遮罩层,提示“请关闭开发者工具后刷新页面”。这个只适合对抗强度高的场景,比如抢购、抽奖、返利。用了就要做好投诉准备,因为误报的用户是真实用户,他们的体验会很差。
function onFlagged() { if (state.reported) return; state.reported = true; // 1. 上报(用 sendBeacon,不阻塞页面卸载) report('devtools_flagged', { score: state.streak, widthGap: window.outerWidth - window.innerWidth, heightGap: window.outerHeight - window.innerHeight, ua: navigator.userAgent }); // 2. 分级处置 const mode = document.body.dataset.devtoolsPolicy || 'soft'; if (mode === 'block') { showBlockOverlay(); } else if (mode === 'degrade') { degradeFeatures(); } else { showBanner(); } }用>function start() { if (state.timer) return; state.timer = setInterval(evaluate, config.interval); } function stop() { if (state.timer) { clearInterval(state.timer); state.timer = null; } } function report(event, payload) { try { const body = JSON.stringify({ event, ...payload, ts: Date.now() }); if (navigator.sendBeacon) { navigator.sendBeacon('/api/risk/report', body); } else { fetch('/api/risk/report', { method: 'POST', body, keepalive: true }); } } catch (e) { // 上报失败不能影响主流程 } } // 页面可见时才轮询,切到后台就停掉,省电 document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') { start(); } else { stop(); } }); start();
提示:
visibilitychange这段别省。用户切到别的标签页时,你的检测还在跑就是纯浪费。而且切回来的时候,尺寸会重新计算,正好重新校准。
调用方式就一行:把整个 IIFE 放在<head>里,越靠前越好。但注意document.body在 head 里还不存在,所以onFlagged里的 DOM 操作要延迟到DOMContentLoaded。这个细节我踩过一次,控制台报了一堆 null 错误,排查了半小时。
4. 阈值怎么定:参数计算与实测数据
前面代码里出现了几个魔法数字——160、180、100。这些不能拍脑袋定,得算。这一段专门讲计算方法,你可以按自己的目标用户群重新算一遍。
4.1 尺寸检测阈值的计算过程
核心公式是这样的:
正常窗口的 UI 占用 = 标题栏 + 标签栏 + 地址栏 + 书签栏(可选)+ 系统任务栏重叠部分我在 Windows 11 + Chrome 上做过一轮测量,用脚本批量输出outerHeight - innerHeight,结果如下:
| 场景 | heightGap(像素) | widthGap(像素) |
|---|---|---|
| 无书签栏、100% 缩放 | 89 - 97 | 0 - 2 |
| 有书签栏、100% 缩放 | 122 - 131 | 0 - 2 |
| 有书签栏 + 侧边栏 | 122 - 131 | 40 - 60 |
| 无书签栏、125% 缩放 | 112 - 122 | 0 - 3 |
| 无书签栏、150% 缩放 | 134 - 146 | 0 - 4 |
| DevTools 停靠底部(默认高度) | 320 - 380 | 0 - 2 |
| DevTools 停靠右侧(默认宽度) | 89 - 97 | 380 - 520 |
怎么读这张表?正常情况下的最大高度差出现在“有书签栏 + 150% 缩放”,大约 146 像素。DevTools 停靠底部时的高度差最小是 320 像素左右。
所以阈值应该卡在 146 和 320 之间。取中间偏保守一点,180 到 220 比较稳妥。我最终选 180,是考虑到了 Mac 上某些版本 Chrome 的 UI 高度略高,留了 34 像素的安全余量。
宽度差就好办多了,正常情况最多到 60(侧边栏),而 DevTools 停靠右侧最少 380。差距巨大,阈值取 160 有极大的容错空间。实际上取 120 到 200 都行,我选 160 是因为它离两边都远。
Mac 上的数据不一样,我测的是:
| 场景 | heightGap |
|---|---|
| 无书签栏、100% 缩放 | 62 - 72 |
| 有书签栏、100% 缩放 | 96 - 108 |
| 有书签栏、150% 缩放 | 118 - 130 |
Mac 的上限比 Windows 低,所以在 Mac 用户为主的站点,阈值可以压到 150,进一步降低漏报。
实操心得:如果你的用户主要集中在某个平台,建议上线前跑一次数据采集——把
heightGap的分布上报到服务端,看看 P99 是多少,阈值就取 P99 加一点余量。这比翻文档靠谱一百倍。我当时采集了 5 万个样本才定的 180,误报率从 3% 降到了 0.2%。
4.2 时间阈值的取值依据
debuggerTimeGap默认 100 毫秒,这个数字怎么来的?
DevTools 关闭时,debugger语句的执行开销在测试中稳定在 0.01 到 0.05 毫秒之间。也就是说,如果你测出 1 毫秒的差值,那基本就是噪声。
DevTools 打开时,debugger会触发断点暂停,暂停时间取决于用户的操作——他可能瞬间就按了继续,也可能去泡了杯咖啡。但即便如此,从触发断点到交给用户控制,中间涉及 DevTools 前端和调试协议通信,实测最小值也在 30 到 60 毫秒之间。
所以阈值不能低于 30,否则会误判。我取 100,是为了过滤掉主线程的长任务。什么是长任务?比如一个 200 毫秒的复杂计算,加上debugger本身的执行,可能就超过 100 了,这时候会误报。
所以实际上更严谨的做法是:在探测前后加一个“主线程负载检查”。如果页面正在执行长任务,这一轮探测直接跳过。
let busy = false; function probeDebuggerSafely() { if (busy) return 0; // 主线程忙的时候不测 busy = true; const result = probeDebugger(); busy = false; return result; }更精准的方案是用PerformanceObserver监听longtask,如果最近 1 秒内出现过长任务,就丢弃这一轮结果。这个方案我在一个重计算的报表页面用过,效果很好。
4.3 误报率控制的三个关键参数
综合下来,控制误报就靠三个旋钮:
第一是hitThreshold,连续命中次数。默认 3 次,意味着打开 DevTools 后至少持续 3 秒才会判定。用户如果只是误触快捷键瞬间打开又关掉,不会触发。这个值调到 5 会更安全,但响应变慢。
第二是scoreThreshold,单轮得分阈值。默认 2,意味着至少要有一个权重为 2 的探测器命中。如果把它降到 1,那 console 探测器一个人就能触发,误报会暴涨。
第三是冷却期。判定为flagged之后,不要立刻重复上报,加一个 60 秒的冷却。
let lastReportTime = 0; function report(event, payload) { const now = Date.now(); if (now - lastReportTime < 60000) return; lastReportTime = now; // ... 上报逻辑 }这三个参数组合起来,我的实测误报率是 0.2% 左右(分母是真实 PV)。这个水平已经是可接受的了。
5. 踩坑实录:常见问题与排查速查表
这一段全是我在实际项目里踩过的坑,按问题现象、原因、解决方案的顺序整理,你可以当手册查。
5.1 移动端与多屏场景的误报
移动端是重灾区。iOS Safari 上,window.outerWidth在横屏和竖屏切换时行为不一致,有时候返回 0,有时候返回屏幕宽度。Android 上各家浏览器实现也不同,UC、QQ 浏览器都有自己的 UI 层,会干扰尺寸计算。
我的处理方案是直接跳过移动端:在 UA 里匹配到移动设备就返回 0。反正移动端打开 DevTools 的门槛本来就高(需要连电脑调试),检测的收益远小于误报的代价。
多屏场景也很坑。用户把浏览器窗口拖到副屏,或者副屏分辨率不同,尺寸计算会完全乱掉。更麻烦的是,某些多屏管理软件会修改窗口尺寸。这里有个小技巧:用screen.availWidth而不是window.screen.width做基准,因为availWidth排除了任务栏,更接近可用区域。
const availW = window.screen.availWidth; const availH = window.screen.availHeight; // 如果窗口明显小于屏幕可用区域,可能是在多屏环境,降低检测权重 const isSmallWindow = window.outerWidth < availW * 0.5; if (isSmallWindow) return 0;5.2 浏览器缩放与侧边栏
浏览器缩放会同时放大 UI 和视口,理论上差值会等比放大。100% 缩放下 90 像素的 UI,到了 150% 就变成 135 像素。这也是为什么阈值不能设太低。
用户手动拉宽侧边栏(比如书签侧边栏)会造成宽度差。Chrome 的书签侧边栏默认宽度约 220 像素,展开后加上图标栏,宽度差能达到 250 像素以上,这就超过 160 的阈值了,会误报。
怎么区分书签侧边栏和 DevTools?看高度差。侧边栏不影响高度,而 DevTools 停靠右侧时,通常也不会改变高度差(还是那 90 多像素)。所以如果只有宽度差超标、高度差正常,就需要谨慎。
我在最终的评分规则里加了一条:宽度差命中且高度差正常时,权重降为 1。这样单纯靠宽度的信号不足以触发判定,需要配合其他探测器。
5.3 问题速查表
| 现象 | 可能原因 | 排查方式 | 解决方式 |
|---|---|---|---|
| 大量正常用户被拦截 | 阈值过低 / 未排除全屏 | 上报heightGap分布,看 P99 | 提高阈值,排除fullscreenElement |
| 检测完全没反应 | debugger 被禁用 | 检查 Sources 面板的断点开关 | 加 console 和尺寸探测器兜底 |
| 页面明显卡顿 | 轮询间隔太短 | Chrome Performance 面板录制 | 间隔调到 1000ms 以上 |
| 移动端误报 | outerWidth 行为不一致 | 真机抓包看上报数据 | UA 匹配移动端直接跳过 |
| 上报量爆炸 | 无冷却机制 | 看服务端 QPS | 加 60 秒冷却 + 状态锁 |
| 上了 CDN 后失效 | 代码被拆分到不同 bundle | 检查执行顺序 | 确保检测代码在 head 内联 |
那个“上了 CDN 后失效”的坑值得多说两句。很多项目用了打包工具,检测代码被打进了某个懒加载的 chunk 里,等到用户点开某个路由才执行。这时候攻击者早就把 DevTools 打开了,检测再执行也没意义——debugger确实会被暂停,但performance.now()的差值判断会因为已经处于调试状态而失效(因为断点会一直暂停在那里)。
所以检测代码必须内联在 HTML 的 head 里,且不能被拆分。这是强约束,写进构建配置里。
6. 被绕过的常见手法与加固思路
写了这么多检测,也得诚实地说说对手会怎么绕。知道怎么被绕过,才知道哪个地方值得加固。
6.1 断点失效与函数 Hook
最直接的绕过手法是Deactivate breakpoints(Ctrl+F8)。这一按,所有debugger语句全部失效,你的时间差探测器直接归零。应对方式是前面说的,必须有多探测器融合,不能只靠一个。
第二种是Hookperformance.now。攻击者在控制台里重写这个方法,让它返回固定值,时间差就永远是 0。
// 攻击者的操作(示意) performance.now = function () { return 0; };防御方式就是我们前面写的原生函数校验,在页面开始就把performance.now的引用存进闭包。
const rawNow = performance.now.bind(performance);之后一律用rawNow(),即使全局被改也不受影响。
第三种是代理脚本。攻击者用本地代理工具在页面里注入一段脚本,把检测模块整个替换成空函数。这个前端基本防不住,只能靠后端做端到端校验(比如给关键请求加时间戳签名)。
6.2 代码格式化与本地替换
DevTools 的 Sources 面板支持 Pretty Print({}按钮)格式化压缩代码,也支持 Overrides 功能把线上文件替换成本地版本。这两个功能在“格式化后慢慢看代码”这个路径上,几乎无解。
你能做的只有:把检测逻辑和业务逻辑混在一起,让格式化后的代码依然难以阅读。常见手法是控制流平坦化、字符串数组加密、死代码注入。这些属于 JS 混淆的范畴,工具链上可以用一些成熟方案,代价是包体积增大、运行时有额外开销。
我的建议是:别自己写混淆,用成熟工具,并且只对核心模块做混淆。全量混淆会让构建时间翻十倍,包体积涨三倍,收益却有限。
6.3 值得加固的几个方向
结合上面的分析,如果只能做三件事,我会选:
第一,原生引用提前固化。在 head 最前面把所有要用的原生方法存进闭包,这是所有防御的基础。
第二,多信号融合 + 连续命中判定。不要指望单一信号,也不要指望单次触发。
第三,后端联动。前端只负责产出信号,判定和处置放在后端,这样即使前端被绕过,后端依然能靠行为特征识别。
至于更高级的方案,比如用 WebAssembly 跑检测逻辑、用 Worker 隔离检测线程,这些我试过,收益不明显。WASM 本身也会被打包进可读的胶水代码里,Worker 的消息通道也容易被拦截。在投入产出比上不划算。
7. 工程化落地:埋点、灰度与体验底线
最后聊聊怎么把这个东西真正推到线上,而不是写完就扔。
7.1 上报数据的字段设计
上报数据设计得好不好,直接决定你能不能定位误报。我一般会带上这些字段:
{ event: 'devtools_check', level: 'suspicious' | 'flagged', score: 4, hits: { debugger: 1, viewport: 1, console: 0, native: 0 }, widthGap: 162, heightGap: 94, innerW: 1280, innerH: 620, outerW: 1442, outerH: 714, screenW: 1920, screenH: 1080, dpr: 1.25, zoom: 1, ua: '...', page: '/activity/draw', ts: 1735e12 }关键是把宽高数据都带上。上线第一周我天天看这些数据,画出heightGap的直方图,很快就看出阈值该往哪调。如果没有这些字段,你只能靠猜。
还有一个细节:不要把上报做成同步请求。用sendBeacon或者keepalive: true的 fetch,避免阻塞页面。我见过有项目用同步 XHR 上报,用户关闭页面时直接卡住两秒,体验极差。
7.2 灰度与分级处置
新上线的检测策略一定要灰度。我的做法是按用户 ID 取模,先放 5%,观察一周。看两个指标:一是误报投诉量,二是被拦截用户的后续行为(有没有大量流失)。
灰度期间建议只上报不拦截,也就是把flagged的处理逻辑设成空操作,只发埋点。等你确认误报率在可接受范围内,再逐步开启软提示、功能降级。
处置强度上,我建议遵循这个顺序:只上报 → 软提示 → 功能降级 → 硬阻断。每上一档,至少要跑三天数据。硬阻断能不用就不用,它带来的用户投诉成本,往往高于它挡住的攻击收益。
注意:如果你的站点有企业客户或者内部员工在用,一定要加白名单机制。通过 URL 参数或者本地存储的标记放行,否则第二天就会有同事来找你。这个坑我踩过,一次上了硬阻断,把测试同学全拦在外面,被拉进群里批评了一顿。
另外,无障碍这块也要考虑。用debugger死循环的检测方式,会让屏幕阅读器用户的操作被打断。如果你的产品有合规要求,建议在检测到辅助技术(比如navigator.userAgent里有屏幕阅读器特征)时直接跳过检测。
我自己现在的默认策略是:新项目一律从“只上报”起步,跑够两周数据再决定要不要开拦截,且默认用软提示,硬阻断只在明确的高风险接口页面启用。这套流程走了三个项目,误报投诉基本控制在每月个位数。
还有个小技巧分享:在判定为flagged之后,不要立刻上报就完事,而是把状态存到sessionStorage,后续的关键接口请求都带上这个标记。这样后端可以用这个标记做二次风控,比如给这个用户的抽奖请求排到低优先级队列,或者要求二次验证。前端检测的价值,在这里才真正被放大。