AST 原理 与 反混淆
- 这里还有一篇巨细的文章 利用 AST 技术还原 JavaScript 混淆代码
- 可以将加密的字符串扣在本地,查找调用的位置,还原回去。
- 可以将垃圾指令,比如必定不成功的 if 进行替换。
- 将+-*/等操作从函数变为标识符
- 在网页解析的时候:语言 JavaScript
- 解析方式选择 :**@babel/parser**
不论编译器还是解释器:由源代码到 AST 抽象语法树所需要的过程主要分为两步
将源代码进行词法分析,生成 token 符号流
token 符号流通过语法分析,生成语法树。
AST 中各节点解释
start:开始位置
end:总字符长度。
type:表示节点类型
VariableDeclaration:说明变量
kind:定义变量的时候使用的关键字
declarations:指声明的具体的变量(数组)
因为可以连续定义
let a,b,c,d
数组内就是每一个的具体信息。但这里我们只定义了一个 let,所以内部只有一个
数组内部:VariableDeclarator
内部每一个变量的定义都以VariableDeclarator表示
id:是唯一的标识符
name:是定义的名字
init:对于 init 是定义对象的属性值,没有给定则是 null。
Identifier:标识符
ObjectProperty:对象表达式(定义对象)
- 对象属性内部是由键值对组成
- 所以内部的对象名字:key
- 内部对象对应的值是:value
computed:是否以对象形式调用
StringLiteral:字符串文字
FunctionExpression:函数表达式
- 这里如果是匿名函数,那么 id 就是 null
params:对应函数的参数
- 因为函数参数可以有多个,所以也是一个数组
- 如果函数没有参数,也是一个空数组,不是 null
BlockStatement:函数体一般都会用BlockStatement进行包裹
ReturnStatement:函数执行的return语句
argument:函数返回的内容。
BinaryExpression:二项式,JS 中所有二元运算都可以解析成二项式。
- 二项式主要分为:left,operator,right
CallExpression:调用表达式,相当于函数直接调用。
在 AST 调用 PATH 的时候,PATH.NODE 才是当前节点
完成一些功能可以先写一个简单的例子,然后例子成功了,再放入真实代码执行
path 和 node 的区别
path.node:能取出来节点的 Node 对象,与网页解析出来的一致
- 节点是生成 JS 代码的原材料,是 Path 中的一部分。
- Path 是一个对象,用来描述两个节点之间的连接。
- Path 除了具有上述显示的这些属性以外,还包含添加,更新,移动,删除节点等许多操作
- 获取节点属性:path.node.n**:获取节点属性值
- 通过这种方式获取到的是具体属性值(就不能再用 path 方法了)
- 要获取该属性的 Path 需要使用 get 方法
path.get(‘n***‘):取该属性的 Path
多节点之间用.链接:path.get(‘name.str’)
path.get(‘left’).isIdentifier():判断获取到的 left 节点是否是Identifier类型
path.get(‘left’).assertIdentifier():断言 left 是Identifier类型,如果不是,就会报错。
path.node.left = t.Identifier(‘f’):将 left 节点属性替换其他属性。
path.parentPath:返回父级 path
**path.stop()**:用来停止遍历节点
**path.evaluate()**:用来计算节点的值
返回一个对象,对象中的 confident 的 bool 值表示是否可以计算出值
如果可以,那么对象中的 value 就存放着该值。
节点操作
替换节点
上面是替换属性,这里是替换整个节点。
- 替换节点的时候要注意,替换的节点也是可以被遍历到的,要防止出现递归调用。
replaceWith:用一个节点替换另一个节点
// 当节点是 StringLiteral 并且 value 为 6666 的时候 if (t.isStringLiteral(path.node, { value: "6666" })) { // 将节点文本替换为 fuck path.replaceWith(t.valueToNode("fuck")); }
- **path.replaceWithMultiple([])**:多个节点替换一个节点,在列表内部实现。
- path.replaceInline():如果参数是是列表,则等于replaceWithMultiple,否则是replaceWith
- **path.replaceWithSourceString(‘’)**:用指定的字符串替换节点。
- 替换节点用节点的 body 配合多节点替换,可以删除外部的{}号
删除节点:path.remove()
插入节点:
- **path.insertBefore(t.expressionStatement(t.stringLiteral(‘Before’)))**:指定节点前插入
- **path.insertAfter(t.expressionStatement(t.stringLiteral(‘After’)))**:指定节点后插入
作用域:
- path.scope.block:获取当前定位到节点的作用域。
代码部分
AST 和代码的转换
// fs 读取本地文件 const fs = require("fs"); // @babel/parser 用来将JS代码转换成AST const parser = require("@babel/parser"); // @babel/traverse 用来遍历AST中节点 const traverse = require("@babel/traverse").default; // @babel/types 用来判断AST的节点类型 生成新的节点 const type = require("@babel/types"); // @babel/generator 用来将AST转换成JS代码 const generator = require("@babel/generator").default; // 读取JS代码 const jsCode = fs.readFileSync("./demo.js", { encoding: "utf-8", }); // 将代码转换成ast let ast = parser.parse(jsCode, { // 默认参数就是script // 如果js代码中有import,export,则需要将参数改为module sourceType: "script", }); // 输出ast请使用以下格式 // console.log(JSON.stringify(ast)) // 下面进行自己的一系列操作 // ------------------------------------------------------------------------------- // ------------------------------------------------------------------------------- // 将ast转换成JS代码 // 转换成功之后是一个对象,对象内部的code才是需要的代码 let code = generator(ast, { // 是否与源代码使用相同行号,false:表示返回格式化后的代码 retainLines: false, // comments:是否保留注释 comments: true, //compact:是否压缩代码 compact: false, }).code; // 写入本地 fs.writeFile("./demoNew.js", code, (err) => {}); // 更多babel参数可以访问 https://babeljs.io/
AST 遍历时进行的操作
// 先定义一个对象 const ass = { // 第一种写法 // 为对象定义一个 FunctionExpression 这个方法就是需要遍历的节点类型 // traverse 会遍历所有节点类型,当遇到 FunctionExpression 节点时,就会调用方法 // 需要处理节点,只需要一直增加即可 // 传入的Path参数是当前节点的Path对象,不是节点。 FunctionExpression(path) { console.log("1"); }, // 第二种写法 // 可以在进入和退出节点时进行调用 Identifier: { enter(path) { console.log("2"); }, exit(path) { console.log("2"); }, }, // 还可以用 | 进行连接,将一个函数运用在多个节点,需要用字符串的形式 "Identifier|BlockStatement": { enter(path) { console.log("2"); }, exit(path) { console.log("2"); }, }, // 还可以将函数以列表的形式传入,会依次执行函数,传入参数。 FunctionExpression: { enter: [func1, func2], }, }; // 然后传递给traverse 就会自动从头开始遍历 traverse(ast, ass);
Type 组件
- 该组件主要用来判断节点类型 以及 创建新的节点
- 判断节点类型
// 当 Identifier 节点的name是a,则修改为ccc Identifier(path) { if (path.node.name === 'a'){ path.node.name = 'ccc'; } } // 可以等价的修改为Type组件的方式。 // 这里改为进入每个节点 // 要判断其他节点只要修改is后面的内容即可 is**** enter(path) { if (t.isIdentifier(path.node, {name: 'a'})) { path.node.name = 'ccc2'; } }
- 可以创建新节点
- 主要在网页上创建,之后在本地模拟网页的格式就可以
// 下面代码将使用ast语句创建以下代码 // 并且上面的所有代码都是以这个对象为准 let obj = { name: "6666", add: function (a, b) { return a + b + 1000; }, mul: function (a, b) { return a + b + 1000; }, }; // ----------------------------------------------------------- // 函数接收两个参数,所以定义a和b let a = t.identifier("a"); let b = t.identifier("b"); // 下面定义函数执行体 // 函数主体二项式用binaryExpression定义 let binExpr2 = t.binaryExpression("+", a, b); let binExpr3 = t.binaryExpression("*", a, b); // 定义函数的return 用 returnStatement 因为原始字符串是 a + b + 1000 所以下面将代码再分为左右的二项式 let retSta2 = t.returnStatement(t.binaryExpression("+", binExpr2, t.numericLiteral(1000))); let retSta3 = t.returnStatement(t.binaryExpression("+", binExpr3, t.numericLiteral(1000))); // 函数的主体内容需要用 blockStatement 包裹起来 (要求就是这样) // 如果希望创建一个空函数,则需要传入空数组,而不是null let bloSta2 = t.blockStatement([retSta2]); let bloSta3 = t.blockStatement([retSta3]); // 然后生成两个函数,但这个函数还没有主体内容 // 第一个参数id:因为是匿名函数,所以为null // 第二个参数是接收的参数列表 // 第三个是函数执行体 let funcExpr2 = t.functionExpression(null, [a, b], bloSta2); let funcExpr3 = t.functionExpression(null, [a, b], bloSta3); // 定义主体内容的属性和方法。 let objProp1 = t.objectProperty(t.identifier("name"), t.stringLiteral("6666")); // 这里的函数,还需要单独定义 let objProp2 = t.objectProperty(t.identifier("add"), funcExpr2); let objProp3 = t.objectProperty(t.identifier("mul"), funcExpr3); // 主体内容定义 let objExpr = t.objectExpression([objProp1, objProp2, objProp3]); // 定义了一个在let右侧的obj名称,然后主体内容还没定义 let varDec = t.variableDeclarator(t.identifier("obj"), objExpr); // 定义一个变量, 第一个参数 var | let | const // 第二个参数是变列表,参数可以连续定义,所以这是一个列表 let a,b,c; let localAst = t.variableDeclaration("let", [varDec]); code = generator(localAst).code; console.log(code);
- 更多 babel 参数可以访问: https://babeljs.io/
- 还提供了一个 valueToNode:来快速转换数据类型
let obj = t.valueToNode([1, "2", false, null, {name: 'pink', age: 6,}, undefined])
- 可以直接输出结果,注意要放在列表内部
AST 自动化防护
混淆前代码的处理
- 改变成员访问方式:在网页中看到.访问和对象访问,只有属性 path.node.computed 的真假发生变化。
// 进行以下替换可以将属性访问改为对象访问 // 将data.prototype 改为 data['prototype'] MemberExpression(path){ if(t.isIdentifier(path.node.property)){ path.node.property = t.stringLiteral(path.node.property.name); } path.node.computed = true; }
- 修改存在于 window 下的属性访问
// 将 Data['prototype'] 改为 window['Data']['prototype'] Identifier(path) { let name = path.node.name; // 当找到Date时,创建一个对象,替换掉原先的Date if ('Date|eval'.indexOf(name) !== -1) { path.replaceWith(t.memberExpression(t.identifier('window'), t.stringLiteral(name), true)) } } // 原本是Date字符串,现在创建了一个memberExpression对象,把原本的替换掉了
常量与标识符混乱
- 比如一个数字是 1000,是属于 NumericLiteral
- 那么可以创建一个二项式(BinaryExpression),然后计算 500+500。
- 避免 1000 直接被搜到,简单的一个思路。
字符串常量混乱
- 比如用 base64 进行加密
- 然后网页运行的时候在用 base64 解密
StringLiteral(path) { // 获取value let value = path.node.value // 构建函数调用 atob('cmVwbGFjZQ') 进行解码 let call = t.callExpression(t.identifier('atob'), [t.stringLiteral(new Buffer.from(value, "binary").toString("base64"))]) // 将原文的文本,替换为函数调用 path.replaceWith(call) // 因为在新的节点中也有 StringLiteral 类型,所以要终止,要不然会无限调用。 path.skip() }
数组混淆
- 现在字符串都加密了,但他们还是在原始的位置上。
- 可以将数组提取出来,然后原始字符串的位置,改为数组下标的形式进行访问。
// 先定义一个对象 let arr = []; const ass = { MemberExpression(path) { if (t.isIdentifier(path.node.property)) { path.node.property = t.stringLiteral(path.node.property.name); } path.node.computed = true; }, Identifier(path) { let name = path.node.name; if ("Date|eval".indexOf(name) !== -1) { path.replaceWith(t.memberExpression(t.identifier("window"), t.stringLiteral(name), true)); } }, StringLiteral(path) { // 首先将字符串提取出来进行加密 let cipherText = new Buffer.from(path.node.value, "binary").toString("base64"); // 加密之后,判断是否已经存在于列表中了 let bigArrIndex = arr.indexOf(cipherText); let index = bigArrIndex; if (bigArrIndex === -1) { // 等于-1表示不存在 let length = arr.push(cipherText); // 将新加入的文本索引给index index = length - 1; } let call = t.callExpression(t.identifier("atob"), [t.memberExpression(t.identifier("arr"), t.numericLiteral(index), true)]); path.replaceWith(call); // 因为修改之后,就没有 StringLiteral 就不会无限循环 // path.skip(); }, }; // 然后传递给traverse 就会自动从头开始遍历 traverse(ast, ass); // 因为下面的数组是转换成ast了,所以要在 traverse 遍历完毕之后再进行添加 // 这里目前只是JS中的字符串,需要将内部的每个值都转为AST中的stringLiteral arr = arr.map(function (v) { return t.stringLiteral(v); }); // 声明一个变量 arr ,内容是上面的 arr 列表 arr = t.variableDeclarator(t.identifier("arr"), t.arrayExpression(arr)); // 上面是声明变量arr 这里是定义上面声明的变量 arr = t.variableDeclaration("var", [arr]); // ast代表生成好的代码,program 是整个JS文件,body是一个列表,将前面的ast列表代码加在body最前面,就达到了增加数组的效果 ast.program.body.unshift(arr);
数组乱序
现在数组已经提取出来,但数组的顺序还是有迹可循的,下面来打乱数组的顺序。
下面先在代码中,把数组混淆(这里是简单的混淆一下)
// 在添加之前把列表顺序打乱(这里简单的打乱,就是把数组倒过来) (function (myArr) { var newArr = []; for (const myArrKey in myArr) { newArr.unshift(myArr[myArrKey]); } arr = newArr; })(arr);
- 然后在代码中,数组的后面插入还原函数。
- 还原函数也是上面的代码,这里没有进行混淆,可以用标识符混淆一下。
// 这里读入数组混淆的还原代码 const jscodeFront = fs.readFileSync("./astFront.js", { encoding: "utf-8", }); // 将代码转成ast代码,插入到代码最前面 let astFront = parser.parse(jscodeFront, { // 默认参数就是script // 如果js代码中有import,export,则需要将参数改为module sourceType: "script", }); // 将代码插入到最前面 astFront.program.body 是代码段开始 [0] 个就是真实代码 ast.program.body.unshift(astFront.program.body[0]); // 加入到代码最前面
标识符混乱
- 在前面的标识符混乱中,混淆的标识符都是不一样的。
- 其实可以将标识符改为不同作用域中相同的名字,并且改为没有全局引用的标识符名称。
- 这样更加具有迷惑性。