喜歡寫代碼的群友們妳們好。
去年的這個時候我就已經在玩編譯器了,我在筆記 👉🏻編譯器學習記錄1️⃣:人肉卡西歐計算器 中提到,我正在學習怎麼手撕虛擬執行環境,然而空手並不能敲開 VM 的大門,我必須先去爬一點解釋器(和編譯器)的知識。
當時的萬里 “長征” 剛到一半,我就被其他東西奪走了注意力 😅 目前我的水平距離前輩們的高度仍然相去甚遠,但是 學以致用 這一塊可不能丟下啊,因此我決定充分利用現有的知識,探索一個我比較感興趣的領域:
代碼混淆 😎
Contents
前言:玩弄編程語言
上輩子我花了一個半月學會了怎麼「玩弄編程語言」,包括怎麼解析數學表達式的語法,比如下面的式子:
就可以解析成語法樹如下:

卡西歐計算器就是靠着這棵樹來理解數學式,並進行運算的。
但是不幸的是,我在做完上述了 AST walker 之後就荒廢了一年,沒能在編譯器這塊做出任何推進,,,
在棄坑一週年之際,死去的記憶它復活了!我現在非常想研究一下代碼混淆的領域!
這時候有的群友可能要問了:代碼混淆和編譯器有何關係呢?為了回答這個問題,我們需要瞭解一下編譯器的工作流程:
編譯器的工作是將源代碼轉換為機器可以執行的形式,這種轉換並非一步登天,而是由如同上圖中的一整套流水線組成。圖中專有名詞和術語衆多,看不明白也不要緊,我也只是懂到 Binding 那一步而已,其實每一個步驟說白了都可以當作代碼的分析/變形/轉換。
由於代碼混淆只是一種代碼變形,所以我們自然而然就能想到,代碼混淆可以作為一個步驟插入編譯流水線之中。之後再經過這套步驟編譯出的程序,就是功能不變,但代碼已經被混淆過的程序了。
這我我要研究的代碼混淆手法就是大名鼎鼎、淵遠流長的 控制流拍平 (Control Flow Flattening)。接下來使用編程語言玩弄編程語言的旅程即將開始,請小心自己的大腦遭到玩弄……
1/ 代碼混淆初探
代碼混淆是程序保護的一部分,而提到程序保護,我相信很多群友想到的都是名如「xx 殼」的加殼保護器。加殼一般指的是加密一個已經編譯好的程序,然後在啓動的時候才動態解密,釋放並運行。然而程序保護的含義更廣,這個詞不僅包括了編譯之後的加殼,也包括了在編譯的過程中就對程序代碼進行變形,還包括了在程序中人為插入各種驗證之類的,保護程序不被篡改的做法。
本文中的「代碼混淆」主要指的是在編譯過程中對代碼進行變形,在保證程序功能不變的情況下,這種變形使得程序在源代碼這一步上就難以閱讀,也使得編譯產物更加複雜,屬於是一種從源頭上扼殺的手法。而且對於一些基本沒法編譯、天生就開源的語言——JavaScript 和 Python,代碼混淆是最重要的一種保護工具。
網上有很多開源的代碼混淆器,JavaScript 這邊有一個歷史悠久 (十年!) 的混淆器 👉🏻javascript-obfuscator,它就提供了幾十種混淆手法可供學習:

要是把圖中這些選項全部舔一遍的話要花三天三夜,不過我們還是可以通過觀察一些典型的手法來一探究竟。另一個我很喜歡的混淆器項目叫做 👉🏻O-MVLL,它通過直接插入 LLVM 的編譯過程來改變代碼,而且他們的網站做得很不錯,提供了不少混淆前後的代碼示例,下面我挑兩個來展示:
1. 混淆數學運算 (Arithmetic Obfuscation)
這個手法可以將簡單的數學運算故意複雜化,原運算形如:
int add = x + y;
int sub = x - y;
int xor = x ^ y;
就可以強行改寫成下面的形式,而且計算結果完全不變:
int add = (x & y) + (x | y);
int sub = (x ^ -y) + 2*(x & -y);
int xor = (x | y) - (x & y);
把這個過程套娃幾層,很快就辨別不了原來的運算形式了。
2. 不透明謂詞 (Opaque Predicates)
在代碼中插入一些只有我才知道運算結果的複雜運算,並以運算結果來控制程序走向。而由於妳並不知道結果如何,導致無法輕易推斷程序的走向。假設有一個程序如下:
def hello_world():
print("hellw world")
加入一點不透明謂詞就變成了:
def hello_world():
x = 8341
y = random.randint(0, 9999)
if ((x & y) + (x | y))**((x | x) - (x & x)) == (y ^ -y) + 2*(y & -y) + 1:
print("hellw world")
else:
os.remove("C:\\Windows\\System32")
編寫程序的人(我)事先知道 if 永遠為真,可是逆向程序的人(妳)卻不知道,所以妳沒法一眼看出這個程序的去向如何。
除此之外還有其他多種混淆手法,要是在編譯的時候愛加多加,編譯出來的程序就🈶️了。不過以上的都還只是小魚小蝦,接下來要介紹的才是本文的主角,也是我最喜歡的一個手法:控制流拍平。
控制流指的就是程序的走向。剛才那個有 if 的程序,它的控制流就是這樣的:
帶有循環的代碼控制流可能長這樣:
如圖所示,控制流圖完全忠實於程序的走向,它對於逆向人員來說也是非常寶貴的情報,有經驗的逆向人員可憑藉控制流圖在 3 秒鐘之內找到那個判斷激活碼是否正確的 if 分支。相反,如果沒了控制流圖,逆向人員想要推斷程序的意圖將會變得非常困難,因此在軟件保護的領域,如何對控制流圖進行混淆是一個非常值得探討的話題。
現在的問題是,控制流應該怎麼混淆呢?程序裏面有分支、循環和異常處理等基礎結構,我們總不可能把這些結構直接全部幹掉吧?這會破壞掉程序原有的功能。既然不能直接幹掉控制流,那開發者能做的就是掩蓋它,讓所有的基礎結構失去原有的特徵。一篇來自 2000 年的論文1首次提出了一種叫控制流拍平的靈感。
為了理解這種靈感,讓我們考慮下列程序。它非常簡單,全程順序執行,沒有分支和跳轉結構。我也把它的控制流圖畫在旁邊:
function calcPlus10(n: number) {
console.log("開始計算 n + 10...");
n = n + 1;
n = n + 2;
console.log("完成了一半力...");
n = n + 3;
n = n + 4;
console.log("結果是 ", n);
writeFileSync("./result.md", "結果是 " + n);
}我需要對代碼做出一些改變,才能讓這個無腦的控制流圖看上去更隱蔽一點。
首先進行代碼切割,把整個程序切分成多個 step,然後給每個 step 附加編號如下:
function calcPlus10(n: number) {
let __step = 0;
console.log("開始計算 n + 10...");
n = n + 1;
n = n + 2;
__step = 1;
console.log("完成了一半力...");
n = n + 3;
n = n + 4;
__step = 2;
console.log("結果是 ", n);
writeFileSync("./result.md", "結果是 " + n);
__step = 3;
}
現在每一個 step 都有了自己的編號,並且他們都知道自己下一步該進入哪個新 step。在上面的例子中,函數會從 step 0 開始運行,然後在 step 3 結束,於是現在我可以繼續改寫代碼,讓程序通過 switch case 自動前往對應編號的 step 塊:
function calcPlus10(n: number) {
let __step = 0;
while (__step !== 3) {
switch (__step) {
case 0:
console.log("開始計算 n + 10...");
n = n + 1; n = n + 2;
__step = 1;
break;
case 1:
console.log("完成了一半力...");
n = n + 3; n = n + 4;
__step = 2;
break;
case 2:
console.log("結果是", n);
writeFileSync("./result.md", "結果是 " + n)
__step = 3;
break;
}
}
}
控制流如圖:
一通魔改之後控制流開始變得奇怪了起來,原本的執行順序消失了,取而代之的是一大堆 switch case,它會自動依據 step 的數值跳轉到對應的語句去執行。而且由於 step 的數值是前後銜接的,冥冥之中還保留了程序原本的運行邏輯。
我知道妳覺得這玩意很邪門,但還有更邪門的,讓我們再來看一個例子。假設原程序是這樣的:
function hello_world() {
console.log("Hello...");
if (getSystem() === "macOS") {
console.log("...World!!");
} else {
fs.rmSync('C:\\Windows\\System32');
}
console.log("Good Luck!");
}像剛才一樣,我給這個程序分配 step 編號,不過這回使用隨機數:
function hello_world() {
let __step = 114;
console.log("Hello...");
__step = 514;
if (getSystem() === "macOS") {
__step = 83;
console.log("...World!!");
} else {
__step = 41;
fs.rmSync('C:\\Windows\\System32');
}
__step = 1919;
console.log("Good Luck!");
__step = 810;
}
這個函數會從 114 開始執行,在 810 結束,於是我將代碼改寫為同樣的 switch case 狀態機,並且將 case 的排列順序打亂:
function hello_world() {
let __step = 114;
while (__step !== 810) {
switch (__step) {
case 41:
fs.rmSync('C:\\Windows\\System32'); __step = 1919; break;
case 514:
if (getSystem() === "macOS") __step = 83; else __step = 41;
break;
case 1919:
console.log("Good Luck!"); __step = 810; break;
case 114:
console.log("Hello..."); __step = 514; break;
case 83:
console.log("...World!!"); __step = 1919; break;
}
}
}
得到混淆後的控制流如下:
妳應該注意到了,無論原本程序有多長、包含何種分支跳轉語句,一旦經過這些工序處理,控制流就被拍平成了一個橫向生長的、巨大的 switch case 循環。這個循環就像一個心臟一樣,不斷地將下一步要執行的動作送進 switch,而程序原有的邏輯就完全隱藏在了 step 的狀態變化之中。開發人員管這種混淆技術叫做「控制流拍平」,隨着代碼規模的增大,switch case 可以達到成百上千個,如果再繼續深入混淆,隱藏 step 變量讓其不要明文顯示,那要理解這個程序就會變得非常困難了。
開發人員多年來發展出了不同的控制流拍平方法,只是其底層思想始終未變:用一個不明顯的狀態變量偷偷取代所有的控制流程。當然,手撕上述拍平風味代碼是非常費事的,我需要一套自動化的方法來高效、準確地拍平需要混淆的程序。
2/ 編程語言的抽象語法樹
代碼混淆需要對代碼進行變形,而這個變形可不是亂變的,是要按照基本法的。混淆器需要保證變形後語法正確(廢話),語義正確,以及原有邏輯功能也正確。那麼有甚麼工具能如此正確地處理源代碼呢?
有的群友可能要說了:這個我董啊!寫宏 (macro) 啊!妳說得很對,宏可以在一定程度上實現代碼替換,就比如 C 語言那個臭名昭著的 #define,就是最純粹的文字替換,至於替換完以後 語法錯誤大大方方往裏進,就是最極致的 debug 享受了。
Rust 那邊也有一個更加高級的聲明式宏系統,叫 macro_rules!,這種宏不再是簡單的字符串替換,而是具備了感知語法的能力,可以辨別 token 的種類,包括字面量、表達式還有關鍵字啥的,創建的變量甚至還擁有自己的作用域,比 C 語言高到不知道哪裏去了。使用這種宏確實已經可以做出相當複雜的自動化代碼變形了。
然而,光能辨認 token 的種類,保證語法正確,生成文字列或者 token 列拼接,對於代碼混淆來說仍然不夠。我需要的是一種能批量、大規模地理解並重新生成程序語義和結構的能力,能符合這些要求的代碼處理系統,必須工作在抽象語法樹 (AST) 的層次上。
抽象語法樹 (AST) 是一種,抽象的,語法的,樹狀結構。光這麼說還是非常抽象,讓我直接上圖。下面是一段簡單的代碼和它的抽象語法樹:
if (getSystem() === "macOS") {
console.log("Hello...");
console.log("...World!!");
} else {
fs.rmSync('C:\\Windows\\System32');
}

如圖所示,源代碼中的 if 語句的「判斷條件」「then 分支」「else 分支」都成為了 AST 上的節點。節點之間存在上下級包含關係,表示語法結構之間的嵌套關係。圖中很容易就能看出那句 console.log("Hello...") 就包含在 if 語句 -> then 分支 的 block { } 裏面。這個 AST 的實際數據結構 (JSON) 如下:
👉🏻 這玩意有 100 多行,點此展開
{
"type": "File",
"errors": [],
"program": {
"type": "Program",
"sourceType": "script",
"interpreter": null,
"body": [
{
"type": "IfStatement",
"test": {
"type": "BinaryExpression",
"left": {
"type": "CallExpression",
"callee": {
"type": "Identifier",
"name": "getSystem"
},
"arguments": []
},
"operator": "===",
"right": {
"type": "StringLiteral",
"extra": {
"rawValue": "macOS",
"raw": "\"macOS\""
},
"value": "macOS"
}
},
"consequent": {
"type": "BlockStatement",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"object": {
"type": "Identifier",
"name": "console"
},
"computed": false,
"property": {
"type": "Identifier",
"name": "log"
}
},
"arguments": [
{
"type": "StringLiteral",
"extra": {
"rawValue": "Hello...",
"raw": "\"Hello...\""
},
"value": "Hello..."
}
]
}
},
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"object": {
"type": "Identifier",
"name": "console"
},
"computed": false,
"property": {
"type": "Identifier",
"name": "log"
}
},
"arguments": [
{
"type": "StringLiteral",
"extra": {
"rawValue": "...World!!",
"raw": "\"...World!!\""
},
"value": "...World!!"
}
]
}
}
],
"directives": []
},
"alternate": {
"type": "BlockStatement",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"object": {
"type": "Identifier",
"name": "fs"
},
"computed": false,
"property": {
"type": "Identifier",
"name": "rmSync"
}
},
"arguments": [
{
"type": "StringLiteral",
"extra": {
"rawValue": "C:\\Windows\\System32",
"raw": "'C:\\\\Windows\\\\System32'"
},
"value": "C:\\Windows\\System32"
}
]
}
}
],
"directives": []
}
}
],
"directives": []
},
"comments": []
}在 AST 上修改代碼是非常方便的,因為編譯器可以幫我檢查代碼並生成 AST,還能一鍵把 AST 轉換回源代碼。假設我要刪除掉上面的 else 分支,我就不需要去找甚麼「else 後面那個花括號在第幾行」了,而是直接在 AST 上砍掉整個 else 分支(就是上方 JSON 裏面那個 alternate)就完事了。
如果我要把 If 改成 While 也很簡單,只需要先把 If 底下那些 block 內容複製出來,然後把 IfStatement 換成 WhileStatement,再把原來那些 block 塞回去就行了。總而言之,有了 AST,代碼變形就從無腦的文字匹配替換變成了精準的「爬樹 - 摘果」操作,極大提高了效率和正確性。
現在再讓我們複習一下編譯器前端的工作流程:
抽象語法樹是一個編譯中間產物,編譯器會在檢查源代碼語法正確以後生成 AST,因此 AST 蘊含了源代碼中的所有信息,卻又不像源代碼一樣多變易錯,方便了後續的編譯處理。至此,代碼混淆的大致思路就變成了:先用編譯器生成 AST,然後對 AST 做變形,最後再把 AST 轉換回源代碼,或者繼續完成編譯。
文章的後續將使用 JavaScript 完成代碼混淆的講解和演示。JS 生態裏面有一個非常成熟的編譯(轉譯)框架,叫 Babel。它提供了一整套庫函數來「爬樹」,能可靠地生成、編輯和處理 AST。下面是一個簡單的例子,用 Babel 來砍掉代碼中所有 if 語句的 else 分支:
traverse(ast, { // 自動爬樹查找 if
IfStatement(path) {
path.node.alternate = null;
},
});
去掉括號,整個步驟就三行,很快吧?要是以後誰還在用規則運算式查找「else 後面那個花括號在第幾行」,我會直接嘲笑她。
3/ 代碼預處理
知道了手撕拍平的做法,擁有了處理 AST 的工具,現在我似乎已經可以開始實現自動化代碼拍平的程序了。不過在這之前,JS 的 while true switch case 語法其實有一個巨大的坑需要解決,這個坑就是:
Case 裏面聲明過的變量會丟失 😇
假設現在有一個程序(左),拍平後變成了(右):
let a = "ciallo";
console.log(a);let step = 0;
while(step !== 2) {
switch(step) {
case 0:
let a = "ciallo";
step = 1; break;
case 1:
console.log(a);
step = 2; break;
}
}執行拍平過的程序 console.log 會爆 ReferenceError: can’t access lexical declaration ‘a’ before initialization 的錯誤,說變量 a 未初始化。看上去 step 0 聲明的變量 a 作用域 (scope) 在第二次循環,也就是退出了 switch 語句塊的時候消失了。
再看另一個程序(左),拍平後變成了(右):
let a = "hello";
let a = "ciallo";let step = 0;
while(step !== 2) {
switch(step) {
case 0:
let a = "hello";
step = 1; break;
case 1:
let a = "ciallo";
step = 2; break;
}
}其中,左邊的原程序是會報錯的,因為變量 a 不能重複聲明兩次;然而到了右邊,由於定義的變量會在一次循環之後消失,這個代碼反而能跑起來了 😅
這會引發 bug,我想讓變量的作用域和原程序一致,不說完全一致吧,但至少別憑空消失行嗎?所以我需要讓變量至少活過整個 while 或者整個函數。另外還有一些弔詭的程序,含有作用域塊和變量遮蔽 (shadowing),像這樣的:
let a = "hello";
{
console.log(a); // 必須要爆「變量未初始化」
let a = "ciallo";
console.log(a); // 必須要是 ciallo
}
console.log(a); // 必須要是 hello
要是拍平的時候不加處理,ciallo 就會把 hello 永久覆蓋掉,這也太壞了,最壞情況下會引發幹爛整個程序的 bug,「ciallo」必須得到有效解決。
JS 規定在退出作用域塊的時候,裏面聲明的變量必須全部消失,所以上面的內層 a 和外層 a 其實是完全不同的兩個變量,只是名字長得一樣而已。解決方法也很直白,既然它們是不同的變量,那就直接給它們安上不同的名字來消除歧義。我可以在 AST 上面掃描變量聲明,當發現聲明的變量和外層 scope 的變量出現了重名的時候,就給內層變量改個名字,確保它們拍平之後不再互相干擾,就像這樣:
let a = "hello";
{ // 在這個作用域裏面給 a 改名成 _a
let _a = "ciallo";
console.log(_a); // 必須要是 ciallo
}
console.log(a); // 必須要是 hello
Babel 裏面提供了一個 API 來查詢上層 scope 有沒有重名,還能自動生成重命名。保險起見,我決定強行更改一個函數中所有符號在不同作用域中的名字,做法是像這樣掃描 AST 進行改名:
functionNode.traverse({
Scope(path) {
if (path.isFunction()) {
path.skip(); // own scope + subtree are handled by that function's pass
return;
}
const scope = path.scope;
for (const name of Object.keys(scope.bindings)) {
// 把 scope 裏面定義的符號 全部拉清单
scope.rename(name, scope.generateUid(name));
// 然後批量改名
}
},
});
給變量改好名了再回去處理作用域報廢的問題。一個解決方法就是把所有變量聲明都移到 while 循環的外面,比如說(左)可以拍平成(右):
let a = "hello";
{
let a = "ciallo";
}var a, _a;
let step = 0;
while(step !== 2) {
switch(step) {
case 0:
a = "hello";
step = 1; break;
case 1:
_a = "ciallo";
step = 2; break;
}
}如此處理,從 case 裏面再訪問變量的時候,訪問到的就是一個全局可用的變量了,迴避了作用域消失的問題,同時上面的重命名系統也避免了名稱衝突的問題。看上去變量聲明的問題就這麼解決了 😇
關於變量聲明、變量訪問和變量遮蔽的問題,本章的內容只是冰山一角,並未完全覆蓋到編譯器定義、查找變量的過程,也沒涉及到把變量綁定到 closure 上的過程。對這些進階話題感興趣的群友,敬請移步 Crafting Interpreters 的 👉🏻第八章 和 👉🏻第十一章。
4/ 控制流拍平實現
安排好了變量的聲明,接下來就可以開始實現基於 AST 的代碼拍平了。在我查找相關資料的時候,發現了一篇 2007 年的論文2,作者首次在 C++ 上實現了控制流拍平的算法。然而他們的算法畫風是這樣的:

這可真的是……文文又本本,拼拼又接接啊 😇 從基於 AST 變形的現代做法來看,這篇論文中的做法無異於石器時代,不過其思路卻被後人沿用至今。
花了一點時間,我用 TypeScript 復刻了上面提到過的所有處理流程,包括:
實現的過程無聊得批爆,所以代碼我就不放了,感興趣的群友可以點上面的連結到我 GitHub 去看。不過值得一提的是,if 和 while 小塊並不是單純的鏈表節點,if 帶有一個判斷和兩個分支節點,分支節點跑完之後匯聚到下一個節點,如圖所示:
While 也帶有一個判斷和一個循環節點,循環結束後才會跳到下一個後繼節點:
當每個小塊都分配到了自己的 id,以及得知下一個節點的 id 之後,這些小塊就會被轉換成 switch 語句裏面的一個 case。每個小塊會在恰當的時候執行,執行完畢後再將下一個小塊的 id 賦予到 step,就像接力一樣,把控制權這根棒子交到下一個小塊的手裏。
至此我已經拍平了普通語句、if 和 while 語句,但是 for 循環和 try catch 語句還沒支持。這些沒支持的語句拍不扁,會被整個塞到一個 case 裏面,失去混淆的效果。雖然我這只是一個學習版項目,但是為了提升實用性,我覺得至少應該支持一下最基礎的 for 循環。
for 循環有個特點,他可以在循環的頭部聲明變量,下面這個循環中的變量 i 就只在循環之中存在:
for (let i = 0; i < 10; i++) {
sum = sum + i;
}
這些變量如果處理不得當容易導致定義域方面的 bug。一個懶狗的解決方法就是把 for 循環寫成 while,並用一個花括號塊來限制定義域,於是上面那個循環可以改寫成:
{
let i = 0;
while (i < 10) {
sum = sum + i;
i++;
}
}
之後就可以按照拍扁 while 循環的方法來進行混淆了,懶狗到家,輕鬆實現,接下來通過一點簡單的 AST 變形完成 for 循環的改寫……但是問題是,萬一 for 裏面有 continue 怎麼辦?他會跳過最後一行的 i++ 導致無限循環。Ok 懶狗做法帥不過 3 秒,還是讓我老實手撕跳轉分支吧 😇
那個 i++ 的部分必須放在獨立的小塊裏面,非常麻煩。經過一通爆改 (👉🏻代碼) 之後,我成功把 for 循環進行了拍平:

一個簡單的 for 循環被混淆得面目全非,我對自己的代碼感到非常滿意!😁
接下來可以進入喜聞樂見的跑分環節了!
5/ 性能測試
JavaScript 的 V8 執行引擎非常先進,其內置的 JIT 編譯器能夠優化出速度堪比 C 語言的機器碼。特別是對於循環的部分,JIT 能夠直接認出變量 i 是循環的索引,然後將它固定在 CPU 的寄存器裏面!這樣每一個循環跳轉只需要執行兩條甚至是一條指令,從而獲得極大的性能提升。
有位群友發表了一篇用 Python 算質數的 blog,對於基於 bytearray 的埃拉托色尼篩法,計算 1 億以下的質數需要 2.57 秒。那我自然是不能放任 Python 囂張的,於是帶着 Node.js 去砸場,在同樣的算法下跑出了 3.5 倍的性能,可見 JIT 的威力 😁
事已至此,我有點好奇控制流拍平會對代碼的效率造成何種影響。下面是埃拉托色尼篩法原本的代碼,循環分支這塊是少不了的:
export function primeLt(n: number): number[] {
const sieve = new Uint8Array(n + 1).fill(1);
if (n >= 0) sieve[0] = 0;
if (n >= 1) sieve[1] = 0;
const limit = Math.floor(Math.sqrt(n));
for (let p = 2; p <= limit; p++) {
if (sieve[p] === 1) {
for (let j = p * p; j <= n; j += p) sieve[j] = 0;
}
}
const primes: number[] = [];
for (let i = 2; i <= n; i++) if (sieve[i] === 1) primes.push(i);
return primes;
}
一通拍平過後,這個代碼變成鬼👻 了:

直接開跑:
表 1:篩法不同實現的性能比較
| Implementation | Source | Time (s) | vs. Python fastest |
|---|---|---|---|
primeLt — Uint8Array | TypeScript | 0.440 | 3.6× |
prime_lt_byte — bytearray slice-assign | Python | 1.568 | 1.0× |
primeLt — Uint8Array (flattened) | TypeScript | 1.784 | 0.88× |
將 Python 的 prime_lt_byte 實現作為對比的基準線,發現 JS 的 JIT 可以帶來 3.6 倍的性能提升,然而在拍平過後,JIT 的優化直接嘎了,跑得還不如 Python 快。我覺得這是 for 循環被拆散以後,編譯器就沒法對熱點數據進行優化,同時到處亂跳的 switch 干擾了分支預測導致的,此時性能被幹回了完全沒有優化的起跑線上。
結論:JS 控制流拍平對高頻計算的循環影響較大,悲觀情況下性能可以去和 Python 坐一桌 😇
但是話又說回來,Python 就算性能再爛,循環每秒計算個幾萬次仍然毫不費力,因此控制流拍平帶來的性能惡化在多數情況下都可以忽略不計,其帶來的代碼保護效果對於性能不敏感的場景仍然有用……嗎?
讓我們來考慮一個保護遊戲代碼、防止作弊的應用場景。下面是一個從 👉🏻GitHub 上剽竊來的小遊戲 Flappy Bird,代碼經過拍平。現在請妳打開瀏覽器 F12 的 Debugger,看看妳要花多久才能把那隻鳥改成無敵模式 😁
按 W 鍵開始遊戲
我已經幫妳看過了,代碼裏面確實充斥了大量的 switch case 啊,但是由於變量名沒有混淆,我僅僅花了 30 秒就找到了那個關鍵的判定分支,妳也快來試試吧 😁
結語:不知正焉知逆
我研究控制流拍平的最初目的是為了解開它,亦或是為了防止別人解開它。很久以前我在為某小衆語言軟件寫殼的時候,就用了控制流拍平的手法,然而當時我沒有編譯器,也沒有 AST,代碼混淆純靠手敲。現在想來當年的我也是年輕氣盛,特別愛在沒有任何理論基礎的情況下強行去碰瓷一些難題,然而最終多半是激流勇退並空手而歸。
但是我並沒有後悔以前的做法,誰讓我以前有得是時間呢 😇 有時間,愛走彎路,真是年輕後生的浪漫啊!然而現在我發現,學習這些東西其實並不算難,這不一定是因為我的「水平」有所長進,也許只是因為我已經習慣於工程思維:把問題拆分、再拆分,就像控制流拍平的結果乍看之下如同不可名狀之物,但它其實也只是把 AST 一步步拆分、編號、重組之後的產物而已。
俺覺得,寫殼和做代碼混淆其實有相當大的區別。寫殼(尤其是在 Windows 下寫殼)本質上是一門電腦考古學,加密者和逆向者比的是誰對 40 年屎山理解更深,誰噴糞更不要臉,誰埋的雷更猥瑣;他們明知自己插的暗樁會在下一次 Windows 更新中藍屏,卻仍然不能自拔。而代碼混淆更像是基於電腦科學和數學的優雅博弈,擁有一種純粹的美感。許多逆向工程教材,如『逆向工程核心原理』『加密與解密』都花費了巨大篇幅講述 Windows 那些陳年老福,而非代碼混淆背後的算法和原理,其內容安排過於偏向遠古屎山而非未來展望,我覺得這是非常可惜的。

在之後的下篇中,我會嘗試親自破解我做出來的這一套控制流拍平混淆。預計內容將會涉及自動化狀態跟蹤、控制流圖大恢復術和語法樹節點重建,還會一腳踏穿圖論算法和反編譯算法的領域。
我還需要一些時間去收集這些知識點。群友們請稍等喵!下次再見。
參考
Footnotes
-
J. Davidson, C. Wang, J. Hill, and J. Knight, “Software Tamper Resistance: Obstructing Static Analysis of Programs,” University of Virginia, Department of Computer Science, Jan. 2000. doi: 10.18130/V36T9V. ↩
-
T. László and Á. Kiss, “OBFUSCATING C++ PROGRAMS VIA CONTROL FLOW FLATTENING,” 2009. Accessed: Sep. 20, 2026. [Online]. Available: 👉🏻Link ↩


請停用 Dark Reader
評論區
妳的留言會讓我的文章睜開眼睛,開始呼吸,然後長出新的一部分。
無論是長還是短,俺期待着妳的回覆……
妳也可以給我發電郵。