“
阅读本文大概需要 15 分钟。
”
在《Python3 反爬虫原理与绕过实战》[1]一书中给出了“爬虫与反爬虫都是综合技术的应用”、“技术在对抗中进步”这样的观点。随着时间的推移、技术的普及和进步,Web 应用方给爬虫增加了越来越多的限制,其中效果最显著的就是代码混淆。
单纯的加密算法或者自定义的字符处理函数已经无法满足防御需求了, Web 应用方将目光转移到了代码混淆技术。代码混淆有几个优点:
•操作门槛低,有现成可用且免费的混淆产品;•混淆效果好,混淆后真的是连亲妈都不认识;•浏览器能够正常解析混淆后的代码,一万行以内的小规模混淆对性能影响不大;•混淆带来的性能影响可以通过其他优化降低,不慌;
加密算法和字符串处理函数配合代码混淆,防御力直线上升。举个简单例子,一个简单的字符处理函数如下:
这里有三个函数,stringArray 返回一个包含字符的数组对象、mergeArray 将数组对象里的元素拼接成为一个字符串并返回、main 调用 stringArray 函数和 mergeArray 函数并打印得到的字符串,下方的 output 注释即运行结果。
这么清晰明了的函数调用,爬虫工程师能看不懂吗?
我们看看上面三个函数混淆后的样子:
一样的功能、一样的输出,但是代码却完全不一样了,变得不可读。如果把下面的注释去掉,那你根本就不知道发生了什么,也不知道会输出什么,这就是代码混淆给 Web 应用方带来的防御力。
作为一名爬虫工程师,你现在有两个选择:
1.通过一个入口函数强行找出关联的函数调用,直到铺满调用链后拿到正确的输出;2.还原部分混淆,从这堆杂乱无章的代码中捋清逻辑,再根据复杂度选择用其他语言实现或回到第一步;
第一种方法,就是平时爬虫工程师说的“硬扣”,如果有跨文件的函数调用和冗长复杂的调用链,那“硬扣”真的是会掉头发的。
第二种方法的技术门槛稍微高一些,需要爬虫工程师懂得 AST 理论,并学会编写还原代码,将杂乱无章的代码阅读难度降低,从而降低自己阅读代码逻辑或者整理调用链的难度与成本。
什么是 AST?
这里引用百度百科对 AST 的解释:
在计算机科学中,抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现;而类似于 if-condition-then 这样的条件跳转语句,可以使用带有两个分支的节点来表示。
嗯,这看起来有点绕,我打算用一个例子来表述。JavaScript 变量声明和赋值的代码示例如下:
代码语言:javascript复制var nick = "vansenb";
这一行代码会被解析成很长的语法树,具体解析可通过 AST Explorer[2] 查看。以下是 JavaScript 语句和语法树的对应关系:
图有点模糊,想看清晰结构的请移步 AST Explorer。
AST 有什么用?
上图的语法树中表明了程序主体、声明类型、标识符、字面量等信息,由此我们可以得出:
•var - VariableDeclarator 变量声明;•nick - Identifier 标识符;•vansenb - Literal 字面量;
从人类阅读的角度来看,这行代码:声明了一个名为 nick 、值为 vansenb 的变量。
如果你想改变这行代码,将它变成:
代码语言:javascript复制var nick = "James";
只需要改变语法树中 type 为 Literal 下的 value 属性对应的值即可,那么代码的语义就变成了:声明了一个名为 nick 、值为 James 的变量。了解到这一点之后,我们就可以思路放在代码的混淆和还原上面了。
你想想,当你使用那些一键混淆/还原工具的时候,是不是只需要将代码粘贴到输入框并点击“混淆”按钮即可得到混淆后的代码?而且相同结构的代码混淆后的结构也是相同的?
这说明一键混淆/还原工具通过改变原代码的抽象语法树实现混淆/还原的效果,例如在树的某个节点前后增加或删除节点,亦或在混淆时将原本直接可以输出结果的单个函数转换为相互调用的多个函数。
常用的 JavaScript AST 解析库
语法树并不是 JavaScript 独有的,几乎所有编程语言都有语法树,例如 Golang、Python 和 Java。JavaScript 的语法树出现频次较高,这是因为 JavaScript 隔代语法的差异和不得不考虑的兼容性造成的,ES5 和 ES6 语法隔代,在实际应用中会需要进行语法的转换,这就使得语法树能够在实际场景中发挥作用。
语法树的作用就像是一个转接头,把代码的表现形式 A 转换为表现形式 B
JavaScript 领域常用的 AST 解析库有 babel、esprima、espree 和 acorn 等,各位工程师可根据自己的喜好和风格选择趁手的库。
这些库常常被前端开发工程师用来编写代码转换的工具或者代码混淆工具,甚至是将 React 和 Vue 的工程代码编译为浏览器能运行的 JavaScript 代码,而在爬虫工程师这里,大概率会用来辅助自己逆向 JavaScript 代码。
AST 节点类型名词基础
语法树相关的知识和技巧需要一定的时间学习(大概一两个月),对此感兴趣的你可以通过以下几篇实战型文章了解它的具体应用:
AST 还原 obfuscator 混淆[3]
操作AST还原混淆代码基础系列课程三:十六进制字符串还原
操作AST还原混淆代码:让代码分析变得如此简单
AST实战:全自动解密经obfuscator混淆的加密字符串
操作AST还原混淆代码课程九:还原简单的CallExpression 类型
上面列举了常用的几个 AST 解析库,虽然各个库解析同一份代码得到的结构不完全一致,但用于表示节点类型的名词几乎都是一致的,例如 VariableDeclaration 代表这是变量声明语句、CallExpression 代表这是调用表达式。
掌握节点类型的名词,有助于我们在阅读语法树结构时更清晰地了解节点的作用和意图,也可以说节点名词是我们成为代码混淆大师或代码逆向大师的必经之路,非常重要!
我们以下图的代码为例,看看 AST 中常用的节点类型名词有哪些。
上图代码包含了 JavaScript 语法中常用的语句,例如变量声明、函数声明、三元表达式、if 控制流语句、switch 控制流、函数调用、赋值语句、数组声明、for 循环等。
将上面的代码复制到 AST Explorer 便可以得到语法树,根据左侧的代码和右侧的语法树,我们可以统计语法树节点名词和具体描述,如下表:
序号 | 类型原名称 | 中文名称 | 描述 |
---|---|---|---|
1 | Program | 程序主体 | 整段代码的主体 |
2 | VariableDeclaration | 变量声明 | 声明一个变量,例如 var let const |
3 | FunctionDeclaration | 函数声明 | 声明一个函数,例如 function |
4 | ExpressionStatement | 表达式语句 | 通常是调用一个函数,例如 console.log() |
5 | BlockStatement | 块语句 | 包裹在 {} 块内的代码,例如 if (condition){var a = 1;} |
6 | BreakStatement | 中断语句 | 通常指 break |
7 | ContinueStatement | 持续语句 | 通常指 continue |
8 | ReturnStatement | 返回语句 | 通常指 return |
9 | SwitchStatement | Switch 语句 | 通常指 Switch Case 语句中的 Switch |
10 | IfStatement | If 控制流语句 | 控制流语句,通常指 if(condition){}else{} |
11 | Identifier | 标识符 | 标识,例如声明变量时 var identi = 5 中的 identi |
12 | CallExpression | 调用表达式 | 通常指调用一个函数,例如 console.log() |
13 | BinaryExpression | 二进制表达式 | 通常指运算,例如 1 2 |
14 | MemberExpression | 成员表达式 | 通常指调用对象的成员,例如 console 对象的 log 成员 |
15 | ArrayExpression | 数组表达式 | 通常指一个数组,例如 [1, 3, 5] |
16 | NewExpression | New 表达式 | 通常指使用 New 关键词 |
17 | AssignmentExpression | 赋值表达式 | 通常指将函数的返回值赋值给变量 |
18 | UpdateExpression | 更新表达式 | 通常指更新成员值,例如 i |
19 | Literal | 字面量 | 通常指字符串型的字面量 |
20 | BooleanLiteral | 布尔型字面量 | 布尔值,例如 true false |
21 | NumericLiteral | 数字型字面量 | 数字,例如 100 |
22 | StringLiteral | 字符型字面量 | 字符串,例如 vansenb |
23 | SwitchCase | Case 语句 | 通常指 Switch 语句中的 Case |
这只是常用的那部分,更多节点类型名词在你需要用到时再补充即可。我会持续更新相关资料,感兴趣的朋友可以到夜幕团队的 GitHub 仓库 https://github.com/NightTeam/JavaScriptAST 查看 。 有了这些名词对照关系之后,我们阅读语法树结构就变得简单了。当你看到节点 tpye 为 IfStatement 的时候,你知道后面必定会有至少一个 BlockStatement,即 if (condition){}。
移动端看不清表格可以看图:
References
[1]
《Python3 反爬虫原理与绕过实战》: https://item.jd.com/12794078.html
[2]
AST Explorer: https://astexplorer.net/
[3]
AST 还原 obfuscator 混淆: https://blog.csdn.net/zhao_5352269/article/details/106492177