LLVM代碼混淆分析及邏輯還原
概述
LLVM Obfuscator是一款工業級別的代碼混淆器,在過去幾年的CTF里我們經常會遇到經過代碼經過它混淆的情況。這片博文記錄了我們對混淆器原理的研究以及從中發現的有關混淆器的設計實現的脆弱之處。基於我們的研究結果,我們在Binary Ninja平台上寫了一個插件,通過這個插件可以自動化的解決掉由於代碼混淆帶來的逆向分析困難。
LLVM Obfuscator簡介
LLVM Obfuscator是一個基於LLVM框架實現的一個開源代碼混淆器,整個項目包含了三個相對獨立的LLVM pass, 每個pass實現了一種混淆方式,通過這些混淆手段,可以模糊原程序的流程或者某一部分的演算法,給逆向分析帶來一些困難。
由於上述的三個pass是基於LLVM IR實現的, 因此從理論上來說, 這種混淆器是支持世界上任何一種語言和機器架構的。
關於每種pass的詳細文檔,可以查看下面的這三個鏈接:
- Instructions Substitution(指令變換)
- Bogus Control Flow(流程偽造)
- Control Flow Flattening(流程平坦化)
上面的這幾個鏈接裡面是各個pass的作者維護的一份簡單文檔,如果你覺得文檔不夠詳盡,建議直接參考相應的源碼即可,可能對你來說會又直觀又準確。
如果說看代碼,其實是比較費勁的一個事情,主要是LLVM Obfuscator的工程代碼結構的原因。現在github上,LLVM Obfuscator是按分支來維護的,每個版本一個分支,也就是說你Clone下來的代碼都雜在一起,直接上來就看代碼很容易迷失在代碼的海洋中。不過我們可以有目的的挑著看,比如我們Clone一份4.0的代碼,然後直接在 lib/Transforms目錄下的代碼, 這裡都是自定義的LLVM pass。
在我們這篇博文裡面,我們只關注流程平坦化這一個主題,這個特性在我看來是比較有趣,並且混淆效果也是比較理想的一個特性。
控制流程平坦化
總體來說,控制流程平坦化這個特性,抽象下來,主要是通過這幾個步驟來實現的:
- 在整個代碼流程中,分析搜集出所有的基本代碼塊(Basic Block)(譯者註:遇到條件分支就算是一個新的代碼塊了)
- 把基本代碼塊放到控制流圖的最底部,然後刪除掉原來的基本塊之間的跳轉關係
- 添加混淆器的流程式控制制分發邏輯,通過新的複雜分發邏輯還原原來程序塊之間的邏輯關係
還是舉個例子吧,為了形象一點,我這裡給出兩幅圖來進行左右對比。
左邊的圖是IDA7.0(Demo版就行)對未混淆程序生成的代碼流程圖,右圖是同一個程序經過LLVM Obfuscator的流程平坦化處理之後IDA7.0分析出的代碼流程圖。
在這兩幅圖裡面,綠色的塊表示函數裡面的代碼基本塊,圖中的藍色的塊就是混淆器為了達到混淆效果和保持原程序邏輯而添加的粘合代碼,這裡我們給這些藍色塊的代碼起個名字好了,叫它 backbone(混淆器運行框架)
對於右邊這幅圖,為了看起來更加的直觀,我們可以使用IDA的node分組的功能把流程圖的顯示方式優化一下,這裡我直接把backbone代碼合併成一個node,這樣看起來就清晰了,看圖:
雖然現在流程圖簡單了不少,但是通過和上面的左圖進行對比, 整個程序流程還是發生了很大的變化,並且各個基本塊之間的邏輯關係也很難判斷了,整個代碼流程看上去更像是一個switch...case結構,每個基本塊是case分支邏輯。
由此我們也可以這樣想,整個邏輯流程變成了一個狀態機架構,每次執行哪個代碼塊由狀態機的值來決定,而每個代碼塊最後會更新狀態機的值,然後backbone框架代碼根據這個值,再來決定執行哪個基本代碼塊,所以一個代碼塊肯定要對應一個固定的狀態機的值.。
流程平坦化的弱點
從現在開始,我們開始藉助 Binary Ninja這個平台來進行後續的分析,選擇這個平台主要是基於這個平台里的幾個特性(IDA中沒有):
- Medium-level IL
- SSA Form
- Value-set analysis
確定Backbone塊(確定骨架代碼)
為了搞清楚流程平坦化的弱點,我們通過一個例子來詳細的分析一下Backbone的代碼,先看下我們的例子:
這個例子就是我們上面的那個經過混淆處理的程序的一部分,其他部分的代碼基本是相似的,因此這裡我們就截取其中一部分代表就可以了。我們仔細觀察這段代碼,這段代碼會讀取狀態變數,然後把變數和某個值進行比較,如果比較相等,就跳轉到某個基本塊執行,如果不等,就跳轉到下一個Backbone裡面繼續上述的過程
注意,這裡我們就發現了一個關鍵的脆弱點:給定一個狀態變數,記為state_var,我們發現每個Backbone代碼塊至少包含一次對這個變數的引用,如果遍歷出所有引用到這個變數的代碼塊,那我們就可以得到所有的Backbone塊,下面我們通過Binary Ninja的medium-level IL特性來搜集所有的塊,這裡我直接給出代碼:
這個演算法可以找出所有對state_var進行過引用的Backbone塊,包括程序的起始塊(這個塊是定義這個變數的塊),起始塊一般是這樣的:
從起始塊我們很容易找到這個狀態變數,然後通過def-use和use-def調用鏈,就能比較順利的找到剩餘的Backbone塊了。
確定真實的程序邏輯塊
通過類似的思想,我們看看能不能找到什麼特徵,通過這個特徵來找到所有的邏輯塊。在我們的這個例子裡面,一個真實基本塊會包含一個或者多個執行出口,而執行出口一般都是以一個無條件跳轉實現的,一個比較典型的真實塊看起來大致是這樣的:
看代碼可以知道,先是修改一下狀態變數state_var的值,然後跳轉到骨架代碼。看上面的代碼,我們基本可以確定,下次執行的真實代碼塊對應的case值是0xfcbce33c,對於那種有多個出口的真實塊會被拆分成多個塊,看起來會大致像下面這樣:
這裡,原程序的一個條件語句其實被轉換成了一個賦值語句,然後根據賦值的結果決定是不是要執行某個代碼塊,舉個例子來說,比如原程序是這樣的(^_^一起截圖了,下面的是變化後的結果):
但是,我們的目標是找到所有的真實代碼塊,為了達到這個目標,我們需要利用LLVM Obfuscator的另一個關鍵弱點:所有邏輯基本塊中,每個塊至少包含一次state_var的定義動作(注意是定義不是引用),就跟起始塊有點類似。
乍一看,可能我們要基於深度優先來進行一次def-use類型的搜索,不過在Ninja上,這個工作被簡化了不少,前面一個小節裡面,我們查找了所有使用到了state_var的代碼塊,但是在這裡我們只查找定義了這個變數的代碼塊就好了,代碼如下:
上面的代碼裡面找到的每個包含了state_var變數定義的代碼塊都被認為是一個基本的邏輯代碼塊,包含起始塊。後面的章節裡面會發現,這種特徵方式得到的結果還是很令人滿意的。
還原代碼流程
到現在為止,我們基本上已經有了重構代碼流程的所有信息,再梳理一下的話,我們現在對於一個經過混淆過的程序,目前掌握了以下兩點信息:
- 所有的代碼基本塊
- 狀態機值和基本代碼塊的映射關係
目前我們還差一步,那就是我們目前還不知道對於一個給定的基本代碼塊,它的下一個執行節點是什麼,也就是我們需要確定一個基本代碼塊執行完畢的時候,它把狀態機的值改成了什麼。為了完成這個目標,我們就要藉助Binary
Ninja的另一個很重要的特性,Value-Set分析,通過這個分析,可以知道某個寄存器或者內存位置裡面的值是什麼(譯者註:相當於一個值跟蹤系統,有點模擬執行的味道),有了這個,我們也可以確定出來最後狀態機的值了。前面提到過,一個基本代碼塊最後會把狀態機state_var更新成一個固定值,現在我們就把這些值都找出來,這樣整條鏈就串起來了:
對於有條件跳轉的情況,處理的方式有點trick的味道,由於我們的目標是要確定基本塊執行完畢的時候,出口的state_var的值是什麼,也就是要確定條件跳轉的時候,哪個是true,
哪個是false, 為了方便,我們使用Ninja的 SSA 圖形視圖來觀察一下,看下面這個例子:在這個例子裡面,函數執行完畢的時候,是有兩種情況的的state_var的,在上圖裡面,凡事會影響到state_var相關的語句全都高亮了。為了更加的直觀,這裡把上述的邏輯用 LLVM-IR再描述一下:
大致邏輯就是:
- 先把%next_state設置成%false_branch
- 如果%original_condition的結果為1, 在把%next_state設置成%true_branch
- 最後再把state結果保存到%state變數
回頭觀察上圖中的SSA-MLIL,
我們看下高亮的語句部分,其實就是在兩個不同版本的nextState變數之間進行選擇,而且每個不同版本的nextState後面跟著一個數字作為版本號標誌,再根據我們分析的邏輯,版本號小的那個就是false,版本號大的是true.最後藉助於Ninja的Value-Set分析,我們就可以得出最後的nextState的最終值,所以就能確定下一個要執行的基本塊是哪個了,還是上代碼:現在版本的API還不太方便,但是結果還是準確的,把上面的東西綜合起來,就形成了一個比較完整的腳本了:
重構乾淨的二進位文件
目前來說,在Binary Ninja平台上patch程序還是比較麻煩的,但是好像也沒有什麼更好的替代品了。到現在為止,我們已經能夠重構原始的控制流程了,剩下的工作就是根據掌握的信息來patch二進位代碼了,讓真實邏輯代碼塊之間直接相連,忽略掉Backbone代碼。
構建代碼原型
在我們目前獲取的所有原始代碼塊裡面,還包含了當時LLVM
Obfuscator插入的一些框架代碼,比如更新狀態機的代碼,這個代碼現在對我們來說是垃圾代碼了,因此需要清理掉這些代碼了,正好用這些代碼的空閑位置來插入一些我們的代碼塊連接指令,我整理了一下,下面這三種類型的指令都可以刪掉了:- 跳轉到Backbone分發器的代碼(譯者註:就是基本塊最後的那種jump指令)
- 更新state_var的指令
- 用來計算state_var結果的相關指令
為了直觀一點,繼續給例子吧,下圖中凡是標紅的指令都是要刪除掉的了,沒用了:
上面這些代碼刪除掉之後, 還能為我們後面修複流程的時候騰出來代碼空間,一舉兩得。
修復控制流程
經過上面的步驟之後,在原始代碼塊裡面騰出了一些空間,我們就利用這些空間來添加一些我們自己的修復指令,我們就用最簡單的跳轉指令來進行代碼塊之間的連接就好了,對於那種沒有條件跳轉的塊,直接在最后街上 jmp next_block就好了
對於有條件分支的情況下,就需要確定是使用哪種jcc指令了,前面的小節裡面我們知道,控制流平坦化pass會用true狀態覆蓋false狀態,如果true分支成立。在實際的代碼中,一般是使用cmovne這樣的語句來操作的,於是這裡取一個巧,我們就乾脆不管這個時候的狀態,而是依葫蘆畫瓢,復用它的狀態結果,直接做一個簡單的映射關係,cmovne直接就替換成 jne,這樣既簡單又準確,所以最後的結果大致就是這樣的:
有了上述的準備工作之後,下面的patch過程就簡單了,對於每個基本代碼塊,按照下面的步驟來操作:
- 把除了垃圾指令之外的指令拷貝一份形成一個新的代碼塊
- 在最後追加一個jump跳轉到下一個塊
- 最後為了跟原先的程序大小保持一致,把剩餘的空間用nop指令填充一下
這裡分別給出一個無條件跳轉和條件跳轉情況下的修復例子:
到現在,整個控制流程就修復好了
最後清理
經過上述的修復之後,那些backbone代碼和垃圾指令肯定是無法執行到了,但是在我們載入IDA分析的時候,還是會在視圖中出現,還是在心理上造成干擾,所以這裡為了看起來乾淨一些,把這些沒用的指令全部都用nop給填充一下(前面我們已經得到了backbone代碼塊集合了)
成果展示
為了展示一下我們的成果,我們再把還原的結果在 Ninja中的結果貼一下,先看沒有經過混淆的原始代碼:
下面是經過混淆過的代碼:
按照我們上述的修複流程修復一下,最終我們得到這樣的結果:
對比第一張圖和第三張圖,其實已經非常接近了,但是也有那麼一點點不同(譯者:但是都無傷大雅,HoHo~~):
- 部分代碼塊被拆分開了
- 插入了一些連接代碼塊的jump指令
上面這兩點算是一點小遺憾,而且不是那麼好修復
插件出爐
上述的所有過程手工起來還是比較麻煩的,我們做成了插件,你可以在上找到源碼,請注意這裡Binary Ninja插件,直接clone下來就可以用了。
使用插件來還願代碼只需要2步:
- 選擇一條更新state_var的指令
- 執行插件
本文由看雪翻譯小組 freakish 編譯,來源rpis 轉載請註明來自看雪社區
推薦閱讀:
※為什麼很多語言的JIT實現最後會失敗,主要的技術原因和難點有哪些?
※LLVM每日談之二十六 riscv-llvm
※LLVM 相比與其他 Compiler Infrastructure 有什麼優勢?
※LLVM每日談之七 Clang