android逆向奇技淫巧十:OLLVM原理、常見破解思路和hook代碼


  搞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代碼混淆技術總結


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM