Open yacan8 opened 4 years ago
这里代码自检的写法是不是有问题啊,代码运行时才能获取到toString的结果,不同的打包机制也会影响toString的结果
这里代码自检的写法是不是有问题啊,代码运行时才能获取到toString的结果,不同的打包机制也会影响toString的结果
实现细节上注意一下就可以,例如手动实现打包插件,设定函数abc,然后在插件打包时增加一层函数名为abc的闭包,同时对函数体本身进行离线的计算签名,注入到打包代码里,如下
源码
var onlineSign = calc_sign(abc.toString());
if (onlineSign !== offlineSign) {
// do something
}
插件代码:
function transform(code) {
var closureCode = `function abc() {${code} }`
var offlineSign = calc_sign(closureCode);
return `var offlineSign = '${offlineSign}';
(${closureCode})();
`;
}
打包打包后的代码
var offlineSign = '离线计算出的签名'
(function abc() {
var onlineSign = calc_sign(abc.toString());
if (onlineSign !== offlineSign) {
// do something
}
})();
前言
在安全攻防战场中,前端代码都是公开的,那么对前端进行加密有意义吗?可能大部分人的回答是,
毫无意义
,不要自创加密算法,直接用HTTPS吧。但事实上,即使不了解密码学,也应知道是有意义
的,因为加密前
和解密后
的环节,是不受保护的。HTTPS只能保护传输层,此外别无用处。而加密环节又分:
本文主要列举一些我见到的,我想到的一些加密方式,其实确切的说,应该叫混淆,不应该叫加密。
那么,代码混淆的具体原理是什么?其实很简单,就是去除代码中尽可能多的有意义的信息,比如注释、换行、空格、代码负号、变量重命名、属性重命名(允许的情况下)、无用代码的移除等等。因为代码是公开的,我们必须承认没有任何一种算法可以完全不被破解,所以,我们只能尽可能增加攻击者阅读代码的成本。
语法树AST混淆
在保证代码原本的功能性的情况下,我们可以对代码的AST按需进行变更,然后将变更后的AST在生成一份代码进行输出,达到混淆的目的,我们最常用的uglify-js就是这样对代码进行混淆的,当然
uglify-js
的混淆只是主要进行代码压缩,即我们下面讲到的变量名混淆。变量名混淆
将变量名混淆成阅读比较难阅读的字符,增加代码阅读难度,上面说的
uglify-js
进行的混淆,就是把变量混淆成了短名(主要是为了进行代码压缩),而现在大部分安全方向的混淆,都会将其混淆成类16进制变量名,效果如下:混淆后:
注意事项:
eval语法,eval函数中可能使用了原来的变量名,如果不对其进行处理,可能会运行报错,如下:
如果不对eval中的console.log(test)进行关联的混淆,则会报错。不过,如果eval语法超出了静态分析的范畴,比如:
这种咋办呢,可能要进行遍历AST找到其运行结果,然后在进行混淆,不过貌似成本比较高。
全局变量的编码,如果代码是作为SDK进行输出的,我们需要保存全局变量名的不变,比如:
$
变量是放在全局下的,混淆过后如下:那么如果依赖这一段代码的模块,使用
$('id')
调用自然会报错,因为这个全局变量已经被混淆了。常量提取
将JS中的常量提取到数组中,调用的时候用数组下标的方式调用,这样的话直接读懂基本不可能了,要么反AST处理下,要么一步一步调试,工作量大增。
以上面的代码为例:
混淆过后:
当然,我们可以根据需求,将数组转化为二位数组、三维数组等,只需要在需要用到的地方获取就可以。
常量混淆
将常量进行加密处理,上面的代码中,虽然已经是混淆过后的代码了,但是
hello
字符串还是以明文的形式出现在代码中,可以利用JS中16进制编码会直接解码的特性将关键字的Unicode进行了16进制编码。如下:结合常量提取得到混淆结果:
当然,除了JS特性自带的Unicode自动解析以外,也可以自定义一些加解密算法,比如对常量进行base64编码,或者其他的什么rc4等等,只需要使用的时候解密就OK,比如上面的代码用base64编码后:
运算混淆
将所有的逻辑运算符、二元运算符都变成函数,目的也是增加代码阅读难度,让其无法直接通过静态分析得到结果。如下:
混淆后:
当然除了逻辑运算符和二元运算符以外,还可以将函数调用、静态字符串进行类似的混淆,如下:
上面的例子中,fun1和fun2内的字符串相加也会被混淆走,静态字符串也会被前面提到的
字符串提取
抽取到数组中(我就是懒,这部分代码就不写了)。需要注意的是,我们每次遇到相同的运算符,需不需要重新生成函数进行替换,这就按个人需求了。
语法丑化
将我们常用的语法混淆成我们不常用的语法,前提是不改变代码的功能。例如for换成do/while,如下:
动态执行
将静态执行代码添加动态判断,运行时动态决定运算符,干扰静态分析。
如下:
混淆过后:
流程混淆
对执行流程进行混淆,又称控制流扁平化,为什么要做混淆执行流程呢?因为在代码开发的过程中,为了使代码逻辑清晰,便于维护和扩展,会把代码编写的逻辑非常清晰。一段代码从输入,经过各种if/else分支,顺序执行之后得到不同的结果,而我们需要将这些执行流程和判定流程进行混淆,让攻击者没那么容易摸清楚我们的执行逻辑。
控制流扁平化又分顺序扁平化、条件扁平化,
顺序扁平化
顾名思义,将按顺序、自上而下执行的代码,分解成数个分支进行执行,如下代码:
流程图如下:
混淆过后代码如下:
混淆过后的流程图如下:
流程看起来
扁
了。条件扁平化
条件扁平化的作用是把所有if/else分支的流程,全部扁平到一个流程中,在流程图中拥有相同的入口和出口。
如下面的代码:
如上代码,流程图是这样的
控制流扁平化后代码如下:
混淆后的流程图如下:
直观的感觉就是代码变
扁
了,所有的代码都挤到了一层当中,这样做的好处在于在让攻击者无法直观,或通过静态分析的方法判断哪些代码先执行哪些后执行,必须要通过动态运行才能记录执行顺序,从而加重了分析的负担。需要注意的是,在我们的流程中,无论是顺序流程还是条件流程,如果出现了块作用域的变量声明(const/let),那么上面的流程扁平化将会出现错误,因为switch/case内部为块作用域,表达式被分到case内部之后,其他case无法取到const/let的变量声明,自然会报错。
不透明谓词
上面的switch/case的判断是通过数字(也就是谓词)的形式判断的,而且是透明的,可以看到的,为了更加的混淆视听,可以将case判断设定为表达式,让其无法直接判断,比如利用上面代码,改为不透明谓词:
谓词用a、b、c三个变量组成,甚至可以把这三个变量隐藏到全局中定义,或者隐藏在某个数组中,让攻击者不能那么轻易找到。
脚本加壳
将脚本进行编码,运行时 解码 再 eval 执行如:
但是实际上这样意义并不大,因为攻击者只需要把alert或者console.log就原形毕露了
改进方案:利用
Function / (function(){}).constructor
将代码当做字符串传入,然后执行,如下:如上代码,可以对code进行加密混淆,例如aaencode,原理也是如此,我们举个例子
利用aaencode混淆过后,代码如下:
这段代码看起来很奇怪,不像是JavaScript代码,但是实际上这段代码是用一些看似表情的符号,声明了一个16位的数组(用来表示16进制位置),然后将code当做字符串遍历,把每个代码符号通过
string.charCodeAt
取这个16位的数组下标,拼接成代码。大概的意思就是把代码当做字符串,然后使用这些符号的拼接代替这一段代码(可以看到代码里有很多加号),最后,通过(new Function(code))('_')
执行。仔细观察上面这一段代码,把代码最后的
('_')
去掉,在运行,你会直接看到源代码,然后Function.constructor
存在(゚Д゚)
变量中,感兴趣的同学可以自行查看。除了aaencode,jjencode原理也是差不多,就不做解释了,其他更霸气的jsfuck,这些都是对代码进行加密的,这里就不详细介绍了。
反调试
由于JavaScript自带
debugger
语法,我们可以利用死循环性的debugger
,当页面打开调试面板的时候,无限进入调试状态。定时执行
在代码开始执行的时候,使用
setInterval
定时触发我们的反调试函数。随机执行
在代码生成阶段,随机在部分函数体中注入我们的反调试函数,当代码执行到特定逻辑的时候,如果调试面板在打开状态,则无限进入调试状态。
内容监测
由于我们的代码可能已经反调试了,攻击者可以会将代码拷贝到自己本地,然后修改,调试,执行,这个时候就需要添加一些检测进行判定,如果不是正常的环境执行,那让代码自行失败。
代码自检
在代码生成的时候,为函数生成一份Hash,在代码执行之前,通过函数 toString 方法,检测代码是否被篡改
环境自检
检查当前脚本的执行环境,例如当前的URL是否在允许的白名单内、当前环境是否正常的浏览器。
如果为Nodejs环境,如果出现异常环境,甚至我们可以启动木马,长期跟踪。
废代码注入
插入一些永远不会发生的代码,让攻击者在分析代码的时候被这些无用的废代码混淆视听,增加阅读难度。
废逻辑注入
与废代码相对立的就是有用的代码,这些有用的代码代表着被执行代码的逻辑,这个时候我们可以收集这些逻辑,增加一段判定来决定执行真逻辑还是假逻辑,如下:
可以看到,所有的console.log都是我们的执行逻辑,这个时候可以收集所有的console.log,然后制造假判定来执行真逻辑代码,收集逻辑注入后如下:
判定逻辑中生成了一些字符串,在没有使用字符串提取的情况下,这是可以通过代码静态分析来得到真实的执行逻辑的,或者我们可以使用上文讲到的动态执行来决定执行真逻辑,可以看一下使用字符串提取和变量名编码后的效果,如下:
求值陷阱
除了注入执行逻辑以外,还可以埋入一个隐蔽的陷阱,在一个
永不到达
且无法静态分析
的分支里,引用该函数,正常用户不会执行,而 AST 遍历求值时,则会触发陷阱!陷阱能干啥呢?加壳干扰
在代码用eval包裹,然后对eval参数进行加密,并埋下陷阱,在解码时插入无用代码,干扰显示,大量换行、注释、字符串等大量特殊字符,导致显示卡顿。
结束
大概我想到的混淆就包括这些,单个特性使用的话,混淆效果一般,各个特性组合起来用的话,最终效果很明显,当然这个看个人需求,毕竟混淆是个双刃剑,在增加了阅读难度的同时,也增大了脚本的体积,降低了代码的运行效率。
参考文献
代码混淆之道——控制流扁平与不透明谓词理论篇