一、业务安全常见场景

  • 登录注册

撞库攻击,注册机批量注册

  • 活动秒杀

刷单、羊毛党泛滥

  • 点赞发帖

广告屠版、恶意灌水、刷票

  • 数据保护

自动机、爬虫盗取网页内容和数据

二、业务安全问题根源

前端代码暴露、逻辑可见、可读(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
前端持久化就是要将数据永久的保存在前端,让数据难以删除或者删除后能够重新恢复。存储的数据可以理解为是一种 僵尸数据。

  • 原理

将数据写入浏览器各个维度,获取的时候再从各个维度中读出来,无论用户怎样清洗,只要其中一个维度有数据就可以得到数据。

  • 特点

    1. 存储的维度非常多,用户很难清理
    2. 取数据的时候会将已经清除的数据重新恢复,名副其实的僵尸cookie
  • 存储维度

    1. 标准HTTP Cookie
    2. Flash Cookie
    3. localStorage
    4. sessionStorage
    5. globalStorage
    6. openDatabase
    7. IndexedDB
    8. 图片缓存数据存储
    9. ETag存储
    10. Silvelright
    11. java应用程序本地存储
    12. IE的userData存储
    13. window.name 存储
    14. a 标签历史访问状态存储
    15. HSTS存储

设备指纹标记

频限(建设指纹池)方式来防御、类似于IP,比IP更有效

pow工作量证明

消耗机器攻击成本从而让攻击得不偿失,基于POW(proof of work)原理,通过服务端下发问题消耗前端的计算量。对于有足够空余CPU资源的普通用户少量的计算并不消耗成本,而攻击者需要达到批量攻击的效果则会占用极大的计算资源,让攻击得不偿失。摘自

七、蜜罐

顾名思义,引诱黑客攻击的陷阱就是蜜罐。从广义上看,蜜罐并不具体指某种技术,而是一种思想。正常用户不会触发,黑产/破解者触发后就将其记录下来,形成一个指纹黑名单。
举几个例子:

  • 在代码中放段注释,加个同域url,为了增加迷惑性,可base64编码一下
  • 滑动轨迹,采集滑动点(x, y, t),最后一个点是特殊点,是之前所有点的平均值
  • 滑动拼图,是否拉取底图

八、总结

  • 单一对抗往往达不到很好的效果,需要结合多种对抗方式,才能有效抵御攻击
  • 安全和体验是相对的,要权衡双方给业务带来的影响和收益,避免过度、极端,反而得不偿失
  • 攻击者也在不断训练跟“成长”,开发者要未雨绸缪,努力打造可信前端环境

九、参考文档


要成为wp专家
19 声望3 粉丝

坚持不一定可贵,找到适合自己的才是王道!