如何让 JS 代码不可断点

绕过断点

调试 JS 代码时,单步执行(F11)可跟踪所有操作。例如这段代码,每次调用 alert 时都会被断住:

debugger
alert(11)
alert(22)
alert(33)
alert(44)

有没有什么办法能让单步执行失效,一次执行多个操作?

事实上有一些巧妙的办法。例如通过数组回调执行这些 alert 函数:

debugger
[11, 22, 33, 44].forEach(alert)

这样只有 forEach 之前和之后会被断住,中间所有 alert 调用都不会被断住。

由此可见,通过 内置回调 执行 原生函数,调试器是无法断住的!

利用这个特性,我们可将一些重要的操作隐藏起来,从而能在调试者眼皮下悄悄执行。

应用案例

主流浏览器的调试器允许拦截特定事件,例如触发 mousemove 时断点;

addEventListener('mousemove', e => {
  console.log(e)
})

因此调试者很容易找到事件回调函数,从而分析相应的处理逻辑。

如何防止事件回调被断点?这就需要前面讲解的黑科技了。我们对上述代码稍微修改,将自己的回调函数改成原生函数:

addEventListener('mousemove', console.log)

这时,每次触发 mousemove 事件都不会被断住!

然而现实中的回调逻辑远比 console.log 复杂,又该如何应用?

事实上我们可以做一些调整,将事件的回调逻辑变得足够简单,简单到只需一个操作 —— 保存结果:

const Q = []
addEventListener('mousemove', Q.push.bind(Q))

由于调用函数 bind 方法后返回的新函数,其实是原生的:

function A() {}
A.bind(window) + ''   // "function () { [native code] }"

而 Q.push 本身也是原生函数,因此它们两都是原生函数。

同时 addEventListener 执行回调也属于内置行为,因此整个操作都是原生函数在执行,没有任何自己的代码可供调试器断点!

现在触发 mousemove 事件不仅不会被断住,而且还能将结果追加到数组 Q 中。

至于读取则有很多办法,例如渲染事件、空闲事件、定期轮询等。

setInterval(() => {
  for (const v of Q) {
    console.log(v)
  }
  Q.length = 0
}, 20)

如果 JS 只是采集信息而没有交互,可用更低的读取频率。

属性访问

前面的案例都是函数调用,例如 alert 函数、数组 push 函数。但属性读写又该如何实现?例如:

window.onclick = function() {
  document.title = 'hello'
}

其实也不难。属性读写本质上是 getter 和 setter 函数的调用。例如:

const setter = Object.getOwnPropertyDescriptor(Document.prototype, 'title').set
setter.call(document, 'hello')

当然这样会立即执行,而不是在 onclick 事件时执行。

因此我们可以给 setter 柯里化,创建一个已绑定参数的新函数,作为事件回调:

const setter = Object.getOwnPropertyDescriptor(Document.prototype, 'title').set
window.onclick = setter.bind(document, 'hello')

这样只有在点击时才会执行。并且调试器的 click 事件断点不会触发。

对象属性

除了原型上的属性,普通对象的属性又该如何访问?例如:

const obj = {}
window.onclick = function() {
  obj.name = 'jack'
}

事实上 JS 基本操作都可通过 Reflect API 实现。例如:

const obj = {}
Reflect.set(obj, 'name', 'jack')

不过需注意的是,Reflect.set 的参数必须是 3 个,多一个也不行。例如:

const obj = {}
Reflect.set(obj, 'age', 20, {})
obj.age   // undefined

这样将其柯里化成事件回调函数是有问题的,因为事件回调还会加上一个 event 参数。

不过 Reflect.apply 方法倒没有这个限制,往后再加几个参数也不影响执行:

Reflect.apply(alert, null, ['hello'],   /* 无用的参数 */ 100, 200, 300)

因此我们可通过 Reflect.apply 执行 Reflect.set,从而过滤多余的参数:

const obj = {}
Reflect.apply(Reflect.set, null, [obj, 'age', 20])
obj.age   // 20

然后将其柯里化成事件回调函数:

const obj = {}
window.onclick = Reflect.apply.bind(null, Reflect.set, null, [obj, 'age', 20])

这样即可通过原生函数执行 obj.age = 20,并且 click 事件断点依然不会触发。

多个操作

前面讲解的都是单个操作,是否可以一次执行多个操作?例如:

console.log('hello')
console.log('world')
alert(123)

最容易想到的办法,就是将每个操作放入数组,然后通过 forEach 回调 Reflect.apply 执行每个操作:

[
  Reflect.apply.bind(null, console.log, null, ['hello']),
  Reflect.apply.bind(null, console.log, null, ['world']),
  Reflect.apply.bind(null, alert, null, [123]),
].forEach(Reflect.apply)

幸运的是 forEach 的回调函数和 Reflect.apply 函数都是 3 个参数,并且第 3 个都是数组类型:

forEach_callback(element, index, array)

Reflect.apply(target, thisArgument, argumentsList)

这样通过 forEach 回调 Reflect.apply 是完全没问题的。于是可以一次执行多个操作,并且都无法断住!

除了上述提到的,其实还有更多玩法,大家可发挥想象~

(2021/11/01)

posted @ 2022-08-04 17:18  EtherDream  阅读(1853)  评论(0编辑  收藏  举报