前言
很多時候,我們都會覺得混淆腳本程序是件困難的事,效果遠不及傳統程序的混淆力度。畢竟,腳本的初衷就是簡單易用。諸多先天不足的特征,使得混淆難以深入實施。
然而從理論上這似乎也說不通,只要是圖靈完備的語言,解決問題的能力都是相同的。舉個最簡單的例子,網上有使用 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.key
和 obj['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 上,得益於模擬器消除了平台間的差距,從而使得前端腳本也能享受到前人積累的對抗技術,完全不必自創一些看似炫酷實則毫無意義的混淆方案。