导语:只要有足够的时间和耐心,人们总能调试和逆向出JavaScript代码片段中的逻辑。相反,我们想要提供的只是一些点子,以增加理解代码内部机制的难度。
在去年夏天的时候,我曾经与@cgvwzq一道,就JavaScript中的反调试技巧进行过长期的探讨。刚开始,我们试图从网上查找该方面的资源或文章,遗憾的是,这方面的文档非常匮乏,并且即使找到的那些文档,其内容也很不完整。而本文的目的,就是收集与JavaScript反调试有关的技巧(其中一些已被恶意软件或商业产品所采用,而其他想法则是我们独创的)。
需要说明的是:我们谈论的不是银弹,而是JavaScript。只要有足够的时间和耐心,人们总能调试和逆向出JavaScript代码片段中的逻辑。相反,我们想要提供的只是一些点子,以增加理解代码内部机制的难度。事实上,我们在这里展示的反调试技术与混淆技术无关(而对于混淆技术来说,网上有大量的信息和工具可用),它们更侧重于如何让调试过程更加困难。
本文将涵盖以下主题:
·检测非预期执行环境(我们只想在浏览器中执行)
·检测调试工具(例如DevTools)
·代码完整性控制
·执行流完整性控制
·反仿真
我们的主要想法是将这里介绍的技术与混淆技术和加密技术整合起来。其中,代码被分割成一系列加密的代码块,每块的解密过程取决于先前解密的其他块。预期的程序执行流程,应该以已知的顺序在加密块之间跳转。如果我们检测到任何“奇怪”的事情,程序流程就会修改原执行流程,转向一些伪装块。 所以,当发现有人正在调试我们的代码时,就会直接让他们跳转到一个蜜罐代码段中,让他们无法接触“真正想要了解”的部分。
当然,我们所了解的相关技巧也还不完善,如果读者有更多的技巧,请通过@ TheXC3LL与我联系,以便将它们添加到本文中。
0x01 函数重定义
这是用来避免代码被别人调试的最简单和最著名的技术。在JavaScript中,用于检索信息的函数通常都可以进行重定义,如console.log(),它们用于在控制台中显示函数、变量等方面的信息。如果我们重新定义这些函数,并且改变其行为,那么就可以隐藏某些信息,或者伪造某些信息。
为了帮助大家理解,请在DevTools中运行下列代码:
console.log("Hello World"); var fake = function() {}; window['console']['log'] = fake; console.log("You can't see me!");
将看到下列输出:
VM48:1 Hello World
我们发现,第二条消息并没有被显示,因为我们已经将其重新定义为一个空函数,从而“禁用”了该函数。 但我们的做法可以更巧妙一点,直接改变其行为,让它显示虚假信息,具体代码如下所示:
console.log("Normal function"); // First we save a reference to the original console.log function var original = window['console']['log']; // Next we create our fake function // Basicly we check the argument and if match we call original function with other param. // If there is no match pass the argument to the original function var fake = function(argument) { if (argument === "Ka0labs") { original("Spoofed!"); } else { original(argument); } } // We redefine now console.log as our fake function window['console']['log'] = fake; // Then we call console.log with any argument console.log("This is unaltered"); // Now we should see other text in console different to "Ka0labs" console.log("Ka0labs"); // Aaaand everything still OK console.log("Bye bye!");
如果一切正常的话,将会看到:
Normal function VM117:11 This is unaltered VM117:9 Spoofed! VM117:11 Bye bye!
如果你之前玩过“hooking”,那么对此肯定不会陌生。
我们可以做的更巧妙一些,重新定义更有趣的其他功能,以便以“出其不意的”方式控制被执行的代码。 例如,我们可以根据前面的代码构建一个代码片段来重新定义eval函数。我们可以将JavaScript代码传递给eval函数,这样的话,这些JavaScript代码就能得到执行了。 但是,如果我们重新定义该函数,我们甚至可以运行一些不同的代码。也就是说,我们实现了所见非所得:)。
// Just a normal eval eval("console.log('1337')"); // Now we repat the process... var original = eval; var fake = function(argument) { // If the code to be evaluated contains 1337... if (argument.indexOf("1337") !== -1) { // ... we just execute a different code original("for (i = 0; i < 10; i++) { console.log(i);}"); } else { original(argument); } } eval = fake; eval("console.log('We should see this...')"); // Now we should see the execution of a for loop instead of what is expected eval("console.log('Too 1337 for you!')");
是的,我们执行的是不同的代码(“for”循环,而不是console.log,字符串为“Too 1337 for you!”)。
1337 VM146:1 We should see this... VM147:1 0 VM147:1 1 VM147:1 2 VM147:1 3 VM147:1 4 VM147:1 5 VM147:1 6 VM147:1 7 VM147:1 8 VM147:1 9
通过这种方式修改程序的执行流程是一种很酷的方法,但是,就像前面提到的那样,这也是最基本的方法——很容易被检出和击败。这是因为,在JavaScript中,每个函数都有一个返回自身代码的toString方法(对于Firefox来说为toSource)。所以,为了检测这种反调试方法,只需要检查目标函数的代码是否被改变就可以了。当然,我们也可以重新定义toString/toSource方法,但是,在这种情况下,我们会面临另一个令人头疼的函数:function.toString.toString()。
在后文中,我们将使用另一种基于代理对象的方法,对“hooking”和函数重定义展开更全面和深入的探讨。
0x02 断点
对于各种JavaScript调试工具(例如DevTools)来说,都能够在任意位置让脚本停止执行,以帮助了解正在发生的事情。实际上,这可以通过“断点”来实现。 在调试时,使用断点可以帮助我们了解发生了什么,正在发生什么以及接下来会发生什么,因此,断点是最为基本的调试功能。
如果读者在x86系统上面玩过调试器的话,很可能会了解0xCC指令。 在JavaScript中,我们有一个称为debugger的模拟指令。在脚本中放入一个debugger指令;当调试程序遇到这个指令的时候,就会停止执行该脚本。例如:
console.log("See me!"); debugger; console.log("See me!");
如果在启动DevTools的情况下执行上面的代码的话,系统会提示您恢复执行。在按“Continue”按钮之前,该脚本将处于停止状态。在商业产品中用过的一个(非常愚蠢的)技巧就是:在一个无限循环中放入debugger;语句。有些浏览器能够防止这种无限循环,但是,某些浏览器则不会这样做。无论如何,这只是为了给调试代码的人制造麻烦。该循环会不断跳出窗口,要求他们恢复执行,所以,在解决这个麻烦之前,根本无法正常调试代码。
setTimeout(function() {while (true) {eval("debugger")
与断点有关的其他技巧将在下一节中介绍。
0x03 时间差异
从经典的反逆向技术中借鉴的另一个反调试技巧是使用基于时间的检测技术。当使用DevTools(或类似的软件)执行脚本时,执行时间会明显变长。 我们可以利用这种现象,将时间检测代码是否处于调试状态。为此,可以使用多种不同的方法来达到这个目的。
例如,我们可以测量代码内两点或多点之间的执行时间。如果我们知道在“正常”条件下这些点之间的平均时间,就可以使用这个值作为基准。如果执行的时间比预期更长,则意味着代码正在调试器下面运行。
基于这个概念的其他思路,是使用含有循环或其他耗时代码的函数:
setInterval(function(){ var startTime = performance.now(), check, diff; for (check = 0; check < 1000; check++){ console.log(check); console.clear(); } diff = performance.now() - startTime; if (diff > 200){ alert("Debugger detected!"); } }, 500);
首先,在未启动DevTools的情况下运行一下代码,然后启动DevTools工具。 正如您所看到的,我们可以检测到调试器的存在,因为这里的时间差比预期的要大。 这种将时间作为参考指标的方法,可以与上一节中所介绍的方法结合使用。这样,我们可以分别测试放置断点前后所消耗的时间。如果执行了断点,则在恢复执行之前,肯定会耗费一定的时间,以此检测调试器的存在。
var startTime = performance.now(); debugger; var stopTime = performance.now(); if ((stopTime - startTime) > 1000) { alert("Debugger detected!") }
这些时间检测点,可以在代码中的随机分布,因此分析人员很难找到它们。
0x04 DevTools检测(Chrome)
我是在这篇文章(https://www.reddit.com/r/firefox/comments/5gtedd/ublock_origin_developer_raymond_hill_on/dav4iiu/)中第一次看到该DevTools检测技术的,根据该文的说法:
使用的技术是在div元素的id属性上实现一个getter。当该div元素被发送到console.log(div)等控制台时,为方便起见,浏览器会自动尝试获取元素的ID。 因此,如果在调用console.log后执行getter的话,这就意味着控制台已打开。
下面给出一个简单的概念验证代码:
let div = document.createElement('div'); let loop = setInterval(() => { console.log(div); console.clear(); }); Object.defineProperty(div, "id", {get: () => { clearInterval(loop); alert("Dev Tools detected!"); }});
0x05 执行流程完整性的隐式控制
对JavaScript代码进行去混淆处理时,通常先对变量和函数进行重命名,以提高源代码的可读性。为此,只需将代码分成更小的代码块,然后重新命名即可。在JavaScript中,我们可以检测函数的名称是否修改过,或从未改变。更准确的说,我们可以检查堆栈跟踪是否包含原始名称和原始顺序。
使用arguments.callee.caller,我们可以创建一个堆栈跟踪,用于保存之前执行过的函数。我们可以使用这些信息来生成一个哈希值,然后将其用于生成解密JavaScript其他部分的密钥的种子。通过这种方式,我们可以对执行流程完整性进行隐式控制,因为如果函数被重命名或被执行的函数的顺序略有不同,则生成的哈希值就会变得截然不同。如果该哈希值不同的话,则生成的密钥也会不同。如果密钥不同,我们就无法解密代码。为了便于理解,下面举例说明:
function getCallStack() { var stack = "#", total = 0, fn = arguments.callee; while ( (fn = fn.caller) ) { stack = stack + "" +fn.name; total++ } return stack } function test1() { console.log(getCallStack()); } function test2() { test1(); } function test3() { test2(); } function test4() { test3(); } test4();
执行这段代码时,将显示字符串#test1test2test3test4。如果修改了某个函数的名称,则返回的字符串也会随之发生变化。所以,我们可以用这个字符串计算一个安全的哈希值,稍后用它作为种子来导出用于解密其他代码块的密钥。需要注意的是,如果无法解密下一个代码块(分析人员一旦修改了函数名,就会导致密钥无效)的话,可以捕获异常并将执行流程重定向到一个虚假路径。
请记住,这个技巧需要结合强大的混淆技术才能发挥作用。
小结
在本文中,我们为读者介绍了与JavaScript反调试有关的一些技巧,其中包括函数重定义、断点、时间差异、DevTools检测和执行流程完整性的隐式控制等,更多的技巧,我们将在下篇中介绍。