一、常量的混淆原理
示例代码:为Date原型对象添加format方法
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.对象属性的两种访问方式
function People(name) {
this.name = name;
}
People.prototype.sayHello = function () {
console.log('Hello');
}
var p = new People('xiaojianbang');
console.log(p.name); //xiaojianbang 第一种访问方式
p.sayHello(); //Hello
console.log(p['name']); //xiaojianbang 第二种访问方式
p['sayHello'](); //Hello
-
p.name这种方式,name是一个标识符,必须明确出现在代码中,不能加密和拼接; -
p['name']这种方式中,name是一个字符串,既然是字符串,访问的时候就可以进行拼接和加密。在JS混淆中,一般会选择用这种方式来访问属性。
访问对象的方法也一样。因为对象的方法可以看作特殊的属性,它是一种值为函数的属性。第一段示例代码可以转换为如下形式:
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'));
//输出结果 2020-07-04
Date方法是全局对象的属性或方法,可以省略全局对象名。
new window.Date() 等同于 new Date()
这里由于要把Date变成字符串,因此前面就必须加window
注:
在JavaScript中,new window.Date和new Date都是创建Date对象的方式,但它们实际上是不同的对象。
window.Date是全局对象window的属性,它指向内置的Date构造函数。而new Date则是直接调用Date构造函数创建一个新的Date对象。
尽管它们都是用于创建Date对象的方式,但是它们在内存中是不同的对象实例。因此,new window.Date和new Date是不相等的,它们的比较结果是false。
可以使用===运算符来比较两个对象是否相等。例如,new window.Date === new Date将会返回false,因为它们是不同的对象实例。
console.log(new window.Date === new Date); // false
console.log(new window.Date == new Date); // false
如果您想比较两个Date对象的值是否相等,可以使用getTime()方法将日期对象转换为时间戳,然后进行比较。例如:
console.log(new window.Date().getTime() === new Date().getTime()); // true
这样可以比较两个日期对象的时间戳是否相等,从而判断它们的值是否相等。
2.十六进制字符串
改变对象属性的访问方式后,代码的可读性仍然较高,需要继续复杂化处理。
因为JS中的字符串支持以十六进制形式表示,所以可以用十六进制形式代替原有的字符串。
可以使用以下代码,完成十六进制字符串的转换:
function hexEnc(code) {
for (var hexStr = [], i = 0, s; i < code.length; i++) {
s = code.charCodeAt(i).toString(16);
hexStr += "\\x" + s;
}
return hexStr
}
在JS中,charAt方法用来去除字符串中对应索引的字符。而charCodeAt方法用来去除字符串中对应索引的字符的ASCII码。然后用toString(16)转换成16进制,再与\x拼接。为了方便理解,这里只处理一个字符串,代码转换为:
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']('\x79\x79\x79\x79\x2d\x4d\x4d\x2d\x64\x64'));
//输出结果 2020-07-04
这种混淆方式很容易被还原,不会大量应用,只用在无法加密的字符串上。
十六进制字符串的还原方法很简单,把字符串放在控制台中输出即可。
3.Unicode字符串
在JS中,字符串除了可以表示成十六进制的形式以外,还支持用Unicode形式表示。
unicode转换代码:
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;
}
处理后:
Date.prototype.\u0066\u006f\u0072\u006d\u0061\u0074 = function(formatStr) {
var \u0073\u0074\u0072 = \u0066\u006f\u0072\u006d\u0061\u0074\u0053\u0074\u0072;
var Week = ['\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d'];
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 \u0077\u0069\u006e\u0064\u006f\u0077['\u0044\u0061\u0074\u0065']()['format']('\x79\x79\x79\x79\x2d\x4d\x4d\x2d\x64\x64') );
//输出结果 2020-07-04
在实际的JS混淆应用中,标识符一般不会替换成Unicode形式,因为还原是否容易。通常的混淆方式是替换成没有语义,但看上去十分相似的名字,如_0x21dd83、_0x21dd84和_0x21dd85,或是由大写字母O、小写字母o以及数字0组成的名字,如Oo00Oo0、Oo00O0o、oO000Oo,注意,标识符不允许以数字开头。后面将介绍如何使用AST实现标识符混淆。
最后,介绍Unicode字符串还原的方法。与16进制字符串一样,把字符串放在控制台中输出即可。
4.字符串的ASCII码混淆
为了完成字符串的ASCII码混淆,这里需要使用两个函数,一个是String对象下的charCodeAt方法,另一个是String类下的fromCharCode方法。先介绍这两个方法的用法。在控制台执行以下代码:
console.log('x'.charCodeAt(0)); //120
console.log('b'.charCodeAt(0)); //98
console.log(String.fromCharCode(120, 98)); //"xb"
这两个方法是相反的两个过程。
-
String.fromCharCode接收的是可变长度的数值类型的参数,
-
.charCodeAt是把字符转换成ASCII码
console.log('x'.charCodeAt(0)); (0)有什么含义,不加0可以吗?
在JavaScript中,字符串的charCodeAt方法用于获取指定索引位置处字符的Unicode编码。
在给定的代码中,.charCodeAt(0)表示获取字符串中索引为0的字符的Unicode编码。索引从0开始,所以0表示第一个字符。
在这个例子中,字符串'x'只有一个字符,它的索引为0。因此,.charCodeAt(0)将返回字符'x'的Unicode编码。
如果不加0,即使用.charCodeAt()而不是.charCodeAt(0),它将默认使用索引0,因为这是该方法的默认行为。因此,两者是等价的
console.log('xb'.charCodeAt()); // 120
console.log('xb'.charCodeAt(0)); // 120
console.log('xb'.charCodeAt(1)); // 98
使用以下代码,把一个字符串转换成字节数组:
function stringToByte(str) {
var byteArr = [];
for (var i = 0; i < str.length; i++) {
byteArr.push(str.charCodeAt(i));
}
return byteArr;
}
console.log(stringToByte('xiaojianbang'));
// [120, 105, 97, 111, 106, 105, 97, 110, 98, 97, 110, 103]
使用String.fromCharCode转换回去:
String.fromCharCode(120, 105, 97, 111, 106, 105, 97, 110, 98, 97, 110, 103)
// 'xiaojianbang'
数组转换String.fromCharCode.apply:
注意:这里的apply是函数本身的方法,从Function.prototype继承来的。它允许你在调用一个函数时,将一个数组或类数组对象作为参数传递给该函数。这里,.apply(null, [120, 105, 97, 111, 106, 105, 97, 110, 98, 97, 110, 103])将数组[120, 105, 97, 111, 106, 105, 97, 110, 98, 97, 110, 103]中的元素作为参数传递给String.fromCharCode函数。
String.fromCharCode.apply(null, [120, 105, 97, 111, 106, 105, 97, 110, 98, 97, 110, 103])
// 'xiaojianbang'
ASCII码不仅用来做字符串混淆,还可以用来做代码混淆。
// str = str['replace'](/yyyy|YYYY/, this['getFullYear']());
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]
这段字节数组通过String.fromCharCode转换成字符串,
再通过eavl执行,或Function生成一个函数
var str = "str = str['replace'](/yyyy|YYYY/, this['getFullYear']());";
var func = new Function(str);
func();
示例代码:
Date.prototype.\u0066\u006f\u0072\u006d\u0061\u0074 = function(formatStr) {
var \u0073\u0074\u0072 = \u0066\u006f\u0072\u006d\u0061\u0074\u0053\u0074\u0072;
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['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 \u0077\u0069\u006e\u0064\u006f\u0077['\u0044\u0061\u0074\u0065']()[String.fromCharCode(102, 111, 114, 109, 97, 116)]('\x79\x79\x79\x79\x2d\x4d\x4d\x2d\x64\x64') );
//输出结果 2023-08-29
5.字符串常量加密
字符串常量加密的核心思想是,先把字符串加密得到密文,然后再使用前,调用对应的解密函数去久解密,得到明文。代码中仅出现解密函数和密文。当然,也可以使用不同的加密方法去加密字符串,再调用不同的解密函数去解密。本节将代码中剩下的字符都处理完,字符串加密方式采用最简单的Base64编码。
btoa("replace")
'cmVwbGFjZQ=='
btoa("getMonth")
'Z2V0TW9udGg='
btoa("getDate")
'Z2V0RGF0ZQ=='
btoa("0")
'MA=='
btoa("toString")
'dG9TdHJpbmc='
浏览器自带的Base64编码和接码的函数,其中btoa用来编码,atob用来解密。实际的混淆中,编码解码方式多为自定义,字符串加密后,需要把对应的解密函数也放入代码中,才能正常运行。
处理后的代码为:
Date.prototype.\u0066\u006f\u0072\u006d\u0061\u0074 = function(formatStr) {
var \u0073\u0074\u0072 = \u0066\u006f\u0072\u006d\u0061\u0074\u0053\u0074\u0072;
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[atob('cmVwbGFjZQ==')](/MM/, (this[atob('Z2V0TW9udGg=')]() + 1) > 9 ? (this[atob('Z2V0TW9udGg=')]() + 1)[atob('dG9TdHJpbmc=')]() : atob('MA==') + (this[atob('Z2V0TW9udGg=')]() + 1));
str = str[atob('cmVwbGFjZQ==')](/dd|DD/, this[atob('Z2V0RGF0ZQ==')]() > 9 ? this[atob('Z2V0RGF0ZQ==')]()[atob('dG9TdHJpbmc=')]() : atob('MA==') + this[atob('Z2V0RGF0ZQ==')]());
return str;
}
console.log( new \u0077\u0069\u006e\u0064\u006f\u0077['\u0044\u0061\u0074\u0065']()[String.fromCharCode(102, 111, 114, 109, 97, 116)]('\x79\x79\x79\x79\x2d\x4d\x4d\x2d\x64\x64') );
//输出结果 2020-07-04
在实际混淆应用中,标识符必须处理成没有语义的,不然很容易就定位到关键代码。此外,建议减少使用系统自带的函数,自己去实现相应的函数。因为不管如何混淆,最终执行过程中,系统函数的名字是固定的,通过hook极易定位到关键代码。
6.数值常量加密
算法加密过程中,会使用到一些固定的数值常量。比如,MD5 中的常量 0x67452301,0xefcdab89,0x98badcfe,0x10325476,SHA1 中的常量 0x67452301,0xefcdab89,0x98badcfe,0x10325476,0xc3d2elf0。因此,在标准算法逆向中,经常会通过搜索这些数值常量,来定位代码关键位置,或者确定使用的是哪个算法。当然,在代码中不一定会写十六进制形式比如,0x67452301,在代码成可能会写成十进制的 1732584193。安全起见,可以把这些数值常量也简单加密下。
可以使用位异或的特性来加密。比如,a^b = c,那么c^b = a。以 SHA1 算法中的0xc3d2elf0常量为例,0xc3d2e1f0^0x12345678 = 0xdle6b788 , 那么在代码中可以用0xdle6b788^0x12345678 来代替 0xc3d2elf0,其中0x12345678 可以理解成密钥,可以随机生成。
混淆方案并不一定是单一使用,各种方案之间也可以结合使用。比如,上述方法中两数进行位异或,实际上就是一个二项式。那么,就可以结合 6.2.3 小节中介绍的,把二项式变为函数多级嵌套的花指令。
二、增加JS逆向者的工作量
1.数组混淆
将所有的字符串都提取到一个数组中,然后再需要引用字符串的地方,全部以数组下标的方式访问数组成员:
var bigArr = ['Date', 'getTime', 'log'];
console[bigArr[2]](new window[bigArr[0]]()[bigArr[1]]());
//console.log( new window.Date().getTime() )
JS语法灵活,同一个数组中,可以同时存放各种类型,例如:
var bigArr = [
true,
'xiaojianbang',
1000,
[100, 200, 300], {
name: 'xiaojianbang',
money: 0
},
function () {
console.log('Hello')
}
];
console.log(bigArr[0]); //true
console.log(bigArr[1]); //xiaojianbang
console.log(bigArr[2]); //1000
console.log(bigArr[3][0]); //100
console.log(bigArr[4].money); //0
console.log(bigArr[5]()); //Hello
因此,可以把代码中的一部分函数提取到大数组中。如String.fromCharCode就可以提取到大数组中。为了安全,通常会对提取到数组中的字符串进行加密处理,把代码处理成字符串就可以进行加密了。对于这个函数,改写为一下形式:
console.log(""['constructor']['fromCharCode'](120)) // 输出x
// 等同于 String.fromCharCode(120)
// ""空字符串的['constructor'],原型对象,即String
处理后的代码:
var bigArr = [
'\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94',
'\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'dG9TdHJpbmc=',
'Z2V0RGF0ZQ==', 'MA==', ""['constructor']['fromCharCode']
];
Date.prototype.\u0066\u006f\u0072\u006d\u0061\u0074 = function(formatStr) {
var \u0073\u0074\u0072 = \u0066\u006f\u0072\u006d\u0061\u0074\u0053\u0074\u0072;
var Week = [bigArr[0], bigArr[1], bigArr[2], bigArr[3], bigArr[4], bigArr[5], bigArr[6]];
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[atob(bigArr[7])](/MM/, (this[atob(bigArr[8])]() + 1) > 9 ? (this[atob(bigArr[8])]() + 1)[atob(bigArr[9])]() : atob(bigArr[11]) + (this[atob(bigArr[8])]() + 1));
str = str[atob(bigArr[7])](/dd|DD/, this[atob(bigArr[10])]() > 9 ? this[atob(bigArr[10])]()[atob(bigArr[9])]() : atob(bigArr[11]) + this[atob(bigArr[10])]());
return str;
}
console.log( new \u0077\u0069\u006e\u0064\u006f\u0077['\u0044\u0061\u0074\u0065']()[bigArr[12](102, 111, 114, 109, 97, 116)]('\x79\x79\x79\x79\x2d\x4d\x4d\x2d\x64\x64') );
//输出结果 2020-07-04
2.数组乱序
在数组混淆的基础上,把大数组的顺序打乱,这样下标就不能一一与数组对应;代码运行前,再执行恢复顺序的函数。
可以使用以下代码打乱数组顺序:
var bigArr = [
'\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94',
'\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'dG9TdHJpbmc=',
'Z2V0RGF0ZQ==', 'MA==', ""['constructor']['fromCharCode']
];
(function(arr, num){
var shuffer = function(nums){
while(--nums){
arr.unshift(arr.pop());
}
};
shuffer(++num);
}(bigArr, 0x20));
console.log( bigArr );
//["cmVwbGFjZQ==", "Z2V0TW9udGg=", "dG9TdHJpbmc=", "Z2V0RGF0ZQ==", "MA==", f, "日", "一", "二", "三", "四", "五", "六"]
//console.log( bigArr[5](120) ); //输出 x
在这段代码中,有一个自执行的匿名函数。实参部分传入的是数组和一个任意数值。在这个函数内部,通过对数组进行弹出和压入操作来打乱顺序。除此之外,只要控制台输出,Unicode处理后的字符串就变成原来的中文。这就是之前说的十六进制字符串和Unicode都很容易还原。
还原数组顺序的方法:
var bigArr = [
'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'dG9TdHJpbmc=', 'Z2V0RGF0ZQ==',
'MA==', ""['constructor']['fromCharCode'], '\u65e5', '\u4e00',
'\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d'
];
(function(arr, num){
var shuffer = function(nums){
while(--nums){
arr['push'](arr['shift']());
}
};
shuffer(++num);
}(bigArr, 0x20));
Date.prototype.\u0066\u006f\u0072\u006d\u0061\u0074 = function(formatStr) {
var \u0073\u0074\u0072 = \u0066\u006f\u0072\u006d\u0061\u0074\u0053\u0074\u0072;
var Week = [bigArr[0], bigArr[1], bigArr[2], bigArr[3], bigArr[4], bigArr[5], bigArr[6]];
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[atob(bigArr[7])](/MM/, (this[atob(bigArr[8])]() + 1) > 9 ? (this[atob(bigArr[8])]() + 1)[atob(bigArr[9])]() : atob(bigArr[11]) + (this[atob(bigArr[8])]() + 1));
str = str[atob(bigArr[7])](/dd|DD/, this[atob(bigArr[10])]() > 9 ? this[atob(bigArr[10])]()[atob(bigArr[9])]() : atob(bigArr[11]) + this[atob(bigArr[10])]());
return str;
}
console.log( new \u0077\u0069\u006e\u0064\u006f\u0077['\u0044\u0061\u0074\u0065']()[bigArr[12](102, 111, 114, 109, 97, 116)]('\x79\x79\x79\x79\x2d\x4d\x4d\x2d\x64\x64') );
//输出结果 2020-07-04
3.花指令
把this.getMonth()+1这个二项式改为如下形式:
function _0x20ab1fxe1(a, b){
return a + b;
}
//_0x20ab1fxe1(this.getMonth(), 1);
_0x20ab1fxe1(new Date().getMonth(), 1); //输出7
//为了能够在控制台正常运行,把this改成new Date()
本质是把二项式拆开成三部分:二项式的左边、二项式的右边和运算符。二项式的左边和右边作为另一个函数的两个参数,二项式的运算符作为该函数的运行逻辑。这个函数本身是没有意义的,但它能瞬间增加代码量,从而增加JS逆向者的工作量。
二项式转变为函数式,进行多级嵌套,代码如下:
function _0x20ab1fxe2(a, b){
return a + b;
}
function _0x20ab1fxe1(a, b){
return _0x20ab1fxe2(a, b);
}
_0x20ab1fxe1(new Date().getMonth(), 1); //输出7
如把'0'+(this.getMonth()+1)这个二项式改为如下所示代码:
function _0x20ab1fxe2(a, b){
return a + b;
}
function _0x20ab1fxe1(a, b){
return _0x20ab1fxe2(a, b);
}
function _0x20ab1fxe3(a, b){
return a + b;
}
function _0x20ab1fxe4(a, b){
return _0x20ab1fxe3(a, b);
}
_0x20ab1fxe4('0', _0x20ab1fxe1(new Date().getMonth(), 1));
//输出 "07"
4.jsfuck
jsfuck也可以算是一种编码。它能把js代码转化成只用6个字符就可以表示的代码。并可以正常执行。这六个字符分别是:(、+、!、[、]、)
转换后的JS代码难以月度,可作为简单的保密措施,如数值常量8转成jsfuck后为:
[!+[]+!+[]+!+[]+!+[]+!+[]+!+[]+!+[]+!+[]]+[]
它使用6个特定字符取到了js中的undefined、true、false、NaN等关键字的字符,并将他们组装成了这个匿名函数。
这几个字符如果你贴到控制台,会输出“false”字符串,其实有用的是前面的 ![],后面的+[]只是将false转换为字符串。
那么这个时候,如果我们再能够使用jsfuck的6个特定字符取到字符串“false”的下标,那么我们就取到对应的特定字符了。
至于为什么等于他们会等于0和1,这里提示一下大家,请把第一个+看做正数的符号,然后就会执行Js的强制类型转换。
到这个时候,我们既有字符串,又有了下标,取对应的字符就方便了。
所以上面那个jsfuck表达式,最终只是一个根据下标取字符串特定字符而已。
而我们最开始的jsfuck示例,正是由这一个个的字符,一个个的下标拼装而成的。26个英文字母都可以使用js的关键字取到。
JsFuck解密网站:CoderTab - JSUnFuck - Decode JSFuck Here
JsFuck加密网站:JSFuck - Write any JavaScript with 6 Characters: []()!+
三、代码执行流程的防护原理
前面的混淆过程并没有改变执行流程
1.流程平坦化
在流程平坦化混淆中,会用到switch语句,因为switch语句中的case块是评级的,而且调换case块的前后顺序并不影响代码原先的执行逻辑。
为了方便理解,这里举一个简单的例子,代码如下:
function test1(){
var a = 1000;
var b = a + 2000;
var c = b + 3000;
var d = c + 4000;
var e = d + 5000;
var f = e + 6000;
return f;
}
console.log( test1() );
//输出 21000
混淆test1函数中的代码执行流程为:首先把代码分块,且打乱代码块的顺序,分别添加到不同的case块中。方便起见,就处理成一行代码对应一个case块的形式,代码如下:
function test1(){
case '1':
var c = b + 3000;
case '2':
var e = d + 5000;
case '3':
var d = c + 4000;
case '4':
var f = e + 6000;
case '5':
var b = a + 2000;
case '6':
return f;
case '7':
var a = 1000;
}
当代码块打乱顺序后,如果想要跟原先的执行顺序一样,那么case块的跳转顺序应该是7,5,1,3,2,4,6,只有case块按照这个顺序执行,才能跟原执行顺序保持一致。
其次,需要一个循环。因为switch语句只计算一次switch表达式,它的执行流程如下:
- 计算一次switch表达式;
- 把表达式的值与每个case的值进行对比(这里是
===的匹配,不转换类型); - 如果存在匹配,则执行对应case块。
因此,代码可以改写成如下形式:
while(!![]){
switch(){
case '1':
var c = b + 3000;
continue;
//每执行一次case块中的代码,就跳到循环末尾,继续下一次循环
case '2':
var e = d + 5000;
continue;
case '3':
var d = c + 4000;
continue;
case '4':
var f = e + 6000;
continue;
case '5':
var b = a + 2000;
continue;
case '6':
return f;
continue;
case '7':
var a = 1000;
continue;
}
break
//当switch计算出来的表达式的值与每个case的值都不匹配时,代码就会运行到这里,再跳出循环
}
这是一个死循环,所以需要一个边界条件来结束循环。假如函数有 return 语句,那么执行到对应的 case 块后,会直接返回。假如函数没有 return 语句,代码执行到最后,就需要让 switch 计算出来的表达式的值与每个 case 的值都不匹配,那么就会执行最后的 break 来跳出循环。
在这个案例里,return 语句后面的 continue 语是不会被执行的,但留着不影响代码运行。假如这是一段由 AST 自动处理出来的代码,这样做更具通用性,不需要考虑函数的最后一条语句是否是 return 语句。
最后,需要构造一个分发器,里面记录了代码执行的真实顺序。例如,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++])。完整代码如下所示:
function test2(){
var arrStr = '7|5|1|3|2|4|6'.split('|'), i = 0;
while (!![]) {
switch(arrStr[i++]){
case '1':
var c = b + 3000;
continue;
case '2':
var e = d + 5000;
continue;
case '3':
var d = c + 4000;
continue;
case '4':
var f = e + 6000;
continue;
case '5':
var b = a + 2000;
continue;
case '6':
return f;
continue;
case '7':
var a = 1000;
continue;
}
break;
}
}
console.log( test2() );
//输出 21000
可以debugger查看下过程:

再来解释 switch(arrStr[i++])的作用。i的初始值为0,会先取到 arrStr[o],然后i增加 1,再取到 arrStr[1],以此类推。假如函数有 return 语句,执行到最后一个 case 块时,函数返回,循环也退出了。假如函数没有 return 语句,当i一直递增到数组越界时,就会取到undefined(JS 中访问数组越界不会报错),然后执行最后的 break 跳出循环。在理解了简单的案例后,就可以对 上一节中的代码做进一步混淆,处理后的代码如下:
//最开始的大数组
var bigArr = [
'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'dG9TdHJpbmc=', 'Z2V0RGF0ZQ==', 'MA==',
""['constructor']['fromCharCode'], '\u65e5', '\u4e00', '\u4e8c',
'\u4e09', '\u56db', '\u4e94', '\u516d'
];
//还原数组顺序的自执行函数
(function(arr, num){
var shuffer = function(nums){
while(--nums){
arr['push'](arr['shift']());
}
};
shuffer(++num);
}(bigArr,0x20));
//本小节处理的switch流程平坦化
Date.prototype.\u0066\u006f\u0072\u006d\u0061\u0074 = function(formatStr) {
var arrStr = '7|5|1|3|2|4'.split('|'), i = 0;
while (!![]) {
switch(arrStr[i++]){
case '1':
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));
continue;
case '2':
str = str[atob(bigArr[7])](/dd|DD/, this[atob(bigArr[10])]() > 9 ? this[atob(bigArr[10])]()[atob(bigArr[9])]() : atob(bigArr[11]) + this[atob(bigArr[10])]());
continue;
case '3':
str = str[atob(bigArr[7])](/MM/, (this[atob(bigArr[8])]() + 1) > 9 ? (this[atob(bigArr[8])]() + 1)[atob(bigArr[9])]() : atob(bigArr[11]) + (this[atob(bigArr[8])]() + 1));
continue;
case '4':
return str;
continue;
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;
}
}
console.log( new \u0077\u0069\u006e\u0064\u006f\u0077['\u0044\u0061\u0074\u0065']()[bigArr[12](102, 111, 114, 109, 97, 116)]('\x79\x79\x79\x79\x2d\x4d\x4d\x2d\x64\x64') );
//输出结果 2020-07-04
JS语法比较灵活,case后面跟的值可以是字符串/字符,也可以是数值,还可以是对象或者数组。
2.逗号表达式混淆
逗号运算符的主要作用是把多个表达式或语句连接成一个复合语句。
逗号运算符主要用于在一行中执行多个操作,而只返回最后一个操作的结果,但逗号前面的表达式依然会执行。
流程平坦化小节中的test1函数,就等价于:
function test1(){
var a, b, c, d, e, f;
return a = 1000,
b = a + 2000,
c = b + 3000,
d = c + 4000,
e = d + 5000,
f = e + 6000,
f
}
console.log( test1() );
//输出 21000
return 语句后通常只能限一个表达式,它会返回这个表达式计算后的结果。但是逗号运算符可以把多个表达式连接成一个复合语句。
因此上述代码中,return 语句的使用也是没有问题的,它会返回最后一个表达式计算后的结果,但是前面的表达式依然会执行。
上案例只是单纯的连接语句,没有混淆力度。下面再介绍一个案例,代码如下:
var a = (a = 1000, a += 2000);
console.log( a);
//输出???
第一行代码中,括号代表这是一个整体,也就是把(a=1000,a十2000)整体赋值给变量。这个整体返回的结果和 return 语句是一样的,会先执行 a=1000,然后执行a+=2000,再把结果赋值给a 变量,最终 a变量的值为 3000。
明白了上述原理后,再介绍逗号运算符的混淆,以本节中的 testl 函数为例,处理思路
如下:
- 执行 a=1000,再执行 a+2000,代码可以改为(a=1000,a+2000)。
- 接着赋值给 b,代码可以改为 b=(a=1000,a+2000)。
- 执行 b+3000,代码可以改为(b=(a=1000,a+2000),b+3000)。
- 接着赋值给 c,代码可以改为 c=(b=(a=1000,a+2000),b+3000)。
- 执行c+4000,代码可以改为(c=(b=(a=1000,a+2000),b+3000),c+4000)。
以此类推。
处理后的代码为:
function test2(){
var a, b, c, d, e, f;
return f = (e = (d = (c = (b = (a = 1000, a + 2000), b + 3000), c + 4000), d + 5000), e + 6000);
}
console.log( test2() );
//输出 21000
这段代码有一个声明一系列变量的语句。这个语句很多余,可以放到参数列表上,这样就不需要 var 声明了。另外,既然逗号运算符连接多个表达式,只会返回最后一个表达式i算后的结果,那么可以在最后一个表达式之前插入不影响结果的花指令。
最终处理后的代码:
function test2(a, b, c, d, e, f){
return f = (e = (d = (c = (b = (a = 1000, a + 50, b + 60, c + 70, a + 2000), d + 80, b + 3000), e + 90, c + 4000), f + 100 ,d + 5000), e + 6000);
}
console.log( test2() );
// 输出 21000
上述代码中 a+50、b+60、c+70、d+80、e+90、f+100 这些花指令并无实际意义,不影响有原先的代码逻辑。test2虽有6个参数,但是不传参也可以调用,只不过各参数的初始值为undefined。
逗号表达式混淆不仅能处理赋值表达式,还能处理调用表达式、成员表达式等。考虑下面这个案例:
var obj = {
name: 'xiaojianbang',
add: function(a, b){
return a + b;
}
}
function sub(a, b){
return a - b;
}
function test(){
var a = 1000;
var b = sub(a,3000) + 1;
var c = b + obj.add(b, 2000);
return c + obj.name
}
test 函数中有函数调用表达式 sub,还有成员表达式 obj.add 等,可以使用以下两种方法对其进行处理。
- 提升变量声明到参数中。
-
b=(a=1000,sub)(a,3000)+1中的(a=1000,sub)可以整体返回 sub 函数,然后直接调用,计算的结果加 1后赋值给 b(等号的运算符优先级很低)。同理,如果 sub 函数改为obj.add 的话,可以处理成(a=1000,obj.add)(a,3000)或者(a=1000,obj).add(a, 3000)。
第2种方法是调用表达式在等号右边的情况。例如 test 函数中的第 3 条语句里面的b+obj.add(b,2000),可以对 obj.add 进行包装,处理成 b+(0,obj.add)(b,2000)或者 b+(0,obj).add(b,2000),括号中的0可以是其他花指令。
综上所述,上述案例中的代码,可以处理成如下形式:
var obj = {
name: 'xiaojianbang',
add: function(a, b){
return a + b;
}
}
function sub(a, b){
return a - b;
}
function test() {
return c = (b = (a = 1000, sub)(a, 3000) + 1, b + (0, obj).add(b, 2000)),
c + (0, obj).name;
}
在理解了简单的案例后,就可以对原先的代码进行更深一步的混淆:
//最开始的大数组
var bigArr = [
'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'dG9TdHJpbmc=', 'Z2V0RGF0ZQ==', 'MA==',
""['constructor']['fromCharCode'], '\u65e5', '\u4e00', '\u4e8c',
'\u4e09', '\u56db', '\u4e94', '\u516d'
];
//还原数组顺序的自执行函数
(function(arr, num){
var shuffer = function(nums){
while(--nums){
arr['push'](arr['shift']());
}
};
shuffer(++num);
}(bigArr,0x20));
//本小节处理的代码
//把原先的变量定义提取到参数列表中
Date.prototype.\u0066\u006f\u0072\u006d\u0061\u0074 = function(formatStr, str, Week) {
//因为基本上都会处理成一行代码,所以return语句可以提到最上面
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]]
//上面这个表达式的结果,会赋值给Week
),
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[atob(bigArr[7])](/MM/, (this[atob(bigArr[8])]() + 1) > 9 ? (this[atob(bigArr[8])]() + 1)[atob(bigArr[9])]() : atob(bigArr[11]) + (this[atob(bigArr[8])]() + 1))
//上面这个表达式的结果,会赋值给第二个str
),
str[atob(bigArr[7])](/dd|DD/, this[atob(bigArr[10])]() > 9 ? this[atob(bigArr[10])]()[atob(bigArr[9])]() : atob(bigArr[11]) + this[atob(bigArr[10])]())
//上面这个表达式的结果,会赋值给第一个str
);
}
console.log( new \u0077\u0069\u006e\u0064\u006f\u0077['\u0044\u0061\u0074\u0065']()[bigArr[12](102, 111, 114, 109, 97, 116)]('\x79\x79\x79\x79\x2d\x4d\x4d\x2d\x64\x64') );
//输出结果 2020-07-04
最后介绍逗号表达式混淆的还原技巧。在逗号表达式混淆中,通常需要使用括号来分组,定位到最里面的那个括号,一般就是第一条语句。然后从里到外,一层层地根据括号对应关系,还原语句顺序。
如果用 AST 还原逗号表达式混淆,就不用这么麻烦地找对应关系,几行代码就可以解决问题。
四、其他代码防护方案
1.eval加密
将这段代码进行eval加密:
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'));
在线加密工具:javascript eval加密解密-1json.com
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()函数,它用来把一段字符串当作 JS 代码来执行。也就是说,给 eval()的参数是一段字符串,但在上述代码中,传给 eval()函数的参数是一个自执行匿名函数。这说明,这个匿名雨数执行后会返回一段字符串,并且用 eval()执行这段字串,执行效果与 eval 加密前的代码效果等同。那就可以把这个匿名函数理解成是一个解密函数了。由此可见,eval 加密其实和 eval()关系不大,eval()只是用来执行解密出来的代码。
再来观察传给这个匿名雨数的实参部分。观察第 1个实参 和第 4个实参 k。可以看出处理方式很简单,提取原始代码中的一部分标识符,然后用它自己的符号占位,最后再
对应替换回去就解密了。
最后介绍 eval 解密。这个比较容易,既然这个自执行的匿名函数就是解密函数,把述代码中的 eval删去,剩余代码在控制台中执行,就得到原始代码。
2.内存爆破
内存爆破是在代码中加人死代码,正常情况下这段代码不执行,当检测到函数被格式化或者函数被 Hook,就跳转到这段代码并执行,直到内存溢出,浏览器会提示 Out of Memory程序崩溃。
3.检测代码是否格式化
检测的思路很简单,在JS中,函数是可以转为字符串的。因此可以选择一个函数转为字符串,然后跟内置的字符串对比或者用正则匹配。
函数转为字符串很简单,代码如下:
function add(a, b){return a + b;}
console.log( add +"");
console.log( add.toString() );
// "function add(a, b){return a + b;)"
在Chrome 开发者工具中,把代码格式化后,会产生一个后缀为:formatted 的文件。后这个文件中设置断点,触发断点后,会停在这个文件中。但是,这时把某个函数转为字符串,取到的依然是格式化之前的代码。
在算法逆向中,分析完算法,为了得到想要的结果,就需要实现这个算法。简单的算法一般可以直接调用现成的加密库。复杂的算法就会选择直接修改原文件,然后运行得到结果。把格式化后的代码保存成一个本地文件,这时某个函数转为字符串,取到的就是格式化后的结果了。
是否触发格式化检测,关键是看原文件中是否有格式化。检测到格式化就跳转到内存爆破代码中执行,程序会崩溃。
小结
混淆的目的是增加逆向开发者的工作量。例如,原本一小时就解决的算法,混淆后可能需要几天才能解决。当算法每天更新,逆向开发者自然就放弃了。目前市面上已有此类方案,只不过变化的算法仅限于微调,如算法中的常量、算法加密前的参数顺序等。如果要实现此类方案,需要一种自动化处理代码的方案,AST 为此而生。