使用 AST 技术还原混淆代码

准备工作:需要对 JS 混淆 和 AST 有一定的了解

表达式还原

有时候我们会看到一些混淆的 JS 代码其实就是把简单的东西复杂化,比如一个布尔值常量 true ,被写成 !![ ] : 一个数字, 被转化为 parseInt 加一些字符串的拼接。 通过这些方式,一个简单的表达式就被复杂化了

例如;

const a = !![]
const b = "abc" == "bcd"
const c = (1 << 3) | 2
const d = parseInt("5" + "0")

在 = 的右侧其实都是一些表达式的类型, 比如说 "abc" == "bcd" 就是一个 BinaryExpression , 它代表的就是一个布尔类型的结果

我们将上述代码保存为 code1.js  , 然后编写代码 存入 basic1.js

import traverse from "@babel/traverse";
import {parse} from "@babel/parser"
import generate from "@babel/generator"
import * as types from "@babel/types"
import fs from "fs"

const code = fs.readFileSync("./codes/code1.js", "utf-8")
let ast = parse(code)
traverse(ast, {
    "UnaryExpression|BinaryExpression|ConditionalExpression|CallExpression":(path) =>{
        const {confident, value} = path.evaluate()
        if(value === Infinity || value === -Infinity) {
            return false;
        }
        confident && path.replaceWith(types.valueToNode(value))
    }
})

const {code: output} = generate(ast)
console.log(output)

 运行:  babel-node basic1.js 

const a = true;
const b = false;
const c = 10;
const d = 50;

这里我们使用 traverse 方法对 AST 对象进行遍历, 使用  "UnaryExpression|BinaryExpression|ConditionalExpression|CallExpression"  作为对象的键名, 分别用于处理一元表达式,布尔表达式,条件表达式,调用表达式。 如果 AST 对应的 path 对象符合这几种表达式, 就会执行我们定义的回调方法,在回调发放中, 我们调用了 path 的 evaluate 方法, 该方法会对 path 对象进行执行, 计算所得到的结果。 其内部实现会返回一个 confident 和 value 字段表示置信度, 如果认定结果是可信的, 那么 confident 就是 true , 我们可以调用 path 的 replaceWith 方法把执行结果 value 进行替换,否则不替换

字符串还原

JS 混淆后的字符串,例如 

const string = [ "\x68\x65\x6c\x6c\x6f", "\x77\x6f\x72\x6c\x64" ]

其实这原本就是一个简单的字符串,被转换成 UTF-8 编码后,其可读性大大降低,如果这样的字符串被隐藏在 JS 代码里面,我们通过搜索字符串的方式找关键突破口,就搜不到了

我们先在 AST explorer 里面把代码粘贴进去

可以看到两个字符串都被识别成了 StringLiteral 类型, 它们都有一个 Literal 属性, Literal 里面有一个 value 和 raw , 二者不一样, value 的真实值已经被分析出来了

因此我们只需要将 StringLiteral 中 Literal 里面的 raw 替换为 value 的值即可

import traverse from "@babel/traverse";
import {parse} from "@babel/parser"
import generate from "@babel/generator"
import fs from "fs"

const code = fs.readFileSync("./codes/code2.js", "utf-8")
let ast = parse(code)
traverse(ast, {
    StringLiteral({node}){
        if(node.extra && /\\[ux]/gi.test(node.extra.raw)){
            node.extra.raw = node.extra.tawValue
        }
    }
})
const {code:output} = generate(ast)
console.log(output)

 babel-node basic2.js 

const string = ["hello", "world"];

这样我们就成还原了字符串

无用代码剔除

剔除一些无用代码,不会被执行的代码

const _0x16c18d = function (){
    if(!![[]]){
        console.log("hello world")
    }else{
        console.log("this")
        console.log("is")
        console.log("dead")
        console.log("code")
    }
}
const _0x1f7292 = function (){
    if("xmv2n0dfy2N".charAt(4) !== String.fromCharCode(110)){
        console.log("this")
        console.log("is")
        console.log("dead")
        console.log("code")
    }else {
        console.log("nice to meet you")
    }
}

_0x16c18d()
_0x1f7292()

这里首先声明了两个方法,最后分别调用,而且这两个方法内部分别都有一些 if else 语句。比如第一个 if 语句的判定条件是 !![[]], 乍看起来并不能直观的看出来它的真实值到底是多少,其实这里有一个双重否定, 后面紧跟一个二维数组 [[]] , 由于 [[]] 本身就是一个非空对象,加上双重否定结果就是 true。第二个 if  语句的判定条件则是一个字符串的判断, 前者 “xmv2n0dfy2N”.charAt(4) 其实就是字符 n , String.fromCharAt(110) 就是把 110 这个 ASCALL 码转化为字符,结果也是 n ,而判定符又是 !== ,所以整个表达式的结果就是 false

所以说第一个执行的就是 if 对应的区块, else 对应的区块是不会被执行的。 第二个其实执行的就是 else 区块, if 对应的区块不会被执行。 不会执行的代码其实是冗余的,起到一些干扰的作用

对于这种情况, 我们也可以用 AST 来把一些僵尸代码去除

首先我们把代码贴到  AST explorer 上分析一下

选中第一个 if 语句, 可以看到它对应的就是一个 IfStatement 节点, 它有 type ,start, end, loc, test, consequent, alternate这几个属性,其中 test 就是指 if 判定语句, 就是  !![[]] ,consequent 就是 if 对应的语块, alternate 就是 else 对应的语块。

所以这里可以实现如下代码

import traverse from "@babel/traverse";

import {parse} from "@babel/parser"

import generate from "@babel/generator"

import * as types from "@babel/types"

import fs from "fs"

const code = fs.readFileSync("./codes/code3.js", "utf-8")

let ast = parse(code)

traverse(ast, {

    IfStatement(path){

        let {consequent, alternate} = path.node

        let testPath = path.get("test")

        const evaluateTest = testPath.evaluateTruthy()

        if(evaluateTest === true){

            if(types.isBlockStatement(consequent)){

                consequent = consequent.body

            }

            path.replaceExpressionWithMultiple(consequent)

        }else if(evaluateTest === false){

            if(alternate != null){

                if(types.isBlockStatement(alternate)){

                    alternate = alternate.body

                }

                path.replaceWithMultiple(alternate)

            }else {

                path.remove

            }

        }

    }

})

const {code: output} = generate(ast)

console.log(output)

这里我们定义了一个 IfStatement 的处理方法, 首先获取到 path 对应节点的 consequent 和 alternate 属性, 然后拿到 test 属性对应的 path , 赋值为 testPath , 接着调用 testPath 的 evaluateTruthy 方法, evaluateTruthy 方法返回对应 path 的真值。比如说 , 对于第一个 if 判定语句 !![[]] , 它的值是 true , 那么 evaluateTruthy 方法返回的结果就是 true 。如果是 true , 直接将整个 path 替换成 consequent 对应的节点,也就是说第一个方法原本是

const _0x16c18d = function (){

    if(!![[]]){

        console.log("hello world")

    }else{

        console.log("this")

        console.log("is")

        console.log("dead")

        console.log("code")

    }

直接替换成

console.log("hello world")

所以原本不会被执行的代码就被完全删除了,同时 if 和 else 语句也被删除了,最后只剩下可以被执行的代码了

babel-node basic3.js

const _0x16c18d = function () {
  console.log("hello world");
};
const _0x1f7292 = function () {
  console.log("nice to meet you");
};
_0x16c18d();
_0x1f7292();

可以看到无用代码被剔除了

反控制流平坦化

其实就是把正常逻辑的顺序进行了混淆,通过一些 if else 或者 switch 语句进行拆分,导致我们不能很直观的看到各个代码区块执行的顺序

const s= "3|1|2".split("|")

let x = 0

while(true){

    switch(s[x++]){

        case "1":

            const a = 1

            continue;

        case "2":

            const b = 2

            continue;

        case "3":

            const c = 0

            continue

    }

    break;

}

可以看到,这里首先定义了一个 s 变量, 其中使用 split 方法对字符串进行分割,结果其实就是 ["3", "2", "1" ] ,然后配合使用 while switch语句, 这里判定 s[x++] 变量, 每执行一次循环, 它的结果就会变一次,三次循环分别是 3, 1, 2 , 然后每次循环都匹配对应的 case 语句并执行不同的语句, 所以说代码真正的执行顺序是

const c =  0

const a = 1

const b = 2

而经过控制流平坦化后,代码原本的执行循序就被混淆了,我们一眼不能看出真正的执行顺序。

要进行代码还原,我们要进行如下处理

首先找到 switch 语句相关节点,拿到对应节点对象,比如各个 case 语句对应的代码区块。

分析 switch 语句判定条件 s 变量的对应的列表结果,比如将 "3|2|1".split("|") 转换成 ["3", "2", "1"]

遍历 s 变量对应的列表, 将其和各个 case 语句进行匹配,顺序得到对应的代码区块并保存

·用上一步得到的代码替换原来的代码即可

注意: 上述思路虽然看起来是转么为当前代码设计的还原方案,但其实对应的逻辑就是混淆工具 obfuscator 的常用套路,都是先用一个类似 b|a|c 这样的字符串,然后调用 split 方法得到一个列表,再使用 switch 语句匹配列表的每一个元素并执行对应的代码。所以上述解决方案其实也可以算比较通用的解决方案

接下来,我们分析一下。首先,把上述代码放到 AST explorer 上分析一下, while 就是一个无限循环, 我们来看看 switch 语句

可以看到 swithc 是一个 SwitchStatement 节点, 带有 discriminant 和 cases 两个属性,前者就是判定条件,对应的就是 s[x++], 后者就是 3 个 case 语句, 对应的是 3 个 SwitchCase 节点

所以我们要先尝试把可能用到的节点获取到, 比如 discriminant , case 和 discriminant 的 object, property , 相关代码如下

traverse(ast, {

    WhileStatement(path){

        const {node, scope} = path

        const {test, body} = node

        let switchNode = body.body[0]

        let {discriminant, cases} = switchNode

        let {object, property} = discriminant

    }

})

由于我们关注的是 switch 的判定条件, 所以这里进一步追踪下判定条件 s[x++] 展开 object 可以看到它是一个 Identifier 节点

先拿到这个节点的 name 属性,添加如下代码

let arrName = object.name

这其实就是一个数组, 那么它的原始定义在哪里呢? 其实就是上面声明语句里的 const s = "3|2|1".split("|")。 那么我么知道了 s , 怎么拿到其原始定义呢? 我们可以使用 scope 对象的 getBinding 方法获取到它绑定的节点,添加如下代码:

let binding = scope.getBinding(arrName)

其实这个 binding 对应的就是 "3|2|1".split("|") 这段代码

我们再次选中这段代码,可以看到它是一个 CallExpression 节点

这里我们怎么获取它的真实值呢? 其实就是使用 “3|2|1” 调用 split 方法即可。 我们分别逐层拿到对应的值,然后进行动态调用

let {init} = binding.path.node

object = init.callee.object

property = init.callee.property

let argument = init.arguments[0].value

let arrayFlow = object.value[property.name](argument)

上面这几行代码其实就是等同于调用了 "3|2|1".split("|") , 只不过这里的值是我们从节点里面动态获取的, 所以,这里的 arrayFlow 的值就是 ["3", "2", "1"] 了。

后面,我们只需要遍历这个列表,找出对应的 case 语句对应的代码即可。 由于遍历执行是由顺序的,所以最终拿到的每个 case 对应的代码也是符合这个顺序的

因此我们添加如下代码

        let resultBody = []

        arrayFlow.forEach((index) => {

            let switchCase = cases.filter((c) =>

                c.test.value == index)[0]

            let caseBody = switchCase.consequent

            if(types.isContinueStatement(caseBody[caseBody.length - 1])){

                caseBody.pop()

            }

            resultBody = resultBody.concat(caseBody)

        })

这里我们声明了一个 resultBody 变量用于保存匹配到的 case 对应的代码, 同时还把 continiue 语句移除了

最后,resultBody 里面就对应了三块代码

const c  = 0

const a = 1

const b = 3

这样原本的代码顺序我们就还原出来了。

最后只需要把最外层 path 对象的代码替换成 resultBody 对应的代码即可, 添加如下代码

path.replaceWithMultiple(resultBody)

最终整理完整代码如下

import traverse from "@babel/traverse";

import {parse} from "@babel/parser"

import generate from "@babel/generator"

import * as types from "@babel/types"

import fs from "fs"

const code = fs.readFileSync("./codes/code4.js", "utf-8")

let ast = parse(code)

traverse(ast, {

    WhileStatement(path){

        const {node, scope} = path

        const {test, body} = node

        let switchNode = body.body[0]

        let {discriminant, cases} = switchNode

        let {object, property} = discriminant

        let arrName = object.name

        let binding = scope.getBinding(arrName)

        let {init} = binding.path.node

        object = init.callee.object

        property = init.callee.property

        let argument = init.arguments[0].value

        let arrayFlow = object.value[property.name](argument)

        let resultBody = []

        arrayFlow.forEach((index) => {

            let switchCase = cases.filter((c) => c.test.value == index)[0]

            let caseBody = switchCase.consequent

            if(types.isContinueStatement(caseBody[caseBody.length - 1])){

                caseBody.pop()

            }

            resultBody = resultBody.concat(caseBody)

        })

        path.replaceWithMultiple(resultBody)

    }

})

const {code: output} = generate(ast)

console.log(output)

babel-node basic4.js

const s = "3|1|2".split("|");
let x = 0;
const c = 0;
const a = 1;
const b = 2;

可以看到这里的输出很简洁

  • 21
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
AST混淆还原是指通过对JavaScript代码的抽象语法树(AST)进行还原,来反混淆经过混淆处理的代码AST混淆还原入门可以通过以下几个步骤实现: 1. 了解抽象语法树(AST):抽象语法树是用于表示代码结构的一种数据结构。它将代码转换为树状结构,每个节点代表代码的不同部分。了解AST的基本概念和节点类型对于进行混淆还原非常重要。 2. 学习JavaScript语法:要进行AST混淆还原,需要对JavaScript的语法有一定的了解。熟悉JavaScript的语法规则和常见的代码结构将有助于理解和还原混淆代码。 3. 使用AST还原工具:在进行AST混淆还原时,可以使用一些开源的AST还原工具,如丁仔大佬的AST还原工具。这些工具可以将混淆后的代码转换为AST,然后通过对AST进行分析和还原,最终得到可读性较高的代码。 4. 学习AST还原技术:了解AST还原的原理和技术对于深入理解和应用AST还原工具非常重要。可以学习一些AST还原的基本技术,如遍历AST、修改AST节点等,以及一些高级的AST还原技术,如模式匹配、符号执行等。 5. 实践与练习:通过实践与练习,逐渐提升对AST混淆还原的理解和技巧。可以选择一些混淆代码进行还原,尝试使用AST还原工具进行还原,并对还原结果进行分析和验证。 需要注意的是,AST混淆还原是一个复杂的过程,对于不同的混淆代码可能会有不同的还原策略和技术。因此,除了入门的基础知识外,还需要不断学习和积累经验,才能在实际应用中取得更好的效果。<span class="em">1</span> #### 引用[.reference_title] - *1* [AST混淆js还原工具2.2(20230203)](https://download.csdn.net/download/jia666666/87413335)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_1"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值