1. 程式人生 > >Android 外掛化原理解析——外掛載入機制

Android 外掛化原理解析——外掛載入機制

上文 Activity生命週期管理 中我們地完成了『啟動沒有在AndroidManifest.xml中顯式宣告的Activity』的任務;通過Hook AMS和攔截ActivityThread中H類對於元件排程我們成功地繞過了AndroidMAnifest.xml的限制。

但是我們啟動的『沒有在AndroidManifet.xml中顯式宣告』的Activity和宿主程式存在於同一個Apk中;通常情況下,外掛均以獨立的檔案存在甚至通過網路獲取,這時候外掛中的Activity能否成功啟動呢?

要啟動Activity元件肯定先要建立對應的Activity類的物件,從上文 Activity生命週期管理

 知道,建立Activity類物件的過程如下:

1
2
3
4
5
java.lang.ClassLoader cl = r.packageInfo.getClassLoader();
activity = mInstrumentation.newActivity(
        cl, component.getClassName(), r.intent);
StrictMode.incrementExpectedActivityCount(activity.getClass());
r.intent.setExtrasClassLoader(cl);

也就是說,系統通過ClassLoader載入了需要的Activity類並通過反射呼叫建構函式創建出了Activity物件。如果Activity元件存在於獨立於宿主程式的檔案之中,系統的ClassLoader怎麼知道去哪裡載入呢?因此,如果不做額外的處理,外掛中的Activity物件甚至都沒有辦法創建出來,談何啟動?

因此,要使存在於獨立檔案或者網路中的外掛被成功啟動,首先就需要解決這個外掛類載入的問題。
下文將圍繞此問題展開,完成『啟動沒有在AndroidManifest.xml中顯示宣告,並且存在於外部外掛中的Activity』的任務。

閱讀本文之前,可以先clone一份 

understand-plugin-framework,參考此專案的classloader-hook 模組。另外,外掛框架原理解析系列文章見索引

ClassLoader機制

或許有的童鞋還不太瞭解Java的ClassLoader機制,我這裡簡要介紹一下。

Java虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校檢、轉換解析和初始化的,最終形成可以被虛擬機器直接使用的Java型別,這就是虛擬機器的類載入機制。
與那些在編譯時進行鏈連線工作的語言不同,在Java語言裡面,型別的載入、連線和初始化都是在程式執行期間完成的,這種策略雖然會令類載入時稍微增加一些效能開銷,但是會為Java應用程式提供高度的靈活性,Java裡天生可以同代拓展的語言特性就是依賴執行期動態載入和動態連結這個特點實現的。例如,如果編寫一個面相介面的應用程式,可以等到執行時在制定實際的實現類;使用者可以通過Java與定義的和自定義的類載入器,讓一個本地的應用程式可以在執行時從網路或其他地方載入一個二進位制流作為程式碼的一部分,這種組裝應用程式的方式目前已經廣泛應用於Java程式之中。從最基礎的Applet,JSP到複雜的OSGi技術,都使用了Java語言執行期類載入的特性。

Java的類載入是一個相對複雜的過程;它包括載入、驗證、準備、解析和初始化五個階段;對於開發者來說,可控性最強的是載入階段;載入階段主要完成三件事:

  1. 根據一個類的全限定名來獲取定義此類的二進位制位元組流
  2. 將這個位元組流所代表的靜態儲存結構轉化為JVM方法區中的執行時資料結構
  3. 在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。

『通過一個類的全限定名獲取描述此類的二進位制位元組流』這個過程被抽象出來,就是Java的類載入器模組,也即JDK中ClassLoader API。

Android Framework提供了DexClassLoader這個類,簡化了『通過一個類的全限定名獲取描述次類的二進位制位元組流』這個過程;我們只需要告訴DexClassLoader一個dex檔案或者apk檔案的路徑就能完成類的載入。因此本文的內容用一句話就可以概括:

將外掛的dex或者apk檔案告訴『合適的』DexClassLoader,藉助它完成外掛類的載入

關於CLassLoader機制更多的內容,請參閱『深入理解Java虛擬機器』這本書。

思路分析

Android系統使用了ClassLoader機制來進行Activity等元件的載入;apk被安裝之後,APK檔案的程式碼以及資源會被系統存放在固定的目錄(比如/data/app/package_name/base-1.apk )系統在進行類載入的時候,會自動去這一個或者幾個特定的路徑來尋找這個類;但是系統並不知道存在於外掛中的Activity元件的資訊(外掛可以是任意位置,甚至是網路,系統無法提前預知),因此正常情況下系統無法載入我們外掛中的類;因此也沒有辦法建立Activity的物件,更不用談啟動元件了。

解決這個問題有兩個思路,要麼全盤接管這個類載入的過程;要麼告知系統我們使用的外掛存在於哪裡,讓系統幫忙載入;這兩種方式或多或少都需要干預這個類載入的過程。老規矩,知己知彼,百戰不殆。我們首先分析一下,系統是如果完成這個類載入過程的。

我們再次搬出Activity的建立過程的程式碼:

1
2
3
4
java.lang.ClassLoader cl = r.packageInfo.getClassLoader();
activity = mInstrumentation.newActivity(cl, component.getClassName(), r.intent);
StrictMode.incrementExpectedActivityCount(activity.getClass());
r.intent.setExtrasClassLoader(cl);

這裡可以很明顯地看到,系統通過待啟動的Activity的類名className,然後使用ClassLoader物件cl把這個類載入進虛擬機器,最後使用反射建立了這個Activity類的例項物件。要想幹預這個ClassLoader(告知它我們的路徑或者替換他),我們首先得看看這玩意到底是個什麼來頭。(從哪裡建立的)

cl這個ClasssLoader物件通過r.packageInfo物件的getClassLoader()方法得到,r.packageInfo是一個LoadedApk類的物件;那麼,LoadedApk到底是個什麼東西??

我們查閱LoadedApk類的文件,只有一句話,不過說的很明白:

Local state maintained about a currently loaded .apk.

LoadedApk物件是APK檔案在記憶體中的表示。 Apk檔案的相關資訊,諸如Apk檔案的程式碼和資源,甚至程式碼裡面的Activity,Service等元件的資訊我們都可以通過此物件獲取。

OK, 我們知道這個LoadedApk是何方神聖了;接下來我們要搞清楚的是:這個 r.packageInfo 到底是從哪裡獲取的?

我們順著 performLaunchActivity上溯,輾轉handleLaunchActivity回到了 H 類的LAUNCH_ACTIVITY訊息,找到了r.packageInfo的來源:

1
2
3
4
final ActivityClientRecord r = (ActivityClientRecord) msg.obj;
r.packageInfo = getPackageInfoNoCheck(
        r.activityInfo.applicationInfo, r.compatInfo);
handleLaunchActivity(r, null);

getPackageInfoNoCheck方法很簡單,直接呼叫了getPackageInfo方法:

1
2
3
4
public final LoadedApk getPackageInfoNoCheck(ApplicationInfo ai,
        CompatibilityInfo compatInfo) {
    return getPackageInfo(ai, compatInfo, null, false, true, false);
}

在這個getPackageInfo方法裡面我們發現了端倪:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private LoadedApk getPackageInfo(ApplicationInfo aInfo, CompatibilityInfo compatInfo,
        ClassLoader baseLoader, boolean securityViolation, boolean includeCode,
        boolean registerPackage) {
        // 獲取userid資訊
    final boolean differentUser = (UserHandle.myUserId() != UserHandle.getUserId(aInfo.uid));
    synchronized (mResourcesManager) {
    // 嘗試獲取快取資訊
        WeakReference<LoadedApk> ref;
        if (differentUser) {
            // Caching not supported across users
            ref = null;
        } else if (includeCode) {
            ref = mPackages.get(aInfo.packageName);
        } else {
            ref = mResourcePackages.get(aInfo.packageName);
        }

        LoadedApk packageInfo = ref != null ? ref.get() : null;
        if (packageInfo == null || (packageInfo.mResources != null
                && !packageInfo.mResources.getAssets().isUpToDate())) {
                // 快取沒有命中,直接new
            packageInfo =
                new LoadedApk(this, aInfo, compatInfo, baseLoader,
                        securityViolation, includeCode &&
                        (aInfo.flags&ApplicationInfo.FLAG_HAS_CODE) != 0, registerPackage);

        // 省略。。更新快取
        return packageInfo;
    }
}

這個方法很重要,我們必須弄清楚每一步;

首先,它判斷了呼叫方和或許App資訊的一方是不是同一個userId;如果是同一個user,那麼可以共享快取資料(要麼快取的程式碼資料,要麼快取的資源資料)

接下來嘗試獲取快取資料;如果沒有命中快取資料,才通過LoadedApk的建構函式建立了LoadedApk物件;建立成功之後,如果是同一個uid還放入了快取。

提到快取資料,看過Hook機制之Binder Hook的童鞋可能就知道了,我們之前成功藉助ServiceManager的本地代理使用快取的機制Hook了各種Binder;因此這裡完全可以如法炮製——我們拿到這一份快取資料,修改裡面的ClassLoader;自己控制類載入的過程,這樣載入外掛中的Activity類的問題就解決了。這就引出了我們載入外掛類的第一種方案:

激進方案:Hook掉ClassLoader,自己操刀

從上述分析中我們得知,在獲取LoadedApk的過程中使用了一份快取資料;這個快取資料是一個Map,從包名到LoadedApk的一個對映。正常情況下,我們的外掛肯定不會存在於這個物件裡面;但是如果我們手動把我們外掛的資訊新增到裡面呢?系統在查詢快取的過程中,會直接命中快取!進而使用我們新增進去的LoadedApk的ClassLoader來載入這個特定的Activity類!這樣我們就能接管我們自己外掛類的載入過程了!

這個快取物件mPackages存在於ActivityThread類中;老方法,我們首先獲取這個物件:

1
2
3
4
5
6
7
8
9
10
// 先獲取到當前的ActivityThread物件
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
currentActivityThreadMethod.setAccessible(true);
Object currentActivityThread = currentActivityThreadMethod.invoke(null);

// 獲取到 mPackages 這個靜態成員變數, 這裡快取了dex包的資訊
Field mPackagesField = activityThreadClass.getDeclaredField("mPackages");
mPackagesField.setAccessible(true);
Map mPackages = (Map) mPackagesField.get(currentActivityThread);

拿到這個Map之後接下來怎麼辦呢?我們需要填充這個map,把外掛的資訊塞進這個map裡面,以便系統在查詢的時候能命中快取。但是這個填充這個Map我們出了需要包名之外,還需要一個LoadedApk物件;如何建立一個LoadedApk物件呢?

我們當然可以直接反射呼叫它的建構函式直接創建出需要的物件,但是萬一哪裡有疏漏,構造引數填錯了怎麼辦?又或者Android的不同版本使用了不同的引數,導致我們創建出來的物件與系統創建出的物件不一致,無法work怎麼辦?

因此我們需要使用與系統完全相同的方式建立LoadedApk物件;從上文分析得知,系統建立LoadedApk物件是通過getPackageInfo來完成的,因此我們可以呼叫這個函式來建立LoadedApk物件;但是這個函式是private的,我們無法使用。

有的童鞋可能會有疑問了,private不是也能反射到嗎?我們確實能夠呼叫這個函式,但是private表明這個函式是內部實現,或許那一天Google高興,把這個函式改個名字我們就直接GG了;但是public函式不同,public被匯出的函式你無法保證是否有別人呼叫它,因此大部分情況下不會修改;我們最好呼叫public函式來保證儘可能少的遇到相容性問題。(當然,如果實在木有路可以考慮呼叫私有方法,自己處理相容性問題,這個我們以後也會遇到)

間接呼叫getPackageInfo這個私有函式的public函式有同名的getPackageInfo系列和getPackageInfoNoCheck;簡單檢視原始碼發現,getPackageInfo除了獲取包的資訊,還檢查了包的一些元件;為了繞過這些驗證,我們選擇使用getPackageInfoNoCheck獲取LoadedApk資訊。

構建外掛LoadedApk物件

我們這一步的目的很明確,通過getPackageInfoNoCheck函式創建出我們需要的LoadedApk物件,以供接下來使用。

這個函式的簽名如下:

1
2
public final LoadedApk getPackageInfoNoCheck(ApplicationInfo ai,
            CompatibilityInfo compatInfo) {

因此,為了呼叫這個函式,我們需要構造兩個引數。其一是ApplicationInfo,其二是CompatibilityInfo;第二個引數顧名思義,代表這個App的相容性資訊,比如targetSDK版本等等,這裡我們只需要提取出app的資訊,因此直接使用預設的相容性即可;在CompatibilityInfo類裡面有一個公有欄位DEFAULT_COMPATIBILITY_INFO代表預設相容性資訊;因此,我們的首要目標是獲取這個ApplicationInfo資訊。

構建外掛ApplicationInfo資訊

我們首先看看ApplicationInfo代表什麼,這個類的文件說的很清楚:

Information you can retrieve about a particular application. This corresponds to information collected from the AndroidManifest.xml’s <application> tag.

也就是說,這個類就是AndroidManifest.xml裡面的 這個標籤下面的資訊;這個AndroidManifest.xml無疑是一個標準的xml檔案,因此我們完全可以自己使用parse來解析這個資訊。

那麼,系統是如何獲取這個資訊的呢?其實Framework就有一個這樣的parser,也即PackageParser;理論上,我們也可以借用系統的parser來解析AndroidMAnifest.xml從而得到ApplicationInfo的資訊。但遺憾的是,這個類的相容性很差;Google幾乎在每一個Android版本都對這個類動刀子,如果堅持使用系統的解析方式,必須寫一系列相容行程式碼!!DroidPlugin就選擇了這種方式,相關類如下:

DroidPlugin的PackageParser

看到這裡我就問你怕不怕!!!這也是我們之前提到的私有或者隱藏的API可以使用,但必須處理好相容性問題;如果Android 7.0釋出,這裡估計得新增一個新的類PackageParseApi24。

我這裡使用API 23作為演示,版本不同的可能無法執行請自行查閱 DroidPlugin 不同版本如何處理。

OK回到正題,我們決定使用PackageParser類來提取ApplicationInfo資訊。下圖是API 23上,PackageParser的部分類結構圖:

看起來有我們需要的方法 generateApplication;確實如此,依靠這個方法我們可以成功地拿到ApplicationInfo。
由於PackageParser是@hide的,因此我們需要通過反射進行呼叫。我們根據這個generateApplicationInfo方法的簽名:

1
2
public static ApplicationInfo generateApplicationInfo(Package p, int flags,
   PackageUserState state)

可以寫出呼叫generateApplicationInfo的反射程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
Class<?> packageParserClass = Class.forName("android.content.pm.PackageParser");
// 首先拿到我們得終極目標: generateApplicationInfo方法
// API 23 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// public static ApplicationInfo generateApplicationInfo(Package p, int flags,
//    PackageUserState state) {
// 其他Android版本不保證也是如此.
Class<?> packageParser$PackageClass = Class.forName("android.content.pm.PackageParser$Package");
Class<?> packageUserStateClass = Class.forName("android.content.pm.PackageUserState");
Method generateApplicationInfoMethod = packageParserClass.getDeclaredMethod("generateApplicationInfo",
        packageParser$PackageClass,
        int.class,
                packageUserStateClass);

要成功呼叫這個方法,還需要三個引數;因此接下來我們需要一步一步構建呼叫此函式的引數資訊。

構建PackageParser.Package

generateApplicationInfo方法需要的第一個引數是PackageParser.Package;從名字上看這個類代表某個apk包的資訊,我們看看文件怎麼解釋:

Representation of a full package parsed from APK files on disk. A package consists of a single base APK, and zero or more split APKs.

果然,這個類代表從PackageParser中解析得到的某個apk包的資訊,是磁碟上apk檔案在記憶體中的資料結構表示;因此,要獲取這個類,肯定需要解析整個apk檔案。PackageParser中解析apk的核心方法是parsePackage,這個方法返回的就是一個Package型別的例項,因此我們呼叫這個方法即可;使用反射程式碼如下:

1
2
3
4
5
6
7
8
9
// 首先, 我們得創建出一個Package物件出來供這個方法呼叫
// 而這個需要得物件可以通過 android.content.pm.PackageParser#parsePackage 這個方法返回得 Package物件得欄位獲取得到
// 創建出一個PackageParser物件供使用
Object packageParser = packageParserClass.newInstance();
// 呼叫 PackageParser.parsePackage 解析apk的資訊
Method parsePackageMethod = packageParserClass.getDeclaredMethod("parsePackage", File.class, int.class);

// 實際上是一個 android.content.pm.PackageParser.Package 物件
Object packageObj = parsePackageMethod.invoke(packageParser, apkFile, 0);

這樣,我們就得到了generateApplicationInfo的第一個引數;第二個引數是解析包使用的flag,我們直接選擇解析全部資訊,也就是0;

構建PackageUserState

第三個引數是PackageUserState,代表不同使用者中包的資訊。由於Android是一個多工多使用者系統,因此不同的使用者同一個包可能有不同的狀態;這裡我們只需要獲取包的資訊,因此直接使用預設的即可;

至此,generateApplicaionInfo的引數我們已經全部構造完成,直接呼叫此方法即可得到我們需要的applicationInfo物件;在返回之前我們需要做一點小小的修改:使用系統系統的這個方法解析得到的ApplicationInfo物件中並沒有apk檔案本身的資訊,所以我們把解析的apk檔案的路徑設定一下(ClassLoader依賴dex檔案以及apk的路徑):

1
2
3
4
5
6
7
8
9
// 第三個引數 mDefaultPackageUserState 我們直接使用預設建構函式構造一個出來即可
Object defaultPackageUserState = packageUserStateClass.newInstance();

// 萬事具備!!!!!!!!!!!!!!
ApplicationInfo applicationInfo = (ApplicationInfo) generateApplicationInfoMethod.invoke(packageParser,
        packageObj, 0, defaultPackageUserState);
String apkPath = apkFile.getPath();
applicationInfo.sourceDir = apkPath;
applicationInfo.publicSourceDir = apkPath;

替換ClassLoader

獲取LoadedApk資訊

方才為了獲取ApplicationInfo我們費了好大一番精力;回顧一下我們的初衷:

我們最終的目的是呼叫getPackageInfoNoCheck得到LoadedApk的資訊,並替換其中的mClassLoader然後把把新增到ActivityThread的mPackages快取中;從而達到我們使用自己的ClassLoader載入外掛中的類的目的。

現在我們已經拿到了getPackageInfoNoCheck這個方法中至關重要的第一個引數applicationInfo;上文提到第二個引數CompatibilityInfo代表裝置相容性資訊,直接使用預設的值即可;因此,兩個引數都已經構造出來,我們可以呼叫getPackageInfoNoCheck獲取LoadedApk:

1
2