目录
一、常量的混淆原理
示例代码:
Date.prototype.format = function (formatStr) {
let str = formatStr;
let Week = ['日', '一', '二', '三', '四', '五', '六'];
str = str.replace(/yyyy|YYYY/, this.getFullYear());
// console.log(str);
str = str.replace(/MM/, (this.getMonth() + 1) > 9 ? (this.getMonth() + 1)
.toString() : '0' + (this.getMonth() + 1));
// console.log(str);
str = str.replace(/dd|DD/, this.getDate() > 9 ? this.getDate().toString() : '0' + this.getDate());
// console.log(str);
return str;
}
// console.log(new Date()); // 2022-04-25T05:25:36.856Z
console.log(new Date().format('yyyy-MM-dd')); //2024-05-01
console.log(new Date().getFullYear()) //2024
console.log(new Date().getMonth()) //4 当前月份为结果+1
//输出结果 2022-04-25
上面的代码用于格式化时间,这段代码整体上来讲逻辑简单清晰,即在 Date 的原型对象上,增加了一个 format 方法,当实例化一个 Date 对象后,就可以直接调用从 Date 原型对象上继承过来的 format 方法。上面代码没有经过任何处理,任何查看脚本的开发者 (js0基础的不算,嘿嘿) 都可以清楚地理解本段代码内容,假如这是某网站开发人员编写的一段关键代码,那么在代码发布后,很容易被第三方破解利用,从而引发安全问题,因此学习 JS 代码的防护技术就显得格外重要了。
ps:学习 JavaScript 混淆原理是非常有必要的,原因:
- 学好 AST 混淆和还原 JavaScript 代码的基础
- 招聘要求,越来越多的招聘要求爬虫人员懂 JavaScript 防护技术(逆向是越来越卷了)
1.1 对象属性的两种访问方式
示例代码:
let person = {
'name': 'amo',
'age': 18,
'address': '重庆市红鼎国际',
eat: function () {
console.log(this.name + '~eating')
}
}
// ①: person.name,name是一个标识符,必须明确出现在代码中,不能进行加密与拼接
console.log(person.name)
person.eat()
// ②: person['name'],该种方式name是一个字符串,既然是字符串,访问的时候就可以进行加密与拼接
// 在js混淆中一般会选择用这种方式来访问属性,操作空间更大
console.log(person['name'])
person['eat']()
访问对象的方法也可以通过 []
的方式,因为对象的方法可以看作特殊的属性,它是一种值为函数的属性。将 一、常量的混淆原理
中的示例代码可以转换为如下形式:
let window = globalThis;
// Date.prototype.format = function (formatStr) {
Date['prototype']['format'] = function (formatStr) {
let str = formatStr;
let Week = ['日', '一', '二', '三', '四', '五', '六'];
// str = str.replace(/yyyy|YYYY/, this.getFullYear());
str = str['replace'](/yyyy|YYYY/, this['getFullYear']());
// console.log(str);
// str = str.replace(/MM/, (this.getMonth() + 1) > 9 ? (this.getMonth() + 1)
// .toString() : '0' + (this.getMonth() + 1));
str = str['replace'](/MM/, (this['getMonth']() + 1) > 9 ? (this['getMonth']() + 1)
['toString']() : '0' + (this['getMonth']() + 1));
// console.log(str);
// str = str.replace(/dd|DD/, this.getDate() > 9 ? this.getDate().toString() : '0' + this.getDate());
str = str['replace'](/dd|DD/, this['getDate']() > 9 ? this['getDate']()['toString']() : '0'
+ this['getDate']());
// console.log(str);
return str;
}
// console.log(new Date()); // 2022-04-25T05:25:36.856Z
console.log(new window['Date']()['format']('yyyy-MM-dd')); //2024-05-01
console.log(new window['Date']()['getFullYear']()) //2024
console.log(new window['Date']()['getMonth']()) //4 当前月份为结果+1
//输出结果 2022-04-25
Date 是 JS 的内置对象,在 JS 中,很多内置对象都是 window 的属性 (上面的代码由于笔者是在 node.js 中运行的,故将 window 指向了 globalThis), 所以 JS 中的内置对象和客户端 JS 中的 DOM 对于 JS 的防护与逆向极为重要。在真实浏览器环境中,代码中定义的全局变量 (var) 都是全局对象 window 的属性,定义的全局函数都是全局对象 window 的方法,全局对象的属性或者方法在调用时,可以省略全局对象名,例如:window.btoa('a')
等同于 btoa('a')
,如果要把 btoa
变为字符串,前面就必须加 window,如上面的示例代码:new window['Date']()
1.2 十六进制字符串
改变对象属性的访问方式后,代码的阅读性仍然较高,要继续进行复杂化处理。因为 JS 中的字符串支持以十六进制形式表示,所以可以用十六进制形式代替原有的字符串。如:amo
,可以表示成 '\x61\x6d\x6f'
,其中字符 a
转换为字节,再用十六进制表示就是 61
(字符 a
的 Hex 形式的 ASCII 码 )。ps:在 JavaScript 中,使用 \x十六进制
来定义一个十六进制的字符串字面量。 可以使用以下代码,完成十六进制字符串的转换。
function charToHex(characters) {
let hexString = ''
for (let i = 0; i < characters.length; i++) {
// ①: charCodeAt()方法用来取出字符串中对应索引字符的ASCII码
// ②: toString(16)转换为十六进制
const hexCode = characters[i].charCodeAt(0).toString(16);
// ③与\x进行拼接
hexString += `\\x${hexCode}`
}
return hexString;
}
// 示例代码
const codes = 'amoGood';
const hexString = charToHex(codes);
console.log(hexString)
将 1.1 对象属性的两种访问方式
中的代码转换为如下形式:
let window = globalThis;
// Date.prototype.format = function (formatStr) {
// Date['prototype']['format'] = function (formatStr) {
Date['\x70\x72\x6f\x74\x6f\x74\x79\x70\x65']['\x66\x6f\x72\x6d\x61\x74'] = function (formatStr) {
let str = formatStr;
let Week = ['日', '一', '二', '三', '四', '五', '六'];
// str = str.replace(/yyyy|YYYY/, this.getFullYear());
// str = str['replace'](/yyyy|YYYY/, this['getFullYear']());
str = str['\x72\x65\x70\x6c\x61\x63\x65'](/yyyy|YYYY/,
this['\x67\x65\x74\x46\x75\x6c\x6c\x59\x65\x61\x72']());
// console.log(str);
// str = str.replace(/MM/, (this.getMonth() + 1) > 9 ? (this.getMonth() + 1)
// .toString() : '0' + (this.getMonth() + 1));
// str = str['replace'](/MM/, (this['getMonth']() + 1) > 9 ? (this['getMonth']() + 1)
// ['toString']() : '0' + (this['getMonth']() + 1));
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)
['\x67\x65\x74\x4d\x6f\x6e\x74\x68']() : '0' +
(this['\x67\x65\x74\x4d\x6f\x6e\x74\x68']() + 1));
// console.log(str);
// str = str.replace(/dd|DD/, this.getDate() > 9 ? this.getDate().toString() : '0' + this.getDate());
// str = str['replace'](/dd|DD/, this['getDate']() > 9 ? this['getDate']()['toString']() : '0'
// + this['getDate']());
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']()['\x74\x6f\x53\x74\x72\x69\x6e\x67']() : '0'
+ this['\x67\x65\x74\x44\x61\x74\x65']());
return str;
}
// console.log(new Date()); // 2022-04-25T05:25:36.856Z
console.log(new window['\x44\x61\x74\x65']()['\x66\x6f\x72\x6d\x61\x74']('yyyy-MM-dd')); //2024-05-01
console.log(new window['\x44\x61\x74\x65']()['\x67\x65\x74\x46\x75\x6c\x6c\x59\x65\x61\x72']()) //2024
console.log(new window['\x44\x61\x74\x65']()['\x67\x65\x74\x4d\x6f\x6e\x74\x68']()) //4 当前月份为结果+1
//输出结果 2022-04-25
这种混淆方式很容易被还原,不会大量应用,只用在无法加密的字符串上。十六进制字符串的还原方法很简单,把字符串放到控制台中输出即可。
1.3 Unicode字符串
在 JavaScript 中,可以使用 Unicode 编码来定义字符串。Unicode 编码通常以 \u
开头,后跟四位十六进制数,不足四位的补0。例如,表示字母 "A"
的 Unicode 编码是 \u0041
。以下是使用 Unicode 编码定义字符串的示例:
let unicodeString1 = '\u0061\u006d\u006f'; // 定义一个包含"amo"的字符串
console.log(unicodeString1); // 输出: amo
let unicodeString2 = '\u91cd\u5e86\u5e02\u7ea2\u9f0e\u56fd\u9645'; //定义一个包含"重庆市红鼎国际"的字符串
console.log(unicodeString2); // 输出: 重庆市红鼎国际
可以使用以下代码完成 Unicode 转换:
function charToUnicode(characters) {
let unicodeString = ''
for (let i = 0; i < characters.length; i++) {
// ①: charCodeAt()方法用来取出字符串中对应索引字符的ASCII码
// ②: toString(16)转换为十六进制
const hexCode = characters[i].charCodeAt(0).toString(16);
// ③与\x进行拼接
unicodeString += hexCode.length < 4 ? `\\u00${hexCode}` : `\\u${hexCode}`
}
return unicodeString;
}
JS 中的标识符也支持 Unicode 形式表示,因此之前代码中的 format、Week、str、formatStr、window 等都支持以 Unicode 形式表示,将 将 1.2 十六进制字符串
中的代码转换为如下形式 (只处理部分代码):
let window = globalThis;
Date['\u0070\u0072\u006f\u0074\u006f\u0074\u0079\u0070\u0065']
['\x66\x6f\x72\x6d\x61\x74'] = function (formatStr) {
let \u0073\u0074\u0072 = \u0066\u006f\u0072\u006d\u0061\u0074\u0053\u0074\u0072;
let \u0057\u0065\u0065\u006b = ['\u65e5', '\u4e00', '\u4e8c', '\u4e09',
'\u56db', '\u4e94', '\u516d'];
\u0057\u0065\u0065\u006b = \u0073\u0074\u0072['\x72\x65\x70\x6c\x61\x63\x65'](/yyyy|YYYY/,
this['\x67\x65\x74\x46\x75\x6c\x6c\x59\x65\x61\x72']());
\u0057\u0065\u0065\u006b = \u0057\u0065\u0065\u006b['\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)
['\x67\x65\x74\x4d\x6f\x6e\x74\x68']() : '0' +
(this['\x67\x65\x74\x4d\x6f\x6e\x74\x68']() + 1));
\u0057\u0065\u0065\u006b = \u0057\u0065\u0065\u006b['\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']()['\x74\x6f\x53\x74\x72\x69\x6e\x67']() : '0'
+ this['\x67\x65\x74\x44\x61\x74\x65']());
return \u0057\u0065\u0065\u006b;
}
// console.log(new Date()); // 2022-04-25T05:25:36.856Z
console.log(new \u0077\u0069\u006e\u0064\u006f\u0077
['\x44\x61\x74\x65']()['\x66\x6f\x72\x6d\x61\x74']('yyyy-MM-dd')); //2024-05-01
console.log(new window['\x44\x61\x74\x65']()['\x67\x65\x74\x46\x75\x6c\x6c\x59\x65\x61\x72']()) //2024
console.log(new window['\x44\x61\x74\x65']()['\x67\x65\x74\x4d\x6f\x6e\x74\x68']()) //4 当前月份为结果+1
//输出结果 2022-04-25
在使用 \u0073\u0074\u0072
定义变量后,依然能够使用对应的 str 来引用变量。在实际 JS 混淆应用中,标识符一般不会替换成 Unicode 形式,因为要还原它十分容易。通常的混淆方式是替换成没有语义,但看上去又很相似的名字,如:_0x278843,_0x278844
和 _0x257799
,或是由大写字母 O、小写字母 o、以及数字 0 组成的名字,Oo00Oo0、Oo00O0o 和 oO000Oo,注意标识符不允许以数字开头,与十六进制字符串一样,把字符串放到控制台中输出即可还原。
1.4 字符串的ASCII码混淆
使用以下代码将一个字符串转换为字节数组:
function stringToBytes(str) {
const encoder = new TextEncoder(); // 创建TextEncoder实例
return encoder.encode(str); // 将字符串转换为字节
}
// 使用例子
const str = "amo";
const bytes = stringToBytes(str);
console.log(bytes)
'yyyy-MM-dd'
字符串转换为字节数组是 [121,121,121,121,45,77,77,45,100,100]
,因此代码中的 'yyyy-MM-dd'
,可以表示为:
//String.fromCharCode()方法将Unicode值转换为字符 接受的是可变长度的数值类型的参数
//String.fromCharCode()方法接收的参数类型并非数组,如果想要传递数组,可以使用String.fromCharCode.apply
String.fromCharCode.apply(null,[121, 121, 121, 121, 45, 77, 77, 45, 100, 100]))
ASCII 码混淆不仅用来做字符串混淆,还可以用来做代码混淆。以下面这段代码为例:
Date.prototype.format = function (formatStr) {
let str = formatStr;
let Week = ['日', '一', '二', '三', '四', '五', '六'];
// str = str.replace(/yyyy|YYYY/, this.getFullYear());
// 字符串的ASCII码混淆 等同于上面的代码 str = str.replace(/yyyy|YYYY/, this.getFullYear());
eval(String.fromCharCode.apply(null, [
115, 116, 114, 32, 61, 32, 115, 116, 114, 46,
114, 101, 112, 108, 97, 99, 101, 40, 47, 121,
121, 121, 121, 124, 89, 89, 89, 89, 47, 44,
32, 116, 104, 105, 115, 46, 103, 101, 116, 70,
117, 108, 108, 89, 101, 97, 114, 40, 41, 41,
59
]
)) // 由于str = str.replace(/yyyy|YYYY/, this.getFullYear());变成了字符串,故执行需要依赖于eval函数
str = str.replace(/MM/, (this.getMonth() + 1) > 9 ? (this.getMonth() + 1)
.toString() : '0' + (this.getMonth() + 1));
// console.log(str);
str = str.replace(/dd|DD/, this.getDate() > 9 ? this.getDate().toString() : '0' + this.getDate());
// console.log(str);
return str;
}
// console.log(new Date()); // 2022-04-25T05:25:36.856Z
console.log(new Date().format('yyyy-MM-dd')); //2024-05-01
console.log(new Date().getFullYear()) //2024
console.log(new Date().getMonth()) //4 当前月份为结果+1
1.5 字符串常量加密
字符串常量加密的核心思想是,先把字符串加密得到密文,然后在使用前调用对应的解密去解密得到明文,代码中仅出现解密函数和密文,当然也可以使用不同的加密方法去加密字符串,再调用不同的解密函数去解密。示例代码:
Date.prototype.format = function (formatStr) {
let str = formatStr;
str = str['replace'](/yyyy|YYYY/, this['getFullYear']());
console.log(str)
}
new Date().format('yyyy'); //2024
将上述代码中的所有字符串进行加密,此处仅为了演示,故采用最简单的 Base64 编码,如下:
console.log(btoa('replace')) // cmVwbGFjZQ==
console.log(btoa('getFullYear')) // Z2V0RnVsbFllYXI=
console.log(btoa('yyyy')) // eXl5eQ==
处理后的代码为:
Date.prototype.format = function (formatStr) {
let str = formatStr;
// 字符串加密后,需要把对应的解密函数也放入代码中,才能正常运行
// btoa: 用来编码,atob: 用来解码 这里使用的是node.js中自带的,在实际的混淆应用中,还是自己实现比较好
str = str[atob('cmVwbGFjZQ==')](/yyyy|YYYY/, this[atob('Z2V0RnVsbFllYXI=')]());
console.log(str)
}
new Date().format(atob('eXl5eQ==')); //2024
在实际混淆应用中,标识符必须处理成没有语义的,不然很容易就定位到关键代码。此外,建议减少使用系统自带的函数,自己去实现相应的函数,因为不管如何混淆,最终执行过程中,系统函数的名字是固定的,通过 Hook 技术极易定位到关键代码。根据写法的不同,代码中有一些字符串常量没法加密和拼接,如以下代码:
let person = {
// 'name': 'amo', //正确写法
// '\x6e\x61\x6d\x65': 'amo', //正确写法
// '\x6e\x61\x6d\x65': 'amo', //正确写法
'\u006e\u0061\u006d\u0065': 'amo', //正确写法
// atob('bmFtZQ=='): 'amo', //直接报错
'age': 18,
'address': '重庆市红鼎国际',
eat: function () {
console.log(this.name + '~eating')
}
}
console.log(person.name)
console.log(btoa('name'))
let person2 = {}
let str = 'na'
person2[str + 'me'] = 'Amo'
console.log(person2.name) // 用这种方式给对象增加属性,属性名可以加密和拼接
1.6 数值常量加密
算法加密过程中,会使用一些固定的数值常量,如 MD5 中的常量 0x67452301、0xefcdab89、0x98badcfe 和 0x10325476,以及 sha1 中的常量 0x67452301、0xefcdab89、0x98badcfe、0x10325476 和 0xc3d2e1f0。因此,在标准算法逆向中,会通过搜索这些数值常量,来定位代码关键位置,或者确定使用的是哪个算法。当然,在代码中不一定会写十六进制形式,如 0x67452301,在代码中可能会写成十进制的 1732584193。为了安全起见,可以把这些数值常量也进行简单加密。可以利用位异或的特性来加密。例如,如果 a^b=c
,那么 c^b=a
。以 sha1 算法中的 0xc3d2e1f0 常量为例,0xc3d2e1f0^0x12345678=0xd1e6b788
,那么在代码中可以用 0xd1e6b788^0x12345678
来代替 0xc3d2e1f0,其中 0x12345678 可以理解成密钥,它可以随机生成。上述方法中两个数字进行位异或,实际上就是一个二项式。
小结:混淆方案不一定是单一使用,各种方案之间可以结合使用。
二、增加 JS 逆向者的工作量
在 一、常量的混淆原理 中介绍了一部分的混淆手段,现在我们应该对 JS 混淆有了一定的认识,但实际上只是处理了一些常量,防护力度并不高。混淆的目的是为了增加破解的难度和时间,因此本小节从这方面入手,继续介绍更加深入的内容。
2.1 数组混淆
之前的示例代码中,在改变对象属性的访问方式后,产生了很多原本没有的字符串。虽然在前面的介绍中,已经对它们做了一系列的处理,但是遇到有混淆逆向经验的逆向开发者,破解这里的混淆十分容易,本小节的方案是将所有的字符串都提取到一个数组中,然后在需要引用字符串的地方,全部都以数组下标的方式访问数组成员。例如:
let bigArr = ['Date', 'getTime', 'log'];
console[bigArr[2]](new window[bigArr[0]]()[bigArr[1]]());
console.log(new window.Date().getTime()) // 1714585619000
这里展示的代码,阅读难度已经大大增加。当代码为上千行,数组提取的字符串也有上千个。在代码中要引用字符串时,全都以 bigArr[1001]
和 bigArr[1002]
访问,就会大大增加理解难度,不容易建立对应关系。在其他静态编程语言中,同一个数组只能存放同一种类型。但是 JavaScript 语法灵活,同一个数组中,可以同时存放各种类型,如布尔值、字符串、数值、数组、对象和函数等。例如:
let bigArr = [
false,
'Amo',
1314520,
[13, 14, 520],
{'name': 'amo', 'age': 18},
function () {
console.log('hello')
}
]
console.log(bigArr[0])
console.log(bigArr[1])
console.log(bigArr[2])
console.log(bigArr[3])
console.log(bigArr[4])
bigArr[5]()
因此,可以把代码中的一部分函数以及字符串提取到大数组中。为了安全,通常会对提取到数组中的字符串进行加密处理,把代码处理成字符串就可以进行加密了。对于之前格式化日期的函数,改写为以下形式:
let window = globalThis;
let bigArr = ['\u65e5', '\u4e00', '\u4e8c', '\u4e09',
'\u56db', '\u4e94', '\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'dG9TdHJpbmc=',
'Z2V0RGF0ZQ==', 'RGF0ZQ==', ''['constructor']['fromCharCode']];
Date.\u0070\u0072\u006f\u0074\u006f\u0074\u0079\u0070\u0065['\x66\x6f\x72\x6d\x61\x74'] =
function (formatStr) {
let \u0073\u0074\u0072 = \u0066\u006f\u0072\u006d\u0061\u0074\u0053\u0074\u0072;
let Week = [bigArr[0], bigArr[1], bigArr[2], bigArr[3], bigArr[4], bigArr[5], bigArr[6]];
eval(bigArr[12][atob('YXBwbHk=')](null, [
115, 116, 114, 32, 61, 32, 115, 116, 114, 46,
114, 101, 112, 108, 97, 99, 101, 40, 47, 121,
121, 121, 121, 124, 89, 89, 89, 89, 47, 44,
32, 116, 104, 105, 115, 46, 103, 101, 116, 70,
117, 108, 108, 89, 101, 97, 114, 40, 41, 41,
59
]
))
str = str[atob(bigArr[7])](/MM/, (this[atob(bigArr[8])]() + 1) > 9 ?
(this[atob(bigArr[8])]() + 1)
[atob(bigArr[9])]() : '0' + (this[atob(bigArr[8])]() + 1));
str = str[atob(bigArr[7])](/dd|DD/, this[atob(bigArr[10])]() > 9 ?
this[atob(bigArr[10])]()[atob(bigArr[9])]() : '0'
+ this[atob(bigArr[10])]());
return str;
}
console.log(new \u0077\u0069\u006e\u0064\u006f\u0077[atob(bigArr[11])]()['\x66\x6f\x72\x6d\x61\x74']
('\u0079\u0079\u0079\u0079\u002d\u004d\u004d\u002d\u0064\u0064')); //2024-05-02
console.log(new \u0077\u0069\u006e\u0064\u006f\u0077[atob(bigArr[11])]()
['\x67\x65\x74\x46\x75\x6c\x6c\x59\x65\x61\x72']()) //2024
console.log(new \u0077\u0069\u006e\u0064\u006f\u0077[atob(bigArr[11])]()
['\x67\x65\x74\x4d\x6f\x6e\x74\x68']() + 1) //5 当前月份为结果+1
这段代码在不使用动态调试,也不使用 AST 的情况下,可读性非常差,但是 JS 代码混淆仍可继续。
2.2 数组乱序
观察 2.1 数组混淆
小节中处理后的代码,数组成员与被引用的地方是一一对应的。如引用 bigArr[12]
的地方,需要的是 String.fromCharCode
函数,而该数组中下标为 12 的成员,也是这个函数。将数组顺序打乱可以解决这个问题,不过在数组顺序混乱后,本身的代码也引用不到正确的数组成员。此处的解决方案是,在代码中内置一段还原顺序的代码。可以使用以下代码打乱数组顺序:
let bigArr = ['\u65e5', '\u4e00', '\u4e8c', '\u4e09',
'\u56db', '\u4e94', '\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'dG9TdHJpbmc=',
'Z2V0RGF0ZQ==', 'RGF0ZQ==', ''['constructor']['fromCharCode']];
(function (arr, num) {
let foo = function (nums) {
while (--nums) {
// 弹出数组的最后一个元素并将其追加到数组的首位
arr.unshift(arr.pop());
}
}
foo(++num);
})(bigArr, 0x20);
console.log(bigArr)
在这段代码中,有一个自执行的匿名函数,实参部分传入的是数组和一个任意数值,在这个函数内部通过对数组进行弹出和压入操作来打乱顺序,除此之外,只要控制台输出,Unicode 处理后的字符串就变成原来的中文,这就是之前说的十六进制字符串和 Unicode 都很容易被还原。String.fromCharCode
函数被移动到了下标为 5
的地方,但代码处引用的仍是 bigArr[12]
,所以需要把还原数组顺序的函数放入代码中,还原数组顺序的代码逆向编写即可,如下所示:
(function (arr, num) {
let foo = function (nums) {
while (--nums) {
// 移除数组的第一个元素并将其追加到数组的尾部
arr.push(arr.shift());
}
}
foo(++num);
})(bigArr, 0x20);
console.log(bigArr)
ps:还原数组顺序中的函数用到的字符串,不能再提取到 bigArr 中。
2.3 花指令
添加一些没有意义却可以混淆视听的代码,是花指令的核心。这里介绍一种比较简单的花指令实现方式,举个例子:
str = str.replace(/MM/, (this.getMonth() + 1) > 9 ? (this.getMonth() + 1)
.toString() : '0' + (this.getMonth() + 1));
把 this.getMonth() + 1
这个二项式改为如下形式:
function _0x20ab1fxe1(a, b) {
return a + b;
}
// _0x20ab1fxe1(this.getMonth(), 1)
str = str.replace(/MM/, _0x20ab1fxe1(this.getMonth(), 1) > 9 ? _0x20ab1fxe1(this.getMonth(), 1)
.toString() : '0' + _0x20ab1fxe1(this.getMonth(), 1));
本质是把二项式拆开成三部分:二项式的左边、二项式的右边和运算符。二项式的左边和右边作为另外一个函数的两个参数,二项式的运算符作为该函数的运行逻辑。这个函救本身是没有意义的,但它能瞬间增加代码量,从而增加 JavaScript 逆向者的工作量。二项式转变为函数时,进行多级嵌套,代码如下:
function _0x20ab1fxe1(a, b) {
return a + b;
}
function _0x20ab1fxe2(a, b) {
return _0x20ab1fxe1(a, b);
}
// _0x20ab1fxe2(this.getMonth(), 1)
str = str.replace(/MM/, _0x20ab1fxe2(this.getMonth(), 1) > 9 ? _0x20ab1fxe2(this.getMonth(), 1)
.toString() : '0' + _0x20ab1fxe2(this.getMonth(), 1));
这个案例较为简单,但是在实际混淆中,代码可能有几千行,函数定义部分与调用部分往往相差甚远。另外具有相同运算符的二项式,并不是一定要调用相同的函数,如把 '0'+(this.getMonth()+1)
这个二项式改为如下所示代码:
function _0x20ab1fxe1(a, b) {
return a + b;
}
function _0x20ab1fxe2(a, b) {
return _0x20ab1fxe1(a, b);
}
function _0x20ab1fxe3(a, b) {
return a + b;
}
function _0x20ab1fxe4(a, b) {
return _0x20ab1fxe3(a, b);
}
str = str.replace(/MM/, _0x20ab1fxe2(this.getMonth(), 1) > 9 ? _0x20ab1fxe2(this.getMonth(), 1)
.toString() : _0x20ab1fxe4('0', _0x20ab1fxe1(this.getMonth(), 1)));
上面介绍的是二项式转变为函数的花指令,其实函数调用表达式也可以处理成类似的花指令。代码如下:
function _0x20ab1fxe7(a, b, c) {
return a.apply(b, c);
}
str = _0x20ab1fxe7(str.replace, str, [
/MM/,
(this.getMonth() + 1) > 9 ? (this.getMonth() + 1)
.toString() : '0' + (this.getMonth() + 1)]);
花指令的生成方案,并不是只有这些。文章后续还会演示另外一种插入花指令的方式。
2.4 jsfuck
样例参考:https://jsfuck.com/
jsfuck 也可以算是一种编码,它能把 JS 代码转化成只用 6 个字符就可以表示的代码并可以正常执行,这 6 个字符分别是 (、+、!、[、]、)
。转换后的 JS 代码难以阅读,可作为简单的保密措施,如数值常量 8
转成 jsfuck 后为:
[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]][([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+([][[]]+[])[+!+[]]+(![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[+!+[]]+([][[]]+[])[+[]]+([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+(!![]+[])[+!+[]]]((!![]+[])[+!+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!+[]]+([][[]]+[])[+!+[]]+(+[![]]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+!+[]]]+(!![]+[])[!+[]+!+[]+!+[]]+(+(!+[]+!+[]+!+[]+[+!+[]]))[(!![]+[])[+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+([]+[])[([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+([][[]]+[])[+!+[]]+(![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[+!+[]]+([][[]]+[])[+[]]+([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+(!![]+[])[+!+[]]][([][[]]+[])[+!+[]]+(![]+[])[+!+[]]+((+[])[([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+([][[]]+[])[+!+[]]+(![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[+!+[]]+([][[]]+[])[+[]]+([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+(!![]+[])[+!+[]]]+[])[+!+[]+[+!+[]]]+(!![]+[])[!+[]+!+[]+!+[]]]](!+[]+!+[]+!+[]+[!+[]+!+[]])+(![]+[])[+!+[]]+(![]+[])[!+[]+!+[]])()([!+[]+!+[]+!+[]+!+[]+!+[]+!+[]+!+[]+!+[]]+[])
接下来介绍 jsfuck 的基本原理,+
是 JS 中的一个算术运算符,当它作为一元运算符使用时代表强转为数值类型,[]
在 JS 中表示空数组,因此 +[]
等于0,!+[]
等同于 !0
,JS 是一种弱类型的语言,弱类型并不是代表没有类型,是指 JS 引擎会在适当的时候,自动完成类型的隐式转换。!
是 JS 中的取反,这时需要一个布尔值,在 JS 中,七种值为假值,其余均为真值,这七种值分别是 false,undefined,null,0,-0,NaN,""
。因此,0
转换为布尔值为 false,再取反就是 true,也就是 !+[]
=== true。又如 !![]
,数组转换成布尔值为 true,然后两次取反,依旧等于 true。JS 中的 +
作为二元运算符时,假如有一边是字符串,就代表着拼接;两边都没有字符串,就代表着数值相加,true 转换为数值等于1,剩余的部分原理相同,不再赘述。在实际开发中,jsfuck 的应用有限,只会应用于 JS 文件中的一部分代码,主要原因是它的代码量非常庞大且还原它较为容易,例如,把上述代码直接输入控制台运行,就会输出 8
。一些网站之所以用它进行加密,是因为个别情况下,把整段 jsfuck 代码输入控制台运行会报错,尤其是当它跟别的代码混杂时。
ps,半淘汰加壳器系列: AAEncode、JJEncode、jsfuck,关于 AAEncode、JJEncode、jsfuck 具体的还原方式,笔者会在后续实战的文章中进行详细演示,这里就不再进行赘述。
三、代码执行流程的防护原理
经过 一、常量的混淆原理 和 二、增加 JS 逆向者的工作量 两节的处理,虽然代码已经被混淆得 面目全非
了,但是执行流程还是跟原先一样。因此,本节从代码的执行流程入手,介绍更深入的代码防护方案。
3.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());
混淆 test1 函数中的代码,代码如下:
function test3() {
// ①:构造一个分发器,里面记录了代码执行的真实顺序。并把字符串通过split分割成一个数组
var arr = 'z|t|y|u|a|d|7|c'.split('|');
var index = 0;
// ② 因为switch语句一次只能计算一次,故需要一个循环
while (!![]) {
// ③ index作为计数器,每次递增,按顺序引用数组中的每一个成员
// switch中把表达式的值与每个case的值进行对比(这里是===的匹配,不进行类型转换)
switch (arr[index++]) {
case 'a':
var e = d + 5000;
break;
case 't':
var b = a + 2000;
break;
case 'y':
var c = b + 3000;
break;
case 'd':
var f = e + 6000;
break;
case 7:
var g = 100000;
g = g + a + b + c + d;
break;
case 'c':
return f;
case 'z':
var a = 1000;
break;
case 'u':
var d = c + 4000;
break;
}
}
}
console.log(test3());
在了解了简单的案例后,对 2.1 数组混淆
一节中的代码做进一步混淆,处理后的代码如下:
let window = globalThis;
let bigArr = ['\u65e5', '\u4e00', '\u4e8c', '\u4e09',
'\u56db', '\u4e94', '\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'dG9TdHJpbmc=',
'Z2V0RGF0ZQ==', 'RGF0ZQ==', ''['constructor']['fromCharCode']];
Date.\u0070\u0072\u006f\u0074\u006f\u0074\u0079\u0070\u0065['\x66\x6f\x72\x6d\x61\x74'] =
function (formatStr) {
// 定义分发器
let arr = 'z|t|y|u|a|d|7|c'.split('|');
let index = 0;
let str = ''
while (!![]) { //需要多次计算,故使用循环
switch (arr[index++]) { // 依次引用数组中的每一个成员
case 'a':
str = str[atob(bigArr[7])](/dd|DD/, this[atob(bigArr[10])]() > 9 ?
this[atob(bigArr[10])]()[atob(bigArr[9])]() : '0'
+ this[atob(bigArr[10])]());
break;
case 't':
let Week = [bigArr[0], bigArr[1], bigArr[2], bigArr[3], bigArr[4],
bigArr[5], bigArr[6]];
break;
case 'y':
eval(bigArr[12][atob('YXBwbHk=')](null, [
115, 116, 114, 32, 61, 32, 115, 116, 114, 46,
114, 101, 112, 108, 97, 99, 101, 40, 47, 121,
121, 121, 121, 124, 89, 89, 89, 89, 47, 44,
32, 116, 104, 105, 115, 46, 103, 101, 116, 70,
117, 108, 108, 89, 101, 97, 114, 40, 41, 41,
59
]
))
break;
case 'd':
console.log('~amo')
break;
// case '7':
case 7:
console.log('~jerry')
break;
case 'c':
return str;
case 'z':
str = \u0066\u006f\u0072\u006d\u0061\u0074\u0053\u0074\u0072;
break;
case 'u':
str = str[atob(bigArr[7])](/MM/, (this[atob(bigArr[8])]() + 1) > 9 ?
(this[atob(bigArr[8])]() + 1)
[atob(bigArr[9])]() : '0' + (this[atob(bigArr[8])]() + 1));
break;
}
}
}
console.log(new \u0077\u0069\u006e\u0064\u006f\u0077[atob(bigArr[11])]()['\x66\x6f\x72\x6d\x61\x74']
('\u0079\u0079\u0079\u0079\u002d\u004d\u004d\u002d\u0064\u0064')); //2024-05-02
console.log(new \u0077\u0069\u006e\u0064\u006f\u0077[atob(bigArr[11])]()
['\x67\x65\x74\x46\x75\x6c\x6c\x59\x65\x61\x72']()) //2024
console.log(new \u0077\u0069\u006e\u0064\u006f\u0077[atob(bigArr[11])]()
['\x67\x65\x74\x4d\x6f\x6e\x74\x68']() + 1) //5 当前月份为结果+1
JS 语法比较灵活,case 后面跟的值可以是字符/字符串,也可以是数值还可以是对象或者数组。
3.2 逗号表达式混淆
逗号运算符的主要作用是把多个表达式或语句连接成一个复合语句。3.1 流程平坦化
中的 test1() 函数等价于:
function test1() {
let 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())
return 语句后通常只能跟一个表达式,它会返回这个表达式计算后的结果,但是逗号运算符可以把多个表达式连接成一个复合语句,因此上述代码中,return 语句的使用也是没有问题的,它会返回最后一个表达式计算后的结果,但是前面的表达式依然会执行。上述案例只是单纯的连接语句,没有混淆力度。下面再介绍一个案例,代码如下:
var a = (a = 1000, a += 2000); // 使用let会报错
console.log(a)
第一行代码中,括号代表这是一个整体,也就是把 (a=1000,a+=2000)
整体赋值给 a 变量,这个整体返回的结果和 return 语句是一样的,会先执行 a=1000,然后执行 a+=2000,再把结果赋值给 a 变量,最终 a 变量的值为 3000。明白了上述原理后,再介绍逗号运算符的混淆,以本节中的 test1 函数为例,处理思路如下:
// ① 执行 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() {
let 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())
这段代码有一个声明一系列变量的语句,这个语句很多余,可以放到参数列表上,这样就不需要 let
声明了。另外,既然逗号运算符连接多个表达式,只会返回最后一个表达式计算后的结果,那么可以在最后一个表达式之前插入不影响结果的花指令。最终处理后的代码如下:
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())
a + 50, b + 60, c + 70,d + 80,e + 90,f + 100
这些花指令并无实际意义,不影响原先的代码逻辑,test2() 虽有 6 个参数,但是不传参也可以调用,只不过各参数的初始值为 undefined。逗号表达式混淆不仅能处理赋值表达式,还能处理调用表达式、成员表达式等。考虑下面这个案例:
let obj = {
'name': 'amo',
add: function (a, b) {
return a + b;
}
}
function sub(a, b) {
return a - b;
}
function test() {
let a = 1000;
let b = sub(a, 3000) + 1;
let c = b + obj.add(b, 2000);
return c + obj.name
}
console.log(test());
上述案例中的代码,可以处理成如下形式:
let obj = {
'name': 'amo',
add: function (a, b) {
return a + b;
}
}
function sub(a, b) {
return a - b;
}
function test(a, b, c) {
return c = (b = (a = 1000, sub)(a, 3000) + 1, b + (0, obj).add(b, 2000)), c + (0,obj).name;
}
console.log(test());
首先提升变量声明到函数参数中,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可以是其他花指令。
最后介绍逗号表达式混淆的还原技巧,在逗号表达式混淆中,通常需要使用括号来分组,定位到最里面的那个括号,一般就是第一条语句,然后从里到外,一层层地根据括号对应关系,还原语句顺序,如果用 AST 还原逗号表达式混淆,就不用这么麻烦地找对应关系:几行代码就可以解决问题,在后续的文章中笔者会对 AST 进行详细地介绍。
四、其他代码防护方案
4.1 eval加密
加密的代码格式化后如下所示:
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个实参p和第4个实参k,可以看出处理方式很简单,提取原始代码中的一部分标识符,然后用它自己的符号占位,最后再对应替换回去就解密了,最后介绍 eval 解密,这个比较容易,既然这个自执行的匿名函数就是解密函数,把上述代码中的 eval 删去,剩余代码在控制台中执行,就得到原始代码。
4.2 内存爆破
内存爆破是在代码中加入死代码,正常情况下这段代码不执行,当检测到函数被格式化或者函数被 Hook,就跳转到这段代码并执行,直到内存溢出,浏览器会提示 Out of Memory 程序崩溃。内存爆破的代码如下所示:
let d = [0x1, 0x1, 0x1]
function b() {
for (let i = 0x0, c = d.length; i < c; i++) {
d.push(Math.round(Math.random()));
c = d.length;
}
}
这段代码中的 for 循环是一个死循环,但它的形式不像 while(true) 这样明显,尤其是代码混淆以后,更具有迷惑性,这段代码其实是从以下这段代码简化而来:
const _0x447a = ['push', 'length'];
const _0x3774 = function (_0x447aa4, _0x377412) {
_0x447aa4 = _0x447aa4 - 0x0;
let _0x2a002f = _0x447a[_0x447aa4];
return _0x2a002f;
};
let d = [0x1, 0x1, 0x1];
function b() {
for (let _0x514f9d = 0x0, _0x1c3f88 = d[_0x3774('0x1')];
_0x514f9d < _0x1c3f88; _0x514f9d++) {
d[_0x3774('0x0')](Math['round'](Math['random']()));
_0x1c3f88 = d['length'];
}
}
for 循环的结束条件是 _0x514f9d < _0x1c3f88
,其中 _0x1c3f88
的初始化值是数组的长度,看着像是一个遍历数组的操作,但是在循环中,又往数组中 push 了成员,接着又重新给 _0x1c3f88
赋值为数组的长度,这时这段代码就永远不会结束了,直到内存溢出。
4.3 检测代码是否格式化
检测的思路很简单,在 JS 中,函数是可以转为字符串的,因此可以选择一个函数转为字符串,然后跟内置的字符串对比或者用正则匹配,函数转为字符串很简单,代码如下:
function add(a, b) {
return a + b;
}
console.log(add + '');
console.log(add.toString()) // 未格式化:function add(a, b) {return a + b;}
// 格式化:
// function add(a, b) {
// return a + b;
// }
在 Chrome 开发者工具中,把代码格式化后,会产生一个后缀为 :formatted
的文件,之后在这个文件中设置断点,触发断点后,会停在这个文件中,但是,这时把某个函数转为字符串,取到的依然是格式化之前的代码。上述检测方法检测不到这种情况,那么,上述检测方法的应用场景是什么?在算法逆向中,分析完算法,为了得到想要的结果,就需要实现这个算法,简单的算法一般可以直接调用现成的加密库,复杂的算法就会选择直接修改原文件,然后运行得到结果,把格式化后的代码保存成一个本地文件,这时某个函数转为字符串,取到的就是格式化后的结果了,是否触发格式化检测,关键是看原文件中是否有格式化,接着把 4.2 内存爆破 小节中的内存爆破代码加入其中,检测到格式化就跳转到内存爆破代码中执行,程序会崩溃。