用 Proxy hook setInterval 拦截反调试 debugger

初版的问题

常见的 setInterval debugger 拦截写法有几个缺陷:

(function(){
  window._setInterval = window.setInterval;   // 污染全局

  window.setInterval = function (fn, delay, ...args) {
    let code = typeof fn === "function" ? fn.toString() : fn;

    if (code.includes("debugger")) {
      return null;   // ❌ 返回 null 会让部分站点报错
    }

    return _setInterval(fn, delay, ...args);   // ❌ _setInterval 在闭包外查找可能丢失
  };
})()

问题汇总:

  • fn.toString() 可被重写 getter 绕过
  • 返回 null 时,站点调用 clearInterval(null) 或存 timer ID 会报错
  • _setInterval 挂到 window 上,污染全局命名空间
  • 没有 hook clearInterval,假 ID 泄漏

改进版:Proxy + 假 timer ID

(() => {
    const rawSetInterval = window.setInterval;
    const rawClearInterval = window.clearInterval;

    const fakeTimers = new Set();
    let fakeId = 1_000_000;

    function shouldBlock(fn) {
        try {
            let code = "";

            if (typeof fn === "function") {
                code = Function.prototype.toString.call(fn);  // 绕过 toString 重写
            } else if (typeof fn === "string") {
                code = fn;
            }

            if (!code) return false;

            if (/\bdebugger\b/.test(code)) {
                console.warn("拦截到 debugger interval:", code);
                return true;
            }

            if (/while\s*\(\s*true\s*\)/.test(code)) {
                console.warn("拦截到死循环 interval:", code);
                return true;
            }

        } catch (e) {
            console.error("检测 interval 出错:", e);
        }

        return false;
    }

    window.setInterval = new Proxy(rawSetInterval, {
        apply(target, thisArg, args) {
            const [fn] = args;

            if (shouldBlock(fn)) {
                const id = fakeId++;
                fakeTimers.add(id);
                return id;   // 返回真实格式的假 ID,不返回 null
            }

            return Reflect.apply(target, thisArg, args);
        }
    });

    window.clearInterval = new Proxy(rawClearInterval, {
        apply(target, thisArg, args) {
            const [id] = args;

            if (fakeTimers.has(id)) {
                fakeTimers.delete(id);   // 吞掉对假 ID 的 clearInterval 调用
                return;
            }

            return Reflect.apply(target, thisArg, args);
        }
    });

    console.log("setInterval hook 已启动");
})();

关键设计点

为什么用 Function.prototype.toString.call(fn) 而不是 fn.toString()

站点可以重写 fn.toString 或者在函数对象上挂 getter:

const fn = () => {};
fn.toString = () => "() => {}";   // 伪造源码

Function.prototype.toString 直接从函数底层获取源码,无法被实例属性覆盖。

为什么返回假 timer ID 而不是 null

很多站点会存 timer ID 用于后续 clearInterval:

const tid = setInterval(check, 100);
// 之后
clearInterval(tid);

如果 tid === nullclearInterval(null) 在部分引擎下会抛异常,或者日志里产生噪声。返回 1_000_000 以上的数字既像真实 ID,又不会和浏览器分配的 ID 冲突。

为什么同步 hook clearInterval

假 ID 进入 fakeTimers Set 之后,站点持有这个 ID 并在后续调用 clearInterval(fakeId)。如果不 hook,这个调用会传给原生 clearInterval,可能误清真实定时器(原生 ID 碰巧相同)或产生错误。

为什么用 Proxy 而不是直接赋值

Proxy 保留了 lengthname 等函数元信息,instanceof 检测也更接近原生。部分站点会用 typeof setInterval === "function" 或特征检测来判断是否被 hook,Proxy 比直接赋值的闭包函数更难被识别。

可扩展检测项

如果需要更宽泛的反调试拦截,可以在 shouldBlock 里补充:

// 检测 setTimeout debugger(同理 hook setTimeout)
// 检测 constructor/eval 执行 debugger 字符串
if (/\bFunction\s*\(/.test(code)) return true;
if (/\beval\s*\(/.test(code)) return true;

现代站点已经较少用 setInterval(debugger) 这种低级方式,更多见的是混淆后的字节码或 WebAssembly 版本的反调试,这类情况 setInterval hook 就无法覆盖了。