如何使用Babel操作JS语法树(一): 还原JS混淆

7,843 阅读12分钟

项目地址 :包括待破解的代码和操作语法树的代码

前言

AST学名抽象语法树,Babel代码的转换其实都是操作的语法树,而拥有操作语法树的能力就是拥有了操作代码的能力,这对于破解还原或者正向混淆js代码是很重要的能力。 本文通过还原某网站用到的混淆js来介绍ast的作用

在看此文之前,希望你对js语法树和babel有基本的认识 下面是我搜到的一些文章

从babel讲到AST 主要介绍了如何把代码解析成语法树

AST in Modern JavaScript 既有介绍也有部分ast的操作

JavaScript抽象语法树AST 关于每个语法树节点的介绍很详细,甚至可以当文档用

babel插件入门-AST(抽象语法树) 偏实战

如果你对js语法树还不了解,希望你阅读完上面的文章再来看这篇。

这篇文章对于没怎么接触过语法树的同学会比较硬,建议先思考然后带着目标如何把项目中的source.js还原成可阅读的代码?

正文

见到这种js以前我是拒绝看下去的,但现在通过我们一步步分析并通过操作ast是可以完全还原并看懂的(代码在项目地址 source.js, 把代码拉下来分析一下会清楚一点)

首先我们先进行静态分析, 源码有四部分:

  1. 一个包含字符串的数组
  2. 一个自执行函数
  3. 一个有两个输入和输出的函数
  4. 一个有5000行的自执行函数

字符串替换

可以看到第一个数组(我们暂称为strArr)出现在了第一二三部分中,第二个自执行函数只是对strArr顺序进行了转换。

strArr真正发挥作用是在第三部分的函数中(我们暂称为strFn), 而strFn在第四部分出现频率及其高,如果混淆研究多了可以多半推测出是对字符串的混淆。
而我们实际运行的话也可以看出就是一个输入,然后一通转换,结合strArr,得到最后真实的字符串。
看出这一些之后我们已经具备了第一个操作代码的目标,即把所有strFn的调用改成真正的字符串 借助Node运行js的天然优势,我们直接把strArr和strFn放到我们的代码中作为一个模块(module.js)导出 module.js:
同时导出方法的名字后面用到

代码

首先看我们需要的babel依赖

// 把js源码转成语法树
const parser = require("@babel/parser")
// 遍历语法树中的节点
const traverse = require("@babel/traverse").default
// 提供对语法树中Node的一系列方法比如判断Node类型,辅助创建Node等
const t = require("@babel/types")
// 根据语法树生成js代码
const generator = require("@babel/generator").default

接下来是对文件的读写流程

// 读取source文件
fs.readFile(path.resolve(__dirname, './source.js'), { "encoding": 'utf-8' }, function (err, data) {
    //  转换成语法树
    const ast = parser.parse(data)
    // 我们要转换的代码
    decrypt(ast)
    // 转换完后放到generator生成新的js
    let { code } = generator(ast)
    // 针对代码中!![]/![] 直接进行通过字符串替换
    code = code.replace(/!!\[\]/g, 'true').replace(/!\[\]/g, 'false')
    // 写到新文件中
    fs.writeFile(path.resolve(__dirname, './result.js'), code, function (err) {
        if (!err) {
            console.log('finished')
        } else {
            console.log(err)
        }
    })
})

主要的代码就在decrypt函数中了

function decrypt(ast) {
    traverse(ast, {
        CallExpression: {
            enter: [callToStr]
        },
        StringLiteral: {
            enter: [removeExtra]
        }
        NumericLiteral: removeExtra
    })

我们直接把ast传到函数中,然后调用@babel/traverse来进行节点的遍历。 如果不知道某个节点的名称,推荐一个网站AST Explorer查询,左边放入js代码,右边就可以显示对应的树节点(不要忘了选上面的解析器babylon7)。

把_0xd1a5('0x1c8')转成ast是外面一个ExpressionStatement, 其属性expression为CallExpression, CallExpression的callee就是函数名_0xd1a5, arguments是个数组,包含一个string, Node Type名叫StringLiteral
那我们只要找到所有CallExpression,然后判断调用的是_0xd1a5,然后通过引入strFn转换回字符串即可(这里更严谨的做法是判断_0xd1a5是否是全局第三部分定义的函数而不是在调用方内部定义的局部变量,判断的话可以结合scope和binding,后面再讲)

我们在CallExpression的enter里面放了callToStr函数,也就是说每次遍历进入到一个CallExpression时,会执行callToStr并传入当前path作为参数

function callToStr(path) {
    let node = path.node
    // decryptStrFnName即_0xd1a5
    if (t.isIdentifier(node.callee) && node.callee.name === decryptStrFnName) {
        // decryptStr就是我们拷贝到我们项目中作为Node运行的strFn
        const result = decryptStr(node.arguments[0].value)
        // t.stringLiteral可以为我们生成一个StringLiteral节点,只要我们传入必须的值, path.replaceWith直接替换掉当前节点
        path.replaceWith(t.stringLiteral(result))
        return
    }
}

上面代码的作用相当于把代码中所有_0xd1a5('0x1c8')直接转换成'0.02'

我们还看到代码里的数字和部分字符串转换成了16进制表示,我们转换成好阅读的,通过babel实现很简单

我们遍历StringLiteral还有NumericLiteral可以发现其实它们正常值包含在value中,只不过多了一个extra属性

所以我们直接删除extra即可

function removeExtra(path) {
    delete path.node.extra
}

这一步做完以后,前三部分的代码(strArr, strFn)就没有作用了,我们可以手动把他们删掉。后面的步骤就以此为源码

结构转换

看到上图结构后第一反应是直接把最下面的一组基本值代入到参数中然后直接替换代码里面的变量,这一步是早晚要做,但只不过运行时我发现代码还会对arguments做转换,所以不能直接带进去。 那我们就先把里面的结构转一下

首先分析这个while结构: 它先定义了两个变量,一个数组,一个数字。 while(true)表明要等到break才会结束,里面包含了一个switch,switch判断条件依次取了前面定义数组的值,匹配到case后运行,然后continue, 等到数组匹配结束,break。简化版:

var idxArr = "0|1|2|6|4|3|5"["split"]("|"),
      idx = 0;

while (true) {
  switch (idxArr[idx++]) {
    case "0":
    // expression
      continue
    case "1":
    // expression
      continue
  }

  break;
}

我们所要做的就是根据idxArr的顺序依次找到case中的语句然后按顺序拼起来。 我个人习惯会先找到while节点, 然后判断包含是个switch和break,这时就可以获取到idxArr的变量名,再通过while的siblings找到前一个节点,获取idx的顺序,最后拼case。当然其实有很多种路径都可以实现,比如先找switch,再判断父节点等。

function decrypt2(ast) {
    traverse(ast, {
        WhileStatement: replaceWhile
    })
}

function replaceWhile(path) {
    let node = path.node
    // 判断基础的结构 while(true) {}
    if (!t.isBooleanLiteral(node.test) || node.test.value !== true) return
    if (!t.isBlockStatement(node.body)) return
    const body = node.body.body
    // 判断包含一个switch和一个break 
    if (!t.isSwitchStatement(body[0]) || !t.isMemberExpression(body[0].discriminant) || !t.isBreakStatement(body[1])) return

    const switchStm = body[0]
    // switch (idxArr[idx++]) 找到idxArr变量的名称
    const arrName = switchStm['discriminant'].object.name
    
    // 找到sibling前一个Node
    let varKey = path.key - 1
    let varPath = path.getSibling(varKey)
    // 找到idxArr这个Node
    let varNode = varPath.node.declarations.filter(declarator => declarator.id.name === arrName)[0]
    // 把值取出来分割成数组 ["0", "1", "3", "6", "2" ...]
    let idxArr = varNode.init.callee.object.value.split('|')

    // 所有的case
    const runBody = switchStm.cases
    let retBody = []
    idxArr.map(targetIdx => {
        // 根据顺序找到对应的语句
        let targetBody = runBody[targetIdx].consequent
        // 把continue删除
        if (t.isContinueStatement(targetBody[targetBody.length - 1])) {
            targetBody.pop()
        }
        retBody = retBody.concat(targetBody)
    })
    // 如果是一个Node替换为多个,要使用replaceWithMultiple
    path.replaceWithMultiple(retBody)
    // remove idxArr var/index
    varPath.remove()
}

下一步, 发现定义了一些包含很多方法的对象,而这些方法基本就是一些基本操作符,逻辑运算符,位运算符或者函数调用这些封装到函数内部,形成了类似函数式的写法。

而我们要做的就是把这些操作符直接替换函数调用。比如

var obj = {
    "add": function(a, b) {
        return a + b
    },
    "equal": function(a, b) {
        return a === b
    }
}
var c = obj.add(1,2) // 源码

var c = 1 + 2  // 我们要替换成的

我们来分析一下源码的结构: 一个大的VariableDeclaration中包含一个VariableDeclarator,其id属性是变量名,type为Identifier, init属性是一个对象字面量ObjectExpression。

ObjectExpression的properties是包含所有key:value的一个数组,每一项key:value是一个ObjectProperty, key为StringLiteral, value为FunctionExpression。

我还原的思路是先找到VariableDeclarator, 然后匹配这个对象字面量结构,再匹配function内部的结构。 等确定好这是我们要找的变量后,再在作用域内找到所有通过varName[key](param1, param2)如此调用的节点,把params带到调用的函数内部,展开函数调用例如if (obj['equal'](a, b)) 转换为 if (a === b)

function replaceFns(path) {
    // 遍历VariableDeclarator
    let node = path.node
    // 变量右边是不是一个对象字面量
    if (!t.isObjectExpression(node.init)) return
    let properties = node.init.properties
    try {
        // 这里简单的判断了对象第一个属性值是不是个函数,并且函数只有一条return语句
        // 看起来有些不严谨,但是对于这份代码没有问题,没有出现和这个结构一样但后面的值不满足的情况。。
        if (!t.isFunctionExpression(properties[0].value)) return
        if (properties[0].value.body.body.length !== 1) return
        let retStmt = properties[0].value.body.body[0]
        if (!t.isReturnStatement(retStmt)) return

    } catch (error) {
        console.log('wrong fn arr', properties)
    }
    // 存储一下变量名,后面调用都是objName[key],所以需要匹配它
    let objName = node.id.name
    // 一个一个函数进行查找
    properties.forEach(prop => {
        // key
        let key = prop.key.value
        // 需要替换成的语句
        let retStmt = prop.value.body.body[0]
        
        // path.getFunctionParent可以方便的帮我们找出最近的一个包含此path的父function, 这样我们就可以在此作用域遍历了
        const fnPath = path.getFunctionParent()
        fnPath.traverse({
            // 找所有函数调用 fn()
            CallExpression: function (_path) {
                // 确保是obj['key'] 或 obj.add等相似的调用
                if (!t.isMemberExpression(_path.node.callee)) return
                let node = _path.node.callee
                // 第一位是上面定义的objName
                if (!t.isIdentifier(node.object) || node.object.name !== objName) return
                // key值是我们当前遍历到的
                if (!t.isStringLiteral(node.property) || node.property.value !== key) return
                
                // 参数
                let args = _path.node.arguments 

                /* 其实定义的函数总共分三类
                 * 1. function _0x3eeee4(a, b) {
                 *        return a & b; // BinaryExpression
                 *    }
                 * 2. function _0x3eeee4(a, b) {
                 *        return a === b; // LogicalExpression
                 *    }
                 * 3. function _0x3eeee4(a, b, c) {
                 *        return a(b, c) // CallExpression
                 *    }
                 * 下面的代码就是对调用的代码做一个转换。这里可以看到t.Node并传入对应的参数可以帮助我们生成相应的节点, t.isNode是判断是否*  为某个type的Node
                 */
                if (t.isBinaryExpression(retStmt.argument) && args.length === 2) {
                    _path.replaceWith(t.binaryExpression(retStmt.argument.operator, args[0], args[1]))
                }
                if (t.isLogicalExpression(retStmt.argument) && args.length === 2) {
                    _path.replaceWith(t.logicalExpression(retStmt.argument.operator, args[0], args[1]))
                }
                if (t.isCallExpression(retStmt.argument) && t.isIdentifier(retStmt.argument.callee)) {
                    _path.replaceWith(t.callExpression(args[0], args.slice(1)))
                }
            }
        })
    })
    // 最后删掉这些定义的函数 已经没有用了
    path.remove()
}

可以看到, 如果最后不调用删除path的话定义的包含很多函数的对象变量已经变暗,证明已经没有用到了

代入参数

最后一步我们把这些基本值带入到代码中,但是在带之前观察到代码对arguments有个操作 其实这个结构已经很清晰了,只要把arguments转换完然后保留里面那个自执行函数即可

我们先看这个自执行函数的语法树结构

(function () {
  // codes
})();

自执行函数并没有自己特有的type,他是几个Node的组合。最外面是个CallExpression,里面是个没有id的FunctionExpression也就是匿名函数

这里有个破解方面的选择,因为在那段操作arguments代码之前的参数是可以直接替换的,而下面的需要转换完再带进去。 简单一点的话可以全都先带进去然后把arguments那一段放Node里运行得到转换后的参数再带进去。但为了更多展示如何操作语法树,决定尝试用语法树去自动转换。

我的思路是先遍历CallExpression,直接粗暴的判断是不是参数大于10,可以直接定位到最外层。然后把前面转换arguments的参数带进去。 这样我们得到了一段转换arguments的语法树,然后把它转成js放到一个文件中(transferArgs.js),通过require引入。

如果是里面的自执行函数,直接跳过。 然后在外面的自执行函数阶段require('transferArgs.js'),并运行转换arguments,这样得到转换后的arguments。 再然后从外面的函数遍历里面自执行函数找到变量替换成这些基本值。

由于我们的arguments是ast node,而这个函数运行时是带的值,我们需要做一些转化。 我们看到两个for循环,第一个是把字符串反转,第二个只是改变参数的顺序。 第二个for不需要改,第一个可以改成对树节点的操作

// 转换完的transfer.js
function transfer() {
  if ("\u202E" !== "\u202E") {
    return;
  }

  var _0x3f0c99 = arguments;

  var _0x83b777;

  for (_0x83b777 = 0; _0x83b777 < 760; _0x83b777++) {
      // typeof node[peoperty] === 'string' => node[property].type === StringLiteral
    if (_0x3f0c99[_0x83b777].type === "StringLiteral") {
         // 加上.value
      _0x3f0c99[_0x83b777].value = _0x3f0c99[_0x83b777].value["split"]("")["reverse"]()["join"]("");
    }
  }

  for (_0x83b777 = 0; _0x83b777 < 760 / 2; _0x83b777++) {
    var _0x1f2bc2 = _0x3f0c99[_0x83b777];
    _0x3f0c99[_0x83b777] = _0x3f0c99[760 - _0x83b777 - 1];
    _0x3f0c99[760 - _0x83b777 - 1] = _0x1f2bc2;
  }

  return _0x3f0c99;
}

module.exports = transfer;

下面看看如何转化的

function generateTransferFile(path, filePath) {
    let node = path.node
    const argValues = node.arguments
    const paramIdentifiers = node.callee.params.map(n => n.name)
    // 找到var _0x3f0c99 = arguments;中变量名_0x3f0c99,我们改造最后要return出去
    let argVarNode
    path.traverse({
        enter: function (_path) {
            if (_path.node.name === 'arguments') {
                const varPath = _path.find(parentPath => {
                    return parentPath.isVariableDeclarator()
                })
                if (varPath) {
                    argVarNode = varPath.node.id
                    _path.stop()
                }

            }
        }
    })
    // node.callee.body是BlockStatement, node.callee.body.body是函数体, 由于最后一个是内部的自执行函数,我们先去掉
    const body = node.callee.body.body.slice(0, node.callee.body.body.length - 1)
    // 里面的代码主体
    const mainBody = node.callee.body.body[node.callee.body.body.length - 1]

    // 把转换完的arguments return出去
    const retStatement = t.returnStatement(argVarNode)
    // 套个function的壳子 function transfer(){codes}
    const fn = t.functionDeclaration(t.identifier('transfer'), [], t.blockStatement(body.concat(retStatement)))
    
    // 因为需要生成一个完整的js,所以我们要补上最外面的program节点 并把函数导出, babel/template可以协助我们把代码的字符串转为ast
    const program = t.file(t.program([fn, template.ast('module.exports = transfer')]))


    traverse(program, {
        Identifier: {
            enter: (path) => {
                // 由于这个转换的时候字符串还是在参数的变量中,我们直接替换
                const node = path.node
                const idIdx = paramIdentifiers.indexOf(node.name)
                if (idIdx > -1) {
                    let valueNode = argValues[idIdx]
                    path.replaceWith(valueNode)
                }
            }
        },
        StringLiteral: {
            // 替换完变量为字符串才好运行, 所以这一步转判断ast的方法在exit的时候执行
            exit: path => {
                // 代码太长了不贴了..方法都差不多 有兴趣去github看吧
            }
        }
    })

    let { code } = generator(program)
    path.get('callee.body').replaceWith(t.blockStatement([mainBody]))
    fs.writeFileSync(filePath, code, { encoding: 'utf-8' })
}

这样我们就把对arguments的转化放到了transfer.js文件中,并且改造成了对ast的判断 最后引用调用一下再把传进来的基本值节点根据变量名替换掉即可。

我们可以看到node操作js ast有着先天的优势,不仅js用起来方便,而且代码可以直接运行。 后面我打算写一点使用babel时用到的path, scope, binding等可以帮助我们更好操作语法树的概念