使用模擬器混淆前端程式碼

發表於2016-09-19

前言

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

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

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

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

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

位元組碼

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

其中就有變數名 el、字串 ‘script’、全域性變數 document、屬性 body 等可讀單詞。

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

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

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

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

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

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

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

這時的每一步,都是一個基本操作。我們到了指令碼層面最低階的形式。(可以試著粘到控制檯,仍能正常執行~ 或者點選jsfiddle.net/qLtojr5z/ 演示)

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

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

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

對於方法呼叫操作,暫且用 CAL 來表示引數正好為 1 個的情況,並且返回值統一存放在 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

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

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

模擬器

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

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

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

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

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

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

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

跳轉指令

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

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

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

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

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

分支指令

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

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

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

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

有了 != 判斷,自然也可實現 == 判斷。不過為了方便使用,我們可提供更豐富的分支操作。例如 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

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

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

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

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 引擎,顯然是不現實的。

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

我們可設計一個指令,將相應的 label 封裝成一個函式物件:

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

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

在回撥函式裡,讓模擬器從 pc 的位置開始解釋,這樣就讓某些指令非同步執行了。


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

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

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

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

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

邏輯保護

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

  • 編譯過程
  • 指令編碼
  • 指令混淆

編譯過程

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

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

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

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

指令編碼

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

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

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

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

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

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

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

指令混淆

指令本身也是記憶體中的資料。因此和普通資料一樣,指令資料也能被修改,例如當前指令可以修改即將執行的下一條指令,這樣就可以在執行時動態調整程式行為了。

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

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

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

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

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

相關文章