ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

JavaScript 反调试与 DevTools 检测实战:多信号融合与误报控制

JavaScript 反调试与 DevTools 检测实战:多信号融合与误报控制 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”快捷键CtrlF8这一按所有debugger语句就全废了二是如果用户把 DevTools 开着但没启用脚本调试比如只开着 Console断点仍然生效检测依然准三是在某些无头浏览器里debugger根本不生效检测会返回 false这反而成了识别无头环境的一个反向特征。2.2 console 对象特性检测getter 与 toString这一类方案流传最广但可靠性在逐年下降必须配合版本实测。最经典的是对象 getter 触发法。思路是创建一个对象用Object.defineProperty给它定义一个带 getter 的属性然后用console.log把这个对象打印出来。当 DevTools 打开时控制台为了渲染这个对象会去读取它的属性从而触发 getterDevTools 关闭时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、CtrlShiftI极低极低菜单栏打开、快捷键自定义最后一行那个键盘拦截我特意放进来是想说别做这个。它拦不住任何人却实打实地影响正常用户的复制粘贴和右键操作还会让你在无障碍审核里扣分。我见过有站点把右键菜单禁了结果用户想复制订单号都复制不了客服电话打爆。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 getter1版本差异大只作辅助原生函数校验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 - 970 - 2有书签栏、100% 缩放122 - 1310 - 2有书签栏 侧边栏122 - 13140 - 60无书签栏、125% 缩放112 - 1220 - 3无书签栏、150% 缩放134 - 1460 - 4DevTools 停靠底部默认高度320 - 3800 - 2DevTools 停靠右侧默认宽度89 - 97380 - 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 - 130Mac 的上限比 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 breakpointsCtrlF8。这一按所有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后续的关键接口请求都带上这个标记。这样后端可以用这个标记做二次风控比如给这个用户的抽奖请求排到低优先级队列或者要求二次验证。前端检测的价值在这里才真正被放大。
返回列表