天天看點

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

什麼是 AST

AST(Abstract Syntax Tree),中文抽象文法樹,簡稱文法樹(Syntax Tree),是源代碼的抽象文法結構的樹狀表現形式,樹上的每個節點都表示源代碼中的一種結構。文法樹不是某一種程式設計語言獨有的,JavaScript、Python、Java、Golang 等幾乎所有程式設計語言都有文法樹。

小時候我們得到一個玩具,總喜歡把玩具拆解成一個一個小零件,然後按照我們自己的想法,把零件重新組裝起來,一個新玩具就誕生了。而 JavaScript 就像一台精妙運作的機器,通過 AST 解析,我們也可以像童年時拆解玩具一樣,深入了解 JavaScript 這台機器的各個零部件,然後重新按照我們自己的意願來組裝。

AST 的用途很廣,IDE 的文法高亮、代碼檢查、格式化、壓縮、轉譯等,都需要先将代碼轉化成 AST 再進行後續的操作,ES5 和 ES6 文法差異,為了向後相容,在實際應用中需要進行文法的轉換,也會用到 AST。AST 并不是為了逆向而生,但做逆向學會了 AST,在解混淆時可以如魚得水。

AST 有一個線上解析網站:https://astexplorer.net/ ,頂部可以選擇語言、編譯器、是否開啟轉化等,如下圖所示,區域①是源代碼,區域②是對應的 AST 文法樹,區域③是轉換代碼,可以對文法樹進行各種操作,區域④是轉換後生成的新代碼。圖中原來的 Unicode 字元經過操作之後就變成了正常字元。

文法樹沒有單一的格式,選擇不同的語言、不同的編譯器,得到的結果也是不一樣的,在 JavaScript 中,編譯器有 Acorn、Espree、Esprima、Recast、Uglify-JS 等,使用最多的是 Babel,後續的學習也是以 Babel 為例。

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

AST 在編譯中的位置

在編譯原理中,編譯器轉換代碼通常要經過三個步驟:詞法分析(Lexical Analysis)、文法分析(Syntax Analysis)、代碼生成(Code Generation),下圖生動展示了這一過程:

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

詞法分析

詞法分析階段是編譯過程的第一個階段,這個階段的任務是從左到右一個字元一個字元地讀入源程式,然後根據構詞規則識别單詞,生成 token 符号流,比如

isPanda('🐼')

,會被拆分成

isPanda

(

'🐼'

)

四部分,每部分都有不同的含義,可以将詞法分析過程想象為不同類型标記的清單或數組。

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

文法分析

文法分析是編譯過程的一個邏輯階段,文法分析的任務是在詞法分析的基礎上将單詞序列組合成各類文法短語,比如“程式”,“語句”,“表達式”等,前面的例子中,

isPanda('🐼')

就會被分析為一條表達語句

ExpressionStatement

isPanda()

就會被分析成一個函數表達式

CallExpression

🐼

就會被分析成一個變量

Literal

等,衆多文法之間的依賴、嵌套關系,就構成了一個樹狀結構,即 AST 文法樹。

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

代碼生成

代碼生成是最後一步,将 AST 文法樹轉換成可執行代碼即可,在轉換之前,我們可以直接操作文法樹,進行增删改查等操作,例如,我們可以确定變量的聲明位置、更改變量的值、删除某些節點等,我們将語句

isPanda('🐼')

修改為一個布爾類型的

Literal

true

,文法樹就有如下變化:

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

Babel 簡介

Babel 是一個 JavaScript 編譯器,也可以說是一個解析庫,Babel 中文網:https://www.babeljs.cn/ ,Babel 英文官網:https://babeljs.io/ ,Babel 内置了很多分析 JavaScript 代碼的方法,我們可以利用 Babel 将 JavaScript 代碼轉換成 AST 文法樹,然後增删改查等操作之後,再轉換成 JavaScript 代碼。

Babel 包含的各種功能包、API、各方法可選參數等,都非常多,本文不一一列舉,在實際使用過程中,應當多查詢官方文檔,或者參考文末給出的一些學習資料。Babel 的安裝和其他 Node 包一樣,需要哪個安裝哪個即可,比如

npm install @babel/core @babel/parser @babel/traverse @babel/generator

在做逆向解混淆中,主要用到了 Babel 的以下幾個功能包,本文也僅介紹以下幾個功能包:

  1. @babel/core

    :Babel 編譯器本身,提供了 babel 的編譯 API;
  2. @babel/parser

    :将 JavaScript 代碼解析成 AST 文法樹;
  3. @babel/traverse

    :周遊、修改 AST 文法樹的各個節點;
  4. @babel/generator

    :将 AST 還原成 JavaScript 代碼;
  5. @babel/types

    :判斷、驗證節點的類型、建構新 AST 節點等。
逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

@babel/core

Babel 編譯器本身,被拆分成了三個子產品:

@babel/parser

@babel/traverse

@babel/generator

,比如以下方法的導入效果都是一樣的:

const parse = require("@babel/parser").parse;
const parse = require("@babel/core").parse;

const traverse = require("@babel/traverse").default
const traverse = require("@babel/core").traverse
           

@babel/parser

@babel/parser

可以将 JavaScript 代碼解析成 AST 文法樹,其中主要提供了兩個方法:

  • parser.parse(code, [{options}])

    :解析一段 JavaScript 代碼;
  • parser.parseExpression(code, [{options}])

    :考慮到了性能問題,解析單個 JavaScript 表達式。

部分可選參數

options

參數 描述

allowImportExportEverywhere

預設

import

export

聲明語句隻能出現在程式的最頂層,設定為

true

則在任何地方都可以聲明

allowReturnOutsideFunction

預設如果在頂層中使用

return

語句會引起錯誤,設定為

true

就不會報錯

sourceType

預設為

script

,當代碼中含有

import

export

等關鍵字時會報錯,需要指定為

module

errorRecovery

預設如果 babel 發現一些不正常的代碼就會抛出錯誤,設定為

true

則會在儲存解析錯誤的同時繼續解析代碼,錯誤的記錄将被儲存在最終生成的 AST 的 errors 屬性中,當然如果遇到嚴重的錯誤,依然會終止解析

舉個例子看得比較清楚:

const parser = require("@babel/parser");

const code = "const a = 1;";
const ast = parser.parse(code, {sourceType: "module"})
console.log(ast)
           

{sourceType: "module"}

示範了如何添加可選參數,輸出的就是 AST 文法樹,這和線上網站 https://astexplorer.net/ 解析出來的文法樹是一樣的:

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

@babel/generator

@babel/generator

可以将 AST 還原成 JavaScript 代碼,提供了一個

generate

方法:

generate(ast, [{options}], code)

部分可選參數

options

參數 描述

auxiliaryCommentBefore

在輸出檔案内容的頭部添加注釋塊文字

auxiliaryCommentAfter

在輸出檔案内容的末尾添加注釋塊文字

comments

輸出内容是否包含注釋

compact

輸出内容是否不添加空格,避免格式化

concise

輸出内容是否減少空格使其更緊湊一些

minified

是否壓縮輸出代碼

retainLines

嘗試在輸出代碼中使用與源代碼中相同的行号

接着前面的例子,原代碼是

const a = 1;

,現在我們把

a

變量修改為

b

,值

1

修改為

2

,然後将 AST 還原生成新的 JS 代碼:

const parser = require("@babel/parser");
const generate = require("@babel/generator").default

const code = "const a = 1;";
const ast = parser.parse(code, {sourceType: "module"})
ast.program.body[0].declarations[0].id.name = "b"
ast.program.body[0].declarations[0].init.value = 2
const result = generate(ast, {minified: true})

console.log(result.code)
           

最終輸出的是

const b=2;

,變量名和值都成功更改了,由于加了壓縮處理,等号左右兩邊的空格也沒了。

代碼裡

{minified: true}

示範了如何添加可選參數,這裡表示壓縮輸出代碼,

generate

得到的

result

得到的是一個對象,其中的

code

屬性才是最終的 JS 代碼。

代碼裡

ast.program.body[0].declarations[0].id.name

是 a 在 AST 中的位置,

ast.program.body[0].declarations[0].init.value

是 1 在 AST 中的位置,如下圖所示:

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

@babel/traverse

當代碼多了,我們不可能像前面那樣挨個定位并修改,對于相同類型的節點,我們可以直接周遊所有節點來進行修改,這裡就用到了

@babel/traverse

,它通常和

visitor

一起使用,

visitor

是一個對象,這個名字是可以随意取的,

visitor

裡可以定義一些方法來過濾節點,這裡還是用一個例子來示範:

const parser = require("@babel/parser");
const generate = require("@babel/generator").default
const traverse = require("@babel/traverse").default

const code = `
const a = 1500;
const b = 60;
const c = "hi";
const d = 787;
const e = "1244";
`
const ast = parser.parse(code)

const visitor = {
    NumericLiteral(path){
        path.node.value = (path.node.value + 100) * 2
    },
    StringLiteral(path){
        path.node.value = "I Love JavaScript!"
    }
}

traverse(ast, visitor)
const result = generate(ast)
console.log(result.code)
           

這裡的原始代碼定義了 abcde 五個變量,其值有數字也有字元串,我們在 AST 中可以看到對應的類型為

NumericLiteral

StringLiteral

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

然後我們聲明了一個

visitor

對象,然後定義對應類型的處理方法,

traverse

接收兩個參數,第一個是 AST 對象,第二個是

visitor

,當

traverse

周遊所有節點,遇到節點類型為

NumericLiteral

StringLiteral

時,就會調用

visitor

中對應的處理方法,

visitor

中的方法會接收一個目前節點的

path

對象,該對象的類型是

NodePath

,該對象有非常多的屬性,以下介紹幾種最常用的:

屬性 描述

toString()

目前路徑的源碼

node

目前路徑的節點

parent

目前路徑的父級節點

parentPath

目前路徑的父級路徑

type

目前路徑的類型

PS:

path

對象除了有很多屬性以外,還有很多方法,比如替換節點、删除節點、插入節點、尋找父級節點、擷取同級節點、添加注釋、判斷節點類型等,可在需要時查詢相關文檔或檢視源碼,後續介紹

@babel/types

部分将會舉部分例子來示範,以後的實戰文章中也會有相關執行個體,篇幅有限本文不再細說。

是以在上面的代碼中,

path.node.value

就拿到了變量的值,然後我們就可以進一步對其進行修改了。以上代碼運作後,所有數字都會加上100後再乘以2,所有字元串都會被替換成

I Love JavaScript!

,結果如下:

const a = 3200;
const b = 320;
const c = "I Love JavaScript!";
const d = 1774;
const e = "I Love JavaScript!";
           

如果多個類型的節點,處理的方式都一樣,那麼還可以使用

|

将所有節點連接配接成字元串,将同一個方法應用到所有節點:

const visitor = {
    "NumericLiteral|StringLiteral"(path) {
        path.node.value = "I Love JavaScript!"
    }
}
           

visitor

對象有多種寫法,以下幾種寫法的效果都是一樣的:

const visitor = {
    NumericLiteral(path){
        path.node.value = (path.node.value + 100) * 2
    },
    StringLiteral(path){
        path.node.value = "I Love JavaScript!"
    }
}
           
const visitor = {
    NumericLiteral: function (path){
        path.node.value = (path.node.value + 100) * 2
    },
    StringLiteral: function (path){
        path.node.value = "I Love JavaScript!"
    }
}
           
const visitor = {
    NumericLiteral: {
        enter(path) {
            path.node.value = (path.node.value + 100) * 2
        }
    },
    StringLiteral: {
        enter(path) {
            path.node.value = "I Love JavaScript!"
        }
    }
}
           
const visitor = {
    enter(path) {
        if (path.node.type === "NumericLiteral") {
            path.node.value = (path.node.value + 100) * 2
        }
        if (path.node.type === "StringLiteral") {
            path.node.value = "I Love JavaScript!"
        }
    }
}
           

以上幾種寫法中有用到了

enter

方法,在節點的周遊過程中,進入節點(enter)與退出(exit)節點都會通路一次節點,

traverse

預設在進入節點時進行節點的處理,如果要在退出節點時處理,那麼在

visitor

中就必須聲明

exit

方法。

@babel/types

@babel/types

主要用于建構新的 AST 節點,前面的示例代碼為

const a = 1;

,如果想要增加内容,比如變成

const a = 1; const b = a * 5 + 1;

,就可以通過

@babel/types

來實作。

首先觀察一下 AST 文法樹,原語句隻有一個

VariableDeclaration

節點,現在增加了一個:

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

那麼我們的思路就是在周遊節點時,周遊到

VariableDeclaration

節點,就在其後面增加一個

VariableDeclaration

節點,生成

VariableDeclaration

節點,可以使用

types.variableDeclaration()

方法,在 types 中各種方法名稱和我們在 AST 中看到的是一樣的,隻不過首字母是小寫的,是以我們不需要知道所有方法的情況下,也能大緻推斷其方法名,隻知道這個方法還不行,還得知道傳入的參數是什麼,可以查文檔,不過K哥這裡推薦直接看源碼,非常清晰明了,以 Pycharm 為例,按住 Ctrl 鍵,再點選方法名,就進到源碼裡了:

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼
function variableDeclaration(kind: "var" | "let" | "const", declarations: Array<BabelNodeVariableDeclarator>)
           

可以看到需要

kind

declarations

兩個參數,其中

declarations

VariableDeclarator

類型的節點組成的清單,是以我們可以先寫出以下

visitor

部分的代碼,其中

path.insertAfter()

是在該節點之後插入新節點的意思:

const visitor = {
    VariableDeclaration(path) {
        let declaration = types.variableDeclaration("const", [declarator])
        path.insertAfter(declaration)
    }
}
           

接下來我們還需要進一步定義

declarator

,也就是

VariableDeclarator

類型的節點,查詢其源碼如下:

function variableDeclarator(id: BabelNodeLVal, init?: BabelNodeExpression)
           

觀察 AST,id 為

Identifier

對象,init 為

BinaryExpression

對象,如下圖所示:

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

先來處理 id,可以使用

types.identifier()

方法來生成,其源碼為

function identifier(name: string)

,name 在這裡就是 b 了,此時

visitor

代碼就可以這麼寫:

const visitor = {
    VariableDeclaration(path) {
        let declarator = types.variableDeclarator(types.identifier("b"), init)
        let declaration = types.variableDeclaration("const", [declarator])
        path.insertAfter(declaration)
    }
}
           

然後再來看 init 該如何定義,首先仍然是看 AST 結構:

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

init 為

BinaryExpression

對象,left 左邊是

BinaryExpression

,right 右邊是

NumericLiteral

,可以用

types.binaryExpression()

方法來生成 init,其源碼如下:

function binaryExpression(
    operator: "+" | "-" | "/" | "%" | "*" | "**" | "&" | "|" | ">>" | ">>>" | "<<" | "^" | "==" | "===" | "!=" | "!==" | "in" | "instanceof" | ">" | "<" | ">=" | "<=",
    left: BabelNodeExpression | BabelNodePrivateName, 
    right: BabelNodeExpression
)
           

此時

visitor

代碼就可以這麼寫:

const visitor = {
    VariableDeclaration(path) {
        let init = types.binaryExpression("+", left, right)
        let declarator = types.variableDeclarator(types.identifier("b"), init)
        let declaration = types.variableDeclaration("const", [declarator])
        path.insertAfter(declaration)
    }
}
           

然後繼續構造 left 和 right,和前面的方法一樣,觀察 AST 文法樹,查詢對應方法應該傳入的參數,層層嵌套,直到把所有的節點都構造完畢,最終的

visitor

代碼應該是這樣的:

const visitor = {
    VariableDeclaration(path) {
        let left = types.binaryExpression("*", types.identifier("a"), types.numericLiteral(5))
        let right = types.numericLiteral(1)
        let init = types.binaryExpression("+", left, right)
        let declarator = types.variableDeclarator(types.identifier("b"), init)
        let declaration = types.variableDeclaration("const", [declarator])
        path.insertAfter(declaration)
        path.stop()
    }
}
           

注意:

path.insertAfter()

插入節點語句後面加了一句

path.stop()

,表示插入完成後立即停止周遊目前節點和後續的子節點,添加的新節點也是

VariableDeclaration

,如果不加停止語句的話,就會無限循環插入下去。

插入新節點後,再轉換成 JavaScript 代碼,就可以看到多了一行新代碼,如下圖所示:

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

常見混淆還原

了解了 AST 和 babel 後,就可以對 JavaScript 混淆代碼進行還原了,以下是部分樣例,帶你進一步熟悉 babel 的各種操作。

字元串還原

文章開頭的圖中舉了個例子,正常字元被換成了 Unicode 編碼:

console['\u006c\u006f\u0067']('\u0048\u0065\u006c\u006c\u006f\u0020\u0077\u006f\u0072\u006c\u0064\u0021')
           

觀察 AST 結構:

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

我們發現 Unicode 編碼對應的是

raw

,而

rawValue

value

都是正常的,是以我們可以将

raw

替換成

rawValue

value

即可,需要注意的是引号的問題,本來是

console["log"]

,你還原後變成了

console[log]

,自然會報錯的,除了替換值以外,這裡直接删除 extra 節點,或者删除 raw 值也是可以的,是以以下幾種寫法都可以還原代碼:

const parser = require("@babel/parser");
const generate = require("@babel/generator").default
const traverse = require("@babel/traverse").default

const code = `console['\u006c\u006f\u0067']('\u0048\u0065\u006c\u006c\u006f\u0020\u0077\u006f\u0072\u006c\u0064\u0021')`
const ast = parser.parse(code)

const visitor = {
    StringLiteral(path) {
        // 以下方法均可
        // path.node.extra.raw = path.node.rawValue
        // path.node.extra.raw = '"' + path.node.value + '"'
        // delete path.node.extra
        delete path.node.extra.raw
    }
}

traverse(ast, visitor)
const result = generate(ast)
console.log(result.code)
           

還原結果:

console["log"]("Hello world!");
           

表達式還原

之前K哥寫過 JSFuck 混淆的還原,其中有介紹

![]

可表示 false,

!![]

或者

!+[]

可表示 true,在一些混淆代碼中,經常有這些操作,把簡單的表達式複雜化,往往需要執行一下語句,才能得到真正的結果,示例代碼如下:

const a = !![]+!![]+!![];
const b = Math.floor(12.34 * 2.12)
const c = 10 >> 3 << 1
const d = String(21.3 + 14 * 1.32)
const e = parseInt("1.893" + "45.9088")
const f = parseFloat("23.2334" + "21.89112")
const g = 20 < 18 ? '未成年' : '成年'
           

想要執行語句,我們需要了解

path.evaluate()

方法,該方法會對 path 對象進行執行操作,自動計算出結果,傳回一個對象,其中的

confident

屬性表示置信度,

value

表示計算結果,使用

types.valueToNode()

方法建立節點,使用

path.replaceInline()

方法将節點替換成計算結果生成的新節點,替換方法有一下幾種:

  • replaceWith

    :用一個節點替換另一個節點;
  • replaceWithMultiple

    :用多個節點替換另一個節點;
  • replaceWithSourceString

    :将傳入的源碼字元串解析成對應 Node 後再替換,性能較差,不建議使用;
  • replaceInline

    :用一個或多個節點替換另一個節點,相當于同時有了前兩個函數的功能。

對應的 AST 處理代碼如下:

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

const code = `
const a = !![]+!![]+!![];
const b = Math.floor(12.34 * 2.12)
const c = 10 >> 3 << 1
const d = String(21.3 + 14 * 1.32)
const e = parseInt("1.893" + "45.9088")
const f = parseFloat("23.2334" + "21.89112")
const g = 20 < 18 ? '未成年' : '成年'
`
const ast = parser.parse(code)

const visitor = {
    "BinaryExpression|CallExpression|ConditionalExpression"(path) {
        const {confident, value} = path.evaluate()
        if (confident){
            path.replaceInline(types.valueToNode(value))
        }
    }
}

traverse(ast, visitor)
const result = generate(ast)
console.log(result.code)
           

最終結果:

const a = 3;
const b = 26;
const c = 2;
const d = "39.78";
const e = parseInt("1.89345.9088");
const f = parseFloat("23.233421.89112");
const g = "\u6210\u5E74";
           

删除未使用變量

有時候代碼裡會有一些并沒有使用到的多餘變量,删除這些多餘變量有助于更加高效的分析代碼,示例代碼如下:

const a = 1;
const b = a * 2;
const c = 2;
const d = b + 1;
const e = 3;
console.log(d)
           

删除多餘變量,首先要了解

NodePath

中的

scope

scope

的作用主要是查找辨別符的作用域、擷取并修改辨別符的所有引用等,删除未使用變量主要用到了

scope.getBinding()

方法,傳入的值是目前節點能夠引用到的辨別符名稱,傳回的關鍵屬性有以下幾個:

  • identifier

    :辨別符的 Node 對象;
  • path

    :辨別符的 NodePath 對象;
  • constant

    :辨別符是否為常量;
  • referenced

    :辨別符是否被引用;
  • references

    :辨別符被引用的次數;
  • constantViolations

    :如果辨別符被修改,則會存放所有修改該辨別符節點的 Path 對象;
  • referencePaths

    :如果辨別符被引用,則會存放所有引用該辨別符節點的 Path 對象。

是以我們可以通過

constantViolations

referenced

references

referencePaths

多個參數來判斷變量是否可以被删除,AST 處理代碼如下:

const parser = require("@babel/parser");
const generate = require("@babel/generator").default
const traverse = require("@babel/traverse").default

const code = `
const a = 1;
const b = a * 2;
const c = 2;
const d = b + 1;
const e = 3;
console.log(d)
`
const ast = parser.parse(code)

const visitor = {
    VariableDeclarator(path){
        const binding = path.scope.getBinding(path.node.id.name);

        // 如辨別符被修改過,則不能進行删除動作。
        if (!binding || binding.constantViolations.length > 0) {
            return;
        }

        // 未被引用
        if (!binding.referenced) {
            path.remove();
        }

        // 被引用次數為0
        // if (binding.references === 0) {
        //     path.remove();
        // }

        // 長度為0,變量沒有被引用過
        // if (binding.referencePaths.length === 0) {
        //     path.remove();
        // }
    }
}

traverse(ast, visitor)
const result = generate(ast)
console.log(result.code)
           

處理後的代碼(未使用的 b、c、e 變量已被删除):

const a = 1;
const b = a * 2;
const d = b + 1;
console.log(d);
           

删除備援邏輯代碼

有時候為了增加逆向難度,會有很多嵌套的 if-else 語句,大量判斷為假的備援邏輯代碼,同樣可以利用 AST 将其删除掉,隻留下判斷為真的,示例代碼如下:

const example = function () {
    let a;
    if (false) {
        a = 1;
    } else {
        if (1) {
            a = 2;
        }
        else {
            a = 3;
        }
    }
    return a;
};
           

觀察 AST,判斷條件對應的是

test

節點,if 對應的是

consequent

節點,else 對應的是

alternate

節點,如下圖所示:

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

AST 處理思路以及代碼:

  1. 篩選出

    BooleanLiteral

    NumericLiteral

    節點,取其對應的值,即

    path.node.test.value

  2. 判斷

    value

    值為真,則将節點替換成

    consequent

    節點下的内容,即

    path.node.consequent.body

  3. 判斷

    value

    值為假,則替換成

    alternate

    節點下的内容,即

    path.node.alternate.body

  4. 有的 if 語句可能沒有寫 else,也就沒有

    alternate

    ,是以這種情況下判斷

    value

    值為假,則直接移除該節點,即

    path.remove()

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

const code = `
const example = function () {
    let a;
    if (false) {
        a = 1;
    } else {
        if (1) {
            a = 2;
        }
        else {
            a = 3;
        }
    }
    return a;
};
`
const ast = parser.parse(code)

const visitor = {
    enter(path) {
        if (types.isBooleanLiteral(path.node.test) || types.isNumericLiteral(path.node.test)) {
            if (path.node.test.value) {
                path.replaceInline(path.node.consequent.body);
            } else {
                if (path.node.alternate) {
                    path.replaceInline(path.node.alternate.body);
                } else {
                    path.remove()
                }
            }
        }
    }
}

traverse(ast, visitor)
const result = generate(ast)
console.log(result.code)
           

處理結果:

const example = function () {
  let a;
  a = 2;
  return a;
};
           

switch-case 反控制流平坦化

控制流平坦化是混淆當中最常見的,通過

if-else

或者

while-switch-case

語句分解步驟,示例代碼:

const _0x34e16a = '3,4,0,5,1,2'['split'](',');
let _0x2eff02 = 0x0;
while (!![]) {
    switch (_0x34e16a[_0x2eff02++]) {
        case'0':
            let _0x38cb15 = _0x4588f1 + _0x470e97;
            continue;
        case'1':
            let _0x1e0e5e = _0x37b9f3[_0x50cee0(0x2e0, 0x2e8, 0x2e1, 0x2e4)];
            continue;
        case'2':
            let _0x35d732 = [_0x388d4b(-0x134, -0x134, -0x139, -0x138)](_0x38cb15 >> _0x4588f1);
            continue;
        case'3':
            let _0x4588f1 = 0x1;
            continue;
        case'4':
            let _0x470e97 = 0x2;
            continue;
        case'5':
            let _0x37b9f3 = 0x5 || _0x38cb15;
            continue;
    }
    break;
}
           

AST 還原思路:

  1. 擷取控制流原始數組,将

    '3,4,0,5,1,2'['split'](',')

    之類的語句轉化成

    ['3','4','0','5','1','2']

    之類的數組,得到該數組之後,也可以選擇把 split 語句對應的節點删除掉,因為最終代碼裡這條語句就沒用了;
  2. 周遊第一步得到的控制流數組,依次取出每個值所對應的 case 節點;
  3. 定義一個數組,儲存每個 case 節點

    consequent

    數組裡面的内容,并删除

    continue

    語句對應的節點;
  4. 周遊完成後,将第三步的數組替換掉整個 while 節點,也就是

    WhileStatement

不同思路,寫法多樣,對于如何擷取控制流數組,可以有以下思路:

  1. 擷取到

    While

    語句節點,然後使用

    path.getAllPrevSiblings()

    方法擷取其前面的所有兄弟節點,周遊每個兄弟節點,找到與

    switch()

    裡面數組的變量名相同的節點,然後再取節點的值進行後續處理;
  2. 直接取

    switch()

    裡面數組的變量名,然後使用

    scope.getBinding()

    方法擷取到它綁定的節點,然後再取這個節點的值進行後續處理。

是以 AST 處理代碼就有兩種寫法,方法一:(code.js 即為前面的示例代碼,為了友善操作,這裡使用 fs 從檔案中讀取代碼)

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

const code = fs.readFileSync("code.js", {encoding: "utf-8"});
const ast = parser.parse(code)

const visitor = {
    WhileStatement(path) {
        // switch 節點
        let switchNode = path.node.body.body[0];
        // switch 語句内的控制流數組名,本例中是 _0x34e16a
        let arrayName = switchNode.discriminant.object.name;
        // 獲得所有 while 前面的兄弟節點,本例中擷取到的是聲明兩個變量的節點,即 const _0x34e16a 和 let _0x2eff02
        let prevSiblings = path.getAllPrevSiblings();
        // 定義緩存控制流數組
        let array = []
        // forEach 方法周遊所有節點
        prevSiblings.forEach(pervNode => {
            let {id, init} = pervNode.node.declarations[0];
            // 如果節點 id.name 與 switch 語句内的控制流數組名相同
            if (arrayName === id.name) {
                // 擷取節點整個表達式的參數、分割方法、分隔符
                let object = init.callee.object.value;
                let property = init.callee.property.value;
                let argument = init.arguments[0].value;
                // 模拟執行 '3,4,0,5,1,2'['split'](',') 語句
                array = object[property](argument)
                // 也可以直接取參數進行分割,方法不通用,比如分隔符換成 | 就不行了
                // array = init.callee.object.value.split(',');
            }
            // 前面的兄弟節點就可以删除了
            pervNode.remove();
        });

        // 儲存正确順序的控制流語句
        let replace = [];
        // 周遊控制流數組,按正确順序取 case 内容
        array.forEach(index => {
                let consequent = switchNode.cases[index].consequent;
                // 如果最後一個節點是 continue 語句,則删除 ContinueStatement 節點
                if (types.isContinueStatement(consequent[consequent.length - 1])) {
                    consequent.pop();
                }
                // concat 方法拼接多個數組,即正确順序的 case 内容
                replace = replace.concat(consequent);
            }
        );
        // 替換整個 while 節點,兩種方法都可以
        path.replaceWithMultiple(replace);
        // path.replaceInline(replace);
    }
}

traverse(ast, visitor)
const result = generate(ast)
console.log(result.code)
           

方法二:

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

const code = fs.readFileSync("code.js", {encoding: "utf-8"});
const ast = parser.parse(code)

const visitor = {
    WhileStatement(path) {
        // switch 節點
        let switchNode = path.node.body.body[0];
        // switch 語句内的控制流數組名,本例中是 _0x34e16a
        let arrayName = switchNode.discriminant.object.name;
        // 擷取控制流數組綁定的節點
        let bindingArray = path.scope.getBinding(arrayName);
        // 擷取節點整個表達式的參數、分割方法、分隔符
        let init = bindingArray.path.node.init;
        let object = init.callee.object.value;
        let property = init.callee.property.value;
        let argument = init.arguments[0].value;
        // 模拟執行 '3,4,0,5,1,2'['split'](',') 語句
        let array = object[property](argument)
        // 也可以直接取參數進行分割,方法不通用,比如分隔符換成 | 就不行了
        // let array = init.callee.object.value.split(',');

        // switch 語句内的控制流自增變量名,本例中是 _0x2eff02
        let autoIncrementName = switchNode.discriminant.property.argument.name;
        // 擷取控制流自增變量名綁定的節點
        let bindingAutoIncrement = path.scope.getBinding(autoIncrementName);
        // 可選擇的操作:删除控制流數組綁定的節點、自增變量名綁定的節點
        bindingArray.path.remove();
        bindingAutoIncrement.path.remove();

        // 儲存正确順序的控制流語句
        let replace = [];
        // 周遊控制流數組,按正确順序取 case 内容
        array.forEach(index => {
                let consequent = switchNode.cases[index].consequent;
                // 如果最後一個節點是 continue 語句,則删除 ContinueStatement 節點
                if (types.isContinueStatement(consequent[consequent.length - 1])) {
                    consequent.pop();
                }
                // concat 方法拼接多個數組,即正确順序的 case 内容
                replace = replace.concat(consequent);
            }
        );
        // 替換整個 while 節點,兩種方法都可以
        path.replaceWithMultiple(replace);
        // path.replaceInline(replace);
    }
}

traverse(ast, visitor)
const result = generate(ast)
console.log(result.code)
           

以上代碼運作後,原來的

switch-case

控制流就被還原了,變成了按順序一行一行的代碼,更加簡潔明了:

let _0x4588f1 = 0x1;
let _0x470e97 = 0x2;
let _0x38cb15 = _0x4588f1 + _0x470e97;
let _0x37b9f3 = 0x5 || _0x38cb15;
let _0x1e0e5e = _0x37b9f3[_0x50cee0(0x2e0, 0x2e8, 0x2e1, 0x2e4)];
let _0x35d732 = [_0x388d4b(-0x134, -0x134, -0x139, -0x138)](_0x38cb15 >> _0x4588f1);
           

參考資料

本文有參考以下資料,也是比較推薦的線上學習資料:

  • Youtube 視訊,Babel 入門:https://www.youtube.com/watch?v=UeVq_U5obnE (作者 Nicolò Ribaudo,視訊中的 PPT 資料可在 K 哥爬蟲公衆号背景回複 Babel 免費擷取!)
  • 官方手冊 Babel Handbook:https://github.com/jamiebuilds/babel-handbook
  • 非官方 Babel API 中文文檔:https://evilrecluse.top/Babel-traverse-api-doc/

END

Babel 編譯器國内的資料其實不是很多,多看源碼、同時線上對照可視化的 AST 文法樹,耐心一點兒一層一層分析即可,本文中的案例也隻是最基本操作,實際遇到一些混淆還得視情況進行修改,比如需要加一些類型判斷來限制等,後續K哥會用實戰來帶領大家進一步熟悉解混淆當中的其他操作。

逆向進階,利用 AST 技術還原 JavaScript 混淆代碼

繼續閱讀