前言
很多時候,我們都會覺得混淆指令碼程式是件困難的事,效果遠不及傳統程式的混淆力度。畢竟,指令碼的初衷就是簡單易用。諸多先天不足的特徵,使得混淆難以深入實施。
然而從理論上這似乎也說不通,只要是圖靈完備的語言,解決問題的能力都是相同的。舉個最簡單的例子,網上有使用 JavaScript 實現的 x86 模擬器,我們拋開效能不說,單論功能,它和本地系統是一樣的。因此使用傳統工具混淆的程式,同樣也是能在瀏覽器中執行的!
當然,這個代價不免有些太大。為了保護一段邏輯,還得載入一個龐大的模擬器和作業系統,顯然是難以接受的。但是這個思路還是很有意義的 —— 將需要保護的程式碼邏輯,放入模擬器中執行。
事實上類似的方案也早已存在,例如大名鼎鼎的 VMProtect。在瀏覽器端同樣也有應用的案例,例如 Google 曾經開發的 reCaptcha 驗證系統,也用到了模擬器來保護重要邏輯。
如何將前端指令碼程式,變成可被模擬器執行的指令?我們從最簡單的案例開始講解。
位元組碼
和傳統的編譯型程式不同,指令碼程式始終是帶語法的文字程式碼。如何將一段充滿各種可讀單詞的程式碼,儘可能多得使用數字來描述?例如這段程式碼:
1 2 3 |
var el = document.createElement('script'); el.text = 'alert(123)'; document.body.appendChild(el); |
其中就有變數名 el、字串 ‘script’、全域性變數 document、屬性 body 等可讀單詞。
對於變數名來說,普通的壓縮工具就能很好處理,變成諸如 a、b、c 這樣的短名字;但是字串和屬性,又該如何處理?
熟悉 JS 的都知道 obj.key
和 obj['key']
是相等的。而且全域性變數都是 window 下的屬性。因此,我們可把全域性變數和屬性都變成字串的形式:
1 2 3 |
var el = window['document']['createElement']('script'); el['text'] = 'alert(123)'; window['document']['body']['appendChild'](el); |
這時,整個程式碼中除了 window 之外,都是字串了。
既然我們的目標是將程式碼數字化,那就將數字以外的常量都提取出來,放到一個單獨的陣列裡:
1 2 3 4 |
var MEM = [ window, 'document', 'createElement', 'script', 'text', 'alert(123)', 'body', 'appendChild' ]; |
這樣,就可以用 MEM[數字]
代替一切了:
1 2 3 |
var el = MEM[0][ MEM[1] ][ MEM[2] ]( MEM[3] ); el[ MEM[4] ] = MEM[5]; MEM[0][ MEM[1] ][ MEM[6] ][ MEM[7] ](el); |
看起來有些眼花繚亂了吧。不過這只是對常量進行替換,語法仍然存在,因此還是能推測出大致的邏輯。不少基於語法樹的混淆工具,大多就到這一步。
下面我們進一步,將語法展開:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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) |
這時的每一步,都是一個基本操作。我們到了指令碼層面最低階的形式。(可以試著粘到控制檯,仍能正常執行~ 或者點選jsfiddle.net/qLtojr5z/ 演示)
由於失去了語法,因此需要一些臨時變數來儲存中間值,這裡使用 A、X、Y、Z 四個變數來暫存。
觀察上述程式碼,其中有大量相似操作,我們嘗試用代號來進行替換。例如讀取 MEM[i] 操作,使用 LDR(Load Reg)來描述:
1 |
r = MEM[i] => LDR r, i |
同樣的,屬性讀寫操作,也進行類似替換:
1 2 |
r1 = r2[r3] => GET r1, r2, r3 r1[r2] = r3 => SET r1, r2, r3 |
對於方法呼叫操作,暫且用 CAL 來表示引數正好為 1 個的情況,並且返回值統一存放在 A 中:
1 |
A = r1[r2](r3) => CAL r1, r2, r3 |
現在,我們用這個幾個虛擬代號,重新描述上述邏輯:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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 |
… | … |
於是之前那段程式邏輯,最終就能用純數字表示了:
1 2 3 4 |
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,執行訪問屬性操作。。。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
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)來表示:
1 2 3 |
Label: ... JMP Label |
和傳統語言 BASIC 或 C 的 goto 一樣,在彙編文字層面,可以使用 label 作為跳轉的目標。當然 label 只是個標記而已,並不存在於最終的位元組碼中。最終儲存的,只是目標指令所在的位置。
因此當模擬器解釋 JMP 指令時,僅僅是修改 pc 而已:
1 2 3 4 5 6 7 8 |
... switch (opcode) { ... case OP_JMP: ... pc = r; ... } |
有跳轉指令,我們就可以靈活操控流程,完全不必按照 JS 那死板的流程控制了。
事實上,這個指令集和 JS 原始碼已經毫無關係。我們完全可以使用其他語言,編譯出相應的虛擬指令。最終的位元組碼,顯然也是無法還原出 語義化 的 JS 程式碼的。
分支指令
除了無條件跳轉,還有帶條件的。例如這段程式碼:
1 2 3 4 5 6 7 |
var str = prompt('password'); if (str == 'hello') { alert('OK'); } else { alert('Fail'); } |
按照先前的方式,我們將其轉換成最低階的 JS 程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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 時執行跳轉:
1 |
JNE r1, r2, label |
這樣,我們就能和 JMP 指令組合,來表達上述邏輯了:
1 2 3 4 5 6 7 8 9 10 11 12 |
... ; 註釋 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 |
… | … |
不過,有時我們只想判斷,未必要跳轉。例如:
1 |
isOK = (stat == 200); |
對於這種情況,使用跳轉指令也能滿足,只是顯得略為累贅。如果想更精簡,則可新增純粹的判斷指令,例如:
1 2 3 |
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 引擎,顯然是不現實的。
因此,我們只需提供一些常用的操作就可以了。閉包之類的特性,就可以不考慮了。不過回撥函式還是需要支援的,例如這段程式碼:
1 |
button.onclick = function() { ... }; |
我們可設計一個指令,將相應的 label 封裝成一個函式物件:
1 2 3 4 |
FUNC r, label ; r = makeCallback(...) label: ... |
這樣,就能提供給 DOM 使用了:
1 2 3 4 5 6 7 8 |
L_CLICK: ... L_MAIN: ... ; A = button, X = 'onclick' FUNC Y, L_CLICK ; Y = makeCallback(...) SET A, X, Y ; A['onclick'] = Y |
至於封裝的細節,大致就這樣:
1 2 3 4 5 |
function makeCallback(pc) { return function() { return vm.run(pc); }; } |
在回撥函式裡,讓模擬器從 pc 的位置開始解釋,這樣就讓某些指令非同步執行了。
在指令碼層面上還有個特殊流程,那就是錯誤捕獲。例如這樣的 JS 邏輯:
1 2 3 4 5 |
try { // safe } catch (...) { // handler } |
這使用指令並不難描述。我們可定義兩個指令,分別用於捕獲的開啟和關閉:
1 2 3 4 5 6 7 |
CATCH L_ERR ... ; safe ... UNCATCH ... L_ERR: ... ; handler |
當模擬器遇到 CATCH 指令時,使用 try 解釋後續指令,若有錯誤發生,則進入 label 的位置;當遇到 UNCATCH 指令時,則退出當前遞迴,返回上一層的捕獲:
1 2 3 4 5 6 7 8 9 10 11 |
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 是個非常有意義的系統。它不僅可用於程式的優化,同樣也可實現程式的「劣化」,讓邏輯變得更亂更難分析。例如在計算過程中,插入大量的中間步驟,干擾邏輯的分析。
指令編碼
因為模擬器的指令是我們自創的,所以對方在逆向分析之前,必須瞭解指令的編碼格式,才能成功反編譯。因此,在編碼上又可以進行一些對抗。
傳統的指令編碼大多都有規律,因為那是從解碼複雜度以及效能上考慮。例如:
1 2 3 4 5 6 7 |
switch (opcode) { ... case OP_SET: r1 = MEM[pc++]; r2 = MEM[pc++]; r3 = MEM[pc++]; ... |
這麼簡單明瞭的解碼過程,顯然是很容易分析的。而我們最終目標是混淆,效能並非是第一位。因此可使出各種千奇百怪的編碼格式,來增加解碼的複雜度。
例如,使用各種邏輯位運算,並且不同的指令格式也各不相同,沒有任何規律。在效能損失可接受的範圍內,將解碼過程變得極其複雜,使分析變得更困難。
1 2 3 4 5 6 7 8 |
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 部分加密回去。。。這樣變執行邊釋放,就能避免一出來就能看到所有指令,從而增加分析成本。
另外,在位元組碼的層面上,跳轉是以位元組為單位的,因此可跳到某個指令的中間:
1 2 3 |
位置 位元組碼 彙編碼 0000 02 01 02 03 GET X, Y, Z 0004 05 00 01 JMP 0001 |
這樣就能執行 01 02 03 05 這串位元組碼,即 LDR Y, 0x0305
了。利用這個方法,就可以將一些指令偽裝起來,實現花指令的效果。
類似的對抗思路還有很多,這裡就不詳細討論了。事實上,這些大多是傳統程式的混淆方案,之所以能用到 JS 上,得益於模擬器消除了平臺間的差距,從而使得前端指令碼也能享受到前人積累的對抗技術。完全不必自創一些看似炫酷實則毫無意義的混淆方案。