总结一下关于JavaScript反调试技巧方面的内容。本文的目的是收集与JavaScript中的反调试有关的小窍门(其中一些已经被恶意软件或商业产品使用)。

对于JavaScript来说,你只需要花一点时间进行调试和分析,你就能够了解到JavaScript代码段的功能逻辑。而我们所要讨论的内容,可以给那些想要分析你JavaScript代码的人增加一定的难度。不过我们的技术跟代码混淆无关,我们主要针对的是如何给代码主动调试增加困难。
本文所要介绍的技术方法大致如下:

    1. 检测未知的执行环境(我们的代码只想在浏览器中被执行);
    1. 检测调试工具(例如DevTools);
    1. 代码完整性控制;
    1. 流完整性控制;
    1. 反模拟;

简而言之,如果我们检测到了“不正常”的情况,程序的运行流程将会改变,并跳转到伪造的代码块,并“隐藏”真正的功能代码。

函数重定义

这是一种最基本也是最常用的代码反调试技术了。在JavaScript中,我们可以对用于收集信息的函数进行重定义。比如说,console.log()函数可以用来收集函数和变量等信息,并将其显示在控制台中。如果我们重新定义了这个函数,我们就可以修改它的行为,并隐藏特定信息或显示伪造的信息。
我们可以直接在DevTools中运行这个函数来了解其功能:

1
2
3
4
console.log("HelloWorld");
var fake = function() {};
window['console']['log']= fake;
console.log("Youcan't see me!");

运行后我们将会看到:

1
VM127:1 HelloWorld

你会发现第二条信息并没有显示,因为我们重新定义了这个函数,即“禁用”了它原本的功能。但是我们也可以让它显示伪造的信息。比如说这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
console.log("Normalfunction");
//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 otherparam.
// 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("Thisis unaltered");
//Now we should see other text in console different to "Ka0labs"
console.log("Ka0labs");
//Aaaand everything still OK
console.log("Byebye!");

如果一切正常的话:

1
2
3
4
VM84:1 Normalfunction
VM84:11 Thisis unaltered
VM84:9 Spoofed!
VM84:11 Byebye!

实际上,为了控制代码的执行方式,我们还能够以更加聪明的方式来修改函数的功能。比如说,我们可以基于上述代码来构建一个代码段,并重定义eval函数。我们可以把JavaScript代码传递给eval函数,接下来代码将会被计算并执行。如果我们重定义了这个函数,我们就可以运行不同的代码了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//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 contains1337...
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('Weshould see this...')");
//Now we should see the execution of a for loop instead of what is expected
eval("console.log('Too1337 for you!')");

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
VM171:1 1337
VM172:1 Weshould see this...
VM173:1 0
VM173:1 1
VM173:1 2
VM173:1 3
VM173:1 4
VM173:1 5
VM173:1 6
VM173:1 7
VM173:1 8
VM173:1 9

通过这种方式修改程序流是一个很酷的技巧,但是正如我们在一开始所说的那样,它是最基本的技巧,很容易被发现并被击败。这是因为在JavaScript中,每个函数都有一个方法toString(或Firefox中的toSource)返回其自己的代码。因此,仅需要检查所需函数的代码是否已更改。当然,我们可以重新定义方法toString / toSource,但是我们陷入了同样的情况:function.toString.toString()

断点

为了帮助我们了解代码的功能,JavaScript调试工具(例如DevTools)都可以通过设置断点的方式阻止脚本代码执行,而断点也是代码调试中最基本的了。
如果你研究过调试器或者x86架构,你可能会比较熟悉0xCC指令。在JavaScript中,我们有一个名叫debugger的类似指令。当我们在代码中声明了debugger函数后,脚本代码将会在debugger指令这里停止运行。比如说:

1
2
3
console.log("Seeme!");
debugger;
console.log("Seeme!");

很多商业产品会在代码中定义一个无限循环的debugger指令,不过某些浏览器会屏蔽这种代码,而有些则不会。这种方法的主要目的就是让那些想要调试你代码的人感到厌烦,因为无限循环意味着代码会不断地弹出窗口来询问你是否要继续运行脚本代码:

1
setTimeout(function(){while (true) {eval("debugger")
1
setInterval(function({var a = new Date(); debuggerreturn new Date() - a > 100;}, 100);

时间差异

这是一种从传统反逆向技术那里借鉴过来的基于时间的反调试技巧。当脚本在DevTools等工具环境下执行时,运行速度会非常慢(时间久),所以我们就可以根据运行时间来判断脚本当前是否正在被调试。比如说,我们可以通过测量代码中两个设置点之间的运行时间,然后用这个值作为参考,如果运行时间超过这个值,说明脚本当前在调试器中运行。
演示代码如下:

1
2
3
4
5
6
7
8
9
10
11
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检测(I)[Chrome]:getter

这项技术利用的是div元素中的id属性,当div元素被发送至控制台(例如console.log(div))时,浏览器会自动尝试获取其中的元素id。如果代码在调用了console.log之后又调用了getter方法,说明控制台当前正在运行。
简单的概念验证代码如下:

1
2
3
4
5
6
7
8
9
let div = document.createElement('div');
let loop = setInterval(() => {
console.log(div);
console.clear();
});
Object.defineProperty(div,"id", {get: () => {
clearInterval(loop);
alert("Dev Tools detected!");
}});

DevTools检测(II)[Chrome]:大小更改

如果打开了DevTools(除非将其取消对接打开),则window.outerWidth / Heightwindow.innerWidth / Height之间的差异将发生变化,因此可以循环检测。Devtools-detect使用此技巧:

1
2
3
const widthThreshold = window.outerWidth - window.innerWidth > threshold;
const heightThreshold = window.outerHeight - window.innerHeight > threshold;
const orientation = widthThreshold ? 'vertical' : 'horizontal';

隐式流完整性控制

当我们尝试对JavaScript代码段进行模糊处理时,第一步就是开始重命名一些变量和函数,以阐明源代码。您只需将代码拆分为较小的代码块,然后开始在此处和此处重命名。在JavaScript中,我们可以检查函数名称是否已更改或保持相同的名称。或更准确地说,我们可以检查堆栈跟踪是否包含原始名称和原始顺序。
使用arguments.callee.caller,我们可以创建堆栈跟踪,以保存先前执行的函数。我们可以使用此信息来生成一个哈希,该哈希将成为用于生成用于解密JavaScript其他部分的密钥的种子。这样,我们就可以对流的完整性进行隐式控制,因为如果重命名功能或要执行的功能顺序稍有不同,则创建的哈希将完全不同。如果哈希不同,则生成的密钥也将不同。如果密钥不同,则无法解密代码。为了更好地理解它,请参见下一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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。如果我们修改(我邀请您这样做)任何函数的名称,返回的字符串也将不同。我们可以使用该字符串计算安全哈希,然后将其用作种子,以得出用于解密其他代码块的密钥。有趣的是,如果由于密钥无效(分析人员更改了函数名称)而无法解密下一个代码块,则可以捕获异常并将执行流重定向到伪路径。

1
VM50:10 #test1test2test3test4

请记住,此技巧需要与强大的混淆功能结合在一起才能使用。

隐式代码完整性控制

在“ 函数重新定义”部分的结尾,我们提到可以使用toString()方法检索JavaScript中函数的代码。就像我们说过的那样,这对于检查函数是否已重新定义很有用,实际上,可以使用相同的想法来知道函数的代码是否被修改。

效果较差的方法是计算函数或代码块的哈希并将其与已知表进行比较。但是这种方法确实很愚蠢。一种更现实,更有效的方法可以重复使用我们之前在堆栈跟踪中使用的相同策略。我们可以计算代码块的哈希值,并将其用作解密其他代码块的密钥。

创建隐式完整性控件的最漂亮方法是在md5中使用冲突。基本上,我们可以创建在自己的函数中测试其自己的md5的函数。为了在功能内执行检查,我们需要进行碰撞处理(我们想创建类似的东西function(){ if (md5(arguments.callee.toString() === ‘‘) code_function; }。

该技术背后的概念与用于生成图像文件的概念相同,在自己的图片中显示了md5校验和。这是一个经典示例:显示自己的md5校验和的gif。

(注:本站的图片处理策略更改了图片md5值,点击查看原图=> 显示自己的md5校验和的gif)

关于如何产生这种冲突,有大量的文章(甚至在PoC || GTFO中出现了一些示例),但是我阅读并可以复制的第一个文章是使用PHP编写的。您可以非常快速地预先计算生成碰撞所需的块。实际上,@cgvwzq创建的示例,通过这种方式检查了函数内容的完整性。

如前所述,我们需要对这种技术进行强力混淆。

代理对象(old,已弃用)

代理对象是目前JavaScript中最有用的一个工具,这种对象可以帮助我们了解代码中的其他对象,包括修改其行为以及触发特定环境下的对象活动。比如说,我们可以创建一个嗲哩对象并跟踪每一次document.createElement调用,然后记录下相关信息:

1
2
3
4
5
6
7
8
9
const handler = { // Our hook to keep the track
apply: function (target, thisArg, args){
console.log("Intercepted a call tocreateElement with args: " + args);
return target.apply(thisArg, args)
}
}

document.createElement= new Proxy(document.createElement, handler) // Create our proxy object withour hook ready to intercept
document.createElement('div');

接下来,我们可以在控制台中记录下相关参数和信息:

1
VM216:3 Intercepted a call tocreateElement with args: div

我们可以利用这些信息并通过拦截某些特定函数来调试代码,但是本文的主要目的是为了介绍反调试技术,那么我们如何检测“对方”是否使用了代理对象呢?其实这就是一场“猫抓老鼠”的游戏,比如说,我们可以使用相同的代码段,然后尝试调用toString方法并捕获异常:

1
2
3
4
5
6
//Call a "virgin" createElement:
try {
document.createElement.toString();
}catch(e){
console.log("I saw your proxy!");
}

信息如下:

1
"function createElement() { [native code] }"

但是当我们使用了代理之后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//Then apply the hook
const handler = {
apply: function (target, thisArg, args){
console.log("Intercepted a call tocreateElement with args: " + args);
return target.apply(thisArg, args)
}
}
document.createElement= new Proxy(document.createElement, handler);

//Callour not-so-virgin-after-that-party createElement
try {
document.createElement.toString();
}catch(e) {
console.log("I saw your proxy!");
}

没错,我们确实可以检测到代理:

1
VM391:13 I saw your proxy!

我们还可以添加toString方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const handler = {
apply: function (target, thisArg, args){
console.log("Intercepted a call tocreateElement with args: " + args);
return target.apply(thisArg, args)
}
}
document.createElement= new Proxy(document.createElement, handler);
document.createElement= Function.prototype.toString.bind(document.createElement); //Add toString
//Callour not-so-virgin-after-that-party createElement
try {
document.createElement.toString();
}catch(e) {
console.log("I saw your proxy!");
}

现在我们就没办法检测到了:

1
"function createElement() { [native code] }"

代理对象

异常把戏不能再使用了。幸运的是,我们仍然可以通过toString长度检测代理对象的使用。例如,document.createElement的大小为42(Chrome):

1
2
document.createElement.toString().length
42

另一方面,当我们创建代理时,此值将更改:

1
2
3
4
5
6
7
8
9
10
11
const handler = {
apply: function(target, thisArg, args) {
console.log("Intercepted call");
return target.apply(thisArg, args);
}
}

document.createElement = new Proxy(document.createElement, handler);

document.createElement.toString().length
29

因此,我们可以执行以下操作:

1
2
3
4
5
6
if (document.createElement.toString().length < 30) {
console.log("I saw your proxy");
}
else {
console.log("Not a proxy");
}

此技巧不能在windoww对象中使用,但仍然有用。

限制环境

如引言中所述,我们想要做的一件事就是尝试检测代码是否在正确的环境中执行。
我们所谓的“正确的环境”是:

  • 该代码正在浏览器(不是仿真器,不是NodeJS等)中执行。
  • 该代码正在指定给它的域/资源中执行(不是本地服务器)

例如,我们可以用来证明代码是否在本地执行的简单检查是:

1
2
3
4
// Pretty stupid idea found in commercial software
if (location.hostname === "localhost" || location.hostname === "127.0.0.1" || location.hostname === "") {
console.log("Don't run me here!")
}

如果我们在本地html中运行此JavaScript代码段,则会看到以下消息:

1
VM28:3 Don't run me here!

按照这个想法,另一个检查选项是用于打开文档的处理程序(类似if (location.protocol == ‘file:’){…}),或者尝试通过HTTP请求进行测试,以确定是否有其他资源(图像,css等)可用。当然,所有这些方法都非常容易被绕过。
如果代码是在NodeJS中执行的(或者正如我们在本文中提到的:将流更改为伪造的路径),则可以避免执行代码。这很危险,但是我在野外看到使用NodeJS来解决JavaScript挑战并绕过反暴力缓解措施
我们可以尝试检测仅存在于浏览器上下文中的对象的存在:

1
2
3
4
5
6
7
8
//Under NodeJS
try {
console.log(window);
} catch(e){
console.log("NodeJS detected!!!!");
}

NodeJS detected!!!!

反之亦然:在NodeJS中,我们具有浏览器上下文中不存在的对象。

1
2
3
4
5
6
7
8
9
10
11
//Under the browser
console.log(global)
VM104:1 Uncaught ReferenceError: global is not defined
at <anonymous>:1:13

//Under NodeJS
console.log(global)
{ console:
Console {
log: [Function: bound log],...
...

我们可以搜索仅存在于浏览器中的大量元数据。我们可以检索到的一些此类想法可以在Panopticlick Project中看到。

相关文献

javascript-antidebugging

评论