搞android逆向,肯定聽說過OLLVM是啥(不知道的肯定就是沒搞過android逆向的)!想要破解OLLVM,首先要了解OLLVM的原理。要了解OLLVM的原理,就要先了解LLVM的原理!
1、LLVM原理介紹
大家平日里在編譯器里寫代碼,寫完后點擊編譯或運行按鈕,都發生了什么了? 可執行的PE或ELF文件都是怎么生成的了?大概的流程如下:
源代碼經過fronted前端這個環節,做詞法分析、語法分析、語義分析、生成中間代碼(簡單講就是檢查語法是不是正確的,並生成中間代碼;這里的中間碼很重要,后續的OLLVM就是在中間碼上做文章,這里先打個伏筆);然后進入Optimizer優化環節,比如有些聲明但沒被使用的變量或函數要不要去掉?很多簡單函數之間的調用要不要直接內聯合並成一個函數,以便運行時減少堆棧開銷? 最后就是bankend后端環節,核心是生成和CPU匹配的機器碼!
咋一看這個流程沒毛病,完美實現了編譯的全流程!然后仔細一想,缺陷也很明顯:我用上述流程讓C/C++生成x86的代碼是OK的,但是我現在想生成arm的代碼,后端backend怎么辦了?是不是要重新換個生成arm機器碼的backend了? 同理:我用Fortran寫代碼,frontend這里是不是要重新換成Fortran的? 這種編譯流程最大的問題:Frontend、Optimizer、backend之間是緊耦合的,互相拆不開!這個問題有點類似計算機網絡: 剛開始通信節點的數量少,節點之間互相直連。但是隨着計算機的增加,通信需求越來越多,如果每兩個節點都要互相連接,最終需要的連接邊數就是n*(n-1)/2,這么大的數量顯然是不現實的,所以誕生了交換機去中轉通信的數據!這里是不是也能借鑒一下交換機的思路了?由此誕生了LLVM架構,如下:
這次把前端、優化、后端解耦分開了!前端每種語言都有對應的Frontend,后端每種cpu都有對應的backend,只有中間優化是統一的!以后每增加一種編程語言,新增一個前端就行了,優化和后端不用改;同理:每增加一種cpu,后端增加一個就行了,前端和優化也不用改,整個架構的擴展性大大提升!LLVM架構詳細說明如下:
- 不同的前端后端使用統一的中間代碼LLVM Intermediate Representation (LLVM IR)
- 如果需要支持一種新的編程語言,那么只需要實現一個新的前端
- 如果需要支持一種新的硬件設備,那么只需要實現一個新的后端
- 優化階段是一個通用的階段,它針對的是統一的LLVM IR,不論是支持新的編程語言,還是支持新的硬件設備,都不需要對優化階段做修改(前后端都遵從統一的IR標准)
- 相比之下,GCC的前端和后端沒分得太開,前端后端耦合在了一起。所以GCC為了支持一門新的語言,或者為了支持一個新的目標平台,就 變得特別困難
- LLVM現在被作為實現各種靜態和運行時編譯語言的通用基礎結構(GCC家族、Java、.NET、Python、Ruby、Scheme、Haskell、D等)
2、OLLVM原理介紹
從名字上看,OLLVM比LLVM多了一個O,這個O就是obfuscator的簡寫!從字面看,OLLVM就是在LLVM的基礎上增加了obfuscator(混淆)!那么這個混淆都是怎么加上的了?
回過頭看看上面的LLVM架構,唯一不變的是不是只有中間的Optimizer呀?新增編程語言要新增frotend,新增CPU架構要新增backend,只有中間的optimizer屹立不倒!所以obfuscator最合適的就是在中間optimizer這個環節了!至於OLLVM怎么實操,感興趣的小伙伴建議google一下,這類文章太多了,這里不再贅述!簡單理解:OLLVM有一個框架,這個框架提供了很多API(注意:正式生產環境下OLLVM的API很多,功能也比較復雜,這里只是簡單舉個例子說明其中之一的功能),調用這些API可以對IR中間代碼做各種操作,比如下面這段代碼:
ConstantDataSequential *CDS =dyn_cast<ConstantDataSequential>(GV->getInitializer()); if (CDS) { std::string str = CDS->getRawDataValues().str(); errs() << "str:" << str << "\r\n"; uint8_t xor_key = llvm::cryptoutils->get_uint8_t(); for (int i = 0; i < str.size(); ++i) { str[i] = str[i] ^ xor_key; }
逐行掃描IR代碼(如下)的字符串(這種IR中間代碼類似於java的smali代碼):
@.str = private unnamed_addr constant [14 x i8] c"test_hello1\0D\0A\00", align 1 @.str.1 = private unnamed_addr constant [22 x i8] c"hello clang!\0D\0A\00", align 1 @.str.2 = private unnamed_addr constant [21 x i8] c"hello pendy clang!\0D\0A\00", align 1 @.str.3 = private unnamed_addr constant [14 x i8] c"test_hello2\0D\0A\00", align 1
然后通過異或逐個加密這些字符串,達到混淆的目的!文章末尾參考2有OLLVM在github的官網連接,里面介紹了4種混淆的方式;
(1)Instructions Substitution:簡單理解就是加減法、邏輯運算混淆(https://github.com/obfuscator-llvm/obfuscator/wiki/Instructions-Substitution):比如加法寫成如下形式:
減法寫成如下形式:
其他的邏輯運算寫成如下形式(這里的思路有點類似VMP的萬用門,簡單的邏輯運算用復雜的表達式替代):
總的來說:Instructions Substitution就是把簡單的四則和邏輯運算復雜化!以加法為例,其中一種的混淆結果如下:
(2) Bogus Control Flow 虛假控制流(https://github.com/obfuscator-llvm/obfuscator/wiki/Bogus-Control-Flow)
一段簡單如下的代碼:
#include <stdlib.h> int main(int argc, char** argv) { int a = atoi(argv[1]); if(a == 0) return 1; else return 10; return 0; }
使用了BCF后的效果:看看多了多少分支!前面這個if條件就是BCF最明顯的特征!
官網提供的IR效果如下:
(3)Control Flow Flattening(https://github.com/obfuscator-llvm/obfuscator/wiki/Control-Flow-Flattening)
最常見的混淆就是這種了,原理就是在不改變源代碼的功能前提下,將C或C++代碼中的if、while、for、do等控制語句轉換成switch分支語句。這樣做的好處是可以模糊switch中case代碼塊之間的關系,從而增加分析難度。具體做法是首先將要實現平坦化的方法分成多個基本塊(就是case代碼塊)和一個入口塊,為每個基本快編號,並讓這些基本塊都有共同的前驅模塊和后繼模塊。前驅模塊主要是進行基本塊的分發,分發通過改變switch變量來實現。后繼模塊也可用於更新switch變量的值,並跳轉到switch開始處,流程如下:
實際效果如下:
把上面那個簡單的案例用虛假控制流和控制流平坦化一起使用,效果如下:
上述這個算好的,真實CFF混淆后的so用IDA打開,效果如下:這種分支被增加地已經沒法靜態分析了!
(4)字符串加密:就是第2節開頭舉得那個例子!調用OLLVM提供的API,找到所有的字符串,然后根據業務需求挨個加密!
3、上面介紹了常見的OLLVM混淆方法,該怎么去破解了?
(1)先拿最簡單的字符串加密/混淆舉例:常見的字符串加密方式是異或,然后在init_array里面解密;有耐心的同學可以嘗試在init_array去分析代碼,看看能不能找到key后解密字符串;這里其實還有更簡單的辦法:直接用frida hook字符串的地址后打印出來即可,腳本如下:findBaseAddress的參數傳入so的名稱,得到so在內存的基址;add函數傳入字符串在so內部的偏移,得到字符串的絕對地址;然后直接用log函數把地址的數據打印出來即可:
function hook_native() { var base_hello_jni = Module.findBaseAddress("libhello-jni.so"); if (base_hello_jni) { //ollvm默認的字符串混淆,靜態的時候沒法看見字符串 //執行起來之后,先調用.init_array里面的函數來解密字符串 //解密完之后,內存中的字符串就是明文狀態了。 var addr_37070 = base_hello_jni.add(0x37070); console.log("addr_37070:", ptr(addr_37070).readCString()); } }
或者這樣寫,然后通過frida -U傳入字符串首地址的偏移:
function print_string(addr) { var base_hello_jni = Module.findBaseAddress("libhello-jni.so"); var addr_str = base_hello_jni.add(addr); console.log("addr:", addr, " ", ptr(addr_str).readCString()); }
還有另一類字符串打印:registerNative的第三個參數,也就是動態注冊時java層函數和native層函數映射關系的JNINativeMethod,簡單的打印函數如下:
Interceptor.attach(addr_RegisterNatives, { onEnter: function (args) { console.log("addr_RegisterNatives:", hexdump(args[2])); //打印第三個參數,也就是java和native映射的數組首地址 console.log("addr_RegisterNatives name:", ptr(args[2]).readPointer().readCString())//java層函數名稱 console.log("addr_RegisterNatives sig:", ptr(args[2]).add(Process.pointerSize).readPointer().readCString());//函數參數 console.log("addr_RegisterNatives sig:", ptr(args[2]).add(Process.pointerSize+Process.pointerSize));//native函數入口地址 }, onLeave: function (retval) { } });
registerNative更完整的打印函數可以參考:https://github.com/lasting-yang/frida_hook_libart 或 https://www.52pojie.cn/thread-1182860-1-1.html
(2)控制流平坦化(虛假控制流原理類似:這兩種方式都是對控制流做文章的,都是改變了原控制流,只是改變的方式不同,兩者沒本質區別):平日見的最多的就是這種混淆方式了!這種方式說白了就是把if、while、for、do等控制語句改造成switch、case,讓case之間看不出明顯的邏輯關系;每個case執行完后更改“信號量”,以此決定下一次循環走那個case;這種混淆方式平白無故增加了很多無用的case分支,但是絕對不敢亂改原來的函數調用關系!所以即使被用這種方式混淆,但原來的函數調用還是真實的!基於這點,可以根據經驗篩選出一些重點函數來hook,看看這些函數的參數都是啥,都返回了什么,以此來猜測這些函數的功能!常見的so層js hook代碼(java層建議直接用objection,非常方便)如下:
- 被動hook某個函數(這里用的是Java.use,而主動調用用的是Java.choose),打印參數和返回值
function hook_sign2() { Java.perform(function () { var HelloJni = Java.use("com.example.hellojni.HelloJni"); HelloJni.sign2.implementation = function (str, str2) { var result = this.sign2(str, str2); console.log("HelloJni.sign2:", str, str2, result); return result; }; }); }
如果函數是靜態注冊的,也能這樣寫代碼,就不用去IDA手動查函數偏移了:
var sign2 = Module.findExportByName("libhello-jni.so", "Java_com_example_hellojni_HelloJni_sign2"); console.log(sign2); Interceptor.attach(sign2, { onEnter: function (args) { //jstring console.log("sign2 str1:", ptr(Java.vm.tryGetEnv().getStringUtfChars(args[2])).readCString()); console.log("sign2 str2:", ptr(Java.vm.tryGetEnv().getStringUtfChars(args[3])).readCString()); }, onLeave: function (retval) { console.log("sign2 retval:", ptr(Java.vm.tryGetEnv().getStringUtfChars(retval)).readCString()); } });
有些函數是通過參數保存返回值的,比如sub_1AB4C(v1,v2,&v3)這種,把V3的地址傳入函數,並且V3在后續的代碼也被使用了,所以這里有可能是V3保存了函數的處理結果,下面這種方式可以打印保存結果(注意:這里還能直接取寄存器的值,調試更方便了):
var sub_1AB4C = base_hello_jni.add(0x1AB4C); Interceptor.attach(sub_1AB4C, { onEnter: function (args) { this.arg2 = args[2];
this.arg8 = this.context.x8;//注意這里還可以讀取寄存器 console.log("sub_1AB4C onEnter:", hexdump(args[0]), args[1], "\r\n", hexdump(args[2])); }, onLeave: function (retval) { console.log("sub_1AB4C onLeave:", hexdump(retval), "\r\n", hexdump(this.arg2));//args[2]傳參時取了地址,在后面也會用到,所以這個參數有可能保存了返回值,這里打印出來看看
console.log("sub_1AB4C onLeave:", hexdump(this.arg8))
}
});
效果如下:retval啥都沒有,但是args2的值就有,說明函數處理的結果確實保存在了參數里,而不是返回值:
- 另一些小技巧:以16進制打印指針,看看指針指向的內容到底是啥,更利於后續分析
console.log("sub_12D70 onLeave arg2:", hexdump(ptr(this.arg2).add(Process.pointerSize).readPointer()));
打印出來就這種效果:
- 主動調用某個函數(這里用的是Java.choose,被動調用用的是Java.use):這個功能很好,可以主動調用某些so層函數,避免了直接操作app才能執行這些函數的尷尬;調用方式也簡單,先進入frida -U,然后在命令行輸入call_sign2即可;
function call_sign2() { Java.perform(function () { Java.choose("com.example.hellojni.HelloJni", { onMatch: function (ins) { var result = ins.sign2("0123456789", "abcdefghakdjshgkjadsfhgkjadshfg"); console.log(result); }, onComplete: function () { } }); }); }
效果還不錯:
以上hook函數的方式,我個人覺得和通過IDA調試so代碼在功能上沒本質區別,但還是更推薦這種hook方式,原因:
- js代碼熱更新:更改js代碼后不需要重啟app,立即生效,非常方便
- IDA調試需要逐行跟蹤匯編指令,遇到各種混淆容易陷入局部“迷茫”(不知道現在走到哪了,不知道現在在干嘛)!而這種hook函數的方式可以站在更高的方法層級觀察某個位置函數的功能(打印參數和返回值),更累利於“站在上帝視角”理解函數整體的功能!
(3)trace:frida、IDA、unicorn/unidbg的trace,逐行跟蹤指令的執行,那些存粹用來混淆靜態分析的指令自然就被過濾掉了!后續會專門分享這些工具的用法!
(4)android加殼方式總結:
參考:
1、https://xz.aliyun.com/t/7257 初探LLVM
2、https://github.com/obfuscator-llvm/obfuscator/wiki/features OLLVM4種features
3、https://www.anquanke.com/post/id/85843 Android代碼混淆技術總結