某APP加固產品的深入分析
樣本
應用寶隨手下載一個安裝包,本文分析的是作業幫v13.28.0。
工具
jadx、idapro、unicorn。
分析
直接將apk拖入jadx,找到入口MyWrapperProxyApplication。

繼承了WrapperProxyApplication,在attachBaseContext中調用了initProxyApplication,而initProxyApplication中調用com.wrapper.proxyapplication.Util.PrepareSecurefiles,然后加載libshell-super.2019.so。

PrepareSecurefiles這個方法反編譯出來又長又臭,主要就是檢查下/data/data/com.baidu.homework/files/prodexdir目錄下的一些文件是否完整。
這些文件都是從assets釋放的,主要有兩個文件tosversion和0OO00l111l1l。
通過后續分析可知tosversion適用于判斷應用升級的,0OO00l111l1l是保存原始dex的加密文件。

然后把libshell-super.2019.so拖入ida進行分析,首先看看.init_array,有一堆函數。

把這些函數挨個大概看下,除了最后一個sub_29BC0都是在做字符串解密,這些字符加密的方式為每個字符串和一個固定的字符進行異或。

雖然解密的方式十分簡單,但是需要處理的字符串數量太多了,手動一個一個處理肯定是不行的,這就需要腳本了。
腳本處理有兩種方式:
一種是分析匯編代碼,找出需要解密的字符串起始地址、字符串長度、解密key;
另一種是unicorn直接運行.init_array中的函數,然后把運行后的內容直接加載到ida中。
首先來看第一種方法,通過分析可知,每次解密的指令格式都是固定的。
通過第三條指令LDR可以獲取到字符串起始地址,第四條有可能是跳轉指令B,也可能沒有。
通過倒數第四條指令SUBS可以獲取到字符串長度,通過EOR.W指令可以獲取到用于解密的字符。


根據以上分析結果,寫出idc腳本,執行一遍發現,存在解密失敗的。
找到失敗的地址,發現當字符串長度為1的時候,指令格式不一致。


把這個情況兼容一下,腳本內容如下:
import idc def find_next_chunk(addr): if not idc.is_code(idc.get_full_flags(addr)): return False, None, None, None, None op = idc.print_insn_mnem(addr) if op != 'MOVS' and op != 'MOV.W': return False, None, None, None, None addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op != 'STRD.W': return False, None, None, None, None addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op != 'LDR': return False, None, None, None, None arr_start = idc.get_wide_dword(idc.get_operand_value(addr, 1)) + addr + 2 * 3 addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op != 'ADD': return False, None, None, None, None addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op == 'B': addr = idc.get_operand_value(addr, 0) op = idc.print_insn_mnem(addr) if op != 'LDRD.W': return False, None, None, None, None addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op != 'LDRB': return False, None, None, None, None addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op != 'EOR.W': return False, None, None, None, None xor_ch = idc.get_operand_value(addr, 2) addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op != 'STRB': return False, None, None, None, None addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op != 'ADDS': return False, None, None, None, None addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op == 'ADC.W': addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op != 'STR': return False, None, None, None, None addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op != 'SUBS': return False, None, None, None, None arr_len = idc.get_operand_value(addr, 1) addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op != 'STR': return False, None, None, None, None addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op != 'SBCS.W': return False, None, None, None, None addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op != 'BCC': return False, None, None, None, None next_addr = idc.next_head(addr) return True, next_addr, xor_ch, arr_start, arr_len elif op == 'STR': addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op != 'ADCS.W': return False, None, None, None, None addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op != 'STR': return False, None, None, None, None addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op != 'ADCS.W': return False, None, None, None, None addr = idc.next_head(addr) op = idc.print_insn_mnem(addr) if op != 'BNE': return False, None, None, None, None next_addr = idc.next_head(addr) return True, next_addr, xor_ch, arr_start, 1 else: return False, None, None, None, None def decode_arr(arr_start, arr_len, xor_ch): for addr in range(arr_start, arr_start + arr_len): ch = idc.get_wide_byte(addr) # print hex(ch) idc.patch_byte(addr, ch ^ xor_ch) idc.create_strlit(arr_start, arr_start + arr_len + 1) def proc_func(func_addr): if not func_addr: return # Thumb, func_start_addr+1==func_addr func_start_addr = idc.get_func_attr(func_addr, idc.FUNCATTR_START) func_end_addr = idc.get_func_attr(func_addr, idc.FUNCATTR_END) print hex(func_start_addr), '----->', hex(func_end_addr) addr = func_start_addr while addr < func_end_addr: succ, next_addr, xor_ch, arr_start, arr_len = find_next_chunk(addr) if succ: print hex(addr).ljust(10), hex(xor_ch).ljust(10), hex(arr_start).ljust(10), arr_len decode_arr(arr_start, arr_len, xor_ch) addr = next_addr else: print '*' * 10, hex(addr) addr = idc.next_head(addr) print '' * 3 def decode_str(): idc.auto_wait() start_addr = idc.get_segm_by_sel(idc.selector_by_name('.init_array')) end_addr = idc.get_segm_end(start_addr) addr = start_addr while addr + 4 <= end_addr: func_addr = idc.get_wide_dword(addr) proc_func(func_addr) addr += 4 decode_str()
ida加載運行該腳本,得到解密后的內容。


然后是第二種方法,通過unicorn直接運行.init_array中的解密函數,然后把解密后的內容直接加載到ida中。
import unicorn import idc def func_block_handle(uc, address, size, user_data): if address in (0, 0x29BC0): uc.emu_stop() def decode_str(): idc.auto_wait() dir_path = r'/Users/lll19/Downloads/legu/' bin_len = idc.prev_addr(idc.BADADDR) bin_len = (bin_len / 0x1000 + (1 if bin_len % 0x1000 else 0)) * 0x1000 bin_path = dir_path + 'elf_bin' idc.savefile(bin_path, 0, 0, bin_len) f_bin = open(bin_path, 'rb') bin_bytes = bytes(f_bin.read()) f_bin.close() stack_size = 0x100000 stack_top = bin_len stack_bottom = stack_top + stack_size uc = unicorn.Uc(unicorn.UC_ARCH_ARM, unicorn.UC_MODE_THUMB) uc.hook_add(unicorn.UC_HOOK_BLOCK, func_block_handle) uc.mem_map(0, bin_len) uc.mem_write(0, bin_bytes) uc.mem_map(stack_top, stack_size) start_addr = idc.get_segm_by_sel(idc.selector_by_name('.init_array')) end_addr = idc.get_segm_end(start_addr) addr = start_addr while addr + 4 <= end_addr: func_addr = idc.get_wide_dword(addr) addr += 4 if not func_addr: continue print hex(func_addr) func_end_addr = idc.find_func_end(func_addr) uc.reg_write(unicorn.arm_const.UC_ARM_REG_SP, stack_bottom) uc.reg_write(unicorn.arm_const.UC_ARM_REG_LR, 0) uc.emu_start(func_addr, func_end_addr) f_save = open(bin_path, 'wb') f_save.write(str(uc.mem_read(0, bin_len))) f_save.close() idc.loadfile(bin_path, 0, 0, bin_len) decode_str()
然后開始分析JNI_OnLoad,這個函數被混淆了,于是繼續unicorn走起。
根據輸出可知,該函數主要執行了以下幾個操作:
通過RegisterNatives注冊WrapperProxyApplication.Ooo0ooO0oO(),
調用0x1f668處的函數進行上下文初始化,調用0x2B604處的函數生成解密key,調用0xcea8處的函數加載dex。

先分析下sub_1f668處的初始化過程,東西比較多就不貼圖了
首先是一些常規操作,獲取vm類型、PackageInfo、ActivityThread、ClassLoader等。
然后是通過GetMethodID獲取之前注冊的WrapperProxyApplication.Ooo0ooO0oO()的MethodID,然后在MethodID指向的內存(通過Android源碼可知,MethodID實際是ArtMethod對象的指針)查找之前注冊的函數地址,找到后保存該偏移值,后面會通過這個偏移值對系統native方法進行hook。
再然后就是加載0OO00l111l1l,解析該文件,把該文件的各種數據指針緩存起來,用于后面數據解密。
再然后判斷系統是否升級,讀取prodexdir/.updateIV.dat中緩存的數據與libart.so和dex2oat的大小進行比較,如果不相等則將.odex和.vdex文件刪除并更新.updateIV.dat。
0OO00l111l1l數據結構如下,前4字節為dex的數量,后面分別為三種數據,通過后面分析可知:
第一部分數據為壓縮的dex,其中的指令被抽取了;第二部分為壓縮且加密的索引數據;第三部分為壓縮且加密的指令數據。

看下sub_2B604的代碼,比較簡單,讀取文件內容與byte_31391處的字符處理后存放在byte_36A8C。

大概分析下sub_cea8過程,調用sub_1CD90,對DexFile.defineClassNative()進行hook,原理是先獲取defineClassNative的MethodID,然后通過先前獲取到的偏移值,將該函數的本地函數指針保存起來,再通過RegisterNatives重新為該函數注冊一個本地函數。
調用java方法com.wrapper.proxyapplication.MultiDex.preparetoinstallDexes(),獲取dexElements,hook幾個系統函數mmap、execve、execv,hook的方式是通過遍歷重定位項實現的。這個幾個hook在我分析的過程中沒用上。
多線程調用函數sub_CE14解密加載dex并opt,等待所有線程結束后,獲取mCookie緩存起來,取消之前hook的幾個系統函數,并設置幾個環境變量,
構造當前應用的原始application,并調用其attach()方法。
剩下的流程都不重要了,現在只關心解密函數sub_CE14,
首先調用sub_CC2C進行dex解壓縮并寫入/data/data/com.baidu.homework/files/prodexdir下的dex文件,再調用java方法com.wrapper.proxyapplication.MultiDex.installDexes()進行dex替換。
解壓縮函數為sub_2B2AC,看看反編譯代碼,全是各種字符操作,具體算法就不看了,待會兒直接上unicorn。
dex加載成功后調用sub_10EB4進行指令解密。
首先對第二部分數據進行解密,解密函數為sub_2315C,解密完成后調用sub_2B2AC解壓縮,再調用sub_115F4生成每個類結構的索引。
然后對第三部分數據進行解密,解密函數為sub_2315C,解密完成后調用sub_2B2AC解壓縮,得到所有抽取的指令。
現在只剩最后一步了,那就是在class加載的時候,對抽取的指令進行填充,這里需要分析的是前面hook的DexFile.defineClassNative(),對應的函數為sub_1C900,其主要內容如下。
首先查找class所在的dex和對應的索引(sub_22B90),然后進行指令填充(sub_101B8),最后調用原來的函數地址。

sub_22B90的代碼和對應的數據如下,通過分析可知,從偏移8開始是一個hash表,每個表項3個字段,分別為hash值、類名偏移、類結構索引偏移。
偏移為4的字段為hash表的大小。

函數sub_101B8對class指令進行填充,函數被混淆了,既然這樣,也就不看了,直接unicorn。

到此為止,所有的流程都分析完了。
開始準備脫殼腳本,需要模擬執行的函數流程分為以下幾部分:
1、執行0x2B604處的代碼。讀取tosversion文件的內容,處理后作為解密的key(該處只讀取了16個字節,但是解密的時候,復制了32字節的key,但是不影響,實際執行解密的時候只用了前16字節)。
2、循環執行0x2B2AC處的代碼。解壓出所有被抽取指令的dex。
3、循環執行0x2315C、0x2B2AC、0x115F4這三處代碼,分別對應class信息的解密、解壓、建索引。
4、循環執行0x2315C、0x2B2AC這兩處代碼,分別對應指令的解密、解壓。
5、遍歷所有dex的hash表,循環執行0x101b8處的代碼進行指令修復。
完整的脫殼腳本就不貼了,提示超出字數限制了,我把它放在附件里面了。
樣本打包后附件大小也超限制了。
樣本我放網盤了,附件只保留了腳本文件。
貼個脫殼修復后的圖:
