某天在應用商店挑選「幸運兒」時,一不小心選到了NP保護的,我想這便是天意。
自那天起便開啟了這段漫長的分析之旅,數以百次的調試,最後留下本文。
樣本: anAuZ29vZHNtaWxlLnRvdWhvdWxvc3R3b3JkX2FuZHJvaWQ=
NP的關鍵so為libcompatible.so、libstub.so和libengine.so,三者之間環環相扣,先看libcompatible.so。
通過readelf找到.init_proc在0x154028。
發現.init_proc被一些導出符號分割了,導致IDA無法F5。

用乐佬那篇文章的解決思路,手動把位於.init_proc範圍內的導出符號置空即可。
patch後就能順利F5了,發現.init_proc中有如下虛假控制流。

bcf的解決思路可參考oacia大佬的這篇文章:f86K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6G2j5h3y4A6j5g2)9J5k6h3c8W2N6W2)9J5c8X3!0D9L8s2k6E0i4K6u0V1M7%4c8#2k6s2W2Q4x3V1j5`.
計算獲取libcompatible.so的基址,保存在libcompatible_base中。然後調用give_load_seg_rwx()。

give_load_seg_rwx()的實現如下,根據libcompatible_base解析 & 遍歷phdr,記錄最後一個loadable seg的結束位置,記為last_loadseg_end。
將last_loadseg_end對齊內存頁大小後的值作為mprotect()的size,保證基本上全部代碼段、數據段都有rwx權限。

回到.init_proc繼續向下看。
調用了mmap系統調用,映射了一頁rwx權限的內存,記為這片內存為mmap_buf。

調用decrypt_something1(),解密了一些數據,結果存放在data_from_dec中。

調用decrypt_something2(),根據data_from_dec來解密args[1]。記解密後的數據為data_from_dec2。

然後會根據data_from_dec2來對mmap_buf賦值。

由於mmap_buf有執行權,因此嘗試將賦予mmap_buf的數據解析為代碼,發現是svc。

之後svc會被保存到全局變量中。

完成svc的賦值後,調用了cache_maintenance()來更新mmap_buf的cache緩存。

調用decrypt_some_str()解密了一些字符串,保存在全局變量中。
它的字符串解密邏輯如下:
解密了一些ro.屬性,大概是在做兼容。

之後獲取了當前so的.dynamic段。

解析.dynamic,獲取重定向相關信息,如RELA表,JMPREL表等。

清空重定向表。


最後會分別調用I1 ~ I3這3個導出函數( 是在上面被解密的 ),調用後會把函數加密回去( 那些異或操作一開始以為是解密,後來才發現是加密 )。

簡單看了下I1和I2,沒有太特別的地方,重點在I3這個導出函數。
sub_7A635BF48C()設置了某些導出符號的其中幾個字節,不知在干什麼。
之後mmap了一片rw權限的內存,記為mmap_buf2。

對mmap_buf2進行賦值。

之後會來到下圖的地方,若F7單步步入紅框慢慢跟,IDA似乎會crash,而F8直接步過則不會?

把某個函數賦給了some_func1變量

之後就來到第1處fla,顯然是關鍵邏輯處。

簡單看看這個fla。
這個地方在解密一段代碼,把v144指向的地址減去基址,得到的偏移記為off。

從dump出來的libcompatible.so中搜off,發現off在JNI_OnLoad()中,由此可知上述解密的代碼正是JNI_OnLoad()。

第2處解密JNI_OnLoad()的地方,由此可知JNI_OnLoad()是分段解密的。

第3處解密JNI_OnLoad()的地方。

JNI_OnLoad()的解密應該只分成了3段,解密完後會調用cache_maintenance(),似乎每次解密完代碼後都會調用該函數做cache相關的處理。
cache_maintenance()的args[0]是被解密代碼的起始地址,args[1]是結束地址,這個地址範圍中包含多個解密後的函數。

然後就是第2處fla,記為fla2。

同樣是一段代碼解密邏輯,解密的是0xF70D0處的代碼。

解密後發現只是一個功能函數。

fla2中第2處代碼解密邏輯。

fla2中第3處代碼解密邏輯。

注:上述的代碼解密邏輯可能會被交替調用來對同一個函數進行解密。
解密完成後,同樣調用了cache_maintenance()。

然後調用了check_something()進行一些檢測,其中又大概可分成check1() ~ check5()5個小檢測。

先看check1(),一開始調用了prctl("PR_SET_DUMPABLE"),不知為何。
然後調用newfstatat系統調用獲取/proc/<pid>/environ相關信息。

將statbuf設置為struct stat statbuf類型後,可以看出調用newfstatat()是為了獲取時間戳來實現時間檢測。


接下來看check2()。
解密了"magisk",然後調用openat系統調用打開"/proc/self/mounts",顯然是在檢測magisk。

調用read系統調用讀取/proc/self/mounts,每次讀2048字節,其中包含換行符,要手動處理。
處理完後就是檢查/proc/self/mounts每行是否包含magisk字樣,以此來檢測magisk。

然後check3()是經典的root檢測。


check4()是動調檢測。

最後是check5()。

check5_func1()打開了/proc/self/maps,解密了解析maps文件的格式化字符串,保存在a1中。

check5_func2()調用read系統調用讀取maps文件,同樣手動處理換行符後,調用自實現的sscanf解析maps信息,最後保存到a1中。

調用完check5_func2()後,解密出了"/system/bin/app_process"字串,然後與maps_lib_path對比。
這裡是為了匹配maps中的/system/bin/app_process。

匹配成功後,調用process_app_process()。

process_app_process()會遍歷內存中的/system/bin/app_process64,看看其中是否存在magisk字串。

process_app_process()檢查完之後,會調用check5_func3()。

check5_func3()同樣是一些magisk檢測,如下:



至此分析完check_something()。
回到I3函數向下看。
有個while循環不斷從一個類似函數列表的地方取函數並調用,這個函數列表大概就是解密後的.init_array。

.init_array[0]中調用了unsetenv("LD_PRELOAD"),這大概是一種反注入的機制。unsetenv()還會再遍歷一次PATH環境變量,確保LD_PRELOAD真的被unset掉,否則會直接exit()。

除了.init_array[0]外,其他.init_array函數似乎都與檢測無關。
由上述分析可知,I3函數執行完後,JNI_OnLoad()也被解密完成。因此在那時機下斷點跳過去分析JNI_OnLoad()。
開始分析JNI_OnLoad()。
調用了mprotect系統調用,賦予某片內存rwx的權限。

保存了JNI_OnLoad的前0x10字節,暫時未知用來做什麼。

然後就是熟悉的控制流。

同樣是一些函數解密的邏輯。


最終同樣會調用cache_maintenance()。
之後調用了sub_777F32BB58(),其中會間接調用a1 + 48指向的函數。

跟進去後發現是GetEnv()。

然後又判斷了一次package name是否以_zygote結尾。

之後調用了JNI_OnLoad_func1()進行一些檢測。

JNI_OnLoad_func1()中解密了以下字符串:( 都是模擬器的特徵 )
檢測的邏輯同樣是按上述方式解析/proc/self/maps後,看看是否存在這些so,是則代表是模擬器。

之後反射調用了一個Java層的函數。

發現只是一個固定返回true函數?

然後動態注冊了一些JNI函數。

第1個register_natives()注冊了以下Java類的native函數:
第2個register_natives()注冊了以下Java類的native函數:

然後又解密了一個函數( 下圖的sub_777E313F98() ) ,記該函數為JNI_OnLoad_decfunc1()。

進行了一些運算後,調用了KM4PI0Z7J8QMILO5G6P6()。

KM4PI0Z7J8QMILO5G6P6()記錄了以下目錄的「最後存取時間」之和,但不知有什麼用。

之後又在一處fla中解密了某個函數,記該函數為big_func()。

之所以叫big_func(),是因為該函數的F5偽代碼有六千多行。
一開始解密了一些與libc相關的字串,通過sprintf()組裝後傳入了parse_libc()。

parse_libc()先從/proc/self/maps獲取libc.so的相關信息,然後調用do_parse_libc()進行解析。

do_parse_libc()開頭解析了libc的dynamic。

do_parse_libc()最後分別調用了get_libc_info()來將ELF GNU Hash Table和dynamic等信息保存在args[0]中。
然後調用save_some_libc_sym()獲取了libc中的一些符號偏移,如clone()、__libc_sysinfo、_ZL13g_thread_list。

回到big_func()繼續向下看。之後調用了lookup_libc_sym()。

lookup_libc_sym()中調用了find_symbol_by_name()來根據函數名獲取對應的符號地址( 原理是gnu hash ),保存在全局變量g_libc_funcs中。


之後又解密了一些函數來加載自己的libc,主要邏輯在load_mylibc()中,加載後的libc記為mylibc。

load_mylibc()主要干了以下事情:



注:分析過程中會發現在LoadSection()和ClearShdr()最後都有以下函數調用,其args[3]似乎就代表了所在函數的功能。
load_mylibc()執行完之後,從mylibc獲取了一些函數單獨保存下來,如malloc、calloc、realloc等等。

之後調用prepare_for_inline_hook()來保存指定mylibc函數的前0x32字節,結果保存在args[0]中,然後傳入inline_hook()進行hook。

以mylibc的calloc()為例,在inline_hook()後,前0x10字節被改為跳到原libc的calloc()。

之後又繼續從mylibc獲取了一堆函數單獨保存到全局變量中,不一一列出了。
但獲取的目的並不是為了像上面那樣inline hook,而大概是之後會用到,所以提前保存下來。

big_func()執行完後,回到JNI_OnLoad_decfunc1()。
同樣的形式解密了一段代碼。

check_xposed()中遍歷了maps,看看是否存在XposedBridge.jar。

之後會來到一個反調試的函數,記為anti_debugging()

調用mylibc的pthread_create()創建了一個線程。

調用mylibc的fork()創建了兩個子線程,之後調用signal(17, 1)忽略子線程結束時送出的SIGCHILD信號。
嘗試動調此處邏輯,但總會發生一些非預期的結果,因此無法詳細分析anti_debugging()的具體實現原理。
唯一確定的是它調用了兩次mylibc的fork(),使得此時機後將IDA無法attach,記這種反調試為三進程保護。

之後調用mylibc裡的pthread_create()創建一個線程,線程回調函數記為JOdf1_pthread_func3()。

一開始調用了check5() ( 上面已經分析過該函數,不再重複 )。
然後調用NativeBridge_check()進行NativeBridge注入檢測。

NativeBridge注入檢測的原理參考這篇文章,檢測流程如下:



JNI_OnLoad_decfunc1()執行完後,回到JNI_OnLoad(),又調用了mylibc的pthread_create()創建了線程。
之後會間接調用一個不正常地址導致SIGSEV?但pass to app後可以繼續執行,過段時間後才會真正crash?

要先bypass掉libcompatible.so的反調試,也能繼續動調後面的libstub.so。
嘗試直接patch掉anti_debugging()。
但報了SIGSEGV的錯,報錯時的PC在0x7130,十分奇怪。

用frida + IDA動調後發現報錯點在下圖這裡。

這種情況有兩種可能性:
而情況1基本可以排除掉,因為直接動調( 沒有patch anti_debugging() ),走到上圖位置時同樣是0x7130,而且用trace排查時,顯示的也是0x7130。
因此大概率是情況2,而且調用的大概率也是mylibc的sigaction。
嘗試hook mylibc的sigaction(),但沒有觸發。
改為hook原libc的sigaction,反而有觸發,看來NP無法使用mylibc的sigaction()來注冊信號回調?
輸出如下,0xb正是SIGSEGV,信號回調函數在libcompatible.so!0xaa994。
0xaa994如下,記為SIGSEGV_cb()

在sub_187310()中可以看到0x7130。

SIGSEGV_cb()最終會根據fault address分發不同的函數,之後會看到。
在bypass掉三進程保護後,終於可以動調分析後續的流程,首先是libstub.so的加載。
libstub.so的.init_proc中會調用libcompatible.so!SoLibraryStart()來解密。
SoLibraryStart()中的qword_1B41E0固定是0x4580,因此必然會觸發SIGSEGV的信號回調SIGSEGV_cb()。

SIGSEGV_cb()會根據導致異常的地址來執行對應的函數,如0x4580會執行func_4580()。

打斷點跳到func_4580()進行分析。
調用了check_and_decrypt_libstub()。

check_and_decrypt_libstub()中會先調用check_enc_flag()確定libstub.so是加密的。

check_enc_flag()具體實現如下,打開本地的libstub.so並檢查最後20字節是否與固定的加密標誌相等。

而decrypt_libstub_data()一開始先獲讀取了libstub[0:0xB758],然後再讀取libstub[0x1CAA8: 0x1CAA8 + 0x3EFEE],記為後者為libstub_enc_data1。

注:從010可知0x1CAA8正好在section header後面。

第1處解密libstub_enc_data1的地方。

第2處解密libstub_enc_data1的地方。

第3處解密libstub_enc_data1的地方。

調用LZ4_decompress_fast()對解密後的libstub_enc_data1進行解壓。

解壓後的數據如下,可以看到包含一些重定向信息,記為dec_data1。

讀取了libstub[0x5BA96:0x5BA96 + 0x15C0],記為libstub_enc_data2,然後解密,再解壓。

解壓後的數據如下,可以看出應該是符號表,記為sym。

讀取了libstub[0x5D056:0x5D056+ 0x1230],記為libstub_enc_data3,然後解密,再解壓。

解壓後的數據如下,可以看出是字符串表,記為strtab。

讀取了libstub[0x5E286:0x5E286 + 0x29B0],記為libstub_enc_data4,然後解密,再解壓。

解壓後的數據如下,可以看到同樣是一些重定向信息( 403重定向 ),記為relocate1。

讀取了libstub[0x60C36:0x60C36 + 0x4F0],記為libstub_enc_data5,然後解密,再解壓。

解壓後的數據如下,可以看到同樣是一些重定向信息( 402重定向 ),記為relocate2。

讀取了libstub[0x61126:0x61126 + 0x30],記為libstub_enc_data6,然後解密,再解壓…

解壓後的數據如下,可以看出是libstub.so的依賴庫。

最後讀取了libstub[0x61146:0x61146 + 0x20],但似乎沒有用到,大概只是加密數據的結束標誌?

至此大致分析完check_and_decrypt_libstub()。回到func_4580()。
調用set_w_perm_to_libstub()賦予libstub.so可寫的權限。dlopen_needs()會調用dlopen()加載libstub.so的所有DT_NEED庫。
MEMORY[0x7510]()會觸發SIGSEGV_cb(),然後跳到func_7510()。

看到"ldrRestoreDynSymInfo",見名知意,大概是在恢復libstub.so的一些動態符號信息。

忽略前面一些不太知道在干什麼的邏輯後,來到下圖這裡,解密了一些符號名。



然後會對比libstub.so的字符串表( 上面解密出來的那個 ),若包含上述的符號名,則會把對應函數( 如下圖的some_func2(),它是libcompatible.so的函數 )保存到某個地方。
猜測是libstub.so之後會用到libcompatible.so中的一些函數,因而用這種方式提前保存函數地址到某處。

func_7510()之後,回到func_4580()會執行重定向的邏輯,然後調用.init_array的函數。
一開始解密了"dlopen"、"dlsym"、"il2cpp.so"等字符串,不知道干什麼。

然後就是熟悉的重定向操作:


注:重定向的對象並非maps中的libstub.so,而是內存中的libstub.so。

通過maps查看內存中的libstub.so範圍,然後把這片內存dump下來,記為libstub_dump.so。

調用libstub.so的.init_array函數。

.init_array的偏移為0x8FDE0。

將dec_data1 dump下來會發現,其中包含了relocate1和relocate2,還有.dynamic信息。

至於解密後的代碼段和數據段,應該已經在libstub_dump.so中,但libstub_dump.so中並不包含strtab、sym和.dynamic的信息。
也不包含shdr和shstrtab字符串表,這兩者可從原libstub.so中獲取,前者在修復時直接覆蓋到原位置,後者隨便寫到最後的一片空區域即可。

從原libstub.so提取shstrtab。

section修複,大概只有.dynstr、.dynamic、.shstrtab是必要的,第0個section要為0,拉入IDA時才不會報錯。

把修復後的libstub.so拉入IDA,發現只有少量的符號。

大概只有第1個.init_array函數是檢測的邏輯,同樣也是對環境變量的檢測,具體做法是在unsetenv("LD_PRELOAD")後,遍歷environ數組,若發現其中仍有LD_PRELOAD則直接exit()。

保存了一些libdl.so、libc.so的函數到某個全局變量中。

嘗試尋找"com/inca/security/DexProtect/SecureApplication"類,但在本例會返回0。

動態注冊了"com/inca/security/Native/AppGuardAssistantNative"、"com/inca/security/AppGuard/TestCase"中的一些JNI函數。

第1處register_func()動態注冊了9個JNI函數,如startEngine()、stopEngine()等等。

第2處register_func()動態注冊了4個JNI函數,如下圖所示。

最後創建了兩個線程,暫時不知有什麼用。

startEngine()被控制流混淆了,如下所示。

同時其中充斥著大量的空函數,或許也是一種混淆。

分析的思路是,忽略上面這樣的空函數,關注那些有內容的函數,下斷點跳過去調試。
最終可把stratEngine()分成三部份,第一部份如下。

第二部份。

第三部份。

在這三個部份中,都會調用某片匿名內存中的函數,一開始沒有多想,直接單步跟了進去調試,後來才想起這會不會就是libengine.so?
記那片匿名內存為mem_libengine,對比mem_libengine和libengine.so後發現,它們前面的字節的確是一樣的。( 本以為startEngine()會是加載libengine.so的邏輯,其實不然 )。


知道了mem_libengine就是libengine.so後,以此作為入手點,分析哪裡加載的。
在libstub.so!JNI_OnLoad leave時機,maps中仍未有mem_libengine。在getVersion、setContext時,maps中有mem_libengine。
而在上面的分析中提過,libstub.so!JNI_OnLoad最後創建了一些線程,最終確定大概率是在sub_53AC4中加載的libengine.so。
通過frida stalker確定了sub_53AC4中會調用libcompatible.so!0xA1990。

接下來看看libcompatible.so!0xA1990是怎麼加載libengine.so的。
首先調用了LoadEngineLibrary(),其中一開始拼接了libengine.so的絕對路徑。

然後調用sys_mmap()映射一片匿名內存,調用sys_lseek() + sys_read()讀取libengine.so。

LoadEngineLibrary()之後會來到熟悉的解密函數( 在上方被我命名為check_and_decrypt_libstub )。

注:邏輯基本相同,不再重複分析,將所需數據dump下來。
[培训]Windows内核深度攻防:从Hook技术到Rootkit实战!