常量的混淆原理

本篇所用的demo如下。接下来的案例都是围绕这个demo进行混淆。

1
2
3
4
5
6
7
8
9
10
Date.prototype.format = function(formatStr) {
var str = formatStr;
var Week = ['日', '一', '二', '三', '四', '五', '六'];
str = str.replace(/yyyy|YYYY/, this.getFullYear());
str = str.replace(/MM/, (this.getMonth() + 1) > 9 ? (this.getMonth() + 1).toString() : '0' + (this.getMonth() + 1));
str = str.replace(/dd|DD/, this.getDate() > 9 ? this.getDate().toString() : '0' + this.getDate());
return str;
}

console.log(new Date().format('yyyy-MM-dd'));
对象属性的两种访问方式

看下面一段代码:

1
2
3
4
5
6
7
8
9
10
11
function People(name) {
this.name = name;
}
People.prototype.sayHello = function() {
console.log('Hello');
}
var p = new People('zhang san');
console.log(p.name); // zhang san
p.sayHello(); // Hello
console.log(p['name']); // zhang san
p['sayHello'](); // Hello
  1. p.name这种方式name是一个标识符,必须明确出现在代码中,不能加密和拼接。
  2. p[‘name’]这种方式name是一个字符串。由于是字符串,所以访问的时候可以进行拼接和加密等操作。所以在JS混淆中,一般会选择这种方式访问属性。

所以改变对象属性的访问方式,是代码混淆的前提。

所以开篇提到的demo改变对象属性的访问方式之后,代码修改如下:

1
2
3
4
5
6
7
8
9
10
11
window['Date']['prototype']['format'] = function(formatStr) {
var str = formatStr;
var Week = ['日', '一', '二', '三', '四', '五', '六'];
str = str['replace'](/yyyy|YYYY/, this['getFullYear']());
str = str['replace'](/MM/, (this['getMonth']() + 1) > 9 ? (this['getMonth']() + 1).toString() : '0' + (this['getMonth']() + 1));
str = str['replace'](/dd|DD/, this['getDate']() > 9 ? this['getDate']().toString() : '0' + this['getDate']());
return str;
}

console.log(new window['Date']()['format']('yyyy-MM-dd'));

Date是JS的内置对象,在JS中很多内置对象都属于window的属性。另外,代码中定义的全局变量都是全局对象window的属性,代码中定义的全局方法都是全局对象window的方法。全局对象的属性或者方法在调用的时候可以省略全局对象名。比如,new window.Date()等同于new Date()。由于把Date变成了字符串,所以前面必须加window。

十六进制字符串

在JS中支持字符串的十六进制形式表示,所以可以用字符串的十六进制形式来代替原有的字符串。比如’yyyy-MM-dd’可以表示成’\x79\x79\x79\x79\x2d\x4d\x4d\x2d\x64\x64’。其实,0x79就是字母y的ASCII码的十六进制形式,其余的字母类推。可以用一个方法来完成十六进制字符串的转换。

1
2
3
4
5
6
7
8
function hexEnc(code) {
let hexStr = []
for (let i = 0, s; i < code.length; i++) {
s = code.charCodeAt(i).toString(16);
hexStr += '\\x' + s;
}
return hexStr;
}

开篇的demo转换为十六进制字符串之后如下:

1
2
3
4
5
6
7
8
9
10
window['\x44\x61\x74\x65']['\x70\x72\x6f\x74\x6f\x74\x79\x70\x65']['\x66\x6f\x72\x6d\x61\x74'] = function(formatStr) {
var str = formatStr;
var Week = ['\x65e5', '\x4e00', '\x4e8c', '\x4e09', '\x56db', '\x4e94', '\x516d'];
str = str['\x72\x65\x70\x6c\x61\x63\x65'](/yyyy|YYYY/, this['\x67\x65\x74\x46\x75\x6c\x6c\x59\x65\x61\x72']());
str = str['\x72\x65\x70\x6c\x61\x63\x65'](/MM/, (this['\x67\x65\x74\x4d\x6f\x6e\x74\x68']() + 1) > 9 ? (this['\x67\x65\x74\x4d\x6f\x6e\x74\x68']() + 1).toString() : '\x30' + (this['\x67\x65\x74\x4d\x6f\x6e\x74\x68']() + 1));
str = str['\x72\x65\x70\x6c\x61\x63\x65'](/dd|DD/, this['\x67\x65\x74\x44\x61\x74\x65']() > 9 ? this['\x67\x65\x74\x44\x61\x74\x65']().toString() : '\x30' + this['\x67\x65\x74\x44\x61\x74\x65']());
return str;
}

console.log(new window['\x44\x61\x74\x65']()['\x66\x6f\x72\x6d\x61\x74']('\x79\x79\x79\x79\x2d\x4d\x4d\x2d\x64\x64'));
unicode字符串

在JS中,字符串除了可以表示成十六进制的形式外,还支持使用unicode形式表示。比如:

  1. 以var Week = [‘日’, ‘一’, ‘二’, ‘三’, ‘四’, ‘五’, ‘六’]为例,可以表示成var Week = [‘\u65e5’, ‘\u4e00’, ‘\u4e8c’, ‘\u4e09’, ‘\u56db’, ‘\u4e94’, ‘\u516d’]
  2. 非中文的情况,Date可以表示成’\u0044\u0061\u0074\u0065’

从上述例子不难看出,unicode形式就是\u开头,后面跟四位数的十六进制形式,不足四位的补0。可以通过以下代码完成unicode转换:

1
2
3
4
5
6
7
function unicodeEnc(str) {
var value = '';
for (var i = 0; i < str.length; i++) {
value += "\\u" + ("0000" + parseInt(str.charCodeAt(i)).toString(16)).substr(-4);
}
return value;
}

开篇的demo转换为unicode编码之后如下:

1
2
3
4
5
6
7
8
9
10
window['\u0044\u0061\u0074\u0065']['\u0070\u0072\u006f\u0074\u006f\u0074\u0079\u0070\u0065']['\u0066\u006f\u0072\u006d\u0061\u0074'] = function(formatStr) {
var str = formatStr;
var Week = ['\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d'];
str = str['\u0072\u0065\u0070\u006c\u0061\u0063\u0065'](/yyyy|YYYY/, this['\u0067\u0065\u0074\u0046\u0075\u006c\u006c\u0059\u0065\u0061\u0072']());
str = str['\u0072\u0065\u0070\u006c\u0061\u0063\u0065'](/MM/, (this['\u0067\u0065\u0074\u004d\u006f\u006e\u0074\u0068']() + 1) > 9 ? (this['\u0067\u0065\u0074\u004d\u006f\u006e\u0074\u0068']() + 1).toString() : '\u0030' + (this['\u0067\u0065\u0074\u004d\u006f\u006e\u0074\u0068']() + 1));
str = str['\u0072\u0065\u0070\u006c\u0061\u0063\u0065'](/dd|DD/, this['\u0067\u0065\u0074\u0044\u0061\u0074\u0065']() > 9 ? this['\u0067\u0065\u0074\u0044\u0061\u0074\u0065']().toString() : '\u0030' + this['\u0067\u0065\u0074\u0044\u0061\u0074\u0065']());
return str;
}

console.log(new window['\u0044\u0061\u0074\u0065']()['\u0066\u006f\u0072\u006d\u0061\u0074']('\u0079\u0079\u0079\u0079\u002d\u004d\u004d\u002d\u0064\u0064'));

unicode字符和十六进制字符串都能轻易的还原,即直接把字符串放到控制台打印即可还原。

字符串的ASCII码混淆

首先关注2个方法。

1
2
3
console.log('x'.charCodeAt(0));						 //120
console.log('b'.charCodeAt(0)); //98
console.log(String.fromCharCode(120, 98)); //xb

charCodeAt方法表示把字符串转换成ASCII编码,fromCharCode表示把ASCII码转换为字符串形式。

可以通过以下代码将字符串变成字节数组。

1
2
3
4
5
6
7
function stringToByte(str) {
var byteArr = [];
for (var i = 0; i < str.length; i++) {
byteArr.push(str.charCodeAt(i));
}
return byteArr;
}

比如demo中的format可以用字节数组表示为[102, 111, 114, 109, 97, 116],因此代码中的format可以表示成String.fromCharCode(102, 111, 114, 109, 97, 116)。注意,fromCharCode接受的参数类型不是数组,而是可变参数类型。如果非要传一个数组,可以使用String.fromCharCode.apply(null, [102, 111, 114, 109, 97, 116])。JS的函数也是对象,可以给函数定义属性和方法,而函数本身也自带一些属性和方法。apply就是从函数的原型对象Function.prototype继承过来的方法

ASCII码混淆不仅可以用于混淆字符串,还可以用来做代码混淆。比如我们把代码str = str['replace'](/yyyy|YYYY/, this['getFullYear']());看作一个字符串:

1
2
stringToByte("str = str['replace'](/yyyy|YYYY/, this['getFullYear']());");
// [ 115, 116, 114, 32, 61, 32, 115, 116, 114, 91, 39, 114, 101, 112, 108, 97, 99, 101, 39, 93, 40, 47, 121, 121, 121, 121, 124, 89, 89, 89, 89, 47, 44, 32, 116, 104, 105, 115, 91, 39, 103, 101, 116, 70, 117, 108, 108, 89, 101, 97, 114, 39, 93, 40, 41, 41, 59 ]

然后再把这个字符串当作代码执行即可。在JS中把字符串当作代码执行的有2个方法,eval和Function。其中eval用来执行一段代码,Function用来生成一个函数。

所以我们之前的demo可以改写如下:

1
2
3
4
5
6
7
8
9
10
window['\u0044\u0061\u0074\u0065']['\u0070\u0072\u006f\u0074\u006f\u0074\u0079\u0070\u0065']['\u0066\u006f\u0072\u006d\u0061\u0074'] = function(formatStr) {
var str = formatStr;
var Week = ['\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d'];
eval(String.fromCharCode(115, 116, 114, 32, 61, 32, 115, 116, 114, 91, 39, 114, 101, 112, 108, 97, 99, 101, 39, 93, 40, 47, 121, 121, 121, 121, 124, 89, 89, 89, 89, 47, 44, 32, 116, 104, 105, 115, 91, 39, 103, 101, 116, 70, 117, 108, 108, 89, 101, 97, 114, 39, 93, 40, 41, 41, 59))
str = str['\u0072\u0065\u0070\u006c\u0061\u0063\u0065'](/MM/, (this['\u0067\u0065\u0074\u004d\u006f\u006e\u0074\u0068']() + 1) > 9 ? (this['\u0067\u0065\u0074\u004d\u006f\u006e\u0074\u0068']() + 1).toString() : '\u0030' + (this['\u0067\u0065\u0074\u004d\u006f\u006e\u0074\u0068']() + 1));
str = str['\u0072\u0065\u0070\u006c\u0061\u0063\u0065'](/dd|DD/, this['\u0067\u0065\u0074\u0044\u0061\u0074\u0065']() > 9 ? this['\u0067\u0065\u0074\u0044\u0061\u0074\u0065']().toString() : '\u0030' + this['\u0067\u0065\u0074\u0044\u0061\u0074\u0065']());
return str;
}

console.log(new window['\u0044\u0061\u0074\u0065']()['\u0066\u006f\u0072\u006d\u0061\u0074']('\u0079\u0079\u0079\u0079\u002d\u004d\u004d\u002d\u0064\u0064'));
字符串常量加密

字符串加密的最核心思想是先把字符串加密得到密文,然后在使用之前,调用对应的函数去解密,得到明文。代码中仅仅出现解密函数和明密文。当然也可以使用不同的加密方法去加密字符串,然后再调用不同的解密函数去解密。字符串加密最简单的方式是Base64编码。

浏览器中自带Base64的编码与解码的函数,其中btoa用来编码,atob用来解码。但是实际的应用中最好是自己去实现,然后加以混淆。注意,字符串加密之后,需要把对应的解码函数放入其中,方能正常运转。比如开头的demo可以改写如下:

1
2
3
4
5
6
7
8
9
10
window[atob('RGF0ZQ==')][atob('cHJvdG90eXBl')][atob('Zm9ybWF0')] = function(formatStr) {
var str = formatStr;
var Week = ['\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d'];
str = str[atob('cmVwbGFjZQ==')](/yyyy|YYYY/, this[atob('Z2V0RnVsbFllYXI=')]());
str = str[atob('cmVwbGFjZQ==')](/MM/, (this[atob('Z2V0TW9udGg=')]() + 1) > 9 ? (this[atob('Z2V0TW9udGg=')]() + 1).toString() : atob('MA==') + (this[atob('Z2V0TW9udGg=')]() + 1));
str = str[atob('cmVwbGFjZQ==')](/dd|DD/, this[atob('Z2V0RGF0ZQ==')]() > 9 ? this[atob('Z2V0RGF0ZQ==')]().toString() : atob('MA==') + this[atob('Z2V0RGF0ZQ==')]());
return str;
}

console.log(new window[atob('RGF0ZQ==')]()[atob('Zm9ybWF0')](atob('eXl5eS1NTS1kZA==')));

在实际的混淆应用中,标识符必须处理成没有语义的,不然很容易定位到关键代码。此外,建议减少使用系统函数,自己去实现相应的函数,因为不管怎样混淆,最终执行过程中,系统函数是固定的,通过Hook技术很容易定位到关键代码。

数值常量加密

算法加密过程中,会使用一些固定的数值常量,如MD5中的常量0×67452301,0xefcdab89,0x98badcfe和0x10325476,以及SHA1中的常量0x67452301, 0xefcdab89,0x98badcte,0x10325476和0xc3d2e1f0。因此,在标准算法逆向中,会通过搜索这些数值量,来定位代码关键位置,或者确定使用的是哪个算法。当然,在代码中不一定会写十六进制形式,如0x67452301,在代码中可能会与成十进制的1732584193。 安全起见,可以把这些数值常量也进行简单加密。
可以利用位异或的特性来加密。例如,如果a^b=c,那么c^b=a。以SHA1算法中的0xc3d2e1f0常量为例,0xc3d2elf0^0x12345678=0xd1e6b788,那么在代码中可以用0xd1e66788^0x12345678来代替0xc3d2e1f0,其中0x12345678可以理解成密钥,它可以随机生成。

增加JS逆向者的工作量

数组混淆

看一行代码:

1
console.log(new window.Date().getTime());

按照前面介绍的对象属性的2种访问方式,使用第二种改写如下:

1
console['log'](new window['Date']()['getTime']());

这样产生了三个字符串,我们把三个字符串放在数组里面。

1
2
var bigArr = ['Date', 'getTime', 'log'];
console[bigArr[2]](new window[bigArr[0]]()[bigArr[1]]());

这就是数组混淆。当代码有上千行,那么数组可以提取的字符串可能也有上千个,然后在代码中引用字符串的时候,全部以bigArr[1001],bigArr[1002]这种去访问,这样更加不容易建立映射关系了。

在JS中,同一个数组可以存放各种类型,比如布尔,数值,字符串,数组,对象和函数等。因此可以把代码中的一部分函数提取到大数组中去。为了安全,通常对提取到数组中的字符串进行加密处理,把代码处理成字符串就可以进行加密了。比如,对于String.fromCharCode可以改写成:

1
""["constructor"]["fromCharCode"]

最前面的表示任意字符串对象或者空字符串,constructor表示构造方法,这样""["constructor"]就相当于String。前面的demo处理成数组混淆的形式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var bigArr = [
'\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'MA==', 'Z2V0RGF0ZQ==', 'RGF0ZQ==', 'Zm9ybWF0', 'eXl5eS1NTS1kZA==', ""['constructor']['fromCharCode']
];

window[atob('RGF0ZQ==')][atob('cHJvdG90eXBl')][atob('Zm9ybWF0')] = function(formatStr) {
var str = formatStr;
var Week = [bigArr[0], bigArr[1], bigArr[2], bigArr[3], bigArr[4], bigArr[5], bigArr[6]];
eval(bigArr[14](115, 116, 114, 32, 61, 32, 115, 116, 114, 91, 39, 114, 101, 112, 108, 97, 99, 101, 39, 93, 40, 47, 121, 121, 121, 121, 124, 89, 89, 89, 89, 47, 44, 32, 116, 104, 105, 115, 91, 39, 103, 101, 116, 70, 117, 108, 108, 89, 101, 97, 114, 39, 93, 40, 41, 41, 59));
str = str[atob(bigArr[7])](/MM/, (this[atob(bigArr[8])]() + 1) > 9 ? (this[atob(bigArr[8])]() + 1).toString() : atob(bigArr[9]) + (this[atob(bigArr[8])]() + 1));
str = str[atob(bigArr[7])](/dd|DD/, this[atob(bigArr[10])]() > 9 ? this[atob(bigArr[10])]().toString() : atob(bigArr[9]) + this[atob(bigArr[10])]());
return str;
}

console.log(new window[atob(bigArr[11])]()[atob(bigArr[12])](atob(bigArr[13])));
数组乱序

上边进行数组混淆之后,数组下标索引与数组成员是一一对应。比如引用bigArr[14]的地方,需要成员String.fromCharCode,而该数组的下标为14的成员刚好是这个方法。可以将数组成员打乱,这样在分析的时候就更加费力,然后在执行的时候,通过一个方法将打乱的数组还原,从而不影响正确的逻辑。

可以使用以下代码打乱数组:

1
2
3
4
5
6
7
8
9
10
11
12
 var bigArr = [
'\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'MA==', 'Z2V0RGF0ZQ==', 'RGF0ZQ==', 'Zm9ybWF0', 'eXl5eS1NTS1kZA==', ""['constructor']['fromCharCode']
];

(function(arr, num) {
var shuffer = function(nums) {
while (--nums) {
arr.unshift(arr.pop());
}
}
shuffer(++num);
}(bigArr, 0x20));

可以使用以下代码还原数组:

1
2
3
4
5
6
7
8
9
10
11
12
var bigArr = [
'eXl5eS1NTS1kZA==', ""['constructor']['fromCharCode'], '\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'MA==', 'Z2V0RGF0ZQ==', 'RGF0ZQ==', 'Zm9ybWF0'
];

(function(arr, num) {
var shuffer = function(nums) {
while (--nums) {
arr['push'](arr['shift']());
}
}
shuffer(++num);
}(bigArr, 0x20));

所以最前面的demo经过数组混淆和数组乱序之后,可以改写为如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var bigArr = [
'eXl5eS1NTS1kZA==', ""['constructor']['fromCharCode'], '\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'MA==', 'Z2V0RGF0ZQ==', 'RGF0ZQ==', 'Zm9ybWF0'
];

(function(arr, num) {
var shuffer = function(nums) {
while (--nums) {
arr['push'](arr['shift']());
}
}
shuffer(++num);
}(bigArr, 0x20));

window[atob('RGF0ZQ==')][atob('cHJvdG90eXBl')][atob('Zm9ybWF0')] = function(formatStr) {
var str = formatStr;
var Week = [bigArr[0], bigArr[1], bigArr[2], bigArr[3], bigArr[4], bigArr[5], bigArr[6]];
eval(bigArr[14](115, 116, 114, 32, 61, 32, 115, 116, 114, 91, 39, 114, 101, 112, 108, 97, 99, 101, 39, 93, 40, 47, 121, 121, 121, 121, 124, 89, 89, 89, 89, 47, 44, 32, 116, 104, 105, 115, 91, 39, 103, 101, 116, 70, 117, 108, 108, 89, 101, 97, 114, 39, 93, 40, 41, 41, 59));
str = str[atob(bigArr[7])](/MM/, (this[atob(bigArr[8])]() + 1) > 9 ? (this[atob(bigArr[8])]() + 1).toString() : atob(bigArr[9]) + (this[atob(bigArr[8])]() + 1));
str = str[atob(bigArr[7])](/dd|DD/, this[atob(bigArr[10])]() > 9 ? this[atob(bigArr[10])]().toString() : atob(bigArr[9]) + this[atob(bigArr[10])]());
return str;
}

console.log(new window[atob(bigArr[11])]()[atob(bigArr[12])](atob(bigArr[13])));
花指令

所谓花指令,就是添加一些没有意义却可以混淆视听的代码。以前面提到的demo中的某一行代码为例:

1
str = str.replace(/MM/, (this.getMonth() + 1) > 9 ? (this.getMonth() + 1).toString() : '0' + (this.getMonth() + 1));

把this.getMonth() + 1这个二项式改写为:

1
2
3
4
function _0x20abefx1(a, b) {
return a + b;
}
// str = str.replace(/MM/, (_0x20abefx1(new Date().getMonth(), 1)) > 9 ? (_0x20abefx1(new Date().getMonth(), 1)).toString() : '0' + (_0x20abefx1(new Date().getMonth(), 1)));

本质是把一个二项式拆成三个部分,最左边和最右边的参数,中间的运算符可以封装成一个函数,这个函数没有什么意义,但是能瞬间增加代码量,从而增加JS你逆向者的工作量。

二项式转成函数时,还可以多级嵌套:

1
2
3
4
5
6
7
function _0x20abefx1(a, b) {
return a + b;
}

function _0x20abefx2(a, b) {
return _0x20abefx1(a, b);
}

具有同样功能的二项式,可以调用不同的函数,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
function _0x20abefx1(a, b) {
return a + b;
}

function _0x20abefx2(a, b) {
return a + b;
}

function _0x20abefx3(a, b) {
return _0x20abefx1(a, b);
}

// str = str.replace(/MM/, (_0x20abefx1(new Date().getMonth(), 1)) > 9 ? (_0x20abefx2(new Date().getMonth(), 1)).toString() : '0' + (_0x20abefx3(new Date().getMonth(), 1)));

除了二项式转为函数可以使用花指令,函数调用表达式也可以处理成类似的花指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function _0x20abefx1(a, b, c) {
return a(b, c);
}

function _0x20abefx2(a, b, c) {
return _0x20abefx1(a, b, c);
}

function _0x20abefx3(a, b) {
return a + b;
}

str = str.replace(
/MM/,
_0x20abefx2(_0x20abefx3, new Date().getMonth(), 1) > 9 ? (_0x20abefx2(_0x20abefx3, new Date().getMonth(), 1)).toString() : _0x20abefx2(_0x20abefx3, '0', (_0x20abefx2(_0x20abefx3, new Date().getMonth(), 1)))
);
jsfuck

jsfuck可以看作一种编码方式,可以把JS代码通过只用(),[],+,!这6种字符表示的代码,并且可以正常阅读。比如数值常量8可以表示成:

1
(!+[]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+[])

[]表示空数组,+[]表示把空数组转换为数值然后进行运行,由于空数组是0,所以+[]表示数值0,!+[]表示对数值0取反为布尔值true。

JS中有七种值为false,其余都为true。这7种值为false, undefine, null, 0, -0, NaN和””。所以!![]为true。

JS中的+运算符作为一个二元运算符时,假如有一边是字符串则代表字符串拼接,否则代表数值相加。所以true + true = 2。

在实际的开发中,jsfuck的应用有限。只会应用于js文件中的一部分代码。主要它的代码量非常庞大,且易于还原,只需要将代码复制到console即可还原。在jsfuck的混淆中,通过用()来进行分组,如果我们遇到JS文件局部应用jsfuck的代码,可以先通过()将代码分组,然后逐组逐组的分析还原。

代码执行流程的防护原理

流程平坦化

在流程平坦化混淆中,会用到switch语句,因为switch语句中的case块是平级的,而且调换case块的前后顺序并不影响代码原先的执行逻辑。看一段代码:

1
2
3
4
5
6
7
8
9
function test() {
var a = 100;
var b = a + 200;
var c = b + 300;
var d = c + 400;
var e = d + 500;
var f = e + 600;
return f;
}

混淆test方法的执行流程为:首先把代码分块,且打乱代码块的顺序,分别添加到不同的case块中,方便起见,这里处理场一行代码对应一个case块的形式,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
switch () {
case '1':
var c = b + 300;
case '2':
var e = d + 500;
case '3':
var d = c + 400;
case '4':
var f = e + 600;
case '5':
var b = a + 200;
case '6':
return f;
case '7':
var a = 100;
}

可以看到,当代码块打乱之后,如果想跟原先的执行顺序一样,那么case块的跳转顺序应该是7,5,1,3,2,4,6。只有case块按照这个流程执行,才能跟原始代码块的顺序保持一致。其次,需要一个循环,因为switch语句只计算一次switch表达式。整个代码改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
while (!![]) {
switch () {
case '1':
var c = b + 300;
continue;
case '2':
var e = d + 500;
continue;
case '3':
var d = c + 400;
continue;
case '4':
var f = e + 600;
continue;
case '5':
var b = a + 200;
continue;
case '6':
return f;
case '7':
var a = 100;
}
break;
}

这是一个死循环,假如函数有返回值,则执行到相应的case语句块后直接返回。假如函数没有返回值,则代码块执行到最后就需要让switch计算出来的表达式的值与每一个case的值都不匹配,那么就会执行最后的break来跳出循环。

接着我们需要构造一个分发器,里面记录代码块执行的真实顺序,例如var arrStr = ‘7|5|1|3|2|4|6’.split(‘|’),i=0。把这个字符串’7|5|1|3|2|4|6’通过split分割成一个数组。i作为计数器,每次递增,按顺序引用数组中的每一个成员。因此,switch中的表达式就可以写成switch(arrStr[i++]),完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function test() {
var arrStr = '7|5|1|3|2|4|6'.split('|'),i=0;
while (!![]) {
switch (arrStr[i++]) {
case '1':
var c = b + 300;
continue;
case '2':
var e = d + 500;
continue;
case '3':
var d = c + 400;
continue;
case '4':
var f = e + 600;
continue;
case '5':
var b = a + 200;
continue;
case '6':
return f;
case '7':
var a = 100;
}
break;
}
}

如果函数没有返回值,即switch中没有return语句,最后一次递增i会导致数组越界,JS中数组越界不会报错,而是取出来为undefined,然后匹配不到任何的switch语句就会执行break跳出死循环。

在了解了流程平坦化之后,我们可以对之前的demo进一步混淆:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
var bigArr = [
'eXl5eS1NTS1kZA==', ""['constructor']['fromCharCode'], '\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'MA==', 'Z2V0RGF0ZQ==', 'RGF0ZQ==', 'Zm9ybWF0'
];

(function(arr, num) {
var shuffer = function(nums) {
while (--nums) {
arr['push'](arr['shift']());
}
}
shuffer(++num);
}(bigArr, 0x20));

function _0x20abefx1(a, b, c) {
return a(b, c);
}

function _0x20abefx2(a, b, c) {
return _0x20abefx1(a, b, c);
}

function _0x20abefx3(a, b) {
return a + b;
}

window[atob('RGF0ZQ==')][atob('cHJvdG90eXBl')][atob('Zm9ybWF0')] = function(formatStr) {
var arrStr = '7|5|1|3|2|4'.split("|"), i = 0;
while (!![]) {
switch (arrStr[i++]) {
case '1':
eval(bigArr[14](115, 116, 114, 32, 61, 32, 115, 116, 114, 91, 39, 114, 101, 112, 108, 97, 99, 101, 39, 93, 40, 47, 121, 121, 121, 121, 124, 89, 89, 89, 89, 47, 44, 32, 116, 104, 105, 115, 91, 39, 103, 101, 116, 70, 117, 108, 108, 89, 101, 97, 114, 39, 93, 40, 41, 41, 59));
continue;
case '2':
str = str[atob(bigArr[7])](
/dd|DD/,
this[atob(bigArr[10])] > 9 ? this[atob(bigArr[10])]().toString() : _0x20abefx2(_0x20abefx3, atob(bigArr[9]), this[atob(bigArr[10])]())
);
continue;
case '3':
str = str[atob(bigArr[7])](
/MM/,
_0x20abefx2(_0x20abefx3, this[atob(bigArr[8])](), 1) > 9 ? (_0x20abefx2(_0x20abefx3, this[atob(bigArr[8])](), 1)).toString() : _0x20abefx2(_0x20abefx3, atob(bigArr[9]), (_0x20abefx2(_0x20abefx3, this[atob(bigArr[8])](), 1)))
);
continue;
case '4':
return str;
case '5':
var Week = [bigArr[0], bigArr[1], bigArr[2], bigArr[3], bigArr[4], bigArr[5], bigArr[6]];
continue;
case '7':
var \u0073\u0074\u0072 = \u0066\u006f\u0072\u006d\u0061\u0074\u0053\u0074\u0072;
continue;
}
break;
}
return str;
}

console.log(new window[atob(bigArr[11])]()[atob(bigArr[12])](atob(bigArr[13])));
逗号表达式混淆

逗号运算符的主要作用是把多个表达式或语句连接成一个复合语句。比如前面提到的test方法,可以改写为:

1
2
3
4
function test() {
var a, b, c, d, e, f;
return a = 100, b = a + 200, c = b + 300, d = c + 400, e = d + 500, f = e + 600, f;
}

return语句通常只跟一个语句,但是逗号表达式可以把多个语句符合成一个语句,这样会依次执行前面的语句,然后把最后一条语句作为返回值。

再看一个例子:

1
2
var a = (a = 100, a += 200)
console.log(a); // 300

括号会作为一个整体,先把括号里面的运算完,然后把这个整体的值赋值给a。明白了这个道理,我们再改写下test方法:

1
2
3
4
function test() {
var a, b, c, d, e, f;
return f = (e = (d = (c = (b = (a = 100, a + 200), b + 300), c + 400), d + 500), e + 600)
}

可以进一步优化,这里声明了一系列的变量,可以把这些变量作为参数传入,同时,在每一个变量的赋值的逗号表达式中可以插入花指令:

1
2
3
function test(a, b, c, d, e, f) {
return f = (e = (d = (c = (b = (a = 100, b + 1000, c + 2000, d + 3000, e + 4000, f + 5000, a + 200), c + 2000, d + 3000, e + 4000, f + 5000, b + 300), d + 3000, e + 4000, f + 5000, c + 400), e + 4000, f + 5000, d + 500), f + 5000, e + 600)
}

虽然需要6个参数,但是实际上不传任何参数,依然是正确的代码逻辑。同时中间的花指令,b+1000,c+2000,d+3000,e+4000,f+5000是没有意义的。

image-20220308150018181
image-20220308150018181

我们对前面给的demo进行改写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
var bigArr = [
'eXl5eS1NTS1kZA==', ""['constructor']['fromCharCode'], '\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'MA==', 'Z2V0RGF0ZQ==', 'RGF0ZQ==', 'Zm9ybWF0'
];

(function(arr, num) {
var shuffer = function(nums) {
while (--nums) {
arr['push'](arr['shift']());
}
}
shuffer(++num);
}(bigArr, 0x20));

window[atob('RGF0ZQ==')][atob('cHJvdG90eXBl')][atob('Zm9ybWF0')] = function(formatStr, str, Week) {
return str = (
(str = (
Week = (
\u0073\u0074\u0072 = \u0066\u006f\u0072\u006d\u0061\u0074\u0053\u0074\u0072,
[bigArr[0], bigArr[1], bigArr[2], bigArr[3], bigArr[4], bigArr[5], bigArr[6]]
),
eval(bigArr[14](115, 116, 114, 32, 61, 32, 115, 116, 114, 91, 39, 114, 101, 112, 108, 97, 99, 101, 39, 93, 40, 47, 121, 121, 121, 121, 124, 89, 89, 89, 89, 47, 44, 32, 116, 104, 105, 115, 91, 39, 103, 101, 116, 70, 117, 108, 108, 89, 101, 97, 114, 39, 93, 40, 41, 41, 59)),
str[atob(bigArr[7])](/MM/, (this[atob(bigArr[8])]() + 1) > 9 ? (this[atob(bigArr[8])]() + 1).toString() : atob(bigArr[9]) + (this[atob(bigArr[8])]() + 1))
),
str[atob(bigArr[7])](/dd|DD/, this[atob(bigArr[10])]() > 9 ? this[atob(bigArr[10])]().toString() : atob(bigArr[9]) + this[atob(bigArr[10])]())
)
)
}

console.log(new window[atob(bigArr[11])]()[atob(bigArr[12])](atob(bigArr[13])));

其它代码防护方案

eval加密

看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
eval(function (p, a, c, k, e, r) {
e = function (c) {
return c.toString(36)
};
if ('0'.replace(0, e) == 0) {
while (c--)
r[e(c)] = k[c];
k = [function (e) {
return r[e] || e
}
];
e = function () {
return '[2-8a-f]'
};
c = 1
};
while (c--)
if (k[c])
p = p.replace(new RegExp('\\b' + e(c) + '\\b', 'g'), k[c]);
return p
}('7.prototype.8=function(a){b 2=a;b Week=[\'日\',\'一\',\'二\',\'三\',\'四\',\'五\',\'六\'];2=2.4(/c|YYYY/,3.getFullYear());2=2.4(/d/,(3.5()+1)>9?(3.5()+1).e():\'0\'+(3.5()+1));2=2.4(/f|DD/,3.6()>9?3.6().e():\'0\'+3.6());return 2};console.log(new 7().8(\'c-d-f\'));', [], 16, '||str|this|replace|getMonth|getDate|Date|format||formatStr|var|yyyy|MM|toString|dd'.split('|'), 0, {}))

传给eval的是一个匿名函数,而不是一个字符串,这就是说先通过匿名函数将加密的代码解密成字符串代码,然后再通过eval之心这串代码。所以eval加密跟eval关系不大,重要的是这个解密函数,eval只是执行解密后的结果,并不参与加解密。

通过解密函数解密出来的字符串代码为:

image-20220308153442661
image-20220308153442661

通过eval执行解密出来的字符串结果为:

image-20220308153523029
image-20220308153523029
内存爆破

内存爆破是指在代码中加入死代码,正常情况下这段代码不执行。当检测到函数被格式化或者函数被Hook的时候,就跳转到这段代码执行,直到内存溢出,浏览器会提示Out of Memory程序奔溃。内存爆破的代码如下:

1
2
3
4
5
6
7
var d = [0x1, 0x0, 0x0];
function b() {
for(var i = 0x0, c = d.length; i < c; i++) {
d.push(Math.round(Math.round()));
c = d.length;
}
}

上述代码中的for循环是一个死循环,但是代码写的又不像 while(true) 这样明显。尤其是代码混淆以后,会更具迷惑性。这段代码其实是从某网站简化而来,原先的代码如下:

1
2
3
4
5
6
7
8
9
10
this['NsTJKl'] = [0x1, 0x0, 0x0]
......
0x4b1809['prototype']['xTDWoN'] = function(_0x597ca7) {
for (var _0x3e27c4 = 0x0, _0x192434 = this['NsTJKl']['length']; _0x3e27c4 < _0x192434; _0x3e27c4++) {
this['NsTJKl']['push'](Math['round'](Math['random']()))
_0x192434 = this['NsTJKl']['length']
}
return _0x597ca7(this['NsTJKl'][0x0])
}

for循环的结束条件是 _03e27c4 < _0x92434,其中 _0x192434 的初始化值是数组的大小。看着像是一个遍历数组的操作,是在循环中,又往数组中push了成员,接着又重新给 _0x192434 赋值为数组的大小。这时这段代码就永远也不会结束了,直到内存溢出。

检测代码是否被格式化

检测的思路很简单,在JS中,函数是可以转为字符串的。因此可以选择一个函数转为字符串,然后跟内置的字符串对比或者用正则匹配。函数转为字符串很简单:

1
2
3
4
5
6
function add (a, b) {
return a + b
}
console.log(add + '')
console.log(add.toString())

在调试窗口使用格式化之后,会产生一个后缀为:formatted的文件。之后这个文件中设置断点,触发断点后,会停在这个文件中,选中这个函数,鼠标悬停在上面,会显示出他原来没有格式化之前的样子。