在介紹O-MVLL之前,首先介紹什么是代碼混淆以及基于LLVM的代碼混淆,O-MVLL專案正是基于此而開發來的,
有關O-MVLL的概括介紹以及安裝和基本使用方式,可參加另一篇隨筆
https://www.cnblogs.com/level5uiharu/p/16912019.html
基于LLVM的代碼混淆
代碼混淆是將代碼轉換成另一種功能上等價,但更難以閱讀的形式,是一種對抗逆向工程的手段,也是一種保護源代碼和程式的手段,
例如修改各種函式、變數名稱以消除其語意,使用非正常邏輯實作功能、使指令復雜化等等,代碼混淆不能從根源上對抗逆向工程,只能增加逆向工程的分析成本,因此還需要結合其他手段來獲得更強的安全性,同時,常見的代碼混淆方式往往會引入大量無關指令,或者用于復雜化程式的指令,盡管引入指令越多安全性越強,但通常還會增加程式體積并降低運行效率,因此在使用代碼混淆時,要平衡好效率和安全性,


目前代碼混淆仍是一個小眾方向,相關研究和進展不多,此外,代碼混淆還可以應用于惡意代碼檢測領域,對惡意程式進行混淆從而生成更多的惡意代碼樣本,一方面擴充了模型訓練的資料集,另一方面代碼混淆對惡意代碼進行的修改可能會隱藏其某些特征,
那么什么是基于LLVM的代碼混淆呢?
代碼混淆有多種實作途徑,根據目標編程語言、架構等不同有不同方式,最初的代碼混淆是直接在源代碼上進行修改然后編譯,這樣雖然保護了源代碼,但也增加了除錯和開發者自己理解原始碼的成本,Java則由于其位元組碼的存在,通常是對存盤在class檔案中的位元組碼進行混淆,這樣就不必修改原始碼,但仍能得到一份混淆后的可執行檔案,將可執行檔案發行即可,
基于LLVM的代碼混淆正是采用了和Java混淆類似的思路,LLVM編譯框架大致分為前端、中端、后端三段:

前端:進行詞法分析、語法分析、語意分析等,生成中間代碼IR
中端:優化器,會在此處對中間代碼IR進行修改優化,中端會有名為Pass的檔案,每一個Pass都會依照自身的邏輯對IR進行修改完成優化
后端:完成連接、匯編、生成目標檔案的作業
如上圖所示,對于不同的編程語言,都會在前端轉換成格式相同的IR檔案后交由中端優化器處理,同時,LLVM提供了Pass開發的API,可以根據自身需求開發特定功能的Pass,
因此基于LLVM的代碼混淆實際上是通過開發Pass的方式,在中端優化器中混淆IR檔案,再講混淆后的IR檔案連接匯編,從而得到混淆的可執行檔案的,
O-MVLL代碼混淆器
O-MVLL專案靈感源自OLLVM,后者則是最著名的基于LLVM的代碼混淆器之一,實作了指令替代、控制流平坦化、虛假控制流這三種代碼混淆方式,O-MVLL在OLLVM的基礎上增強了這三種代碼混淆方式,同時新增了一些代碼混淆方式(當然,以Python API的形式呼叫代碼混淆也是其創新和特點,在上一篇文章中有所介紹)
下面介紹O-MVLL中使用的代碼混淆方式
對抗掛鉤Anti_Hooking
使用方式:重寫anti_hooking方法
def anti_hooking(self, mod: omvll.Module, func: omvll.Function) -> omvll.AntiHookOpt: if func.name in ["encrypt", "has_secure_enclave"]: return True return False
以上的代碼能夠將這種代碼混淆方式作用于函式名為encrypt、has_secure_enclave的函式,
對抗hook技術是另一門值得深入研究的學問,因此O-MVLL對此進行的保護適用范圍有限,安全性也有限,
該方式只適用于對抗frida,這跟它的設計有關,通常來說,hook框架需要使用幾個臨時的暫存器來重新定位或訪問當前函式的原資料,對于frida來說,它需要使用x16,x17兩個暫存器之一,這一點可以在frida專案的檔案gumarm64relocator.c中分析出來:
if (available_scratch_reg != NULL) { gboolean x16_used, x17_used; guint insn_index; x16_used = FALSE; x17_used = FALSE; ... if (!x16_used) *available_scratch_reg = ARM64_REG_X16; else if (!x17_used) *available_scratch_reg = ARM64_REG_X17; else *available_scratch_reg = ARM64_REG_INVALID; }
因此如果在函式的序言開始的地方插入指令,占用x16,x17這兩個暫存器,就能夠讓frida拋出錯誤,O-MVLL也正是這樣做的,具體做法為在函式的開頭插入
mov x17,x17;mov x16,x16或者mov x16,x16;mov x17,x17兩條陳述句,插入哪組指令則是由亂數隨機選擇
可以參見O-MVLL/src/passes/anti-hook/AntiHook.cpp中的定義
static const std::vector<PrologueInfoTy> ANTI_FRIDA_PROLOGUES = { {R"delim( mov x17, x17; mov x16, x16; )delim", 2}, {R"delim( mov x16, x16; mov x17, x17; )delim", 2} };

這種保護缺點明顯,那就是只能適用于frida,換一個不適用x16,x17的hook框架就會被繞過,甚至也可以通過簡單的patch,將函式開頭兩條占用x16,x17的陳述句覆寫掉來讓frida能夠正常使用,
運算混淆Arithmetic Obfuscation
使用方法:重寫obfuscate_arithmetic方法
def obfuscate_arithmetic(self, mod: omvll.Module, fun: omvll.Function) -> omvll.ArithmeticOpt: if func.name == "encode": return omvll.ArithmeticOpt(8)
上述配置會將該代碼混淆方式應用于encode函式,并且迭代混淆8次
這種方式是將運算指令復雜化,能夠被復雜化的運算指令包括加、減、與、或、異或,乘法和除法由于其運算的復雜性和溢位、借位等操作難以實作,
具體來說,它會將這些運算使用混合布爾算術(MBA)構造的等價式替代,這些等價式和原本的運算指令之間的映射關系如下

迭代混淆會在上一輪混淆的基礎上再次呼叫該混淆方式,多次的迭代將產生大量的混淆代碼,因此一定要考慮安全性和運行效率的平衡,
以下是混淆前后的對比,迭代次數為1


使用這種方式混淆出來的特征明顯,且由于每種運算指令和替代式一一對應,因此每一種特定的混淆形式都能唯一確定相應的運算指令,也能由混淆形式還原,這種混淆方式往往需要和其他代碼混淆方式結合使用才能發揮更大的威力,
不透明常量Opaque Constants
使用方式:重寫obfuscate_constants方法
def obfuscate_constants(self, mod: omvll.Module, func: omvll.Function): # Logic goes here
在函式的回傳值方面,作者進行了設計,目前提供以下幾個回傳值的處理:
1.BOOL:回傳true時啟動混淆,false時不啟動
2.回傳一個整型常量的list:混淆list中出現的常量
3.回傳omvll.OpaqueConstantsLowerLimit(n),混淆不小于n的常量
與運算混淆類似,這里則是使用構造的復雜等價式替換掉程式中出現的常量,這對于一些加密演算法的特征常量(例如AES的S盒)的保護效果很好,
對于用來替代常量的復雜等價式的構造,作者采用以下三個方式:
1.0的構造
0 = MBA(X ^ Y) - (X ^ Y)
0 = (X | Y) - (X & Y) - (X ^ Y)
2.1的構造
LSB = 當前堆疊頂地址 Odd = 隨機生成的奇數 1 = (LSB + Odd) % 2
由于堆疊地址一定要滿足對齊的條件因此低位一定是0,即堆疊地址是一個偶數,這樣就能保證LSB + Odd一定等于一個奇數
3.其他值的構造
Split = random(1,min(255,var)) LHS = var - Split + 0 RHS = Split + 0
var = LHS + RHS
通過上述構造的替換,逆向工程時能看到的原常量var被替換成LHS + RHS,此外LHS和RHS都分別加上了0,這個0會被之前提到的0的構造替換,并且整個Opaque Constants會默認啟用運算混淆,迭代次數為1,其中相應的運算指令也會被復雜化,
下圖為混淆前后的對比圖,展示的是0的構造替代,
可以看到盡管沒有在配置中啟用運算混淆,但是常量混淆的混淆代碼中有明顯運算混淆的特點,
控制流破壞Control-Flow Breaking
使用方式:重寫break_control_flow方法
def break_control_flow(self, mod: omvll.Module, func: omvll.Function): if func.name == "break_control_flow": return True return False
上述配置會將該混淆方式應用于break_control_flow函式
該混淆方式破壞控制流,準確地說是破壞函式呼叫的控制流,但本質上并沒有改變函式呼叫的流程圖,而是將被保護的函式中的指令復制到另一個函式當中,再洗掉原函式的指令并添加混淆指令,最后使用隱含的方式跳轉到復制函式中以保證功能不變,
具體而言,它做了三件事:
1.clone克隆
克隆原函式的所有指令,并記錄克隆函式的地址,
2.插入混淆指令
洗掉原函式的指令,并插入混淆指令,這些混淆指令包含類似于ldr x0,#offset的指令,pc+#offset處則是要保護的函式的地址,添加這種指令會讓反匯編器認為這個偏移處的內容可能不是指令而是資料,然而實際上它就是指令,
此外還會添加一些運算,運算結果為克隆函式地址,將地址保存到區域變數中,并使用運算混淆和不透明常量進行聯合保護,
3.添加跳轉
在原函式的結尾處插入跳轉指令,跳轉到保存了原函式正常功能的克隆函式處,以完成正常功能,
首先會從區域變數中取出克隆函式的地址到暫存器中,再使用BLR指令跳轉到暫存器中保存的地址,

以這樣的方式跳轉,克隆函式不會出現在函式呼叫的流程圖當中,相比于硬編碼使用函式地址完成跳轉來說是一種隱含的函式呼叫
下圖是該混淆方式作業的模式圖

以下是混淆前后函式的對比


混淆后函式原先的代碼被存放在了克隆函式sub_18D8當中,并通過最后一條指令跳轉到克隆函式,克隆函式當中的內容,與未混淆前原函式的內容相同

控制流平坦化Control-Flow Flattening
使用方式:重寫flatten_cfg函式
def flatten_cfg(self, mod: omvll.Module, func: omvll.Function): if func.name == "check_password": return True return False
如上所示,將會對函式check_password使用該混淆方式,
控制流平坦化是對程式中出現的分支和跳轉進行修改,全部轉換為switch的形式,從控制流程圖的角度看,就像是把控制流程圖給壓平了,如下圖所示

那么如何保證執行的順序和分支條件不發生變化呢?
假設switch陳述句根據變數var來進行跳轉,那么首先為每個基本塊打上標簽,從基本塊2到基本塊5分別為a,b,c,d,e,這五個標簽為生成的亂數,
之后在每一個基本塊結束的時候對var進行賦值,將var賦值為下一個基本塊對應的標簽,然后再跳轉到分發塊,例如基本塊2的分支可能跳轉到基本塊3和基本塊4,那么根據判斷條件,將var賦值為b或者c,然后跳轉到分支塊switch,switch陳述句就會根據基本塊2對var的修改來進行相應的跳轉,
以上是OLLVM實作的控制流平坦化,O-MVLL對其進行了增強
1.對var進行編碼處理
在OLLVM中,可以根據基本塊最后的賦值來判斷下一個基本塊是哪個,你可能能夠看到如下的偽代碼:
switch(var){ case a: var = b; case b: ; ... }
可以很明顯地根據對var的賦值判斷出基本塊a的下一個基本塊是基本塊b,
但在O-MVLL中,賦值給var的值實際上是經過編碼后的值,也就是如下的偽代碼:
switch(encode(var)){ case a: var = c; case b: ; ... }
而encode(c)= b,這保證了流程的正常執行,但僅僅通過分析switch處的代碼,無法得知基本塊a的下一個基本塊是基本塊b,還需要對編碼的演算法進行分析和破解,
2.在default中填充垃圾代碼
由控制流平坦化而來的switch陳述句中,default所指示的代碼塊是永遠不會被執行的(否則原程式的控制流程被混淆后就發生改變了),因此O-MVLL在default指示的代碼塊中插入了一些垃圾代碼,這些代碼不會被執行,但仍會出現在控制流程圖中混淆視線,
這里添加的垃圾代碼,就像Control-Flow Breaking中添加的混淆指令一樣,
以下是混淆前后函式的控制流程圖:

不透明欄位訪問Opaque Fields Access
使用方法:重寫obfuscate_struct_access函式
def obfuscate_struct_access(self, _: omvll.Module, __: omvll.Function, struct: omvll.Struct):if struct.name == "class.SecretString": return True return False
如上所示,將會對名字為SecretString的類進行混淆(該混淆方式也可以應用于結構體)
這種方式能夠增加分析結構體和類的難度,在逆向工程時更難分析出類和結構體內部的成員和型別,
通常,對于結構體和類的訪問采用
ldr x0, [x1, #offset]
這樣的形式,而offset的組成是由區域變數和堆疊頂的偏移組成的,因此無法直接對#offset進行混淆,
O-MVLL的做法是將上述指令轉換如下:
$var := #offset + 0 ldr x0, [x1, $var]
這樣就將偏移保存在變數當中,再通過變數進行尋址,此時就可以對var進行混淆了,混淆的方式采用了之前提到的運算混淆和不透明常量兩種方式結合,
以下是混淆前后的對比圖
本質上是將不能使用運算混淆和不透明常量混淆的堆疊中偏移量,以變數的形式使用,從而讓它能夠被混淆
字串加密Strings Encoding
使用方式:重寫obfuscate_string方法
def obfuscate_string(self, _, __, string: bytes): if b'debug.cpp' in string:
return 'REMOVED'
該方法支持多種回傳值,具體如下:
1.回傳一個字串:將字串替換為回傳的字串,如果用于替換的字串長度比原字串長,則超出部分會被截斷,回傳空字串時表示洗掉
2.回傳omvll.StringEncOptGlobal(),加密字串并存盤為全域變數,在.data段可見,一旦程式被加載,該字串就可以被搜索到
3.回傳omvll.StringEncOptStack(),加密字串并存盤在當前堆疊上
4.回傳omvll.StringEncOptStack(loopThreshold=0),加密字串并存盤在當前堆疊上,解密流程相比3更簡單,代碼量減少
上述四種方式的實作細節如下:
omvll.StringEncOptGlobal()
這種方式加密后,字串被保存在.data段上

對應的解密函式為sub_1818,該解密函式被存放在.init_array當中,也就是說它是在程式加載后的初始化當中被呼叫,因此一旦程式完成加載初始化,字串就會被還原

omvll.StringEncOptStack()
這種方式會將加密后的字串作為區域變數保存在函式的堆疊上,使用該字串之前的解密步驟也是在使用該字串的函式中完成

在解密演算法呼叫的時候,也都默認呼叫了運算混淆和不透明常量兩種混淆方式,
沒有啟用loopThreshould=0時,解密的演算法大致如下所示
char OMVLL_DECODED[6]; OMVLL_DECODED[1] = ENC_OMVLL[1] ^ 0xd7; OMVLL_DECODED[5] = ENC_OMVLL[5] ^ 0x02; OMVLL_DECODED[2] = ENC_OMVLL[2] ^ 0x77; OMVLL_DECODED[0] = ENC_OMVLL[0] ^ 0x55; OMVLL_DECODED[4] = ENC_OMVLL[4] ^ 0x7b; OMVLL_DECODED[3] = ENC_OMVLL[3] ^ 0x35;
這種方式產生大量指令,但好在打亂了秘鑰流,增強了安全性,當字串很長時,這種方式會非常耗費空間,
因此啟用了loopThreshould=0時,解密演算法大致可以如下表示:
char OMVLL_DECODED[6]; for (size_t i = 0; i < 6; ++i) { OMVLL_DECODED[i] = ENC_OMVLL[i] ^ KEY[i]; }
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/538029.html
標籤:其他
上一篇:第一次打靶
下一篇:unittest學習筆記
