使用模擬器混淆前端代碼


前言

很多時候,我們都會覺得混淆腳本程序是件困難的事,效果遠不及傳統程序的混淆力度。畢竟,腳本的初衷就是簡單易用。諸多先天不足的特征,使得混淆難以深入實施。

然而從理論上這似乎也說不通,只要是圖靈完備的語言,解決問題的能力都是相同的。舉個最簡單的例子,網上有使用 JavaScript 實現的 x86 模擬器,我們拋開性能不說,單論功能,它和本地系統是一樣的。因此使用傳統工具混淆的程序,同樣也是能在瀏覽器中運行的!

當然,這個代價不免有些太大。為了保護一段邏輯,還得加載一個龐大的模擬器和操作系統,顯然是難以接受的。但是這個思路還是很有意義的 —— 將需要保護的代碼邏輯,放入模擬器中執行。

事實上類似的方案也早已存在,例如大名鼎鼎的 VMProtect。在瀏覽器端同樣也有應用的案例,例如 Google 曾經開發的 reCaptcha 驗證系統,也用到了模擬器來保護重要邏輯。

如何將前端腳本程序,變成可被模擬器運行的指令?我們從最簡單的案例開始講解。

字節碼

和傳統的編譯型程序不同,腳本程序始終是帶語法的文本代碼。如何將一段充滿各種可讀單詞的代碼,盡可能多得使用數字來描述?例如這段代碼:

var el = document.createElement('script');
el.text = 'alert(123)';
document.body.appendChild(el);

其中就有變量名 el、字符串 'script'、全局變量 document、屬性 body 等可讀單詞。

對於變量名來說,普通的壓縮工具就能很好處理,變成諸如 a、b、c 這樣的短名字;但是字符串和屬性,又該如何處理?

熟悉 JS 的都知道 obj.keyobj['key'] 是相等的。而且全局變量都是 window 下的屬性。因此,我們可把全局變量和屬性都變成字符串的形式:

var el = window['document']['createElement']('script');
el['text'] = 'alert(123)';
window['document']['body']['appendChild'](el);

這時,整個代碼中除了 window 之外,都是字符串了。

既然我們的目標是將代碼數字化,那就將數字以外的常量都提取出來,放到一個單獨的數組里:

var MEM = [
    window, 'document', 'createElement', 'script',
    'text', 'alert(123)', 'body', 'appendChild'
];

這樣,就可以用 MEM[數字] 代替一切了:

var el = MEM[0][ MEM[1] ][ MEM[2] ]( MEM[3] );
el[ MEM[4] ] = MEM[5];
MEM[0][ MEM[1] ][ MEM[6] ][ MEM[7] ](el);

看起來有些眼花繚亂了吧。不過這只是對常量進行替換,語法仍然存在,因此還是能推測出大致的邏輯。不少基於語法樹的混淆工具,大多就到這一步。

下面我們進一步,將語法展開:

var A, X, Y, Z

A = MEM[0]       //  window
X = MEM[1]       //  'document'
X = A[X]         //  X = window['document']

A = MEM[2]       //  'createElement'
Y = MEM[3]       //  'script'
A = X[A](Y)      //  A = document['createElement']('script')

Y = MEM[4]       //  'text'
Z = MEM[5]       //  'alert(123)'
A[Y] = Z         //  A['text'] = 'alert(123)'

Y = MEM[6]       //  'body'
X = X[Y]         //  X = document['body']

Y = MEM[7]       //  'appendChild'
X[Y](A)          //  body['appendChild'](A)

由於失去了語法,因此需要一些臨時變量來保存中間值,這里使用 A、X、Y、Z 四個變量來暫存。

這時的每一步,都是一個基本操作。我們到了腳本層面最低級的形式。(可以試着粘到控制台,仍能正常運行~ 或者點擊 jsfiddle.net/qLtojr5z/ 演示)

觀察上述代碼,其中有大量相似操作,我們嘗試用代號來進行替換。例如讀取 MEM[i] 操作,使用 LDR(Load Reg)來描述:

r = MEM[i]      =>    LDR r, i

同樣的,屬性讀寫操作,也進行類似替換:

r1 = r2[r3]     =>    GET r1, r2, r3
r1[r2] = r3     =>    SET r1, r2, r3

對於方法調用操作,暫且用 CAL 來表示參數正好為 1 個的情況,並且返回值統一存放在 A 中:

A = r1[r2](r3)  =>    CAL r1, r2, r3

現在,我們用這個幾個虛擬代號,重新描述上述邏輯:

LDR A, 0
LDR X, 1
GET X, A, X

LDR A, 2
LDR Y, 3
CAL X, A, Y

LDR Y, 4
LDR Z, 5
SET A, Y, Z

LDR Y, 6
GET X, X, Y

LDR Y, 7
CAL X, Y, A

這是不是有一種匯編指令的感覺!之后的處理過程自然就很明確了,我們將這些可讀的文本匯編碼,轉換成二進制字節碼。

例如用 1 代表 LDR 指令,2 代表 GET 指令。。。同樣的,暫存器也可以用數字表示,例如用 0 代表 A ,1 代表 X。。。

匯編碼 字節碼
LDR A, 5 01 00 00 05
GET X, Y, Z 02 01 02 03
SET Z, Y, X 03 03 02 01
... ...

於是之前那段程序邏輯,最終就能用純數字表示了:

01 00 00 00 01 01 00 01 02 01 00 01 01 00 00 02
01 02 00 03 04 01 00 02 01 02 00 04 01 03 00 05
03 00 02 03 01 02 00 06 02 01 01 02 01 02 00 07
04 01 02 00

注意,這部分只是程序邏輯的指令數據,那些字符串等常量數據並不在此,需要另外存儲。

模擬器

我們的字節碼在瀏覽器看來,只是一堆數據而已,並無實際意義。因此需要一個模擬器,來解釋執行這些數據。

模擬器聽起來高大上,其實原理是非常簡單的 —— 根據指令數據,做相應操作而已。例如遇到 1,執行讀取存儲操作;遇到 2,執行訪問屬性操作。。。

REG = [];   // 暫存器

do {
    opcode = MEM[pc++];

    switch (opcode) {
        case 1:     // LDR
            ...
        case 2:     // GET
            ...
        case 3:     // SET
            r1 = MEM[pc++];
            r2 = MEM[pc++];
            r3 = MEM[pc++];

            obj = REG[r1];
            key = REG[r2];
            val = REG[r3];

            obj[key] = val;
        ...
    }
} while (...)

[運行演示]

我們將字節碼當做二進制數據加載到存儲中,然后使用一個計數器,指向當前指令所在的存儲位置,暫且稱之 pc(program counter)。每執行一條指令,pc 進行相應增加,指向下一條指令。周而復始。

這樣,一個模擬器的雛形就出現了。

我們可以添加更多的指令,例如算數、位運算等等,使模擬器變得更完善。同一個指令,也可以有多種模式。例如 LDR 指令,地址可以是立即數、暫存器,或是 暫存器+立即數、暫存器+暫存器 等多種模式,方便各種尋址操作。

指令越豐富,相應的邏輯實現就越簡單。相反,指令越少,同樣的操作就需要多個指令組合才能完成。一個極端的例子就是 Brainfuck 程序,它只提供極少的指令,因此即便非常簡單的功能,也需要大量冗長的組合才能完成。

當然,指令越豐富模擬器也會越龐大,因此得根據實際需求折中考慮。

跳轉指令

程序不可能永遠都是順着執行的,否則一下就執行完了。因此還需跳轉操作,可反復執行先前指令。最簡單的跳轉,就是無條件跳轉,我們暫且用 JMP(Jump)來表示:

Label:
  ...
  JMP Label

和傳統語言 BASIC 或 C 的 goto 一樣,在匯編文本層面,可以使用 label 作為跳轉的目標。當然 label 只是個標記而已,並不存在於最終的字節碼中。最終存儲的,只是目標指令所在的位置。

因此當模擬器解釋 JMP 指令時,僅僅是修改 pc 而已:

...
switch (opcode) {
    ...
    case OP_JMP:
        ...
        pc = r;
    ...
}

有跳轉指令,我們就可以靈活操控流程,完全不必按照 JS 那死板的流程控制了。

事實上,這個指令集和 JS 源碼已經毫無關系。我們完全可以使用其他語言,編譯出相應的虛擬指令。最終的字節碼,顯然也是無法還原出 語義化 的 JS 代碼的。

分支指令

除了無條件跳轉,還有帶條件的。例如這段代碼:

var str = prompt('password'); 

if (str == 'hello') {
    alert('OK');
} else {
    alert('Fail');
}

按照先前的方式,我們將其轉換成最低級的 JS 代碼:

var MEM = [window, 'prompt', 'password', 'hello', 'alert', 'OK', 'Fail']
var A, X, Y, Z

X = MEM[0]      // X = window
A = MEM[1]
Y = MEM[2]
A = X[A](Y)     // A = window['prompt']('password')

Y = MEM[3]      // Y = 'hello'

if (A == Y)
    A = MEM[5]  // A = 'OK'
else
    A = MEM[6]  // A = 'Fail'

Y = MEM[4]
X[Y](A)         // window['alert'](A)

相比之前,現在多了判斷操作。因此,我們再添加一個帶條件的跳轉指令。例如當 r1 != r2 時執行跳轉:

JNE r1, r2, label

這樣,我們就能和 JMP 指令組合,來表達上述邏輯了:

  ...                 ; 注釋
  LDR Y, 3            ; Y = 'hello'

  JNE A, Y, L_ELSE    ; if (A != Y) goto L_ELSE

  LDR A, 5            ; A = 'OK'
  JMP L_END
L_ELSE:
  LDR A, 6            ; A = 'Fail'
L_END:
  ...
  CAL X, Y, A         ; alert(A)

有了 != 判斷,自然也可實現 == 判斷。不過為了方便使用,我們可提供更豐富的分支操作。例如 JS 中的各種判斷:

跳轉指令 條件 備注
JE r1 == r2 Jump if Equal
JNE r1 != r2 Jump if Not Equal
JES r1 === r2 Jump if Equal Strict
JNES r1 !== r2 Jump if Not Equal Strict
JG r1 > r2 Jump if Greater
JGE r1 >= r2 Jump if Greater or Equal
JL r1 < r2 Jump if Less
JLE r1 <= r2 Jump if Less or Equal
JIN r1 in r2 Jump if IN
JINSOF r1 instanceof r2 Jump if INStanceOF

甚至對於一些常見情況,還可再進一步封裝:

跳轉指令 條件
JTRUE r1 === true
JFALSE r1 === false
JZERO r1 === 0
JNULL r1 === null
JUNDEF r1 === undefined
... ...

不過,有時我們只想判斷,未必要跳轉。例如:

isOK = (stat == 200);

對於這種情況,使用跳轉指令也能滿足,只是顯得略為累贅。如果想更精簡,則可添加純粹的判斷指令,例如:

A = (r1 != r2)    =>    TEST_NE r1, r2
A = (r1 in r2)    =>    TEST_IN r1, r2
...

當然,其本質都是一樣的。

JS 操作

既然我們的模擬器是用於瀏覽器環境,顯然應該提供完善的 JS/DOM 操作。因此我們再添加幾個腳本相關的指令,例如:

指令 功能 備注
CONCAT r1, r2, r3 r1 = r2 + r3 字符拼接
OBJECT r1 r1 = {} 創建對象
TYPEOF r1, r2 r1 = typeof r2 typeof
DELETE r1, r2 delete r1[r2] delete
NEWCAL r1, ... A = new r1(...) new

這里提一下 JS 的 + 操作符:它既可以用於數字加法,也可用於字符串拼接。為了不和 ADD 指令混在一起,我們可單獨提供一個字符串拼接的指令。

現在來思考一個問題:如何提供回調函數?

從理論上說,我們可實現一個完全兼容 JS 的字節碼模擬器,但事實上這是相當復雜的。JS 有眾多靈活的特征,例如閉包、with、eval 等等,要實現這些,相當於得重新造一個 JS 引擎,顯然是不現實的。

因此,我們只需提供一些常用的操作就可以了。閉包之類的特性,就可以不考慮了。不過回調函數還是需要支持的,例如這段代碼:

button.onclick = function() { ... };

我們可設計一個指令,將相應的 label 封裝成一個函數對象:

FUN  r, label      ; r = makeCallback(...)

label:
  ...

這樣,就能提供給 DOM 使用了:

L_CLICK:
  ...

L_MAIN:
  ...                ; A = button, X = 'onclick'

  FUNC  Y, L_CLICK   ; Y = makeCallback(...)
  SET   A, X, Y      ; A['onclick'] = Y

至於封裝的細節,大致就這樣:

function makeCallback(pc) {
    return function() {
        return vm.run(pc);
    };
}

在回調函數里,讓模擬器從 pc 的位置開始解釋,這樣就讓某些指令異步執行了。

這里簡單的演示一下。例如這個回調函數:

var i = 0;

function render() {
    txt.value = i++;
    if (i <= 255) {
        requestAnimationFrame(render);
    }
}
render();

將其轉換成字節碼:

0000    05 03 00 00             MOV Z, 0
0004    01 00 00 00  L_TIMER:   LDR A, 0
0008    01 01 00 01             LDR X, 1
000C    02 02 00 01             GET Y, A, X
0010    01 01 00 02             LDR X, 2
0014    03 02 01 03             SET Y, X, Z
0018    06 03 00 00             INC Z
001C    05 01 00 ff             MOV X, 255
0020    07 03 01 10             JG  Z, X, L_END
0024    01 01 00 03             LDR X, 3
0028    08 02 00 04             FUN Y, L_TIMER
002C    04 00 01 02             CAL A, X, Y
0030    00 00 00 00  L_END:     BRK

運行演示


在腳本層面上還有個特殊流程,那就是錯誤捕獲。例如這樣的 JS 邏輯:

try {
    // safe
} catch (...) {
    // handler
}

這使用指令並不難描述。我們可定義兩個指令,分別用於捕獲的開啟和關閉:

  CATCH L_ERR
  ...           ; safe
  ...
  UNCATCH
  ...
L_ERR:
  ...           ; handler

當模擬器遇到 CATCH 指令時,使用 try 解釋后續指令,若有錯誤發生,則進入 label 的位置;當遇到 UNCATCH 指令時,則退出當前遞歸,返回上一層的捕獲:

function run(...) {
    ...
    case OP_CATCH:
        try {
            run(...);     // 安全模式 遞歸
        } catch (e) {
            pc = ...      // 錯誤處理流程
        }
        ...
    case OP_UNCATCH:
        return;

這樣,就能放心地執行一些可能報錯的操作了。

類似的邏輯實現還有很多,這里就不詳細介紹了。關於模擬器的基本原理簡介,就到此為止。不過我們的目標並非只是為了實現一個模擬器,而是利用模擬器來保護代碼邏輯。

邏輯保護

相比過去那些基於 AST(抽象語法樹)的混淆方案,使用模擬器可以實施得更深入。大致可以在這幾點上對抗:

  • 編譯過程

  • 指令編碼

  • 指令混淆

編譯過程

從源程序到字節碼,需要一個編譯的過程。這個過程本身就有一定的混淆效果,例如一些優化工作會對邏輯進行調整。和傳統的編譯型語言一樣,這個過程是不可逆的。反編譯的代碼,是很難回到原始語義的。(不知大家是否見過那些自稱能把 exe 程序還原成 c 代碼的工具,結果當然是慘不忍睹)

由於模擬器難以完全兼容 JS 所有的特性,因此不能直接用於現有的腳本。需混淆的代碼必須遵循一定的規范編寫,例如不能使用 with、eval 等高級特性。所以,不推薦對整個程序都進行混淆,而是只針對一些核心邏輯。

如果核心部分只是算法,甚至完全可以不用 JS 編寫,而是選擇 C 這種更適合計算的語言。我們可以使用 clang 編譯出 LLVM 中間碼,然后開發一個 LLVM Backend 插件,將中間碼編譯成我們模擬器的目標指令。

LLVM 是個非常有意義的系統。它不僅可用於程序的優化,同樣也可實現程序的「劣化」,讓邏輯變得更亂更難分析。例如在計算過程中,插入大量的中間步驟,干擾邏輯的分析。

指令編碼

因為模擬器的指令是我們自創的,所以對方在逆向分析之前,必須了解指令的編碼格式,才能成功反編譯。因此,在編碼上又可以進行一些對抗。

傳統的指令編碼大多都有規律,因為那是從解碼復雜度以及性能上考慮。例如:

switch (opcode) {
    ...
    case OP_SET:
        r1 = MEM[pc++];
        r2 = MEM[pc++];
        r3 = MEM[pc++];
        ...

這么簡單明了的解碼過程,顯然是很容易分析的。而我們最終目標是混淆,性能並非是第一位。因此可使出各種千奇百怪的編碼格式,來增加解碼的復雜度。

例如,使用各種邏輯位運算,並且不同的指令格式也各不相同,沒有任何規律。在性能損失可接受的范圍內,將解碼過程變得極其復雜,使分析變得更困難。

a = MEM[pc++]
b = MEM[pc++]
if (a & 128)
    if (a & 64)    // OP_SET
        r1 = (a >> 4) & 16
        r2 = (b & 16) ^ ~r1
        r3 = (b >> 4 & 16) ^ r1
        ...

當然再復雜的格式也有破解的時候。因此我們不能永遠使用一種格式,而必須不定期的進行升級。不過,每次升級都得重新設計一遍,會不會很麻煩?

如果編碼格式由人工制定,那顯然是很麻煩的。因此必須借助工具,自動化生成「編碼器」和「解釋器」。我們只需設計一些策略就可以了,讓工具將這些套路隨機組合,生成千奇百怪的格式。最終格式是什么樣的,我們自己都不需要了解:)

總之,用最簡單的正向設計達到最困難的逆向分析,這就符合對抗的意義了。

指令混淆

指令本身也是內存中的數據。因此和普通數據一樣,指令數據也能被修改,例如當前指令可以修改即將執行的下一條指令,這樣就可以在運行時動態調整程序行為了。

利用這個特征,我們可對程序的大部分指令事先進行加密,然后在運行時再逐步解密。假如程序有 a、b、c、d 幾個部分,我們事先將 b、c、d 部分進行簡單加密,只保留明文的 a 部分。

當程序執行 a 部分時,將 b 部分的二進制數據進行解密,還原出明文指令;執行到 b 部分時,還原 c 部分,同時再將 a 部分加密回去。。。這樣變執行邊釋放,就能避免一出來就能看到所有指令,從而增加分析成本。

另外,在字節碼的層面上,跳轉是以字節為單位的,因此可跳到某個指令的中間:

位置     字節碼          匯編碼
0000    02 01 02 03    GET X, Y, Z
0004    05 00 01       JMP 0001

這樣就能執行 01 02 03 05 這串字節碼,即 LDR Y, 0x0305 了。利用這個方法,就可以將一些指令偽裝起來,實現花指令的效果。

類似的對抗思路還有很多,這里就不詳細討論了。事實上,這些大多是傳統程序的混淆方案,之所以能用到 JS 上,得益於模擬器消除了平台間的差距,從而使得前端腳本也能享受到前人積累的對抗技術,完全不必自創一些看似炫酷實則毫無意義的混淆方案。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM