以变制变 - 前端动态化代码保护方案探索

866 阅读14分钟
原文链接: cloud.tencent.com

本文分享了腾讯防水墙团队关于机器对抗的动态化思路,希望能抛砖引玉,给现在正在做人机对抗的团队一些启发,帮助更多中小型公司的业务摆脱机器和爬虫之痛。

0x00 前言

浏览器作为当今互联网的一大流量入口,正在变得越来越强大。为了有更好的Web体验,各类新的标准被制定并实施。PWA的出现,更是把移动端H5的体验推向了另一个极致。越来越多业务使用H5作为主要入口的同时,也带来了另一个问题:机器行为泛滥。只要有利益的地方就会有恶意,登录注册、投票领券等页面很容易成为机器刷量的重灾区,如今写一个普通刷投票脚本的难度基本就跟写一个“Hello World!”的难度差不多。在与机器对抗的历程中,Web前端一直是非常薄弱的一环。浏览器毫无保留地把所有前端代码拉取到本地并执行、所有前端代码均透明可见,拿什么拯救前端代码安全?

0x01 名词解释

代码安全

本文中所提及的代码安全,是指前端JavaScript代码的安全。通常,如果一段JavaScript代码只能在正常的浏览器中运行,无法或尚未在非正常浏览器的运行环境执行得到结果、无法被等价翻译成其他编程语言的代码,则认为这段代码是安全的。

一段重要的JavaScript逻辑被置于其他环境以高于正常浏览器几个数量级的效率运行并得到正确的结果,对于服务端及后面的业务来说,几乎是一个灾难。

数据保护

本文说的数据保护是指对HTTP/HTTPS协议上承载内容(如POST的body)的保护。HTTP协议是一个文本的协议,所有传输的内容从客户端(即浏览器)的角度看都是可见且富有语义的,这意味着内容如果不加以保护,恶意用户只需要理解内容中的各项参数,即可模拟相应的请求而无需阅读或逆向前端JavaScript代码的逻辑。注意,这里说的保护不是指TLS等传输过程的保护,而是指HTTP协议上层承载的具体数据内容的保护。

比如,正常一个查询请求的URL形如https://example.com/query?from=shenzhen&destination=beijing,爬虫开发者无需阅读JavaScript便可知道参数要如何构造。而如果请求形如https://example.com/query?params=ZnJvbT1zaGVuemhlbiZkZXN0aW5hdGlvbj1iZWlqaW5n,恶意用户在无法通过观察立即知道参数构造方法的前提下,只能阅读或需要先逆向JavaScript代码,才能知道构造参数方法。这样就达到了对数据进行保护的目的。

混淆

通过一些字符串替换规则或者抽象语法树变换规则,将一段代码等价替换成另一段可读性很差的代码,从而达到保护原有代码安全。这个过程通常是不可逆的。

如:

function foo() {    console.log('hello world!');
}
foo();

被变换成:

var a = 'console', b = 'log', c = 'hello', d = ' world!';function e() {  window[a][b](c + d);
}
e();

此时代码的可读性被降低了,这是一种很简单的混淆方式。

一些能被搜索引擎搜索到的文章会将代码压缩与混淆混为一谈,类似Uglify的工具能把代码压缩成可读性很低的代码,如下图:

但被浏览器强大的格式化功能格式化之后,各种逻辑仍然一览无余。

代码压缩工具并不会对代码起到太多的保护作用,其作用只是缩短变量名、删减空格以及删除未被使用的代码,这些工具的目的是优化而非保护,只能“防君子而不防小人”。为了进一步保护前端代码,需要使用一些代码混淆工具。

0x02 常规化方案及缺陷

1. 可逆变换保护数据

常规的数据保护方式是设计一个可逆变换函数f对数据进行变换,浏览器端提交给服务端的数据 d 经过该可逆变换函数 f 处理后得到变换后的数据 d′

d′=f(d)

d′ 提交到服务端后使用反函数 f−1 即可得到原数据d。

d=f−1(d)

如果恶意用户不知道f的运算步骤,则无法构造出合法的d′。其中 f 可以是一种数据处理算法,也可以是一种加密算法。但是,由于 f 的运算步骤是固定的,且算法最终执行在浏览器中,即使 f 是一种私有的数据处理算法,也终究会被逆向得到其运算步骤。如今nodejs已经相当成熟,在未使用任何混淆工具对 f 进行保护的前提下,恶意用户可直接从前端JavaScript代码中截取出核心逻辑,不需要太多成本便可编写出能在nodejs上运行的破解工具。

特别的,如果函数 f 中含有额外的参数 p ,运算步骤形如 d′=f(d,p) ,此时变换 p 并不能增加 f 的安全性。恶意用户在截取变换函数f的同时可以一并得到参数p,并且在未经混淆的代码里,p是很容易使用工具提取的。

因此,只有数据变换是很难很好保护数据的。那么再对代码进行混淆是否可以有效保护数据呢?

2. 对代码进行混淆保护

目前能找到的公开混淆工具并不多,常见的有:

  • jscrambler(商业)
  • JavaScript-Obfuscator(开源)

jscrambler作为一款商业软件,混淆效果好,但其付费计划较为昂贵。JavaScript-Obfuscator是目前比较热门的一款开源混淆器,但混淆效果不尽人意。为了验证JavaScript-Obfuscator混淆效果,本文以字符串混淆为例,编写了一个简单的脚本对经过JavaScript-Obfuscator混淆后的字符串进行自动化还原,代码开源请戳:https://github.com/conanliu/de-js-obfuscator

有了这个工具,逆向出字符串的成本几乎为0。其实逆向字符串相对比较简单,但这已经是个开始,逆向出逻辑是迟早的事。普通强度的混淆可以在一段时间内保护业务逻辑,一段时间以后,代码便没那么安全了。以JavaScript-Obfuscator的混淆强度,「一段时间」通常不会超过一周。如果页面承载的是一个高收益多恶意的业务,即使页面的JavaScript代码被JavaScript-Obfuscator混淆过,上线一周时间后,大部分关键逻辑也可能已经被逆向出来了。关键逻辑被逆向意味着刷量工具很快会被编写出来,该业务将面临被刷的风险。

对于一个正常的业务来说,JavaScript中数据保护相关的逻辑一个月变化一次已经相当频繁了。如果为了达到较好的抗破解需要在一周改变一次逻辑,这种对抗成本是很高的。那么有没有一种长效的机制,既能保证前端代码的安全,而又不需要付出过量的成本呢?本文后面试图从动态化的角度,探索一种新的人机对抗方式。

0x03 动态化方案介绍

如果我们有5个数据变换函数 f1,f2,f3,f4,f5,针对每次请求,我们随机挑选2个变换函数 fx 和 fy,并随机挑选一个分隔符 s ,真实数据 d 被随机拆分成 d1和 d2 ,最终数据为

d′=combine(fx(d1),s,fy(d2))

d′提交到服务端后,服务端进行切分操作得到一个二元组

(d′1,d′2)=split(d′,s)

再用fx和fy的反函数处理d′1和d′2,最终得到原始数据:

d1=f−1x(d′1)

d2=f−1y(d′2)

d=d1+d2

虽然单次破解的难度仍为t≈1week,但由于每次请求对应的算法组合均不同,单次破解后并不适用与后面的请求。因此理论上需要逆向并脚本化该逻辑的时间代价是指数级增长的,最终恶意用户因为逆向成本太高,而转向了使用起来更简单的模拟器。至于模拟器的对抗不在本文的讨论中。但可以明确的是,模拟器的对抗比自动脚本的对抗要容易一些。同时由于执行模拟器比执行自动脚本需要更多的资源,这也无形中增加的恶意的作恶成本,最终导致恶意在投入和产出中失衡。

该动态化方案虽然听起来可行,但在实际工程化中会遇到很多问题:

  1. 如何标识某次请求的函数组合?
  2. 如何权衡页面性能?
  3. 如何解决js编译速度太慢的问题?
  4. 是否需要混淆?

接下来将针对以上问题,探索如何在工程上一一解决。

0x04 工程化问题探索

1. 如何标识某次请求的函数组合?

经过随机组合后用户每次得到的js均可能不同,此时需要有一个标识告知服务端 fx 和 fy 分别是哪两个。一种可行的方案是将 x 和 y 的内容连同变换函数 fx 和 fy一起,直接明文编码到js中,提交数据时将x和y跟随d′一起提交。但这种标识容易被某些正则规则直接从js文件中提取,恶意用户可遍历出所有变换函数及其对应的逻辑,再根据匹配出的标识进行组合。

更严谨的做法是编译js文件时生成一个签名串signature,将该signature作为变量编译到js文件中。最后signature连同生成的数据d′一起提交到服务端,服务端使用f−1sig(signature)得到解密d′的关键参数,进而对d′进行解密。签名的生成算法可表示成如下形式:

signature=fsig(x,y,s,random,timestamp)

其中x为第一个变换函数的标识,y为第二个变换函数的标识,s为分隔符,random为一个随机数,timestamp为生成签名的时间戳。设计随机数的目的是让每次生成的签名均不同,而时间戳可以感知签名对应js文件的新鲜度,并且一定程度上能对重放攻击进行聚集。

2. 如何权衡页面性能?

前端页面性能是一个Web应用必然会关注的问题,一种通用而有效的性能优化方式是合理地为页面中的资源文件设置缓存。通常对于一个模块化良好且使用成熟打包工具打包的项目,入口html的缓存策略会被配置为Cache-Control: no-cache,而js/css/image等资源文件会设置一个比较长的缓存时间。但负责数据保护的js文件如果含有动态生成的逻辑,该js文件将不能再使用缓存,否则一旦缓存时间控制不当,将会引发各类数据解密失败的问题。

正常情况下在人机对抗的场景中,页面并不需要对所有的请求均做人机验证,也就是说,负责人机验证的JavaScript代码并不会被正常用户访问多次,所以在人机验证的环节,部分基于缓存的优化是可以省略的。理想情况下,用户在一段时间内仅会访问一次人机验证的逻辑。此时要做好的是保证用户首次加载的体验,而二次访问的体验可以暂且不予考虑。

建议的方案是将数据保护相关的逻辑从整个工程的JavaScript代码中剥离出来,直接inline编译到html页面中,或者编译到一个独立的js文件中,为该js文件单独设置Cache-Control: no-cache的response头部。该js与其他js之间可以使用全局变量、postMessage等方式通信。

3. 如何解决js编译速度太慢的问题?

前端的打包工具有很多,如gulp、webpack、Rollup等,这些工具各有长处,也有很多针对编译过程的优化,但目前都无法在需要毫秒级响应的场景完成一次打包,因此编译打包需要异步完成。那么如何生成出足够数量的js满足正常访问和对抗场景的需求?比较简单的方案是循环跑编译脚本,编译好一个替换一次,短时间内用户可能会访问到同一个js,随着旧js被新编译出来的js替换,一段时间内用户访问的js可以认为是随机的,此时js的变换间隔取决于编译速度。

除了简单方案外,这里介绍一种更灵活的方案,即将编译产物缓存并提供随机访问。首先,把安全相关的js文件从静态服务中剥离出来,由一个后端的web server输出js内容。该server上维护着一个长度一定的数组,构建工具编译好一个js文件后,将该文件的内容发送给web server,web server将接收到的内容顺序填充到数组中;当有用户页面时,浏览器向web server请求该js内容,web server从数组中随机挑选一个,返回给浏览器。

这种服务方式有很多好处,除了可以保证安全js的随机性,还能将signature的生成放到web server中完成。构建工具在编译js时将编译的元信息发送给web server,此时并不生成出signature。用户需要请求该js时再根据元信息实时生成一个signature,填充到js文件内容中。这样生成的signature每次都是独立的,通过检测signature的使用次数,可以很容易标识并拦截重放的请求。

4. 是否需要混淆?

既然有了随机的动态,是否还需要混淆?答案是肯定的。虽然fx和fy每次都不一样,但两个不同的变化函数,一定会有其独有的特征。例如fx和fy在JavaScript中的算法实现如下:

function foo(x) {
  x = String.fromCharCode.apply(null, x.split('').map(i => i.charCodeAt(0) + 23);  return btoa(x)
}function bar(y) {
  y = String.fromCharCode.apply(null, y.split('').reverse().map(i => i.charCodeAt(0) + 13);  return btoa(y);
}

本例中23、reverse、13便是fx和fy的特征,如果某次请求的js文件中包含reverse和13,则很大可能是使用了bar变换,如果包含23,则很大可能使用了foo变换。通过这种特征检测,可以轻松得到请求的js中使用了何种变换组合。而检测方法并不会很复杂,只需要一些简单正则表达式即可。

0x05 总结

本文分析了常规数据保护及混淆的短板,并从动态的角度,给出了一种对抗机器行为的方式,同时在工程化上有一些思考。人机对抗之路艰辛且漫长,是在未来很长时间内都会存在的业务安全问题。希望动态化思路能给现在正在做人机对抗的团队一些启发,帮助更多中小型公司的业务摆脱机器和爬虫之痛。另外,腾讯防水墙团队在机器对抗上有较深的积累,如果想直接体验成果可以点击:https://007.qq.com