反调试

JS工具/反调试

1 个文件 9.7 KB
打包下载

文件清单

文件 大小
反调试.js 9.7 KB 下载

反调试(客户端 JS)

定位:单文件客户端反调试脚本 —— 检测开发者工具是否被打开、调试会话是否存在、关键原生函数是否被替换或包装,命中后进入 debugger 心跳,用来抬高页面被断点调试、抓包、改包、分析源码的成本。 执行端:客户端浏览器 语言:JavaScript 依赖:无(单文件、零依赖,引入即生效)

0. 一句话说明

把 反调试.js 放到页面 <head> 最靠前的位置即可:脚本加载时先同步自检一次(早于页面渲染、早于业务放行),之后每 1000ms 复检一次;一旦命中「正在被调试 / 控制台在渲染日志 / 关键原生函数被替换」,就进入 debugger 心跳并对业务层持续返回「不通过」。

相对上一版,这一版重点加强了四件事:

加强点 防住的手段
检测与自身循环都用出厂原生引用 替换 window.setTimeout / console.log / performance.now 等,既绕不过检测,也停不掉心跳
校验改成引用身份比对 伪造 Function.prototype.toString 的返回串(上一版可被这条绕过)不再有效
两个对外接口设为不可写、不可配置 window.antiDebugCheck = () => true 这种把业务放行前置架空的写法
关闭开关解耦 stopAntiDebug() 只让页面不再被卡,不放行(上一版一句就全放行)

同时把心跳改成按需:真被暂停时立刻再拦,没被暂停时退避成低频,不会再出现"空转把 CPU 吃满、标签页卡死"。


1. 文件清单

反调试/
├── 反调试.js     # 全部逻辑,IIFE 立即执行;对外只暴露两个只读函数
└── README.md

2. 怎么引入

2.1 外链引入(推荐)

<head>
  <!-- 放在 <head> 里,且要早于页面上其它脚本 -->
  <script src="/js/反调试.js"></script>
</head>

不要加 defer / async —— 两者都会把执行推迟到 HTML 解析完成之后,等于放弃「加载即自检」这条防线,重新打开「先开控制台再刷新」的窗口。

2.2 内联引入

不想多一次请求、或不想让人一眼看出是哪个文件时,把全文粘进页面即可(它本身是 IIFE,不需要再包一层):

<head>
  <script>
  /* 把 反调试.js 全文粘贴到这里 */
  </script>
</head>

2.3 位置与顺序(要点)

要点 原因
放在 <head> 最靠前 自检发生在脚本执行的那一刻,越早越能拦住「控制台先开着再刷新」
不要放进 DOMContentLoaded、动态 import()、打包器的懒加载 chunk 那些都是页面渲染之后才执行,自检的意义就没了
同一页只引一次 重复引入会装两条复检定时器,控制台输出翻倍(重复引入不会报错,后一次会保留先定义好的接口)

2.4 三步验证是否生效

  1. 打开页面(先不按 F12)→ 页面一切正常;
  2. 按 F12 打开 DevTools → 最多 1 秒内被 debugger 打断(Sources 面板停在断点处);
  3. 在 Console(暂停状态下也能用)执行 window.stopAntiDebug(),再按 F8 继续 → 页面恢复可操作,但业务层仍视为未通过、不会放行。

3. 对外接口

两个函数以不可写、不可配置的属性挂在 window 上(想 window.antiDebugCheck = () => true 会静默失败 / 抛错,接口不会被换掉)。

接口 说明
window.antiDebugCheck() 同步复检一次。true = 通过;false = 命中(不该放行)。命中过一次之后恒为 false,且脚本内部只会在第一次命中时启动心跳
window.stopAntiDebug() 只静音 debugger 心跳(页面恢复正常操作,方便自己人排查)。它不放行,也不停止复检 —— 命中过之后 antiDebugCheck() 仍然返回 false

要彻底不拦(例如自己要在真机上随意调试):别引入这个脚本,或用一份本地副本把 antiDebugCheck() 改成恒返回 true。这是刻意不提供"网页里一句就全放行"的开关。


4. 检测原理

命中任意一条即进入心跳,并对业务层持续返回「不通过」。判定顺序是 原生函数校验 → debugger 计时 → 控制台探针:第一个不产生任何控制台输出、无副作用,且它一旦被替换,后两个手段的结论本就不该信。

手段 触发条件 原理 什么情况下会失效
1. 原生函数身份 / 实现校验 下列任一函数被替换或包装:setTimeout、setInterval、performance.now、Date.now、console.log、document.createElement、Object.defineProperty、Function、Function.prototype.toString 加载瞬间把原生引用存进闭包,之后每轮比对引用是否还是出厂那一个(不看字符串,所以伪造 toString 无效);另外每 5 轮抽检一次"实现字符串是否还是 [native code]",专门抓"本脚本加载之前就已被 hook"的情况 无 —— 想 hook 就必须替换引用,一替换就命中。代价是误判:Zone.js、部分埋点 SDK、安全类扩展也会包装 console / 计时器
2. debugger 计时 单次 debugger 执行耗时 > PAUSE_THRESHOLD(默认 200ms) 每次动态生成一条带随机后缀的 debugger 语句并执行;真被调试器暂停则耗时必然异常变长,performance.now 与 Date.now 双时钟取大者。随机后缀让"不在此处停留 / 忽略此脚本"拉黑的是上一个脚本,一次点不掉 站点 CSP 禁 eval 类构造(Function(...) 抛错,被 try/catch 吞掉,本手段失效,其余手段兜底);DevTools 里关掉 "Pause on debugger statement";把所有断点全局停用(Ctrl+F8)
3. 控制台探针 控制台面板真的在渲染被 console.log 的对象 给一个临时元素挂 id 的 getter 再 console.log:只有控制台读取该属性时才会触发 getter。不依赖暂停,所以"停用断点"这把开关绕不过它 控制台没打开(对象不会被渲染);不走控制台渲染的远程调试通道

检查节奏

  1. 加载时同步自检一次 —— 早于页面渲染、早于任何业务放行。所以「先开控制台、再刷新页面」会在渲染出任何内容之前就被按住;
  2. 之后每 CHECK_INTERVAL(默认 1000ms)复检一次,兜住「页面打开之后才打开控制台」的情况。

5. 性能与"不卡死页面"

这一版把上一版"setTimeout(…, 0) 无间隔自旋"的心跳改成了按需心跳,两边的行为都是确定的:

状态 心跳间隔 结果
命中,且 debugger 确实被暂停 TRAP_PAUSE_DELAY = 0 你按 F8 继续,立刻又停在下一个 debugger 上 —— 反复按也出不去;此时 JS 线程是被暂停的,不消耗 CPU
命中,但 debugger 没被暂停(例如全局停用了断点) TRAP_IDLE_DELAY = 1000 退避成每秒一次的低频心跳,不会以最快速率空转,CPU 不会被吃满,标签页始终能响应

其余开销(干净环境下,实测):

  • 每轮复检只做几次引用比对 + 1 次极小的动态函数 + 1 次 createElement(探针用),不改动页面 DOM、不发任何网络请求;
  • "实现字符串"抽检每 5 轮才做一次;
  • 一旦命中过,antiDebugCheck() 会在第一行直接短路返回 —— 之后每轮的复检开销近似为 0;
  • 控制台打开时,每轮会多出一条 <div> 日志(控制台探针的正常输出,不是报错);命中后因为短路,不再继续刷。

6. 和业务代码配合:放行前同步复检

反调试是 1s 轮询的,而业务放行往往在 DOMContentLoaded(几十毫秒)就发生。只靠轮询挡不住「先开控制台再刷新」,所以放行前要再同步确认一次:

<script src="/js/反调试.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
  // 第零层:反调试没引入、或被拦截、或复检不通过 → 一律不放行
  if (typeof window.antiDebugCheck !== 'function' || !window.antiDebugCheck()) {
    return;
  }
  // 通过后再走你自己的业务判断
});
</script>

三点提醒:

  1. 把复检放在最前,等于把「已被调试」的窗口压到最小;代价是业务从此依赖这个脚本 —— 它被拦掉或加载失败时页面就永远不放行,这是有意的安全默认;
  2. 业务侧不需要也无法覆盖 window.antiDebugCheck(它是只读属性)。要临时放行请用 stopAntiDebug() 静音心跳,或干脆不引入脚本;
  3. 如果只想让反调试当"辅助噪音"而不参与放行,那就不要调用 antiDebugCheck(),让它自己后台轮询即可。

7. 常见问题

现象 原因 / 处理
一打开 DevTools 页面就卡在断点 预期行为。Console 里执行 window.stopAntiDebug(),按 F8 继续:页面恢复可操作,但业务层不会放行(要放行就别引入脚本)
控制台每秒多一条 <div> 记录 控制台探针的正常输出(console.log(decoy)),不是报错;命中后不再继续刷
页面进入死循环,Console 也像卡住了 心跳是 setTimeout 递归,每次暂停后都会让出事件循环,Console 仍可执行开关;先执行 stopAntiDebug() 再按 F8
把断点全局停用(Ctrl+F8)后还在被拦 手段 2 会失效,但只要控制台面板在渲染日志,手段 3 照样命中;两者都躲开时脚本无法强迫暂停,但仍拦住放行(见第 8 节)
window.antiDebugCheck = () => true 无效 属性是只读的,改不动;这是刻意设计(防"架空业务放行前置")
页面永远空白且没被卡住 说明业务侧把复检当成了放行前置(见第 6 节),而反调试被拦下或误判了。在 Console 看 typeof window.antiDebugCheck 与 window.antiDebugCheck() 的结果即可定位
自己调试时一直被拦 页面加载后先执行 window.stopAntiDebug():心跳停止,页面可自由操作(业务放行仍为未通过,需按第 3 节处理)
某些浏览器/环境下不生效 见第 4 节「什么情况下会失效」,最常见是 CSP 禁 eval 与 DevTools 关了断点开关
加了 Zone.js / 埋点 SDK / 安全插件后频繁误判 它们会包装 console、计时器等原生函数,触发手段 1。请先在该环境实测;误判时可用 stopAntiDebug() 静音心跳,或去掉相关包装

8. 已知局限与风险

  1. 只能提高成本,不能阻止分析:view-source:、curl/代理直接抓源码、禁用 JS、换没有调试器的浏览器,全都不受影响。
  2. 无法强迫调试器暂停:如果对方把所有断点全局停用(Chrome 的 Ctrl+F8)且始终不切到 Console 面板,手段 2 与 3 都不触发 —— 此时脚本仍会让业务层的复检返回 false(页面不放行),但拦不住他阅读已经下发的代码。
  3. 存在误判可能:包装 console / 计时器的第三方库与浏览器扩展(Zone.js、埋点、安全类插件)会被判成篡改。所以心跳用 setTimeout 递归而不是 while,且命中后仍保持页面可响应,留出处理余地。
  4. 误判代价是「页面不可用」:如果把复检作为放行前置,被误判的正常用户(开着控制台的开发者、装了相关扩展的访客)会直接看不到内容。上生产前请评估这一点。
  5. 接口可被"抢注":如果攻击者能让一段脚本先于本脚本执行(浏览器扩展、服务端注入、书签外的注入手段),抢先用不可配置的方式定义同名 antiDebugCheck,本脚本会保留既有接口。客户端脚本无法根除这种情况。
  6. 没有「服务端」成分:纯客户端,不产生任何网络请求,也不上报数据。
  7. 移动端基本不触发:检测的是调试器/控制台/hook,与设备无关;手机上一般没有这些,等于不生效。

9. 与 dp-2026-2 内置副本的关系

位置 角色
本程序(JS工具/反调试/反调试.js) 对外发布的通用版(加强版):单独打包下载、单独维护,配本说明文档
cloak/dp-2026/2/辅助工具/反调试.js 斗篷演示程序里的内置旧版副本:为了让 dp-2026-2 单独打包下载后仍能直接跑

⚠️ 两份现在不是同一版本:演示副本停留在旧版(stopAntiDebug() 一句即全放行、篡改校验只比字符串),本程序是加强版。两边是拷贝关系,新功能不要再往回同步;要统一时以本程序的通用版为准。


10. 合规提示

本脚本的作用是「检测调试环境并阻断调试会话」,属于对抗性技术,可能违反部分平台的服务条款;把它包在「对审查方展示与用户不同内容」这类规避行为里使用,后果由使用者承担。

本程序仅用于安全研究、原理分析与防护能力建设(例如给检测方提供样本以强化检测)。请勿用于任何未授权的攻击、欺诈或其他违法场景;下载与使用前请自行确认符合所在地法律法规及目标平台的服务条款,风险自负。

小影API · 通用 API 聚合服务