一、前言

前一段時間在逆向某游戲Y,被其喪心病狂的ollvm混淆折磨的欲仙欲死。游戲Y以犧牲性能為代價做安全保護的精神實在令人佩服,截圖如圖:

竟然對所有常數都進行了加密!

于是惱羞成怒打開另外一款小眾的手游,想破解練練手,打開后發現是Unity的,上Il2cppdumper伺候:

直接報錯。

打開global-metadata.dat看看,發現被加密了,正常的MAgic 是 AF 1B B1 FA,被換上了奇怪的Magic:HTPX

打開apk包,看到lib目錄下有libNetHTProtect.so字樣。原來是某盾手游加固:

抱著試一試的心情,想看看他的保護是怎么做的,但是一不小心就搞了一周。由于內容太過精彩,于是想總結成文章供大家學習賞析,這就是這篇文章的來源。

二、尋找Init_Array

使用ida加載libil2cpp.so,ida直接給出提示:

顯示section header有問題。直接點ok進去,發現一大堆奇怪的函數名:

查看segement信息,發現出現了一些不常見的note.gnu.proc,note.gnu.text等節區,這顯然不正常。

我們知道,linker(鏈接器)將一個動態庫加載到內存時,做完重定位(relocate)操作后就會調用動態庫的init函數,用來完成一些初始化操作。由于init函數是linker調用的,所以沒法做加密。看到這么離譜的一個動態庫,顯然需要init函數來解密。

通常,init函數會出現在.init_array這個節區里,這是個函數指針數組,鏈接器會依次調用里面的函數。

但是我們的動態庫沒有.init_array這個節,linker去哪里找初始化函數?

我們可以使用readelf工具來查找:使用readelf -d 操作:

發現INIT_ARRAY在地0x7df6a50這個位置。

跳過去看看:

確實,上面的藍字寫了這里是ELF Initialization Function Table,我們點進第一個地址看看:

發現是一堆無意義的數據,ida也無法將其解析為匯編指令。這不科學!

因為這是鏈接器第一個調用的函數,如果這個函數都是加密的,那在鏈接階段就會報錯。所以我們合理懷疑初始化函數位置找錯了。

其實之所以會搞錯,是因為錯誤的section header干擾了ida的解析。這里有一個技巧,因為ida在解析動態庫的時候不需要section信息,只需要segement信息ida也可以解析。所以我們直接將原來的section header全部抹去。

使用010Editor打開libil2cpp.so,運行ELF模板,直接跳轉到section header的起始位置。

將所有數據用0覆蓋:

然后重新打開ida,再次跳轉的0x7df6a50這個位置,發現ida已經正常解析INIT函數的地址了:

并且代碼也被成功的解析了出來:

我們愉快的按下F5,ida又報錯了:

堆棧不平衡,ida發現了錯誤的棧指針。這通常是因為代碼中有花指令的緣故,我們要考慮去除花指令了。

三、去除花指令

我們看看出錯位置附近的代碼:

從0x7D4415C開始,如果w8不為0就會跳轉的0x7d4416c,然后給棧寄存器sp減了0x20然后ret。這顯然錯了!

因為函數的一開始并沒有給sp寄存器加0x20,這里減去0x20再返回一定會堆棧不平衡。所以有理由懷疑,這里就是花指令,用來干擾ida解析的。

但是如果w8為0,會先執行sub_7d459bc這個函數,然后給棧寄存器加0x20,再跳過錯誤的部分,去0x7d44184。我們看看sub_7d459bc這個函數:

是給sp減去0x20,那這樣就沒問題。執行完后再加上0x20,棧是平衡的。

所以我們確信,中間的ret部分就是花指令。

觀察后發現,這樣的花指令在代碼中有幾十處,并且有兩種模式,我們肯定不能手動Patch了。

模式1:

模式2:

我們可以根據這兩種模式的機器碼的特點,在原so中進行匹配,匹配后換上arm64下nop指令的機器碼:0x1f2003d5就可以了。

去花指令腳本如下:

#去除花指令mod1 = [0x86,0x10,0x40,0xb9,0xa6,0x19,0x0,0x18,0xff,0x43,0x0,0xd1,0xc0,0x3,0x5f,0xd6]mod2 = [0x86,0x8,0x40,0xf9,0xff,0x43,0x0,0xd1,0x0,0x0,0x0,0x1b,0xFF,0x25,0x0,0x18,0xff,0x43,0x0,0xd1,0xc0,0x3,0x5f,0xd6]nop = [0x1f,0x20,0x3,0xd5] def match(data,index,mod,ignorerange):    for j in range(len(mod)):        if data[index + j] == mod[j] or j in ignorerange:            continue        else:            return False    return True def patchWord(data,index,code):    for i in range(4):        data[index+i] = code[i] def patch(data,index):    start = index - 0x10    patchWord(data,start,nop)    patchWord(data,start+4,nop)    patchWord(data,start+8,nop) def patch_mod1(data,index):    start = index    patchWord(data,start,nop)    patchWord(data,start+4,nop)    patchWord(data,start+8,nop)    patchWord(data,start+12,nop) def patch_mod2(data,index):    start = index    for i in range(6):        patchWord(data,start+i*4,nop) with open("f:\\test\\libil2cpp.so","rb") as f:    data = list(f.read()) for i in range(len(data)):    if match(data,i,mod1,[4,5,6,7]):        patch(data,i)        patch_mod1(data,i)    if match(data,i,mod2,[12,13,14,15]):        patch(data,i)        patch_mod2(data,i) with open("f:\\test\\libil2cpp_patch.so",'wb+') as f:    f.write(bytes(data))

非常簡單的讀文件,匹配,寫文件的操作。

去除完花指令,我們就可以愉快的F5了:

去除后的匯編代碼變成這個樣子:

中間混淆的部分全部去掉,直接跳轉到正確位置。

接下來就看看三個init函數都做了些什么。

四、分析init函數

1、第一個init函數

第一個init函數主要進行了一個初始化操作:

將一些常用的系統函數如fopen,mmap,memcpy等賦值給一個全局的函數數組,后面通過這個函數數組來間接調用基本的系統函數。顯然這種脫褲子放*的做法是為了增加代碼閱讀難度,提升安全性:

2、第二個init函數

第二個init函數主要做了三件事:

1.遍歷elf文件,尋找dynmic segement

具體邏輯就是遍歷program header,尋找p_type為2的段。

為什么要找Dynamic 段?我們轉到Dynamic段看看:

原來,dynamic段保存了所有動態鏈接需要的信息。注意到0x19,這個正是INIT_ARRAY的編碼,而后面的0x7df6a50則正是初始化函數在內存中的位置。

其實readelf -d的工作原理也是遍歷這個dynamic段。

可以看到所有動態信息與文件中的數據對應。同時,ida之所以不需要section信息也能解析,也是因為有了dynamic段就足夠的緣故。

這里附一份動態段type表:

這張表中,我們可以清楚的看到,有字符串表DT_STRTAB(5),有符號表DT_SYMTAB(6),有重定位表DT_RELA(7),大家可以對照這張表,將readelf的輸出,和動態信息在文件中二進制數據結合在一起看,會更加清楚。

2.執行prelink操作

prelink是android linker源碼里進行的一個操作。這個操作的本質就是解析dynamic段的各種數據,將他們分門別類的存儲起來,供后面使用。

libil2cpp.so中自己實現了prelink操作,令人毛骨悚然,看看代碼:

就是通過一個大的switch case去解析各項數據。

而andorid源碼里是這樣的:

可以看到幾乎是一樣的。但是如果不熟悉那些type的具體數值,如init_array是0x19,即10進制的25,則很難在ida中搞懂他是在做什么。

3.解密字符串表和符號表

執行完prelink操作后,就拿到了字符串表和符號表在內存中的偏移和大小。然后對其進行了解密操作:

其中,v11[4]是符號表的偏移,v11[6]是字符串表的偏移。而每個偏移后面跟隨的是解密起始位置和總長度。

這個加固并沒有將全部的字符串和符號都加密,而是進行了部分加密和部分解密。

可以看到,字符串表的解密方式是按DWord進行異或0x56312342,而符號表的解密是與解密長度本身有關。

我們可以找到文件中字符串表和符號表的偏移,然后手動用腳本進行解密,解密完后再將數據覆蓋回去:

def getInt32(data,offset):    return data[offset]|data[offset+1]<<8|data[offset+2]<<16|data[offset+3]<<24 def getInt64(data,offset):    return getInt32(data,offset)|getInt32(data,offset+4)<<32 def putInt64(data,offset,value):    for i in range(8):        data[offset+i]=value&0xff        value = value >> 8 def decodeStrTable():    start = 0x45BBC38    encryptLen = 0x19c98//4    key = [0x42,0x23,0x31,0x56]    with open("f:\\test\\libil2cpp.so",'rb') as f:        data = list(f.read())    for i in range(encryptLen):        for j in range(4):            data[start+4*i+j]=data[start+4*i+j]^key[j]    with open("f:\\test\\libil2cpp_str.so","wb") as f:        f.write(bytes(data)) def decodeSymTable():    start = 0x4599990    _from = 0x8a8    _to = 0x113c    with open("f:\\test\\libil2cpp_str.so", 'rb') as f:        data = list(f.read())    while _from<_to:        offset = start + 0x18*_from        val0 = getInt64(data,offset+8)^((0x8a8+0x151)&0xffffffff)        putInt64(data,offset+8,val0)        val0 = getInt64(data,offset+0x10)^((0x8a8+0x15b)&0xffffffff)        putInt64(data,offset+0x10,val0)        _from = _from+1    with open("f:\\test\\libil2cpp_str_sym.so","wb") as f:        f.write(bytes(data)) decodeStrTable()decodeSymTable()

覆蓋回去后,重新用ida加載so,我們看到函數名已經正常了:

但是,這時候還只是殼在運行,真正的原始的so還沒有出來。真正的so要出來,在第三個init函數里。

五、自定義鏈接與重定位加載真正的so——第三個INIT函數

進入第三個init函數,首先解密了一小段代碼,執行完又加密回去,功能是初始化一些全局變量,這里就不展開講了。

核心是進入了自定義的鏈接器函數:

具體怎么看出來的是需要結合android linker部分的源碼來研究,這里直接說最終的結論。已經在圖中標出了各個函數的作用。

首先是初始化了soinfo,(先malloc一段內存,然后清零)

接下來解密了子so的代碼段和數據段:

解密算法是魔改的rc4。

解密之后,也解密出了子so的program header。然后根據子so的program header,將解密后的代碼和數據通過load_segement操作映射到libil2cpp.so的內存空間。

子so的program header 是解密后存在別處的,在使用時單獨調用。這樣做可以防止無腦dump整個內存,因為dump下來沒有正確的program header信息,子so的program header:

load_segement也是android linker加載動態庫的操作,核心步驟是:

1、遍歷program header,找到PT_TYPE為1的段。(上面說過,type為2的是dynamic段,而type為1 的段是loadable段,即需要加載到內存中的部分)!

數據段和代碼段通常都是loadable的。而dynamic段通常包含在數據段中,只是單獨用type=2指明出來,方便鏈接器快速定位。

2.通過mmap將文件中數據映射到內存,同時修改內存的訪問權限。

3.進行頁對齊操作,將多映射的內存用0填充。

對于某盾的加固,他自己實現的操作有些不一樣,具體是:

1.計算出需要映射的地址的偏移和大小,通過mprotect函數將內存權限修改為可讀可寫可執行。

2.使用0xBB填充需要映射的內存。

3.使用memcpy將解密出來的數據和代碼復制到對應內存。

4.將多余的內存用0填充。

5.根據segement的讀寫權限,再次調用,mprotect,修改為對應的權限。

等等,沒有調用mmap,那么往哪里映射?

其實,在殼so加載的時候,已經映射了足夠的內存空間:

看看第一段。第一段其實是原始的so的代碼,文件大小只有0x447c000,但是居然在內存中要了0x7cd4000的空間。這么大的空間就是為了后面把解密的代碼和數據全部復制過來用的。

由于這部分數據是加密的,沒什么卵用,所以直接將解密后的數據覆蓋過來挺好。某盾的殼就是這么做的。

load_segement之后,接著根據殼so的dynamic信息,執行了一次prelink操作,這次操作的目的是獲取到字符串表等信息。

(由于殼so和子so是共享了部分信息,所以有必要獲取一下殼so的各種數據信息)

接著,根據解密后子so的dynamic段信息,再次執行prelink。

為什么再次執行prelink?

因為殼so和子so的初始化函數肯定不一樣,要把子so成功加載進內存,顯然要執行子so的init函數。所以這次prelink要把init_array修改為子so的init_array,方便后面執行。子so的dynamic 段數據如下:

可以看到,子so的dynamic信息很少。只有init_array,fini_array的信息。

其實,這個動態信息是殼so的dynamic 和子so的dynamic diff出來的信息。對于子so,大部分信息已經包含在殼的信息里了,只有少部分需要修正。

第二次prelink后,子so的數據已經全部加載到內存中了,但是還沒有進行重定位,無法執行。

需要執行重定位(relocate)操作。

具體的,也是仿照android 源碼,根據重定位符號類型,對符號地址做修正:

其中,0x401,0x402是pltgot類型的重定位,0x403是相對距離調用類型的重定位。需要對這些數字很敏感,才能看出這是在做重定位。這就需要各位仔細閱讀andorid linker部分的源碼了,我也是在做這次逆向過程中讀了好幾遍,才基本搞懂的。

上圖是android 源碼里關于重定位類型的定義0x401對應10進制的1025,依次類推。

android 源碼中重定位部分,也是根據重定位類型做switch case循環。(只不過為了跨平臺,把宏又全部重新定義為了R_GENERIC類型)

執行完重定位后,調用了子so的init函數:

到這里,子so就被正確的加載進了內存。

六、修復——借尸還魂

6.1移植代碼段和數據段

我們將所有解密的數據先在內存中dump下來,idadump腳本:

import idaapidata = idaapi.dbg_read_memory(start, len)fp = open('filename', 'wb')fp.write(data)fp.close()

子so有兩個segement(代碼段與數據段),同時,子so的program header,dynamic段信息,符號表,重定位表是存在別處的,我們都要dump下來(或者截屏保存,如果數據不多的話):

然后,我們對已經解密過字符串表和符號表的殼so做修改。(因為很多數據是共享的,不能只組裝一個子so,所以我們要借殼so的尸,來還子so的魂)

首先將解密后的第一段復制到program header后面(我們保留殼so的program header。

因為殼的很多東西還要映射,后面我們在殼so的program header上做修改,把子so的移植進去)

殼so的第一段,可以看到數據是加密的。

復制后:

第一段其實是加密過的子so的代碼段。

接下來需要將子so的數據段弄進來。但是program header里沒有關于子so數據段的映射信息。我們需要先修改program header。

殼so有七個program header,期中,最后一個是GNU Read-only After Relocation。里面保留的主要是pltgot表信息。在重定位后提示鏈接器,這里不要再動了。

我們可以把這段刪掉,換成子so數據段對應的header信息:

直接復制過來,這時候第七個program header也變成loadable 的了:

然后我們將子so的數據段復制過來,復制到哪?

由于在文件中,數據十分緊密,如果隨意覆蓋,可能會破話其他部分的數據,會出問題。

所以我們將子so的數據段數據附在文件的末尾,然后通過修改program header里面的 file offset字段來完成修改。

將子so的代碼和數據都移植進去后,我們打開ida看看:

直接報錯。

是因為我們在把子so的數據段往文件末尾添加時,忘記修改section header的偏移了。錯誤的section header會干擾ida的分析。

于是我們調整section header的偏移。在文件的末尾再加上一個空的section header,然后修改elf 頭中關于section header offset的信息:

再次打開ida,我們興奮的發現,熟悉的JNI_ONLoad出現了:

并且有關于IL2CPP的信息。但是點進sub_b0bca0后發現,函數是空的:

看這個樣子像是子so的plt表。但是函數都是sub_b0b360。原來,我們沒有修復子so的重定位信息,導致ida找不到這些符號的真實地址。因為,子so的重定位表還在我們手里呢?

6.2修復符號表與重定位表

由于子so與殼so都需要重定位,所以我們可以把子so和殼so的重定位表合在一起。但是這樣一來,殼so數據段空間不夠了。(文件中數據很緊密的)而且如果直接將子so的重定位表覆蓋在殼so重定位表的后面,誰知道會破壞什么數據。

所以我們需要做的操作是:

1.將殼so的重定位表copy一份出來。

2.將子so的重定位表與殼so的重定位表合成一個大的重定位表。

3.將新的重定位表附在殼數據段的末尾。(此時我們覆蓋已經了子so的數據段數據,因為殼so的數據段末尾基本在原文件的末尾)

4.修改program header中殼so的數據段中,file_size字段(多出了子so的重定位表,需要一起映射進去)

5.修改dynamic 段中關于重定位表偏移和大小的信息(因為我們把重定位表附在了文件末尾,原來的已經不用了,并且大小變了)

dynamic 段中關于重定位表的信息(7,8為重定位表相關,可以參考前面的那張dynamic type表):

修改后:

6.由于我們覆蓋了子so的數據段。所以我們需要重新把子so的數據段附在文件末尾,然后修改該段對應的program header中偏移信息,然后附加上新的section header,然后再次修改elf頭中section header的偏移。

這樣,子so的重定位表就加進去了。

但是沒完。重定位中主要有兩種,一種是pltgot表的重定位,一種是相對距離調用的重定位。

相對距離調用的重定位比較簡單,linker會直接用elf文件 的base 加上重定位信息中的addend信息作為內存中真實的地址。

例如上圖中紅框內是一個重定位信息。

重定位表的數據結構是這樣的:

typedef struct elf64_rela {   Elf64_Addr r_offset;   Elf64_Xword r_info;   Elf64_Sxword r_addend;} Elf64_Rela;

第一個字段是符號的偏移(重定位前),第二個是重定位類型(0x403,0x402之類),第三個是附加信息。

對于紅框中的數據來說,他的意思是在0x448b4d0這個位置的符號,是0x403(相對距離)類型的重定位信息。addend是0xb0d5dc。

假設我們的so文件加載的基地址是0x70000000。那么在重定位的過程中,linker會做這樣的操作:

*(0x70000000+0x448b4d0)=0x700000000+0xb0d5dc

這樣就完成了重定位。但是pltgot表的重定位比較特殊:

上面是一個pltgot類型的重定位,可以看到符號的地址是0x7dfbae8,但是沒有addend,反而在02 04后面有一個0x51。

其實,這個0x51是符號表的符號索引。

linker會根據這個索引,先在符號表中找到對應的符號,然后獲取符號名。

通過符號名分別在依賴庫和自身的符號表里找這個符號。

然后將找到的符號的真實地址直接用來重定位。

某盾在做重定位時,用的是子so的符號表而不是殼so的符號表。由于符號表只能有一張,如果我們直接按照重定位表那樣融合,索引一定會亂掉。所以我們只能用子so的符號表去覆蓋殼so的符號表。

這樣一來,殼so的重定位會有一部分亂掉。我們手動修復一下,運氣好的話不會太多,10處左右。

修復完符號表和重定位信息后,我們看到了之前JNI_onload里的函數:

原來是在打log。

6.3修復init_array

記得要把init_array修復為子so的init_array。具體過程就不在贅述了。

6.4將殼so的第一個init_array添加

因為某盾的保護在子so運行過程中會用到之前初始化過的全局函數數組。所以要把殼so的第一個函數的init_array函數加到子so的init_array里。

具體的:

1.修改dynamic 段init_array_size部分,加一個函數。

2.對應的需要對finit_array_size部分減一個函數,同時將finit_array的偏移向后移動一個指針的距離(空出來的部分給添加的init函數)

3.找到殼so 第一個init函數的重定位信息,將其復制給新添加的init函數對應的重定位表里。

如圖,是原來fini_array的樣子:

把第一個函數替換后:

可以到fini 函數少了一個,而init函數多了一個!這個函數正是殼so的第一個init函數。

6.5添加正確的section header

對于ida來說,沒有正常的section header是可以正常解析的。但是對于系統linker來說,沒有section header會無法正確加載so。所以我們需要移植一個正確的section header。

android linker需要的section header信息其實不多,主要由三個:

1.shstr,2.dynamic,3.dynstr

具體過程就不再贅述了,大家可以根據加載時候的報錯信息,結合andorid源碼來針對性的修改:

三個section header就可以了(第一個需要為空)

七、起飛

接下來將我們修復的so替換app里面的so,發現游戲可以正常運行:)

但是global-metadata還是加密的。

沒關系,我們已經把il2cpp扒光了,可以直接定位到加載global-metadata的位置:

在sub_bc9564下斷點,執行完后直接內存中dump出global-metadata就可以了。

不過某盾把global-metadata的magic和version信息抹去了。這兩個信息在運行時是不用的,但是il2cppdumper解析需要。我們手動修復就好:

上il2cppdumper:

成功dump出cs腳本。簡單搜索了一個叫get_hp的函數,如果把這個函數修改了,后果不堪設想。

當然,作為社會主義好青年,我們不會做這種事滴!至此,某盾手游加固的脫殼與修復,就全部完成了。

八、后記

某盾手游加固不同于傳統的加固方式。傳統的加固方式是直接整體加密,解密,執行。內存dump下來很好修復。

但是某盾自己實現了linker中加載,重定位的全部代碼,將子so的數據全部加密,并且分開存儲,使得內存中從來沒有出現過完整的子so的影子,無法整體dump,只能分段dump,然后重組,安全強度確實高了不止一個數量級。

本來只是想逆向一個普通游戲泄憤,沒想到入了某盾加固的坑。不過整個實驗的過程中,看雪前輩關于android linker的文章我都仔細讀了,andorid linker相關的源碼也是翻了個遍,之前模糊的鏈接與重定位,elf文件格式相關的知識也在腦海里明晰了起來,也算是收獲頗豐。

以上內容僅供學習技術,交流,嚴禁用于違法的活動!