一、业务安全常见场景
- 登录注册
撞库攻击,注册机批量注册
- 活动秒杀
刷单、羊毛党泛滥
- 点赞发帖
广告屠版、恶意灌水、刷票
- 数据保护
自动机、爬虫盗取网页内容和数据
二、业务安全问题根源
前端代码暴露、逻辑可见、可读(JavaScript解释型语言)等原因导致前端不可信,一个可信前端环境对业务来说是非常重要的。
三、黑产常用破解手段
- 自动机:debug逻辑,破解协议,直接构造请求
- 模拟器:直接模拟前端环境,模拟器多开,进行操作
四、防御思路
针对自动机(防止debug逻辑)
-
代码加固
- JS混淆
- 虚拟机
- 动态脚本
针对模拟器
- 模拟器探测
- 设备指纹(频限)
- 持久化指纹
- pwa(工作量证明)
- 其他(图片分析)
通用手段
- 蜜罐
顾名思义,引诱黑客攻击的陷阱就是蜜罐。从广义上看,蜜罐并不具体指某种技术,而是一种思想。正常用户不会触发,黑产/破解者触发后就将其记录下来,形成一个指纹黑名单。
五、代码加固
JS混淆
- 为什么要混淆?
前端代码是直接暴漏在浏览器中的,很多web攻击都是通过直接debug业务逻辑找到漏洞进行攻击,另外还有些喜欢"不劳而获"的分子暴力盗取他人网页简单修改后用来获利,总体上来说就是前端的逻辑太容易读懂了。
- 压缩 vs 混淆
一些能被搜索引擎搜索到的文章会将代码压缩与混淆混为一谈,类似uglify/terser的工具能把代码压缩成可读性很低的代码,但被浏览器强大的格式化功能格式化之后,各种逻辑仍然一览无余,严格意义上说算不上混淆。代码压缩工具并不会对代码起到太多的保护作用,其作用只是缩短变量名、删减空格以及删除未被使用的代码,这些工具的目的是优化而非保护,只能"防君子而不防小人"。为了进一步保护前端代码,需要使用一些代码混淆工具。
1. 原理
对JS进行一下AST(抽象语法树)分析、修改,再重新根据AST生成JS。
2. 混淆过程
混淆过程:code --> parser --> AST --> transform --> AST --> generate --> code
AST解析常用工具:babel、esprima
AST的解析过程
- 词法分析
把js解析器能识别的最小词法单元
-
语法分析
- 语句
- 表达式
var esprima = require('esprima');
var program = 'const answer = 42';
// 词法分析
esprima.tokenize(program);
>[
{ type: 'Keyword', value: 'const' },
{ type: 'Identifier', value: 'answer' },
{ type: 'Punctuator', value: '=' },
{ type: 'Numeric', value: '42' }
]
// 语法分析
esprima.parse(program);
> Script {
type: 'Program',
body: [
VariableDeclaration {
type: 'VariableDeclaration',
declarations: [Array],
kind: 'const'
}
],
sourceType: 'script'
}
3. 混淆工具
- JsFuck:It uses only six different characters ( []!+() ) to write and execute code.
[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]]([+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[+!+[]+[+[]]]+([][[]]+[])[+!+[]]+(![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[+!+[]]+([][[]]+[])[+[]]+([][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[+!+[]+[+[]]]+(!![]+[])[+!+[]]]((![]+[])[+!+[]]+(![]+[])[!+[]+!+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]+(!![]+[])[+[]]+(![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[!+[]+!+[]+[+[]]]+[+!+[]]+(!![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[!+[]+!+[]+[+[]]])()
- aaencode
- JavaScript Obfuscator
4. 混淆规则
- 关键字提取,增加读取难度
将js里面的关键字,如字符串常量等提取出来放到数组中,调用的时候用数组下标的方式调用。
var test = "hello";
// 处理后
var _0x7deb = ['hello'];
var _0xb7de = function (_0x4c7513, _0x1cb87c) {
_0x4c7513 = _0x4c7513 - 0x0;
var _0x96ade5 = _0x7deb[_0x4c7513];
return _0x96ade5;
};
var test = _0xb7de('0x0');
-
关键字编码|加密
- 编码,进一步增加阅读难度
从上面的混淆可以看出,虽然做了关键字提取,但数组中hello还是清晰可见,为了进一步增加代码难度,还可以利用js中16进制编码会直接解码的特性将关键字的Unicode进行16进制编码。
var test = "hello";
//处理后
var _0x5f41=['\x68\x65\x6c\x6c\x6f'];(function(_0x265fed,_0x59b917){var _0x468703=function(_0x2e4674){while(--_0x2e4674){_0x265fed['push'](_0x265fed['shift']());}};_0x468703(++_0x59b917);}(_0x5f41,0xdd));var _0x15f4=function(_0x551d6e,_0x2697e4){_0x551d6e=_0x551d6e-0x0;var _0x40c0ad=_0x5f41[_0x551d6e];return _0x40c0ad;};var test=_0x15f4('0x0');
- 加密,增加手动调试难度
做了关键字提取后,如果想要破解那么必须要单步调试才可以(先忽略反AST的情况),JavaScript Obfuscator提供了两种关键字加密方式用来对抗单步调试,base64加密和rc4加密,这样处理后单步调试就会加大一些成本。
- 控制流扁平化,增加手动调试难度
JavaScript Obfuscator提供了一个控制流平展的能力,可以用控制流来控制逻辑(有限状态机),增加调试的复杂度, 这样处理的好处在于在反汇编、反编译静态分析的时候,无法判断哪些代码先执行哪些后执行,必须要通过动态运行才能记录执行顺序,从而加重了分析的负担会发现当代码量很大的时候手动debug困难就非常大了。
function testFn() {
var test = "hello";
if (test) {
test = "hello juno";
}
return test;
}
// 处理后 为了大家能看清将上面的方法都去掉了 这里只处理控制流 并且做了格式化
function testFn() {
var _0x25ac20 = {
'roscj' : 'hello',
'BjrCW' : 'hello\x20juno'
};
var _0x52a030 = _0x25ac20['roscj'];
if (_0x52a030) {
_0x52a030 = _0x25ac20['BjrCW'];
}
return _0x52a030;
}
- 废代码注入
如果增加了以上变换以及控制流难度还不够的话,JavaScript Obfuscator还提供了废代码注入的机制,可以随机注入废代码,增加手动调试难度。还可以在注释中设计蜜罐钓鱼,比如一个简单的http请求,web hook 等。
- 反debug(debugger 时间校验)
上面的思路都是在增加手动调试的难度,debug防护可以让开启控制台的用户一直卡在debugger控制台上,这里的实现思路比较暴力,一直在调用debugger,实际上可以做些时间上的控制逻辑,可以自由发挥。
- 反美化(toString CRC循环校验)
恶意在试调试代码的时候都会使用devTools的美化功能,将代码美化后进行调试,JavaScript Obfuscator针对这种情况提供了selfDefending的功能,如果美化代码整个JS会报错无法执行,原理就是一个CRC校验,不详细说了。
- 域名锁定
上面的debug防护、代码美化都是在JS里面加了控制代码实现的,如果将JS拖到本地去掉后就可以继续破解,JavaScript Obfuscator还做了一个域名锁定的功能,即判断当前域名是否是设置域名,不是就无法执行下去。
- 其他:逗号操作、eval、数字转换等
5. 对性能影响
由于增加了废代码,改变了原有的AST,所以对性能肯定会造成一定的影响,要尽量控制影响的大小可以通过一些规则来控制:
- 减少循环混淆,循环太多会直接影响代码执行效率
- 避免过多的字符串拼接,因为字符串拼接在低版本IE下面会有性能问题
- 控制代码体积,在插入废代码时应该控制插入比例,文件过大会给网络请求和代码执行都带来压力
动态对抗
- 为什么要做动态对抗?
单一脚本只能增加破解难度,普通强度的混淆可以在一段时间内保护业务逻辑,一段时间以后,代码便没那么安全了。以JavaScript Obfuscator的混淆强度,「一段时间」通常不会超过一周。如果页面承载的是一个高收益多恶意的业务,即使页面的js代码被JavaScript Obfuscator混淆过,上线一周时间后,大部分关键逻辑也可能已经被逆向出来了。关键逻辑被逆向意味着刷量工具很快会被编写出来,该业务将面临被刷的风险。
- 动态JS
尽量保证用户每时每刻拉取到的JS是不同的,配合混淆以及webpack加固,这样来打击自动机,增加破解成本,提高坏人debug的门槛。
对抗方案、变换维度
- 数据上报顺序
- 随机插入spot
- 变换加密方式
- 变换加密密钥(可变换、拆分)
- 组合加密
- 自定义上报格式
JS虚拟机
- 天然JS虚拟机:webAssembly --兼容性不好、代码膨胀率太高、开发调试等问题
- Google虚拟机:reCaptcha,后台动态输出不同的指令,让逻辑隐藏在自己设计的指令之中
主要流程
byteCode -> 解密 -> VM执行 -> byteCode -> 加密上报
核心部分
- 指令(directive):计算机执行某种操作的命令,一般由开发者自己设计
- 寄存器(register):保存当前正在执行的一条指令
- 程序计数器(PC):记录指令位置,用来确定下一条指令的地址
- 字节码(byteCode):数据的ascii码
简单示例demo
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>demo</title>
</head>
<body>
<script>
var str2arry = function(str){
return str.split("").map((item, idx)=>{
return item.charCodeAt();
})
}
var bytecode = [
110, 1, 8,
100, 111, 99, 117, 109, 101, 110, 116,//document
110, 2, 5,
119, 114, 105, 116, 101,//write
184, 1, 3,
185, 3, 2, 4,
110, 5, 15,
104, 101, 108, 108, 111, 32, 101, 118, 101, 114, 121, 111, 110, 101, 33,//hello everyone!
88, 4, 6, 2, 3, 5
]
var toString = "toString"
var set_name = "set"
// 寄存器 register
var regs = [] //reg[0] PC
var get_with_value = function (init_value) {
var func, my_value = init_value
func = function () {
return my_value
}
func[set_name] = function (value) {
my_value = value
}
return func
}
var set_reg = function (reg_num, value) { // 加入寄存器
if (regs[reg_num])
regs[reg_num][set_name](value)
else
regs[reg_num] = get_with_value(value)
}
var get_reg = function (reg_num) { // 读取寄存器
return regs[reg_num]()
}
// 指令 directive
set_reg(0, 0)
set_reg(253, function (obj) {
mov_int(obj, 1);
});
// push reg(int8) to reg(array)
set_reg(131, function (obj) {
push_int(obj, 1);
});
// mov reg, int32
set_reg(167, function (obj) {
mov_int(obj, 4);
});
// push reg(int32) to reg(array)
set_reg(84, function(obj) {
push_int(obj, 4);
});
// mov reg, int16
set_reg(198, function(obj) {
mov_int(obj, 2);
});
// mov reg, str
set_reg(110, function(obj, T, X, a, J, Z, Y, e, z, q) {
var reg_num = decrypt_next(obj), size = get_size(obj), str = "", i = 0;
if (false && void 0 != obj.regs[10]) {
var arr = obj.get_reg(10), len = arr.length;
for (; size--;) {
i = (i + get_size(obj)) % len;
console.log("index:" + i);
str += arr[i];
}
} else {
var j = 0, result = [], chr1, chr2, chr3, chr4;
i = 0, str = [];
for (; i < size; i++) {
str.push(decrypt_next());
}
for (i = 0; i < str.length;) {
chr1 = str[i++];
if (chr1 < 128) {
result[j++] = String.fromCharCode(chr1);
} else if (191 < chr1 && chr1 < 224) {
chr2 = str[i++];
result[j++] = String.fromCharCode((chr1 & 31) << 6 | chr2 & 63);
} else if (239 < chr1 && chr1 < 365) {
chr2 = str[i++];
chr3 = str[i++];
chr4 = str[i++];
chr1 = ((chr1 & 7) << 18 | (chr2 & 63) << 12 | (chr3 & 63) << 6 | chr4 & 63) - 65536;
result[j++] = String.fromCharCode(55296 + (chr1 >> 10));
result[j++] = String.fromCharCode(56320 + (chr1 & 1023));
} else {
chr2 = str[i++];
chr3 = str[i++];
result[j++] = String.fromCharCode((chr1 & 15) << 12 | (chr2 & 63) << 6 | chr3 & 63);
}
}
str = result.join("");
}
console.log(`mov reg[${reg_num}] '${str}'`);
set_reg(reg_num, str);
});
// mov reg, eval(str)
set_reg(184, function(obj, T, X) {
var eval_reg = decrypt_next(), save_reg = decrypt_next(), eval_str = get_reg(eval_reg);
console.log("mov reg[" + save_reg + "], eval('" + eval_str + "')");
set_reg(save_reg, function(str) {
return eval(str)
}(eval_str));
});
// mov reg, reg[reg]
set_reg(185, function(obj, T, X) {
var reg_0 = decrypt_next(), reg_1 = decrypt_next(), save_reg = decrypt_next();
console.log(`mov reg[${save_reg}] reg[${reg_0}][reg[${reg_1}]]`);
set_reg(save_reg, get_reg(reg_0)[get_reg(reg_1)]);
});
// mov reg, func.apply(obj, params)
set_reg(88, function (obj, T) {
var call = get_call();
console.log("mov reg[" + call.save_reg + "], " + call.func + ".apply(" + call.obj + ", [ " + call.params + " ])");
set_reg(call.save_reg, call.func.apply(call.obj, call.params));
});
var get_size = function() {
var num = decrypt_next();
// 128 == 0b10000000
if (num & 128) {
num = num & 127 | decrypt_next() << 7;
}
return num;
}
var decrypt_next = function (T, X) {
var pc = get_reg(0);
set_reg(0, pc + 1);
return bytecode[pc];
}
var mov_int = function (size) {
var num = 0, reg = decrypt_next();
for (; 0 < size; size--) {
num = num << 8 | decrypt_next();
}
set_reg(reg, num);
}
// 4, 6, 2, 3, 5
var get_call = function() {
debugger;
var call = {}, i = 0, func_reg = decrypt_next(), param_size, obj_reg;
call.save_reg = decrypt_next();
call.params = [];
param_size = decrypt_next() - 1;
obj_reg = decrypt_next();
for (; i < param_size; i++) {
call.params.push(decrypt_next());
}
call.func = get_reg(func_reg);
call.obj = get_reg(obj_reg);
for (; param_size--;) {
call.params[param_size] = get_reg(call.params[param_size]);
}
return call;
}
var run = function () {
var current_pc = 0, opcode_func = void 0, total = 500, opcode, lth;
for (lth = bytecode.length; --total && (current_pc = get_reg(0)) < lth;) {
try {
//set_reg(this_obj, 219, current_pc);
opcode = decrypt_next();
console.log('opcode', opcode), 'pc', current_pc;
opcode_func = get_reg(opcode);
if(opcode_func && opcode_func.call){
opcode_func();
}
} catch (e) {
}
}
}
run();
//reg[0] -> PC
// mov reg[1] 'document'
// mov reg[2] 'write'
// mov reg[3] eval(reg[1]) --> document
// mov reg[4] reg[3][reg[2]] --> function document.write
// mov reg[5] 'hello everyone!'
// apply reg[4](reg[5]) --save[6]
// document.write("hello everyone!")
</script>
</body>
</html>
var bytecode = [
110, 1, 8, // 指令1
100, 111, 99, 117, 109, 101, 110, 116, // 数据:'document'的ascii码
110, 2, 5, // 指令2
119, 114, 105, 116, 101, // 数据:'write'的ascii码
184, 1, 3, // 指令3
185, 3, 2, 4, // 指令4
110, 5, 15, // 指令5
104, 101, 108, 108, 111, 32, 101, 118, 101, 114, 121, 111, 110, 101, 33, // 数据:'hello everyone!'的的ascii码
88, 4, 6, 2, 3, 5 // 指令6
]
mov reg[1] 'document'
mov reg[2] 'write'
mov reg[3] eval('document') -> document对象
mov reg[4] reg[3][reg[2]] -> document.write
mov reg[5] 'hello everyone!'
mov reg[6] function write() { [native code] }.apply([object HTMLDocument], [ hello everyone! ])
六、模拟器对抗
常用的模拟器
-
无头浏览器:可以在图形界面情况下运行的浏览器,一般用作爬虫工具
- Phantomjs
- Selenium
- Puppeteer
-
群控设备
- 猫池:是一个设备,模拟成手机终端,能同时放多张卡,使运营商系统上显示这些卡为开机状态。
模拟器特征检测
userAgent、其他特征:如 window.navigator.webdriver ,nodejs环境检测
设备指纹
什么是设备指纹?
关联设备的硬件、系统、网络等信息,通过专有加密算法,赋予其全球唯一的设备标识符,即设备指纹。
设备指纹采集维度
50+
- cpu、ua、色深、字体、设备像素比、插件
- canvas指纹
- 声卡信息、声纹
- 内网ip(webRtc)
冲突率以及解决方法
冲突率在1/1000左右,前端持久化加固
前端持久化方法——evercookie
前端持久化就是要将数据永久的保存在前端,让数据难以删除或者删除后能够重新恢复。存储的数据可以理解为是一种 僵尸数据。
- 原理
将数据写入浏览器各个维度,获取的时候再从各个维度中读出来,无论用户怎样清洗,只要其中一个维度有数据就可以得到数据。
-
特点
- 存储的维度非常多,用户很难清理
- 取数据的时候会将已经清除的数据重新恢复,名副其实的僵尸cookie
-
存储维度
- 标准HTTP Cookie
- Flash Cookie
- localStorage
- sessionStorage
- globalStorage
- openDatabase
- IndexedDB
- 图片缓存数据存储
- ETag存储
- Silvelright
- java应用程序本地存储
- IE的userData存储
- window.name 存储
- a 标签历史访问状态存储
- HSTS存储
设备指纹标记
频限(建设指纹池)方式来防御、类似于IP,比IP更有效
pow工作量证明
消耗机器攻击成本从而让攻击得不偿失,基于POW(proof of work)原理,通过服务端下发问题消耗前端的计算量。对于有足够空余CPU资源的普通用户少量的计算并不消耗成本,而攻击者需要达到批量攻击的效果则会占用极大的计算资源,让攻击得不偿失。摘自
七、蜜罐
顾名思义,引诱黑客攻击的陷阱就是蜜罐。从广义上看,蜜罐并不具体指某种技术,而是一种思想。正常用户不会触发,黑产/破解者触发后就将其记录下来,形成一个指纹黑名单。
举几个例子:
- 在代码中放段注释,加个同域url,为了增加迷惑性,可base64编码一下
- 滑动轨迹,采集滑动点(x, y, t),最后一个点是特殊点,是之前所有点的平均值
- 滑动拼图,是否拉取底图
八、总结
- 单一对抗往往达不到很好的效果,需要结合多种对抗方式,才能有效抵御攻击
- 安全和体验是相对的,要权衡双方给业务带来的影响和收益,避免过度、极端,反而得不偿失
- 攻击者也在不断训练跟“成长”,开发者要未雨绸缪,努力打造可信前端环境
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。