十分钟的爬虫erAST解混淆

前言

本文大约4000字,阅读大约时间10分钟。

可以一口气读完入门在爬虫er手中如何使用AST去解混淆。

正文

抽象语法树(Abstract Syntax Tree)通常被称为AST语法树,指的是源代码语法所对应的树状结构。也就是一种将源代码通过构建语法树,将源代码的语句映射到树上的每一个节点。

在爬虫er手中,通常将JavaScript源代码解析为语法树,操作节点的增删改查来实现解混淆的目的。

需要用到的技术:

  1. node.js
  2. node.js的第三方库包Babel下的部分工具(@babel/parser,@babel/traverse,@babel/types,@babel/generator)

安装

1、下载node.js msi安装包一路确认就好了,最后在命令行下输入 node -v 验证是否安装成功

2、使用npm安装Babel库

// 安装命令
// 只需要babel中以下的工具,不建议使用npm install @babel/core安装,会造成编辑器中只能补全不全。
npm install @babel/parser
npm install @babel/traverse
npm install @babel/types
npm install @babel/generator
// 查看版本
npm ls [package_name]

必须的知识

1、node.js中文件读写

node中提供文件系统模块(fs)进行文件读写,提供异步(readFile)和同步(readFileSync)的方法读取文件,相应的也提供writeFile、writeFileSync写入文件。

基本使用如下:

// 读取input.js文件base64编码后写入output.js
// node.js 在16.00之后也有atob,btoa了

const fs = require('fs')

fs.readFile('input.txt', "utf8", (err, input_js_code) => {
    console.log(input_js_code);
    let output_code = btoa(encodeURIComponent(input_js_code));
    console.log(output_code);
    fs.writeFileSync('output.txt', output_code, {encoding: "utf-8"})
});

// 输出:
// 又是一个爬虫er
// JUU1JThGJTg4JUU2JTk4JUFGJUU0JUI4JTgwJUU0JUI4JUFBJUU3JTg4JUFDJUU4JTk5JUFCZXI=
2、babel中的一些工具的基本使用

需要用到babel中的工具有:@babel/parser@babel/traverse@babel/generator@babel/types。其中@babel/parser负责接受源码进行词法分析、语法分析最终生成AST;@babel/traverse负责对AST进行深度优先的遍历;@babel/generator则和@babel/parser相反,负责将AST转化为AST源码;@babel/types用于判断节点类型、生成新节点。

  1. parser.parse将JavaScript源代码解析为AST;generator(ast).code将AST转化为JavaScript源代码。
  2. traverse遍历AST节点,当遍历到节点名与visitor对象方法名一致时调用该visitor方法。
  3. types构造新节点、判断节点类型
  4. path,remove()删除节点path.insertBefore(node)

基本使用如下:

const fs = require('fs')
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const b_type = require("@babel/types");
const generator = require("@babel/generator").default;


fs.readFile('resources/input.js', "utf8", (err, input_js_code) => {
    // 将JavaScript源代码转化为AST
    let ast = parser.parse(input_js_code);
    //对AST各个节点进行遍历,当遍历到visitor内声明的节点时,进入并执行。
    traverse(ast, visitor)

    // 将AST转化为JavaScript代码,jsescOption选项去除16进制和Unicode
    let output_code = generator(ast, {minified: true, jsescOption: {minimal: true}}).code
    fs.writeFileSync('resources/output.js', output_code, {encoding: "utf-8"})
});

function funToStr(path) {
    // @babel/types常用api
    // let new_node = b_type.stringValue()
    // let new_node = b_type.numberValue()
    // let new_node = b_type.valueToNode()

    // 替换节点
    // path.replaceWith(string_node)
    // path.replaceWithMultiple(string_node)

    // 删除节点
    // path.remove()
    // 在这之前、之后插入节点
    // path.insertBefore()
    // path.insertAfter()
    
    // 获取兄弟节点
    // path.getAllPrevSiblings()
    // ...
}


const visitor = {
    // 调用函数节点
    CallExpression: {enter: [funToStr]},
    // ...
}
3、利用astexplorer和AST节点速查手册快速编写反混淆代码。

astexplorer网址:https://astexplorer.net/

AST节点速查手册网址:https://github.com/yacan8/blog/blob/master/posts/JavaScript%E6%8A%BD%E8%B1%A1%E8%AF%AD%E6%B3%95%E6%A0%91AST.md

使用AST进行解混淆的一般步骤为

  1. 观察JavaScript源代码,分析那些部分需要替换、删除
  2. 将源代码放入到astexplorer,观察需要处理的JavaScript片段特征。在astexplorer中点击左侧源代码,右侧AST即会跳转到对于节点。
  3. 结合AST节点速查手册在visitor中快速编写规则处理需要修改的节点。
4、一些能提升开发效率的工具
  • babel中文文档:https://www.babeljs.cn/docs
  • ast explorer助手(油猴插件):https://github.com/CC11001100/ast-explorer-helper

基本操作

了解完基础知识我们就可以进行一些简单的基本操作了

1、简单还原常量与标识符的混淆

在本小段中只讨论了常见的对字符常量名进行Unicode编码、数值常量进制转化混淆的还原。其他对字符常量btoa、ob混淆,对数值常量替换为表达式运算结果的情况不做讨论。

以下代码也可以作为我们利用AST解混淆的基本模板,本文所有代码都是基于此模板。

const fs = require('fs')
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const b_type = require("@babel/types");
const generator = require("@babel/generator").default;

// 模板
fs.readFile('resources/Unicode.js', "utf8", (err, input_js_code) => {
    // 将JavaScript源代码转化为AST
    let ast = parser.parse(input_js_code);
    // 对AST各个节点进行遍历,当遍历到visitor内声明的节点时,进入并执行。
    traverse(ast, visitor)

    // 将AST转化为JavaScript代码,jsescOption选项还原16进制和Unicode
    let output_code = generator(ast, {minified: true, jsescOption: {minimal: true}}).code
    fs.writeFileSync('resources/deUnicode.js', output_code, {encoding: "utf-8"})
});

const visitor = {}

还原前后对照

2、简单还原ob混淆

对JavaScript源码使用基本的ob混淆后在文件开头会见到一个大数组,之后可能会有一个自执行函数对开头的大数组进行位移操作,在这后面还会提供一个解密函数返回被混淆的字符串。

还原这种类型的混淆我们首先需要将这个大数组、自执行的位移函数和解密函数提取出来,部分开发同学还会将解密函数在函数内赋值给一个新的局部变量在一定程度上干扰ob混淆被还原。

  1. 提出数组、自执行的位移函数和解密函数
    为了提高代码的可读性,一般情况下我们将数组、自执行的位移函数和解密函数提出到一个单独的文件,使用exports导出解密函数提供给解混淆使用。

    // 一般情况下只有一个解密函数
    exports.decryptStr = _0x36a8;
    exports.decryptStrFnName = '_0x36a8';
    

  2. 编写规则处理代码
    以下以jsjiami的v5基础版本做实例,在这一小段只简单还原了OB混淆段。

const fs = require('fs')
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const b_type = require("@babel/types");
const generator = require("@babel/generator").default;
const {decryptStr, decryptStrFnName} = require("./tools_v5")

// 模板
fs.readFile('resources/jsjiami_v5.js', "utf8", (err, input_js_code) => {
    // 将JavaScript源代码转化为AST
    let ast = parser.parse(input_js_code);
    // 对AST各个节点进行遍历,当遍历到visitor内声明的节点时,进入并执行。
    traverse(ast, visitor)
    // 将AST转化为JavaScript代码,jsescOption选项还原16进制和Unicode
    let output_code = generator(ast, {minified: true, jsescOption: {minimal: true}}).code
    fs.writeFileSync('resources/dejsjiami_v5.js', output_code, {encoding: "utf-8"})
});

function funToStr(path) {
    let {callee, arguments} = path.node
    if (callee.name === decryptStrFnName) {
        let replace_value = decryptStr(arguments[0].value, arguments[1].value)
        let replace_node = b_type.stringLiteral(replace_value)
        path.replaceWith(replace_node)
    }
}

const visitor = {
    CallExpression: {
        enter: [funToStr]
    }
}

还原前后对照:

3、简单还原平坦化

除去对变量的混淆,常见的还有代码执行流程的混淆,通常使用while-switch,for-switch来将正常的执行流程打乱,然后通过swtich case块分发代码块并控制执行顺序,部分混淆还会塞入假代码块。对于这一类的混淆,在while-switch中我们首先需要确定分发器,之后通过分发器的执行顺序抽取出对应case内的代码块存入数组使用replaceWithMultiple替换整个节点;在for-switch中和while-switch不同的是分发器的生成,while-switch会在代码进入while前就生成分发器确定代码执行顺序且while一般为死循环在执行完分发器分发的最后一个case后跳出;for-switch则是在case内确定下一个case的执行,当分发器的值和for循环中的跳出吻合时结束。

while-switch平坦化还原:

const fs = require('fs')
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const b_type = require("@babel/types");
const generator = require("@babel/generator").default;


fs.readFile('resources/switch_case.js', "utf8", (err, input_js_code) => {
    // 将JavaScript源代码转化为AST
    let ast = parser.parse(input_js_code);
    //对AST各个节点进行遍历,当遍历到visitor内声明的节点时,进入并执行。
    traverse(ast, visitor);

    // 将AST转化为JavaScript代码,jsescOption选项去除16进制和Unicode
    let output_code = generator(ast, {minified: false, jsescOption: {minimal: true}}).code;
    fs.writeFileSync('resources/de_switch_case.js', output_code, {encoding: "utf-8"})
});

function de_planarization(path) {
    let {body, test} = path.node
    // 判断while内是否为!![]
    if (test.type !== "UnaryExpression" || test.operator !== "!"
        || test.argument.type !== "UnaryExpression" || test.argument.operator !== "!"
        || test.argument.argument.type !== "ArrayExpression" || test.argument.argument.elements.length !== 0
    ) return;

    // 判断while内代码块是否为switch case
    if (body.body.length === 0
        || body.body[0].type !== 'SwitchStatement'
        || body.body[1].type !== 'BreakStatement'
    ) return;

    // 取switch内分发器名字
    let discriminant = body.body[0].discriminant
    let dispatcher_name = discriminant.object.name

    // 取while循环上面的兄弟节点,用于获取case执行步骤
    let PrevSiblings = path.getAllPrevSiblings();
    // case执行步骤
    let replace_node = []

    //迭代兄弟节点
    PrevSiblings.forEach(Sibling => {
        // 判断是否为分发器的赋值语句
        if (Sibling.node.type === "VariableDeclaration" && Sibling.node.declarations[0].id.name === dispatcher_name) {
            let {callee, arguments} = Sibling.node.declarations[0].init

            // 分发器字符串切割符号
            let split_str = arguments[0].value
            // 分发器字符串的值
            let dispatcher_value = callee.object.value
            // 分发器处理函数,一般看源码就可以确认了,split_str、dispatcher_func可以不获取
            let dispatcher_func = callee.property.value

            // case代码块执行步骤
            let real_steps = dispatcher_value[dispatcher_func](split_str)
            // let real_steps = dispatcher_value.split('|')

            let switch_node = body.body[0]
            real_steps.forEach(step => {
                // 获取case内代码块
                let consequent = switch_node.cases[step].consequent
                // 移出continue代码
                if (b_type.isContinueStatement(consequent[consequent.length - 1])) {
                    consequent.pop();
                }

                replace_node = replace_node.concat(consequent);
            })
        }
        //删除前面的兄弟节点
        Sibling.remove();
    })

    //替换整个while节点
    path.replaceWithMultiple(replace_node);
}

const visitor = {
    WhileStatement: {
        enter: [de_planarization]
    }
}

还原前后对比:

for-switch平坦化还原:

const fs = require('fs')
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const b_type = require("@babel/types");
const generator = require("@babel/generator").default;


fs.readFile('resources/for_switch_case.js', "utf8", (err, input_js_code) => {
    // 将JavaScript源代码转化为AST
    let ast = parser.parse(input_js_code);
    //对AST各个节点进行遍历,当遍历到visitor内声明的节点时,进入并执行。
    traverse(ast, visitor)

    // 将AST转化为JavaScript代码,jsescOption选项去除16进制和Unicode
    let output_code = generator(ast, {minified: false, jsescOption: {minimal: true}}).code
    fs.writeFileSync('resources/de_for_switch_case.js', output_code, {encoding: "utf-8"})
});

function de_planarization(path) {
    let {init, test, update, body} = path.node
    // 判断for循环跳出特征
    if (test.operator !== "!==") return;

    // 起始代码块索引
    let start_index = init.declarations[0].init.value
    // 结束索引
    let end_index = test.right.value
    // 索引在源代码中的名字
    let dispatcher_name = test.left.name;

    // 替换节点
    let replace_node = []
    body.body.forEach(node => {
        if (node.type === "SwitchStatement") {
            // switch case分发器特征是否满足条件
            if (node.discriminant.name !== dispatcher_name) return;
            // 按源代码顺序迭代case
            for (let i = start_index; i !== end_index;) {
                let SwitchCase = node.cases[i]
                let need_save = []
                SwitchCase.consequent.forEach(Statement => {
                    // 获取下一个执行的代码块index
                    if (Statement.type !== 'ExpressionStatement'
                        || Statement.expression.type !== "AssignmentExpression"
                        || Statement.expression.left.name !== dispatcher_name
                    ) {
                        // 将其与代码保存
                        need_save.push(SwitchCase.consequent.indexOf(Statement))
                        return
                    }
                    // 下一个执行的代码块
                    i = Statement.expression.right.value

                })
                need_save.forEach(index => {
                    let case_code = SwitchCase.consequent[index]
                    // 删除continue
                    if (b_type.isContinueStatement(case_code)) return;
                    replace_node = replace_node.concat(case_code);
                })
               
            }
            path.replaceWithMultiple(replace_node)
        }
    })
}

const visitor = {
    ForStatement: {
        enter: [de_planarization]
    }
}

还原前后代码对比:

使用基本操作进行基本还原

实战我们以某验三代滑块最新fullpage.9.1.0.jshttps://static.geetest.com/static/js/fullpage.9.1.0.js为例进行实战还原。

观察JavaScript源码明显可见有常量unicode编码混淆,收缩代码观察JavaScript源代码主体,最后一个代码块为自执行函数,前五个代码块定义了一个zmSjO函数对象,之后往塞了里面四个方法 A i 、 _Ai、 Ai_BE、、 C s 、 _Cs、 Cs_DB,我们暂时不知道这个有啥用。展开最后一个自执行函数观察,发现一段大量重复的代码,截取一段如下:

var $_CBJHn = zmSjO.$_Cs
, $_CBJGy = ['$_CCAAe'].concat($_CBJHn)
, $_CBJIX = $_CBJGy[1];
$_CBJGy.shift();
var $_CBJJK = $_CBJGy[0];

zmSjO. C s 是在前五个代码块中塞入的方法,这段代码中 _Cs是在前五个代码块中塞入的方法,这段代码中 Cs是在前五个代码块中塞入的方法,这段代码中_CBJHn、 C B J I X 、 _CBJIX、 CBJIX_CBJGy都等于zmSjO.$_Cs,在之后的代码中使用函数返回字符串来代替可见字符串也就是常说的ob混淆。虽然与基本操作中使用的示例有一定区别但是基本思路一致。

继续观察源代码,除此之外还存在基本操作中提到的for-switch类型平坦化,截取一段代码如下:

var $_DEFDa = zmSjO.$_DB()[2][4];
for (; $_DEFDa !== zmSjO.$_DB()[0][3];) {
    switch ($_DEFDa) {
        case zmSjO.$_DB()[2][4]:
            this[$_DAHB(20)] = this[$_DAHB(905)]();
            $_DEFDa = zmSjO.$_DB()[2][3];
            break;
    }
}

代码中定义的分发器 D E F D a 是从 z m S j O . _DEFDa是从zmSjO. DEFDa是从zmSjO._DB函数返回的大数组中取的值,case也是从zmSjO.$_DB函数返回的大数组中取的值作的索引,这种和我们基本操作中使用的示例有一定区别,case索引不在是顺序的1-9,但是还原思路一致。

还原代码如下:

const fs = require('fs')
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const b_type = require("@babel/types");
const generator = require("@babel/generator").default;
const {decryptStr, decryptStrFnName, case_arrays} = require("./tools_fullpage9.1.0")

fs.readFile('resources/fullpage.9.1.0.js', "utf8", (err, input_js_code) => {
    // 将JavaScript源代码转化为AST
    let ast = parser.parse(input_js_code);
    //对AST各个节点进行遍历,当遍历到visitor内声明的节点时,进入并执行。
    traverse(ast, visitor)

    // 将AST转化为JavaScript代码,jsescOption选项去除16进制和Unicode
    let output_code = generator(ast, {minified: true, jsescOption: {minimal: true}}).code
    fs.writeFileSync('resources/de_fullpage.9.1.0.js', output_code, {encoding: "utf-8"})
});

let fake_array = []

function affirm_ob_variable(path) {
    let {kind, declarations} = path.node
    fake_array = []
    if (kind !== "var"
        || declarations[0].init === null
        || declarations[0].init.type !== "MemberExpression"
        || declarations[0].init.object.name !== "zmSjO"
        || declarations[0].init.property.name !== decryptStrFnName
    ) return
    fake_array.push(declarations[0].id.name)
    fake_array.push(declarations[1].id.name)
    fake_array.push(declarations[2].id.name)
    path.getFunctionParent().traverse(visitor_2)
    path.getNextSibling().remove()
    path.getNextSibling().remove()
    path.remove()
}

function funToStr(path) {
    let {callee, arguments} = path.node
    if (fake_array.indexOf(callee.name) > -1) {
        let replace_str = b_type.valueToNode(decryptStr(arguments[0].value))
        path.replaceWith(replace_str)
    }

}

function de_planarization(path) {
    let {init, test, update, body} = path.node
    // 判断for循环跳出特征

    if (test.operator !== "!=="
        || test.right.type !== "MemberExpression"
        || test.right.object.type !== "MemberExpression"
        || test.right.object.object.type !== "CallExpression"
        || test.right.object.object.callee.object.name !== "zmSjO"
        || test.right.object.object.callee.property.name !== "$_DB"
    ) return;

    // 用于替换for-switch节点的节点列表
    let replace_node = []
    // case分发器值和相应case代码段、下一步组成的字典
    let case_map = {}


    // 起始代码块索引
    let before_code = path.getPrevSibling()
    // 分发器特征判断
    if (before_code.type !== "VariableDeclaration") return;
    let {declarations, kind} = before_code.node
    // 起始索引
    let start_index = case_arrays[declarations[0].init.object.property.value] [declarations[0].init.property.value]
    // 分发器名
    let dispatcher_name = declarations[0].id.name
    // 删除分发器初始化赋值代码
    before_code.remove()

    // 第一步执行的代码段


    // 遍历 for内代码块
    body.body.forEach(node => {
        if (node.type === "SwitchStatement") {
            // switch case分发器特征是否满足条件
            if (node.discriminant.name !== dispatcher_name) return;
            // 遍历case节点
            node.cases.forEach(SwitchCase => {
                // case对应的分发器值
                let case_index = case_arrays[SwitchCase.test.object.property.value][SwitchCase.test.property.value]
                let next_index;
                // 如果该case最后一句为break则不需要记录下一步分发器的值
                if (!b_type.isBreakStatement(SwitchCase.consequent[SwitchCase.consequent.length - 1])) {
                    SwitchCase.consequent.forEach(Statement => {
                            if (Statement.type === "ExpressionStatement") {
                                let {expression} = Statement
                                if (expression.type !== "AssignmentExpression"
                                    || expression.left.name !== dispatcher_name
                                ) return
                                // 读取下一步分发器的值
                                next_index = case_arrays[expression.right.object.property.value][expression.right.property.value]
                            }
                        }
                    )
                } else {
                    // 移出 最后一句break语句
                    SwitchCase.consequent.pop()
                    // 移出 倒数第二句break中无效的分发器赋值
                    if (b_type.isExpressionStatement(SwitchCase.consequent[SwitchCase.consequent.length - 1])) {
                        let consequent = SwitchCase.consequent[SwitchCase.consequent.length - 1]
                        if (consequent.expression.type === "AssignmentExpression"
                            || consequent.expression.left.name === dispatcher_name
                        ) {
                            SwitchCase.consequent.pop()
                        }

                    }
                }
                // 存入字典
                case_map[case_index] = {
                    "consequent": SwitchCase.consequent,
                    'next_index': next_index
                }

            })

        }
    })
    // 当start_index 为undefined时,结束循环
    while (start_index) {
        // 存在假代码,删除不执行的case
        if (!case_map[start_index]) break
        let consequent = case_map[start_index]["consequent"]
        // 下一步执行顺序
        start_index = case_map[start_index]["next_index"]
        // 将节点数组合并 存入replace_node
        replace_node = replace_node.concat(consequent)
    }
    // 批量替换
    path.replaceWithMultiple(replace_node)
}


const visitor = {
    VariableDeclaration: {enter: [affirm_ob_variable]},
    ForStatement: {enter: [de_planarization]},
}

const visitor_2 = {
    CallExpression: {
        enter: [funToStr]
    }
}

结尾

本文为入门科普向文章,还有很多有用的API和混淆没有提到,总归是常见的混淆都讲了一遍,足够我们对绝大多数JavaScript源代码混淆在一定程度上还原。AST还原JavaScript代码混淆并不困,但要还原出精简的JavaScript源代码还是需要大量的练习。

在web端逆向、还原过程中使用AST反混淆可以极大地提升我们的效率,但是更需要的是我们对JavaScript的熟悉、对JavaScript逆向还原的经验。

所有代码已经上传Github:https://github.com/luojunjunjun/article/tree/master/10min%20AST

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
安装AST混淆的具体步骤如下: 1. 首先,下载并安装Java开发工具包(JDK)。AST混淆工具是基于Java的,所以您需要先安装JDK并将其配置到系统环境变量中。 2. 下载并安装AST混淆工具。您可以在AST混淆工具的官方网站或代码托管平台上找到安装包,并按照提供的指示进行安装。 3. 打开AST混淆工具的安装目录,并找到主程序的可执行文件。 4. 配置AST混淆工具。在主程序的安装目录中,找到配置文件并打开它。根据您的需求,配置文件中的选项包括输入文件路径、输出文件路径、混淆算法选择等。根据您的实际需求,修改这些选项。 5. 使用AST混淆工具进行混淆。在命令行或终端中导航到AST混淆工具的安装目录,并执行主程序的可执行文件。根据您在配置文件中设置的选项,输入需要混淆的文件路径,并选择适当的混淆算法。然后,执行混淆命令。 6. 等待混淆过程完成。根据您需要混淆的文件大小和复杂性,混淆过程可能需要一些时间。请耐心等待,直到混淆过程完成。 7. 检查混淆结果。混淆工具通常会生成一个混淆后的文件,并将其保存到您在配置文件中指定的输出路径中。您可以打开该文件以查看混淆结果,并验证其有效性。 总之,安装AST混淆工具的过程大致分为下载安装、配置选项、执行混淆命令和检查结果等步骤。请按照以上步骤操作,以完成安装和使用AST混淆工具。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值