一、无限debugger的原理与绕过

debugger 是 JavaScript 中定义的一个专门用于断点调试的关键字,只要遇到它,JavaScript 的执行便会在此处中断,进入调试模式。有了 debugger 这个关键字,我们就可以非常方便地对 JavaScript 代码进行调试,比如使用 JavaScript Hook 时,我们可以加入 debugger 关键字,使其在关键的位置停下来,以便查找逆向突破口。但有时候 debugger 会被网站开发者利用,使其成为阻挠我们正常调试的拦路虎。本节中,我们介绍一个案例来绕过无限 debugger。

1.1 案例介绍

案例介绍:我们先看一个案例,网址是 http://shanzhi.spbeen.com/,打开这个网站,一般操作和之前的网站没有什么不同。但是,一旦我们打开开发者工具,就发现它立即进入了断点模式,如下图所示:

在这里插入图片描述
我们既没有设置任何断点,也没有执行任何额外的脚本,它就直接进入了断点模式。这时候我们可以点击 Resume script execution(恢复脚本执行)按钮,尝试跳过这个断点继续执行,如下图所示:

请添加图片描述
然而不管我们点击多少次按钮,它仍然一次次地进入断点模式,无限循环下去,我们称这样的情况为无限 debugger。怎么办呢?似乎无法正常添加断点调试了,有什么解决办法吗? 办法当然是有的,本节中我们就来总结一下无限 debugger 的应对方案,在后面部分实战的案例中我们也会遇到无限 debugger。

1.2 实现原理

首先要做的是找到无限 debugger 的源头,上面的案例通过堆栈回溯,查看 debugger 是如何生成的,如下图所示:
在这里插入图片描述
继续往上进行追溯,如下图所示:
在这里插入图片描述
这时点击左下角的格式化按钮:

setInterval(()=>{
    (function(a) {
        return (function(a) {
            return (Function('Function(arguments[0]+"' + a + '")()'))
        }
        )(a)
    }
    )('bugger')('de', 0, 0, (0,
    0));
}
, 1000);

利用 Function 产生 debugger,然后通过 setInterval 循环,每秒执行1次产生 debugger 语句的操作。当然,还有很多类似的实现,比如无限 for 循环、无限 while 循环、无限递归调用等,它们都可以实现这样的效果,原理大同小异。ps:从某种意义上来说,无限 debugger 不会真正的死循环(只不过这个执行次数多到我们本身靠手点难以接受罢了),而是有规律得执行逻辑,一般用定时器。 无限 debugger 产生小结:

  1. 一定会先产生 debugger 关键字,产生 debugger 关键字,可以是明文也可以混淆。

    // ① 明文 直接书写完整的 debugger
    debugger;
    // ② 可以混淆(可轻度混淆) 即eval配合 debugger
    eval('debug' + 'ger;')
    // ③ 可以重度混淆
    // 结合constructor,debugger,call,apply,action 等关键字进行混淆,增加调试的困难
    Function('debugger').call()
    Function('debugger').apply()
    Function('debugger').bind()
    Function.constructor('debugger').call('action')
    funObj.constructor('debugger').call('action')
    (function(){return !![];}['constructor']('debugger')['call']('action'))
    eval('(function (){}["constructor"]("debugger")["call"]("action"));')
    //总结:这些debugger方法,是实现debugger的基础,可以理解为是三元素。基于三种元素,可以形成多种多样的玩法
    
  2. 结合循环,循环的方式可以是:while/for 循环、包含 debugger 的函数调用自身、方法间的循环调用、计时器(setInterval)

1.3 绕过debugger方法

因为 debugger 其实就是对应的一个断点,它相当于用代码显示地声明了一个断点,要解除它,我们只需要禁用这个断点就好了。

1.3.1 禁用所有断点

全局禁用开关位于 Sources 面板的右上角,叫做 Deactivate breakpoints,如下图所示:
在这里插入图片描述
点击它,该按钮会被激活,变成蓝色,如下图所示:
在这里插入图片描述
这个时候我们再重新点击一下 Resume script execution(恢复脚本执行)按钮,跳过当前断点,页面就不会再进入到无限 debugger 的状态了。但是这种全局禁用其实并不是一个好的方案,因为禁用之后我们也无法在其他位置增加断点进行调试了,所有的断点都失效了!

ps: 解决无限 debugger 名词解释应该为在没有 debugger 干扰的情况下调试,而不是放弃所有的 debugger 调试(也就是说我们自己的调试还得能正常使用),所以此种方式基本不用。

1.3.2 禁用局部断点

取消刚才的 Deactivate breakpoints 模式,页面会重新进入无限 debugger 模式,尝试使用另一种方法来跳过这个无限 debugger。在 debugger 语句所在的行的行号上单击鼠标右键,此时会出现一个快捷菜单,如下图所示:
在这里插入图片描述
这里有一个 Never pause here 选项,意思是从不在此处暂停。选择这个选项,于是页面变成如下图所示的样子:

在这里插入图片描述
当前断点显示为橙色,并且断点前面多了一个 ? 符号,同时 Breakpoints 也出现了刚才添加的断点位置,这时再次点击 Resume script execution(恢复脚本执行)按钮,就可以发现我们不会再进入无限 debugger 模式了。当然,我们也可以选择另外一个选项 Add conditional breakpoint,如下图所示:

这个模式更加高级,我们可以设置进入断点的条件,比如在调试过程中,期望某个变量的值大于某个具体的值的时候才停下来。但在本案例中,由于这里是无限循环,我们没有什么具体的变量可以作为判定依据,因此可以直接写一个简单的表达式来控制。选择 Add conditional breakpoint 选项,直接填入 false 然后回车即可,如下图所示:

在这里插入图片描述

1.3.3 替换文件

利用 Overrides 面板我们可以将远程的 JavaScript 文件替换成本地的 JavaScript 文件,这里我们依然可以使用这个方法来对文件进行替换,替换成什么呢?很简单,我们只需要在新的文件里把 debugger 这个关键字删除。我们将当前的 JavaScript 文件复制到文本编辑器中,删除或者直接注释掉 debugger 这个关键字,修改如下:

setInterval(()=>{
    (function(a) {
        return (function(a) {
            return (Function('Function(arguments[0]+"' + a + '")()'))
        }
        )(a)
    }
    // 直接把参数置空,当然这里也可以把整个文件替换掉(没啥业务逻辑),或者去掉setInterval等都可以
    )('')('', 0, 0, (0,
    0));
}
, 1000);

打开 Sources 面板下的 Overrides 面板,将修改后的完整 JavaScript 文件复制进去。替换完成之后,重新刷新网页,这时候发现不会进入无限 debugger 模式了。如果该操作不熟悉,可以参照下面的 3.1 改写JavaScript文件。

1.3.4 函数置空与hook

ps:一定要在 debugger 进入之前。

无限 debugger 产生的原因是定时器造成的,所以我们可以重写这个函数,使无限 debugger 失效:

// 这里是业务代码和setInterval无关,所以直接置空即可
setInterval = function(){} //定时器置空,置空之后上面的无限debugger消失
function xxx(){} //执行函数置空 xxx

② hook:不同的情况书写不同的 hook 代码,即 hook 不同的函数即可,这里我只以 setInterval 为例,其他类似。

_setInterval = setInterval
setInterval = function (a,b) {
    if(a.toString().indexOf('debugger') == -1){
        return function(){}
    }else{
        _setInterval(a,b)
    }
}

Function.prototype.toString = function () {
    return `function ${this.name}() { [native code] }`
}

小结:

  1. 优先尝试禁用局部断点,即 Never pause here (最方便快捷,但是最卡–深有体会,也最容出问题)

  2. 次优先尝试重写调用函数,缺陷:容易破坏业务逻辑,导致控制流变化。如:

    Function = function(){}
    setInterval = function(){}
    
  3. 文件替换。缺陷:操作稍微有一点点的麻烦,对动态情况的支持不太好,也可能会改变控制流走向

  4. 万一以上三个都难受了怎么办? 别逆向了,反调试都那么难了,那加密不得难上天啊。放弃吧,嗷~

二、补充

2.1 改写JavaScript文件

我们知道,一个网页里面的 JavaScript 是从对应服务器上下载下来并在浏览器执行的。有时候,我们可能想要在调试的过程中对 JavaScript 做一些更改,比如说有以下需求:

  1. 发现 JavaScript 文件中包含很多阻挠调试的代码或者无效代码、干扰代码,想要将其删除(如上面的无限 debugger)。
  2. 调试到某处,想要加一行 console.log 输出一些内容,以便观察某个变量或方法在页面加载过程中的调用情况。在某些情况下,这种方法比打断点调试更方便。
  3. 调试过程遇到某个局部变量或方法,想要把它赋值给 window 对象以便全局可以访问或调用。
  4. 在调试的时候,得到的某个变量中可能包含一些关键的结果,想要加一些逻辑将这些结果转发到对应的目标服务器。

这时候我们可以试着在 Sources 面板中对 JavaScript 进行更改,但这种更改并不能长久生效,一旦刷新页面,更改就全都没有了。比如我们在 JavaScript 文件中写入一行 JavaScript 代码,然后保存,如下图所示:

在这里插入图片描述

注意:点击了左下角的格式化按钮后,不能向格式化的文件中添加内容。

这时候我们可以发现 JavaScript 文件名左侧上出现了一个警告标志,提示我们做的更改是不会保存的。这时候重新刷新一下页面,再看一下更改的这个文件,如下图所示:

有什么方法可以修改呢?其实有一些浏览器插件可以实现,比如:ReRes。在插件中,我们可以添加自定义的 JavaScript 文件,并配置 URL 映射规则,这样浏览器在加载某个在线 JavaScript 文件的时候就可以将内容替换成自定义的 JavaScript 文件了。另外,还有一些代理服务器也可以实现,比如 Charles、Fiddler,借助它们可以加载 JavaScript 文件时修改对应 URL 的响应内容,以实现对 JavaScript 文件的修改。其实浏览器的开发者工具已经原生支持这个功能了,即浏览器的 Overrides 功能,它在 Sources 面板左侧,如下图所示:

我们可以在 Overrides 面板上选定一个本地的文件夹,用于保存需要更改的 JavaScript 文件,下面来实际操作一下。切到 Overrides 面板,点击 + 按钮,如下图所示:

这时候浏览器会提示我们选择一个本地文件夹,用于存储要替换的 JavaScript 文件。这里我选定了一个新建的文件夹:FunddbOverrides,注意这时候可能会遇到下图所示的提示,如果没有问题,直接点击 允许 即可。

在这里插入图片描述

这时,在 Overrides 面板下就多了 FunddbOverrides 文件夹,用于存储所有我们想要更改的 JavaScript 文件,如下图所示:

我们可以看到,现在所在的 JavaScript 选项卡是 app.d0a16ab3b7972174cc88.js:formatted,代码已经被格式化了。因为格式化后的代码是无法直接在浏览器中修改的,所以为了方便,我把格式化后的文件复制到了 Notepad++ 中,然后把 window.eval 这行代码注释了,如下图所示:

在这里插入图片描述

接着把修改后的内容替换到原来的 JavaScript 文件中。这里要注意,要切换到 app.d0a16ab3b7972174cc88.js 文件才能修改,直接替换 JavaScript 文件的所有内容即可,如下图所示:

在这里插入图片描述

替换完毕之后 ctrl + s 保存,这时候再切换回 Overrides 面板,就可以发现成功生成了新的 JavaScript 文件,它用于替换原有的 JavaScript 文件,如下图所示:

在这里插入图片描述

替换完成之后,重新刷新网页,正如我们所料,这时候不会再进入无限 debugger 模式了,证明改写 JavaScript 成功!而且刷新页面也不会丢失了,除了注释掉干扰代码外,在一些场景下,我们还可以增加一些 JavaScript 逻辑,比如直接将某个变量的结果通过 API 发送到远程服务器,并通过服务器将数据保存下来,也就完成了直接拦截 Ajax请求并保存数据的过程了,修改 JavaScript 文件有很多用途,此方案可以为我们进行 JavaScript 逆向带来极大便利。

2.2 浏览器开发者工具中出现的VM开头的JS文件是什么?

在 Chrome 的开发者工具中,你可能会看到一些以 VM 开头的 JavaScript 文件(如 VM1057)。
在这里插入图片描述
VM 表示的是 Virtual Machine(虚拟机),这些文件通常表示由浏览器生成和执行的虚拟机脚本环境中的临时脚本。这些脚本并不是项目源代码的一部分,也不是实际存在的物理文件,它们在浏览器的内存中创建并执行。 比如说,当你在调试一个网页时,如果在某些动态生成并执行的 JS 代码上设定了断点,Chrome 调试器会在一个以 VM 开头的文件中显示这些代码,例如 VM1057。这个 VM 文件的存在只是为了调试目的,它并不存在于服务器端,也不会被存储在本地,而是存在于浏览器内存中。一般情况下,这类文件的出现是因为浏览器对 JavaScript 代码的处理方式,如动态编译或者 JavaScript 堆栈跟踪。出现的原因:

  1. 动态执行的 JavaScript 代码。比如通过 eval 函数或者 new Function 方法,Chrome 浏览器会创建一个 VM 文件来展示这段临时执行的代码。比如某个网页因为反爬虫,动态生成了 debugger,这些断点并没有直接写在服务器上的原始 JavaScript 文件中,而是在某些 JavaScript 代码的执行过程中被生成,并因此触发 debugger。这些代码也会在执行时被浏览器视为临时的 VM 脚本,并在执行到 debugger 时暂停执行,从而造成所谓的 无限 debugger 循环
  2. 来自执行栈的代码。有时候,当 JavaScript 引擎处理异步操作(例如 Promise、setTimeout 等)中的错误时,错误堆栈可能包含到 VM 脚本的引用,这是因为内部错误回调函数是在虚拟环境中执行的。

三、实战

F12 打开调试工具,打上 script 断点,如下图所示:
在这里插入图片描述
接着一直单击 Resume script execution(恢复脚本执行)按钮,直到左边 Page 下方 top 中,出现我们要分析网站的域名为止(因为有时候会受浏览器等插件的影响,不是在我们要分析的网站下断住的),如下图所示:

接着放开 script 断点,再次点击 Resume script execution(恢复脚本执行)按钮,跳到如下位置:

过这个 debugger 很简单了,使用改写 JavaScript 文件的方式即可,在 1.3.3 替换文件 已经详细讲解过,这里就不再进行赘述。替换完成之后,我们重新刷新一下网页,又出现一个 debugger,如下图所示:

优先使用禁用局部断点的方式,即鼠标放在 debugger 行号处,单击鼠标右键,选择 Never pause here,发现是可以过掉的,这里也可以采用其他方式,在产生 debugger 之前,选择堆栈 K 的时机执行如下代码:

// 因为这里产生debugger的逻辑比较复杂,我就不去分析了,写起来很累
_Function = Function
Function.prototype.constructor = function () {
    if (arguments[0].indexOf('debugger') !== -1) {
        return _Function('')
    }
    return _Function(arguments[0])
}

我这里为了简便就直接采用禁用局部断点的方式了,接着单击 Resume script execution(恢复脚本执行)按钮,发现又出现了新的 debugger,如下图所示:



发现这里在调用 document 的 appendChild 方法,目的是向 n.head 中添加元素,控制台看一下小 o,如下所示:
在这里插入图片描述
到这里我们就知道此处产生 debugger 的原因了,就是使用 appendChild 方法往页面动态插入 script 标签,然后 script 标签对中包含 debugger 关键字,故此处采取的方案,直接将 appendChild 方法重写即可,重写代码如下:

let oldAppendChild = Node.prototype.appendChild
Node.prototype.appendChild = function () {
    if (arguments[0].innerHTML && arguments[0].innerHTML.indexOf('debugger') !== -1) {
        arguments[0].innerHTML = ''
    }
    return oldAppendChild.apply(this, arguments)
}

在 debugger 出现之前(即在堆栈处于 b 或者 le 时都可以,或者更早之前都行)执行此代码,发现是可以过掉 debugger 的,然后调试到了如下位置:
在这里插入图片描述
这个就很简单了,写死的 debugger + 固定的循环次数,也没有相关的业务逻辑代码,故直接使用文件替换的方式直接替换即可,我是将 for 循环整体进行了替换,替换之后,同样,重新刷新一下网页,发现又到了 appendChild 那里,采取上面重写 appendChild 方法的方案即可,接着单击 Resume script execution(恢复脚本执行)按钮,进入到一个新的 debugger 调试,如下:

优先使用禁用局部断点的方式,即鼠标放在 debugger 行号处,单击鼠标右键,选择 Never pause here,如下图所示:

发现是可以过掉的,这里也可以采用其他方式,追溯到 debugger 产生的源头:


可以看到此处的 debugger 为 eval 结合 debugger 关键字实现,并且观察该代码中无业务逻辑代码,故直接重写 eval 函数即可,重写代码如下:

old_eval = eval
eval = function () {
    if (arguments[0].indexOf('debugger') !== -1) {
        return function () {
        }
    } else {
        return old_eval(arguments[0])
    }
}

Function.prototype.toString = function () {
    return `function ${this.name}() { [native code] }`
}

至此此题目的所有涉及到的 debugger 调试我们都已经过掉,可以正常地进行接口分析了。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐