0x00 前言
上周我发布了Bypass_Debugger脚本的第一版,效果十分显著,但是后来我在测试某个存在无限debugger的站点时出现了异常,经本人测试就是因为我重写了eval而出的问题,并且这个问题按照主流hook eval写法都难逃于此,所以下文主要就是介绍一下问题出现原因,并提供针对该类型案例的解决方案。
另外特别感谢一下Dexter师傅,前几天为我的脚本提供了一些建议,并和我讲解了一些关于本文核心Proxy的一些知识点,其次Dexter师傅的公众号叫我真的不是蜘蛛
,如果有师傅对爬虫或逆向感兴趣的可以关注支持一下。
注:强调一下,本文给出的解决方案只能解决类似本文案例中的情况,不能完全解决Hook eval后存在的作用域问题,下文还会解释。
0x01 前提提要
首先我要讲一下Dexter师傅前两天给我提的建议,他当时看完我写的Bypass_Debugger脚本后告诉我我这种重写toString的方式很容易被检测到,也就是说我当时重写toString时并没有考虑到代码会检测toString被重写的情况,例如:
var temp_toString = Function.prototype.toString;
Function.prototype.toString = function () {
if (this === eval) {
return'function eval() { [native code] }';
}
return temp_toString.apply(this, arguments);
}
Function.prototype.toString.toString();
而正常情况是这样的:
解决方案很简单,只需要像如下这样写即可(说个题外话,当时我解决完后Dexter师傅就给我发过来他自己写的,思路和我的一模一样,英雄所见略同这句话永不过时):
var temp_toString = Function.prototype.toString;
Function.prototype.toString = function () {
if (this === eval) {
return'function eval() { [native code] }';
} elseif (this === Function.prototype.toString) {
return'function toString() { [native code] }';
}
return temp_toString.apply(this, arguments);
}
其实那篇文章发布后的第二天我就已经意识到了这个问题,只不过我当时没有去细想,因为我在测试过程中发现大多数防火墙仅仅是去检测了eval有没有被重写,尚未去查看toString方法有没有被重写,不过话虽是这么说,我肯定还是要填补一下这个漏洞的。
0x02 正文
直接上图:
这是我用我Bypass_Debugger脚本hook本文前言提到的案例后的效果,可见异常信息为s未定义,但是我不启用这个脚本就没事,所以我判定是我脚本造成的问题。先跟进一下这个VM,看看里面是什么内容:
说实话,我第一次看到这个时也挺蒙的,不着急先跟一下堆栈:
很明显,这个函数return了一个eval执行后的结果,而我的hook脚本里是重写了eval的,所以这个异常大概率是由于我重写了eval而出的问题,另外这个函数也很有意思:
itemApiUrl函数的形参是s和h,函数在调用时传了topAddr
和_0xf8e(_0xdb[16])
,这两个参数各自对应的就是s和h。也就是说代码让eval里执行了s + h
,然后让这俩拼接的结果return回去,这就是代码想干的,但其实并没有那么简单,我们可以从代码中看到,eval最后执行的是s + h
,为什么eval执行后的js中能读取到s和h呢,这就不得不提到eval的一个尿性:
这是MDN里对eval的部分介绍,也就是说如果我在一个函数内部使用了eval,eval执行的这段js就会使用当前eval所在函数的局部作用域
。下面我用代码给大家简单演示一下:
var a = function () {
var b = 1;
returneval("console.log(b)");
}
a()
可见eval成功访问到了a函数里的b变量,所以说当我们重写了eval后,eval就丧失了这个功能。比如我现在把hook关闭了重新访问并打个断点跟一下:
显而易见eval是可以访问到函数里的s和h的,但是我hook了以后就成这样了:
我重写了eval后就不能访问到它所在函数内部的变量了,因为这个重写后的eval已经不具备这个功能了,这个eval已经无异于我们平常写的那种函数了。那么该怎么解决这个问题,我当时是想了一天也没想出来解决方案,百度和bing查不到任何相关资料,迫于无奈我去问了GPT,但GPT也给不出任何解决方案,直到前两天我上课时突然想到了一个东西:Proxy
,这才终于变相
解决了这个问题(注:再次强调一下,下文给出的解决方案依然解决不了重写eval后读取不到eval所在函数内部定义的变量的问题,但是可以解决上文案例出现的问题,只要是本文这种情况的都可以解决)。
首先我需要明确一下解决本案例问题的核心,我们先来看一下这个函数中的s和h是在哪里:
显而易见s和h是形参,也就是说如果我们能获取到调用这个函数时传递的形参就能解决问题了,这就是当时我想不出该怎么解决这个问题的原因,我不知道有什么方法能获取到这些形参,但岂不闻天无绝人之路,Proxy就很好地解决了这个问题:
描述里写的很清楚,Proxy对象可以用于创建一个对象的代理
,这样我们就能实现代码如果对该代理对象进行了操作,我们就能拦截对其的操作,并执行一些我们想要的效果。另外Proxy是一个构造函数:
语法:
newProxy(target, handler)
实例时传的第一个参数target就是被代理对象
,被代理对象可以是任何类型,比如原生数组、函数等等,比如如果要解决上文的问题,那么这个被代理对象就是eval。第二个参数handler也是一个对象,我们通常称这个handler为处理器,我们可以在这个处理器里写我们想要实现的拦截功能,例如:
本文要用到的就是apply方法,因为当代理对象被调用时我们就能用这个方法去拦截对其的调用操作,然后去实现我们想要的功能。我先拿MDN里的示例代码(我进行了简化)演示一下Prxoy和apply方法的效果:
functionsum(a, b) {
return a + b;
}
const handler = {
apply: function (target, thisArg, argumentsList) {
console.log(`调用时的参数: ${argumentsList}`);
returntarget(argumentsList[0], argumentsList[1]) * 10;
},
};
const proxy1 = newProxy(sum, handler);
console.log(sum(1, 2));
console.log(proxy1(1, 2));
代码首先定义了一个sum函数,随后定义了一个处理器handler,然后创建了一个Proxy代理对象,第一个参数传的就是我们要代理的对象sum函数,第二个参数就是handler处理器,随后代码分别调用了sum函数和proxy1代理对象,我们可以看到调用结果是不一样的,所以接下来我要详细讲一下handler处理器内部执行的操作:
const handler = {
apply: function (target, thisArg, argumentsList) {
console.log(`调用时的参数: ${argumentsList}`);
returntarget(argumentsList[0], argumentsList[1]) * 10;
},
};
handler里定义了一个apply方法,它就是我上面提到的可以拦截函数调用的方法:
当代码试图调用代理对象时就会被apply方法拦截,方法里有三个参数:target, thisArg, argumentsList
:
MDN里写的很清楚,最重要的两个参数就是target
和argumentsList
,target代表的就是被代理的函数:
argumentsList就是我们向代理对象传的参数:
apply方法最后return时执行了sum函数,将执行的结果乘了10:
这就是apply方法和Proxy的用处,那么问题来了,这怎么解决我上面提到的hook eval后出现的作用域问题?也就是怎么获取到调用eval时它所在函数的形参?不着急,我这里一步一步给大家讲解,先简单修改一下代码:
functionsum(a, b) {
return a + b;
}
var temp_sum = sum;
const handler = {
apply: function (target, thisArg, argumentsList) {
console.log(`调用时的参数: ${argumentsList}`);
console.log(target === sum)
console.log(argumentsList);
returntemp_sum(argumentsList[0], argumentsList[1]) * 10;
},
};
sum = newProxy(sum, handler);
console.log(sum(1, 2));
我将new Proxy后返回的代理对象直接覆盖给了sum函数,这样我们接下来调用的sum函数都是调用的sum代理对象,所以我们还得在实例Proxy前将sum保存到一个临时变量中,这就是为什么我在上面将sum赋给了temp_sum的原因,最后我在apply方法执行return结果时调用的就是temp_sum。
通过上面代码的修改,大家可能还不懂我是怎么通过Proxy获取到调用eval时它所在函数的形参,我先给大家看一下我修改后的Hook_eval脚本:
在代码的最后我将eval覆盖为了代理对象,我给大家看一下hook后的效果大家就懂了:
可见调用eval后就进入了我们的apply方法,但是读者们可以清楚地看到我通过arguments.callee.caller.arguments
这个神奇的属性获取到了调用eval的那个函数的形参。接着向下看:
因为函数要用的是s和h拼接的结果,那么我只返回这个结果即可,于是我实例了一个Function,并将形参都写进了实例的这个匿名函数的形参上,随后我在调用函数时将函数传的s和h都传给了实例的这个Function:
最后返回了s和h拼接的结果:
这样就解决了本案例出现的问题,此刻我相信大家有很多疑问,不着急我一步一步来给大家讲解,先给大家解释一下arguments.callee.caller.arguments
是什么东西:
•arguments.callee:代表apply方法。•arguments.callee.caller:代表调用eval的那个函数,如果eval是在全局中调用的即为null:
但是如果我是在函数里调用的就是这样:
•arguments.callee.caller.arguments:代表调用eval的那个函数都被传了什么参数,上文已演示过,这就是Proxy的强大之处。
这段修改后的Hook_eval脚本我主要就是要重点讲解一下我在apply方法里做的操作,但在之前我还要给大家看一下其他修改的地方:
我将重写eval的函数写成了一个临时函数bridge_fun,最后调用的是temp_eval,也就是eval:
接下来就是apply方法里做的操作:
let handler = {
apply: function (target, thisArg, argumentsList) {
if (arguments.callee.caller != null && arguments.callee.caller.arguments.length !== 0 && typeof argumentsList[0] == "string" && argumentsList[0].indexOf('()') === -1) {
let fun = arguments.callee.caller.toString();
let parameter = fun.substring(fun.indexOf('(') + 1, fun.indexOf(')')).split(',');
let temp_str = '';
for (let i = 0; i < argumentsList.length; i++) {
temp_str = temp_str + argumentsList[i];
}
returnFunction(...parameter, 'return ' + temp_str)(...arguments.callee.caller.arguments);
}
returnbridge_fun(...argumentsList);
},
};
首先我判断了好几个条件,第一个就是判定了eval是不是在一个函数里被调用的,如果不是就直接调用bridge_fun,然后我判断了一下eval所在的函数被调用时传了参数没有:
arguments.callee.caller.arguments.length !== 0
这点我需要重点解释一下为什么要这样写,这是因为通过Proxy只能获取到eval所在函数在被调用时所传的形参
,比如:
根本获取不到,所以我在上文中也说过了,我这种办法只能解决本案例出现的问题,但如果代码是这么写的我就没办法了,至少我现在没有任何便捷的解决方案。所以我判断了eval所在函数在被调用时有没有传参,主要就是为了解决本案例出现的问题,如果传参了就接着判断下一个条件:
typeof argumentsList[0] == "string"
这么写是因为后期要判断传的字符串中有没有写()
,所以要判断一下传的参数是不是string类型,接下来就是刚刚说的判断传的字符串中有没有写()
:
argumentsList[0].indexOf('()') === -1
一般情况下来说,我们调用eval时传的字符串都是一些立即执行函数,比如:
所以我直接匹配()
,毕竟我们调用函数时必须得写()
,而本文提到的案例纯纯是一种另类情况:
它让eval执行的是一个表达式,所以我这整个if语句的条件主要就是判断一下传的字符串是不是一个表达式,这就是我的主要目的,所以我需要判断一下代码调用eval时传的字符串里是不是调用了函数,如果没有就代表代码调用eval时传的是一个表达式(我这里指的表达式是本文这种简单表达式),毕竟除此之外我想不出开发在调用eval时还会传什么(其实这个if条件写的不是很好,比如我仅仅是通过一个()
就判断传的字符串是不是一个表达式,所以这个脚本我暂时只会当做是一个备用脚本,不建议读者将其作为主要使用的脚本)。
接下来就是if语句内部做的操作:
let fun = arguments.callee.caller.toString();
let parameter = fun.substring(fun.indexOf('(') + 1, fun.indexOf(')')).split(',');
let temp_str = '';
for (let i = 0; i < argumentsList.length; i++) {
temp_str = temp_str + argumentsList[i];
}
return Function(...parameter, 'return ' + temp_str)(...arguments.callee.caller.arguments);
我先获取了eval所在函数的源代码:
let fun = arguments.callee.caller.toString();
toString方法在上期文章中讲到过,如果有朋友不知道可以去了解一下。然后我获取了这个函数的形参名:
let fun = arguments.callee.caller.toString();
let parameter = fun.substring(fun.indexOf('(') + 1, fun.indexOf(')')).split(',');
给大家看一下这个效果:
大家不用担心立即执行函数,依然会匹配到的:
随后我获取了代码调用eval时传的内容:
let temp_str = '';
for (let i = 0; i < argumentsList.length; i++) {
temp_str = temp_str + argumentsList[i];
}
最后我return操作是这样写的:
returnFunction(...parameter, 'return ' + temp_str)(...arguments.callee.caller.arguments);
这句代码就是apply方法中最重要的,因为我这个if语句主要就是为了匹配表达式,所以eval执行完返回的就是一个表达式的结果,例如:
functiona() {
returneval("1>0")
}
console.log(a())
所以我只需要开辟一个新的匿名函数,函数体里什么都不写,只写一个return就行,return的内容就是表达式,但是此时我还没有解决掉本文的问题,也就是要将s和h传进去,具体实现就是我在最后调用这个匿名函数时传进去的内容:
(...arguments.callee.caller.arguments)
我将eval所在函数在被调用时所传的形参都传了进去,这样就解决了本文的问题,也就是上文中已演示过的:
以上就是我改写后的Hook_eval脚本的全部逻辑。
但是这段代码依然是不能解决掉重写eval后读取不到eval所在函数内部的变量的问题,只能解决掉类似上文中案例的问题,也就是eval执行的js中涉及到的变量就在eval所在函数被调用的形参当中的,这种我们就可以通过Proxy解决。
如果让我说怎么完全解决这个问题,其实应该也是可以通过Proxy解决,比如我们可以获取到eval所在函数的源代码,然后通过某种方式获取到其作用域的目标变量,这是一种办法,其他办法我暂时就想不出来了,另外我刚刚讲的这种办法我也没有亲自实践过,也仅仅是一个设想。
改写后的Hook_eval脚本我已经发布到了github上,Bypass_Debugger脚本我也照着改了一份,另外我已经标明了这些脚本是备用脚本,因为我还需要再优化一下,我在上面也说过我那个if条件写的不是太好,另外本文案例出现的这种问题可能十个站点只有一个会出现,概率很低,读者朋友们也可以先放心使用我现在的Bypass_Debugger脚本,如果真出现了什么异常就可以考虑一下使用这些备用脚本。github地址:https://github.com/0xsdeo/Hook_JS
原文始发于微信公众号(Spade sec):JS逆向系列15-深入探究Hook eval后存在的作用域问题
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论