packagename:Y29tLmhlcm8uc20uYW5kcm9pZC5oZXJv
聲明:本文內容僅供學習交流之用
前言
很久之前看過乐佬的那篇「误入虎穴,喜得虎子——记一次手游加固的脱壳与修复」,寫得實在太好,奈何當時水平有限,看得兩眼一黑。
最近重溫經典時發現兩眼不再發黑,於是打算好好復現一下,這才有了這篇文章。
壞掉的libil2cpp.so
將libil2cpp.so
拉入IDA,直接報了一個錯,這時大概就可以知道這個so被動了手腳。

ctrl+s
沒有發現.init_array
,改為用readelf工具,發現.init_array
位於0x5953a50

但發現0x5953a50
根本無法被解析被函數,像是被加密的樣子。
理論上來說第一個.init_array
函數是無法被加密,因此這種情況大概率是IDA分析出了錯。


將shdr table置0,這樣IDA就會根據.dynamic來解析。

再次拉入IDA,這次init_array終於被正確解析了。

動調的坑
直接動調會發現一直觸發SIGCHILD
信號,然後crash。
解決方案是直接忽略SIGCHILD
信號:Debugger → Debugger options → Edit exceptions

init_array_func1
注:每個init_array函數裡都有一堆花指令( junk_codeX
),可能會造成堆棧不平衡、無法F5的情況,使用IDA9.0可以無視這種情況,低版本( 7.7 )在動調1次後同樣可以無視這情況。
.init_array
的第1個函數主要在初始化一些全局變量,以及打開/proc/self/maps
做了一些檢查。

init_some_global_var
實現如下,全是一些賦值操作:

接著看看open_maps_and_do_some_check
。
一開始是一些字符串解密操作,解密後會得到"/proc/self/maps"
。

隨後便是fopen
+ fgets
+ sscanf
來遍歷/proc/self/maps
,整體邏輯大概只是在檢查libc.so是否有執行權限。
看到/proc/self/maps
本以為是一個檢測點,但實際上應該只是一些普通的安全檢查,防止程序崩潰之類的?

init_array_func2
接下來分析.init_array
的第2個函數。
do_something1
一開始調用了do_something1
,動調後發現它會先解密一段代碼,然後調用這段代碼( 同樣是一些全局變量的賦值操作 ),之後會把這段代碼加密回去。


獲取.dynamic段
然後調用get_dynamic
獲取.dynamic
段,為後面prelink_image
和decrypt_str_sym
做準備。

看看get_dynamic
的實現方式。
一開始的while循環並不會走,也看不懂在干什麼 ( 不重要 )

然後是解析elf header,獲取了e_phnum
、phdr table( 段表 )的起始位置等信息。
注:在分析時最好是動調配合010來看,能更好地弄清楚每個變量的含義。

然後會遍歷phdr table,*(_DWORD *)phdr
獲取的是phdr的p_type
成員,1
代表PT_LOAD
( 可加載的段 )。
這裡是在遍歷所有Loadable Segment,記錄第1個Loadable Segment的起始位置( 通常是0 )和最後一個Loadable Segment的結束位置。


最後才是獲取.dyncmic
段的邏輯,同樣是遍歷phdr table,但這次的目標是PT_DYNAMIC(2)
,保存在res[6]
中。


prelink_image
獲取完.dynamic
後,會調用prelink_image
。

進入prelink_image
後,會看到一大段switch…case
語句,看過Android源碼的會發現這與/bionic/linker/linker.cpp
裡的prelink_image
十分相似,做的事情也差不多,都是利用.dynamic
的信息來初始化si
( soinfo結構,用於表示一個內存中的so )。
這個加固的soinfo
結構是魔改的,嘗試直接導入oacia大佬這篇文章的soinfo結構會發現完全對不上,需要手動調整,下圖是我手動調整soinfo結構後的結果,雖然無法完全還原,但也勉強能用,看起來也方便一點。


最後的while
循環是在處理依據庫的部份,沒有仔細看,處理邏輯大概也與Android源碼差不多,會對每個依賴庫調用prelink_image
。

字符串、符號表解密
init_array_func2
最後會調用decrypt_str_sym
來解密字符串表和部份的符號表。

簡單分析後可以反推出decrypt_str_sym
每個參數的含義:
args[0]
:符號表起始地址
args[1]
:待解密數據的起始offset( 相對符號表來說 )
args[2]
:待解密數據的結束offset( 相對符號表來說 )
args[3]
:字符串表
args[4]
:字符串表大小
然後可以選擇將解密後的數據dump下來回填到so中,或者手寫解密腳本進行解密。

這時再將解密後的so拉入IDA就能看到一些符號了,不再是一堆奇怪的字符串。

init_array_func3
接下來分析.init_array
的第3個函數,它也調用了do_something1
,但進去沒走兩步就返回了。重點關注do_something2
。

do_something2
函數可以分成3大部份:
- 加載子so( 真正的
libil2cpp.so
)

- 啟動了一些線程,沒有細看,大概是一些保護邏輯。

- 調用子so的
init_func
和每個.init_array
函數

接下來重點看看第1部份加載子so的邏輯。
解密子so數據
加載子so的第一步是先解密子so的一些數據,在decrypt_phdr_loadable_seg
中分別調用了2次解密函數解密兩段不同的密文。
第1次調用解密函數解密出來的信息包含子so的phdr table、符號表、重定向表、dynamic表。而第2次調用解密函數解密出來的數據不太確定有什麼用,可能會跟後面提到的loadable_data1
有關。下面來具體看看這整個過程。
進入decrypt_phdr_loadable_seg
後,忽略一些不重要的部份,之後第一個遇到的函數是decrypt_something2
,這就是上面提到的解密函數。

進入decrypt_something2
可以看到明顯的RC4的特徵,而且是魔改過的RC4。具體的算法細節我沒有細看,我關注的是解密後的結果,因此選擇直接將解密後的數據dump下來。
args[0]
是密文、args[2]
用來存放解密後的明文、args[3]
是args[2]
的長度,將dump下來的文件名記為dec_data1
。

注:IDA Python dump memory script
1 2 3 4 5 6 7 | import idaapi
start = 0x7DF8080000
len = 0x3844AA0
data = idaapi.dbg_read_memory(start, len )
fp = open (r 'path' , 'wb' )
fp.write(data)
fp.close()
|
dump出來的dec_data1
如下:

一開始無法直接知道dec_data1
每部份的含義,我是由後續的分析反推出dec_data1
分成4個部份的。

加載子so的loadable段
調用decrypt_phdr_loadable_seg
解密完子so所需的數據後,會調用load_realso_PTLOAD
來加載子so的所有loadable段。
一開始會先保存殼so的phdr,然後獲取殼so的大小。

繼續向下看,看到56
這個數字可以大概猜到是在遍歷phdr table( 子so的phdr table ),而1
代表PT_LOAD
,因此這裡是在遍歷所有loadable的段,並計算段的映射地址和大小。

子so的phdr如下,複製前3行然後在dec_data1
裡搜,會發現與dec_data1
的前3行一樣,由此可知dec_data1
的第1部份是子so的phdr信息。


然後會調用mmap
將fd
的內容映射到子so的loadable段的內存起始地址,基址是殼so的起始地址,fd
是殼so的句柄。即最終是在殼so的基礎上進行修正,將殼so的某些位置修正為子so的代碼段和數據段。
然後調用mprotect
設置段的權限,調用memset
將段置為0xBB
。


之後調用的memcpy
才會真正將解密後子so的loadable段數據複製到對應的位置,然後會將多餘的內存空間置0
。

子so第1個loadable段如下,dump下來,記為loadable_data1
。
( loadable_data1
的頭4個字節7F B2 B3 0F
其實是代表子so的魔數,前0x40
字節是elf_header區域,接著的0x188
個字節是殼的phdr table區域,這兩部份數據沒有用,直接刪掉,後面提到的loadabe_data1
均不包含這兩部份 )

子so第2個loadable段如下,記為loadable_data2
。

子so總共只有這兩個loadable段,一個是代碼段、一個是數據段,可從子so的phdr table來區分哪個是代碼段或數據段。
注:殼so的第1個loadable段的p_memsz
特意設置得很大,就是為了讓子so的loadable_data2
裝載於此。

最後會再調用一次mprotect
將段設置回原本的權限。

子so兩次的prelink_image
調用完load_realso_PTLOAD
裝載子so的loadable段後,會進行兩次的prelink_image。

第一次prelink_image用的.dynamic數據是殼so的,這是因為子so的一些基礎信息是依賴於殼so的。具體獲取.dynamic和prelink_image
的過程在上面已經分析了,就不再重複。

第二次調用prelink_image
用的.dynamic數據才是子so的。
複製第一行然後在dec_data1
裡搜,發現dec_data1
最後一部份就是子so的.dynamic。


relocate重定向
子so完成兩次prelink_image後,會調用maybe_init_something
和decrypt_something3
,調了好幾遍都沒搞清楚這2個函數具體在干什麼,猜測大概與後面的do_relocate
有關。
接著調用do_relocate
進行重定向。

do_relocate
會根據rela
和plt_rela
的信息調用relocate
進行重定向,本例沒有plt_rela
,只需關注rela
即可。
在調用relocate
前先將rela dump下來,為後續修復so做準備。

注:dump下來的rela同樣可以在dec_data1
中搜到,起始位置為0x1700
。

簡單分析relocate
後會發現,它與Android源碼的relocate
其實大同小異。


簡單解釋下so重定向的原理。
重定向表每個元素都是如下結構,r_info
的高4位代表sym
( 符號表索引 )、低4位代表type( 重定向類型 )。
1 2 3 4 5 | typedef struct elf64_rela {
Elf64_Addr r_offset;
Elf64_Xword r_info;
Elf64_Sxword r_addend;
} Elf64_Rela;
|
個人總結出重定向大致可以分為2種情況:
r_addend
不為0
的情況:base + r_offset = base + r_addend
下面藍框是一組真實數據:

最終重定向過程表現為:
1 2 3 4 5 6 | *(&base + 0x5953A50) = *(&base + 0x58A1808)
|
r_addend
為0
的情況:base + r_offset = base + sym_addr
下面藍框是一組真實數據:

最終重定向過程表現為:
1 2 3 4 5 6 7 | sym_addr = find_sym_addr(symtab[sym])
*(&base + 0x5958AA0) = *(&base + sym_addr)
|
relocate
中查找符號地址的過程如下:
sym
是符號表的索引,symtab_
是對應的符號表。
- 獲取符號的
st_name
,它是字符串表的索引。
- 獲取
st_name
對應的字符串,傳入get_symbol
,其中會調用dlsym
來獲取符號地址。

symtab_
如下,同樣可以在dec_data1
中找到,這代表子so有自己的符號表( 這樣說是因為字符串表用的是殼so的 )。


至此可以總結出dec_data1
的分佈如下:
1 2 3 4 | phdr table ( 0x0 -> 0xE0 )
sym_table ( 0xE0 -> 0x1700 )
relocate ( 0x1700 -> 0x8BC070 )
dynamic ( 0x8BC070 -> end )
|
so修復
通過上述分析可以知道,子so的phdr table、符號表、重定向表、dynamic等信息只有在使用時才會從其他地方讀取來使用,因此整體dump so變得沒有太大意義。
而子so與殼so會共用一些基礎信息,因此不能單單重建子so,而是要在殼so的基礎上修正成子so。
注:使用解密字符串表、符號表後的殼so作為外殼,該外殼記為libil2cpp_str_sym.so
。
數據準備
以下提取的數據也是從libil2cpp_str_sym.so
中提取。
提取libil2cpp_str_sym.so
的符號表,記為orig_sym
。

提取libil2cpp_str_sym.so
的重定向表,記為orig_rela
。

rela
、sym
是從dec_data1
中提取出來子so的重定向表和符號表,loadable_data1
、loadable_data2
是子so的2個可加載段。
至於子so的phdr table和dynamic,要用時再直接從dec_data1
中複製即可。

合併符號表 & 重定向表
重定向表依賴於符號表,而現在殼so和子so分別有自己的符號表和重定向表,若直接將子so的符號表覆蓋到殼so上,可能會有問題。
我的想法是將子so的符號表與殼so的符號表合併( 順序是殼 → 子 ),然後修改子so重定向表的符號索引。
腳本如下:修改後的子so重定向表記為rela_modify
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | rela_size = 0x18
orig_sym_num = 0xFD50 / / 0x18
with open ( "./rela" , mode = "rb" ) as f:
data = bytearray(f.read())
for i in range ( 0 , len (data), rela_size):
type = int .from_bytes(data[i + 8 : i + 12 ], byteorder = "little" )
sym = int .from_bytes(data[i + 12 : i + 16 ], byteorder = "little" )
if sym:
data[i + 12 : i + 16 ] = bytearray( int .to_bytes(sym + orig_sym_num, 4 , byteorder = "little" ))
with open ( "./rela_modify" , mode = "wb" ) as f2:
f2.write(data)
|
合併orig_sym
和sym
,記為merge_sym

合併orig_rela
和rela_modify
,記為merge_rela

正式開始修復
複製loadable_data1
,取代由0x1C8
開始的密文。
取代前:

取代後:

將merge_sym
放在( 取代 )libil2cpp_str_sym.so
最後一個loadable段之後的空間( 這裡通常是文件末了 )。
取代前:

取代後:

緊接著merge_sym
後面再插入merge_rela
:

緊接著merge_rela
後面插入loadable_data2
:

接下來修改phdr table。
從dec_data1
複製loadable_data2
對應的phdr。

取代libil2cpp_str_sym.so
最後一個phdr,取代後可以看到該段的屬性是RW,即loadable_data2
是數據段。將p_offset
修改成loadable_data2
的文件偏移。
其p_vaddr
是0x2E54000
,先記著這個值。

修改第1個phdr,它原本的p_memsz
是0x5825000
,改成0x2E54000

再回到最後一個phdr,修改p_memsz
為0x5825000
- 0x2E54000
= 0x29D1000
。

上面在libil2cpp_str_sym.so
最後一個loadable段後面插入了merge_sym
和merge_rela
,因此要修改該段對應的phdr中的p_filesz
和p_memsz
。

接下來修改.dynamic的信息,包括:
DT_SYMTAB(0x6)
DT_RELA(0x7)
DT_RELASZ(0x8)
DT_INIT_ARRAY(0x19)
DT_INIT_ARRAYSZ(0x1B)
DT_FINI_ARRAY(0x1A)
DT_FINI_ARRAYSZ(0x1C)
修改後如下,順帶將init_array的數量加1、fini_array起始位置加8、fini_array的數量減1,因為之後要將殼so的第1個init_array函數插入( 該函數初始化了一些全局變量,子so也需要 )。

本例的子so中,有個init_array函數是空的( 位於0x2E54160
),在010中搜60 41 E5 02
會發現找不到關於0x2E54160
位置的重定向信息。
搜68 41 E5 02
,定位到原fini_array[0]
的重定向信息。

將其offset改成0x2E54160
、addend改成0x58A1808
( 殼so的第1個init_array函數 )。

注:其實是可以直接添加多一條重定向記錄的。
最後添加3個必須的section:.dynstr
、.dynamic
、.shstrtab


然後修正elf_header:

嘗試將修復後的so替換APP原本的libil2cpp.so
,發現會閃退,大概是上面某一步出了問題。
Use Il2CppDumper
找到加載global-metadata.dat
的地方,dump下來。

前幾個字節被抹去了,手動修復一下。

最後成功使用Il2CppDumper dump出關鍵信息。

結語
總的來說,分析的過程是比較順利的( 畢竟站在了巨人的肩膀上 ),但so修復卻簡直是一波十三折,遇到大大小小各種的坑,每天都在放棄的邊緣掙扎。最終修復的so雖然無法替換原so,但起碼能用於Il2CppDumper,也算是達到逆向的目的了吧。
最後,祝各位新年快樂,願每個逆向路上的同路人都能順順利利吧^^