Android應用程序通用自動脫殼方法研究

本文為GoSSIP小組2015年的一項重要研究成果——Android應用程序的通用自動化脫殼方法的總結,本工作在2015年烏雲峰會上首次對外報告,相關學術論文 AppSpear: Bytecode Decrypting and DEXReassembling for Packed Android Malware 在同年的國際學術會議RAID上發表(題圖是作者在京都作報告)

論文下載:(loccs.sjtu.edu.cn//~rom

背景及意義

Android應用程序相比傳統PC應用程序更容易被逆向,因為被逆向後能夠完整的還原出Java代碼或者smali中間語言,兩者都具有很豐富的高層語義信息,理解起來更為容易,讓程序邏輯輕易暴露給技術能力甚至並不需要很高門檻的攻擊者面前。因此Android應用程序加固保護服務隨之應運而生。從一開始只有甲方公司提供服務到現在大型互聯網公司都有自己的加固保護服務,同時與金錢相關的Android應用程序例如銀行等也越來越多開始使用加固保護自己,這個市場在不斷的擴大。

一個典型的加固保護服務通常能夠提供如下保護:防逆向,防篡改,反調試,反竊取等功能。加固服務雖然不能夠避免和防止應用程序自身的安全問題和漏洞,但能夠有效的保護程序真實邏輯,保護應用程序完整性。但是這些特點同時也容易被惡意程序利用,有數據表明隨著加固保護的流行,加殼惡意程序的比例也在不斷上升。一方面惡意程序分析需要先脫殼,另一方面正常的應用程序如果被輕易脫殼後分析,其面臨的風險也會上升。

研究對象

通常加固服務提供DEX的整體加固方案和定製化的加固。定製化的加固通常需要與開發更為緊密的結合,可能涉及更深層次加固(如native代碼加固等),而DEX整體加固只需要用戶提供編譯好的Android應用程序APK即可。前者目前缺乏樣本並需要與加固廠商深度合作,而後者被大多數加固服務廠商作為最基本的免費服務提供,因而後者被使用的更為廣泛。本文主要研究對象是針對後者的Android應用程序可執行文件DEX的保護,即DEX文件加密,旨在研究通用的DEX文件恢復方法。而定製化的加固服務或針對native代碼的混淆保護等不在本文研究範圍內。

加固服務特點

我們通過一個靜態逆向加固方法的例子來詳細描述加固服務通常具有的特點。該例子是幾個月前某加固廠商使用的方案,由於加固服務經常變換解密演算法和方案,因此實現細節並不適用於現在的產品,或其他加固服務,但整體的加固思想和方法和使用的保護手段基本上大同小異。

通常當我們用靜態工具分析一個加固後的APP時,AndroidManifest.xml文件里會在保留原始的所有信息,包括定義的組件、許可權等等的基礎上,新增一個入口點類,通常是application。

而DEX的代碼是這樣的:

DEX代碼只包含很少的類和代碼,其主要是做些檢測工作或者準備工作,然後通過載入一個native庫 去動態載入原始的DEX文件。由於使用了動態載入機制,因此加固過的DEX文件中不會涉及原始DEX的真正代碼(也有一些加固並沒有採取完整DEX的動態載入)。

接著使用IDA去逆向入口點載入運行的native代碼,通常so庫也是被混淆加殼的。手段包括破壞ELF頭部信息讓IDA解析失敗,如下圖:

通過readelf可以明顯看到ELF頭部的幾個欄位是有問題的。

修復之後,IDA可以正常反彙編so文件了。接著我們從入口點開始分析,會發現F5反編譯成C代碼會有問題,多個函數內容都不能反編譯成正常的C代碼。直接看彙編代碼看到如下的花指令:

這是我們總結的該產品的花指令模式。它會通過壓棧跳轉出棧的方式讓反編譯的函數辨識出現問題,因為反編譯通常會認為一個壓棧操作為函數調用,而其實他通過壓棧,計算寄存器值,跳轉再出棧讓反編譯失效後並平衡棧後,再執行一條真正有用的指令。因此上述例子中只有兩條真正有用的指令。

通過寫腳本甚至是人工的方式可以把真正的彙編指令提取出來。提取後再逆向代碼,其功能是去解密JNI_OnLoad函數。JNI_OnLoad會從一段數據中再解密出另一個ELF文件,而此時這個新的ELF文件還不能正確反彙編,後面的代碼會接著對該ELF進行數據的修正。先解壓新ELF文件中的text端,從text端中提取一個key再去解密rotext,最後才解密出一個真正的對DEX的殼程序,形如:

以上步驟其實是一個ELF文件的殼。新的被解密修正後的ELF文件才是真正對DEX殼的解密程序。這個程序並沒有混淆或者加殼,通過逆向後發現,他會取原始DEX後的一段padding數據,獲取一些解密和解壓需要的參數,對整段padding數據解密解壓,就能得到真正原始的DEX文件了。當然ELF中還包括一些反調試反分析的代碼,由於我們這是靜態分析,不需要顧及這部分代碼,如果是使用調試器去附加進程使用dump等動態分析時就需要考慮怎麼優雅的bypass這些反調試技巧了。

以上例子是一個動態載入DEX的例子,雖然不同的加固服務在很多技術細節包括解密演算法、花指令模式、ELF殼等等上天差地別,但基本上能夠代表絕大多數使用動態載入DEX方式的加固服務的整體解密釋放運行和靜態逆向和破解它的思想方法。我們也是以這個例子來管中窺豹。因為頻繁的變換解密演算法和加固方式也是加固服務的第一大特點。

同時事實上還存在一些加固並沒有使用完整DEX文件的動態載入機制,而是使用運行時動態自修改,這種機制下加固後的DEX文件中將存在原始DEX中的部分準確信息,但受保護的部分代碼還是會選擇其他方式隱藏。另外還有兩者相結合的方式。後面的案例分析中我們將有所涉及。

總結一下,一個加固過的Android應用程序實際上主要是隱藏真正的DEX文件,其自身也會加入諸多保護措施來防止被輕易逆向。可以看到如果純靜態逆向分析其脫殼演算法會非常耗時耗力,另外不同的加固服務採取不一樣的演算法,而每個本身又會頻繁變換演算法和加固技術讓純靜態的逆向脫殼方法短時間內就失效。同時加固服務還會採取除DEX動態載入以外的諸多安卓應用程序保護措施,我們這裡稍作總結,並不展開,因為這部分內容甚至可以單獨寫文章詳細說。

第一大類是完整性檢驗。包含了在運行時對自身的完整性校驗,如檢查內存中DEX文件的檢驗值和檢查應用程序證書來檢測是否被重打包及插入代碼。以及對自身環境的檢測,如通過檢查特定設備文件等方式檢測模擬器,通過ptrace或者進程狀態等方式檢測是否被調試,hook特定的函數防止代碼內存被讀取或dump等。

第二大類是代碼混淆。通常混淆需要基於源碼或位元組碼上修改,其目的是為了讓分析者更難以理解程序的語義。最常見的包括修改變數名,方法名,類名等,加密常量字元串,使用Java反射機制調用方法,插入垃圾指令或無效代碼打亂程序控制流,使用更為複雜的操作替換原始的基本指令,使用JNI方法打斷控制流等。

第三大類我們定義為防分析或代碼隱藏技術,其目的是為了用各種方法防止程序代碼被直接暴露,輕易分析。最常見的就是上述的DEX整體加密保護,以及運行時動態自修改。運行時動態自修改主要是在程序運行時當執行到特定的類或方法時才將代碼解密並執行,同時還可能動態之後才修正或修改部分dalvik數據結構讓分析變得困難。另外一些防分析技術需要利用一些小的技巧。例如利用靜態分析工具的bug,或解析時的特性來做對抗,包括曾經出現的manifest cheating,APK偽加密,dex文件中的方法隱藏,插入非法指令或不存在的類讓靜態分析工具崩潰等等。

脫殼方法思想

面對加固程序,當前比較流行和常用的脫殼方法主要是兩種方法。一種是靜態的逆向分析,其缺點也很明顯,難度大而且無法對抗變換演算法。另一種主要是基於內存dump技術的脫殼。缺點在於需要考慮先bypass各種反調試的方法,同時還需要面對日益發展和新的層出不窮的反內存dump的各種技巧。例如篡改dex文件頭防窮搜,動態篡改dalvik數據結構破壞內存中的DEX文件等等,這些對抗技術讓即使dump出DEX文件後,還需要做大量的通過觀察加固特性後的人工修復工作。

所以我們提出一種通用的自動化脫殼方法,我們的方法基於動態分析,無需關心各個不同的加固保護具體實現,也可以統一繞過各種反調試的手段,同時也不需要做後期大量的修復工作。

首先我們脫殼的對象是Android應用程序中的DEX文件,因此我們選擇直接修改Android系統中Dalvik虛擬機的源代碼進行插樁。因為DEX文件中的代碼都需要在Dalvik虛擬機上解釋執行,所有的真實行為都能在Dalvik虛擬機上暴露。Dalvik有多個解釋模式,其中有portable模式是基於C++實現的,而其他模式由於優化的緣故使用平台相關的彙編語言開發,為了方便實現我們的插樁的代碼,一旦發現開始解釋執行需要被脫殼的APP時,我們先(源碼目錄dalvik/vm/interp/Interp.cpp)將解釋模式改為portable。這麼做的一個好處在於直接修改執行環境可以讓加殼程序更加難以檢測脫殼行為的存在,相比於調試器附加等方法,該方法更為透明。在解釋器上做的另一個好處在於不需要去關心加固程序在哪個階段進行類的載入和初始化以及解密代碼等,直接在運行時就能得到最真實的數據和行為。插樁代碼實現在Dalvik解釋執行的每條指令切換處(dalvik/vm/mterp/out/InterpC-portable.cpp),這樣可以在執行過程中的任意指令處進行脫殼的操作,一邊應對邊運行邊解密的加固程序。最後基於源碼的修改能夠實施真機部署,Android原生源碼可以完美支持所有的Nexus系列手機,也不需要去應對加固程序的檢測模擬器手段。

脫殼的本質是去獲取程序真實的行為,因此插樁代碼其實就是去得到內存中的Dalvik數據結構,來反映被執行的真實代碼。在指令執行時可以直接得到該條指令屬於的方法,Method這個結構。而每個被執行的方法中都有該方法屬於的類對象clazz,而clazz(源碼目錄dalvik/vm/oo/Object.h)中又有pDvmDex(dalvik/vm/DvmDex.h)對象,其中有pDexFile(dalvik/libdex/DexFile.h)結構體代表了DEX文件,也就是說,執行過程中獲取當前方法後,用curMethod->clazz->pDvmDex->pDexFile就能夠得到這個方法屬於的DEX文件結構。該結構體中包含了所有DEX文件在解釋其中被執行時的內存信息,通過解析這個DexFile結構體就能恢復出最真實的DEX。

簡單脫殼實現

至此,我們的第一個反應是有沒有現成的程序,可以去翻譯Dalvik位元組碼的,但是以讀入內存中的DexFile結構體為輸入,同時可以直接基於源碼實現,也就是用C/C++實現的,而不是像更多的靜態逆向工具直接以讀入一個靜態DEX文件為輸入。找了下發現Android系統源碼里本身就提供了DexDump(dalvik/dexdump/DexDump.cpp)這個工具,直接能滿足這個要求。我們對DexDump代碼稍作修改,插入到解釋器中,如下圖:

讓他去讀取DexFile,默認就直接在一個APP的主Activity處執行這個代碼,主Activity可以通過AndroidManifest.xml文件獲取,因為該文件中的入口點類都不會被隱藏。

我們發現這樣幾乎就能夠應對大多數加固程序了,能夠得到加固程序被隱藏的DEX文件中的真實代碼,輸出如下圖:

但這個方法的缺點也很明顯,就是輸出是dalvik位元組碼的文本形式,一方面無法反彙編成Java,另一方面文本形式非常不適合後續的複雜程序的分析,我們的最佳目的是得到一個完整的DEX文件。

完善脫殼實現

通常到上一步,許多其他的脫殼工具為了恢復出完整的DEX文件,會選擇直接讀取pDexFile->baseAddr或者pDvmDex->memMap為起始地址,直接將整個文件大小的內存dump出來。然而我們發現對某些加固軟體,這樣dump出來的代碼里依然不包含真實的代碼,這是由於DEX文件中部分真實信息在運行時被修改和映射到了文件連續內存以外的部分,如下圖,一個DEX文件被載入內存後,理應是在一個連續的內存空間中,然後被解析賦值為各個動態執行時Dalvik所需的結構體,而部分索性性質的結構體應該指向連續的data數據塊。但加固程序可能會做些修改,例如將header的部分數據篡改,以及重新分配不連續的內存來存放data數據,並讓那些索引數據塊指向的新分配的data塊。這樣如果直接用dump的方法,則無法得到完整的DEX文件。

我們旨在以統一的方法恢復出原始的DEX文件,不希望還需要針對的不同的殼來做後續的修復,因為這樣又將進入到和靜態逆向加固演算法一樣的困境。因此我們基於上述簡單實現,有了個更加完善的實現方案,稱之為DEX文件重組。過程非常簡單,就是在程序執行過程中先獲取所有解釋器所需的Dalvik數據結構,這裡都是內存中真實的被解釋執行的數據結構,然後再將這些數據結構組合重新寫回成一個新的DEX文件。如上圖所示,即使內存不連續,我們也無需關心他對原始映射內存的操作,可以直接獲取每塊不連續的數據,按照一定的規範去把這些數據重組成一個新的DEX文件。

第一步是去準確獲取每個Dalvik數據機構,為了保證獲取的準確性,我們採取的方式是和運行中解釋器中去執行程序時的獲取方式一致(參考DexFile.h 文件中的dexGetXXXX方法),因為一個DEX文件,同一塊數據可能有很多種方式去獲取的,打個比方,常量字元串可以去讀文件頭裡的偏移去獲取,也可以通過stringId列表去獲取,等等。正常情況下這些方式都應該是正確的,但是加固程序會去做一些破壞。但它不能去破壞運行時這些數據被獲取時用的數據,因為這個一旦破壞,程序就無法正常運行了。具體的獲取方式如下圖所示:

我們需要遍歷每個數組(如pStringIds,pProtoIds,…,pClassDefs)里的某些指針和偏移,每項中都逐一獲取,將其內容再合併成一個大類(如stringData,typeList,…,ClassData,Code)。接著獲取完重寫的時候,需要注意幾個問題。首先是對獲取這些數據塊的排列問題,我們參考了dalvik/libdex/DexFile.h里的map item type codes枚舉的順序進行排列。排列好需要調整每個數據項里的偏移值為新的偏移,如stringDataOff, parametersOff, interfacesOff, classDataOff, codeOff等,接著對於DexHeader,MapList這兩個結構體中的值,我們需要重新計算後填寫,而不是直接取原來的值,對於一些固定的值例如Header裡面的文件頭等,我們根據已有知識直接填寫。最後需要考慮到內存中的數據表達和DEX文件中的某些數據格式的差異,例如有些數據項在文件中是ULEB128編碼的,而在內存中就直接是int類型,另外還需要注意4位元組的對齊,以及encoded_method_format里是field_idx_diff,method_idx_diff而不是簡單的index等。具體細節可參考官方的DEX文件格式文檔

source.android.com/devi

我們在重組的時候忽略了一些數據塊,例如所有和annotation相關的數據結構,因為這部分真實程序使用不多,而結構又特別複雜,忽略以後對分析程序真實行為影響不大。

實驗與發現

改完代碼後,我們重新編譯了libdvm模塊並將新生成libdvm.so寫入系統目錄/system/lib/下覆蓋掉原始的庫文件,我們實驗的對象是Galaxy Nexus手機對應Android 4.3版本和Nexus 4手機對應的Android 4.4.2版本。然後我們提交了一個簡單的應用程序,送到各個在線加固服務上獲取加固後的應用程序版本再實施脫殼。實驗發現幾乎能夠針對所有的加固程序恢復出原始的DEX文件。以下是一些針對加固程序的發現。主要集中在不同的加固所使用的自我保護的手段,這裡有些結果是DexDump的文本,因為有些保護措施用這個方式更好的展示出細節,當然全部都能直接恢復成DEX文件。

以上兩個例子表明,有些加固程序會將magic number抹去,來隱藏內存中的DEX文件,讓窮搜DEX文件的方式失效,另外還會篡改header的大小,以及將header中的各種欄位偏移值抹去,由於我們用的方法是對header重新計算,因此重組後的DEX不受其影響。

另外有些加固程序會額外插入一些類來破壞正常的反編譯效果,例如這個類就有個方法是能夠讓dex2jar失效。

還有殼將codeOff改成了負的值,這樣代碼就會被映射到文件內存範圍之外。我們的方法可以直接將代碼獲取後重新寫回到正常的位置。

另外還有殼是重寫了某些方法,將代碼放入一個新的方法中,並在執行前去解密,執行後再重新抹去。對於這種情況,由於我們脫殼代碼插樁於每個方法調用處,因此我們只需要調整脫殼點到該方法執行處去實施脫殼就能恢復出代碼了。

除以上例子外,我們還發現某些加固程序會hook進程空間中的write函數,檢測寫的內容如果是特定的數據(如dex文件頭),則讓write操作失敗,或者獲取內存地址空間在映射的DEX文件區域內,也會讓write失敗等。還有加固程序會將原始的DEX文件分離出多個DEX,以及修改特定的數據項如debug_info_off為錯誤值,運行時再動態改回正確值。還有殼會在位元組碼基礎上對原始的程序做代碼混淆。

(註:以上例子都並非最新版本,不保證特定的加固程序現有產品與上述例子依然一致)

討論與思考

首先,我們的方法依然有局限性,一來在研究對象里說明了我們只針對DEX文件加密保護,並不做反混淆的工作。其次我們的方法依然是基於動態分析,將面臨動態分析的局限性,如一段加密代碼是運行到才解密,但該方法無法被觸發執行,我們的方法也無法解密這個方法的代碼。最後用該方法雖然難以被加固程序檢測,但用該方法製作的工具在實現上勢必會有某些特徵,這些特徵可能會被加固程序加以利用和對抗。

最後,我們想和大家一起探討的關於更好的Android平台應用程序加固的想法。事實上Android平台的加固破解還是相對容易的,然而並不是沒有更難更安全的加固方案,而是在手機平台上商用的加固方案需要考慮到性能損耗和兼容性的問題,這是無法避免的。同時綜合這幾個方面,我們認為加固保護的趨勢和做法發展主要集中在以下的幾個點:一是Android混淆和加殼其實可以結合使用。從攻擊者的角度來看,我認為強力的混淆可能要比加殼在保護代碼邏輯方面更加有效。但是好的混淆方案事實上非常難以設計。目前來看國內的加固幾乎不會對原始的代碼做大的變換和混淆,可能是怕修改的代碼在兼容性上會有問題。我們發現國外比較優秀的工具會在深度混淆這個點上做文章,比如dexprotector,他既有加殼,也有混淆,即使脫殼成功,還是需要去面對難以理解的混淆後代碼。二是對部分代碼加固的效果在安全性上可能要強過整體加固。就像之前的一個例子,一個方法只有在運行時才解密自己,一旦脫離運行則重新加密或抹掉。這個等於是利用了動態執行覆蓋率低的缺陷來進一步保護自己。三是為了更好的加固效果,加固過程應該儘可能從現在的開發後加固變成開發中的加固。現在有一些加固SDK就是這方面比較好的嘗試。直接在開發的過程中敏感的操作使用一個安全庫的介面。這個無論是在性能上還是效果都可以對現在的整體一刀切式的加固做個質的提高。熟悉業務的開發人員會很清楚他們需要保護的代碼是哪一部分,因為一個程序事實上真正需要被保護的邏輯可能只是很小一部分,加固範圍的縮小可以大大提高性能,同時單獨的安全庫文件可以有針對性的保護措施,效果會非常好,另外比起整個APP加固也更容易做的兼容性測試。

加固另一個思路是儘可能用Native的代碼,特別是關鍵的程序邏輯,Native代碼逆向本身就比Java困難,加了混淆或者殼後就更難了,同時Native代碼事實上還能在性能上有所提高,是一舉兩得的方案。由此又可以延伸出如何對Android應用程序中native代碼做深度保護的問題,如果敏感操作用深度混淆保護的native代碼做保護,則攻擊成本勢必將極大提升。

最後我們覺得加固保護的一個趨勢是盡量少的去利用小trick來做防護,比如那些利用靜態分析工具的BUG或者系統解析APK的BUG來做加固其實意義不是很大,加固保護更應該從整個計算機系統的體系結構上來考慮和強化,而不應該集中於一些小的技巧。

推薦閱讀:

為什麼國內的各個應用市場會提供更高版本的Google Apps?
我的Android進階之旅------>Android 關於arm64-v8a、armeabi-v7a、armeabi、x86下的so文件兼容問題

TAG:Android | 软件安全 | 程序分析 |