淺析Android外掛化

全世界_Coder發表於2018-04-05

前言

Android P preview版本中,已限制對@hide api的反射呼叫,具體的原理可以閱讀Android P呼叫隱藏API限制原理這篇文章。由於最近團隊分享也在分享外掛化、熱修復相關的東西。因此,寫一篇文章,好好記錄一下。

準備知識

  • 反射、動態代理
  • Android中的幾個相關的ClassLoader,注意PathClassLoader在ART虛擬機器上是可以載入未安裝的APK的,Dalvik虛擬機器則不可以。
  • Android中四大元件的相關原理
  • PackageManagerServer
  • 資源載入、資源打包
  • 其他

文章中所涉及到的程式碼均通過Nexus 5(dalvik虛擬機器) Android 6.0版本的測試

文章中所涉及到的一切資源都在這個倉庫下

特別說明,本部落格不會特別解釋過多原理性的東西。如果讀者不具備相關的知識儲備,建議先閱讀weishu和gityuan兩位大神的部落格,資源打包的知識可以閱讀 老羅的部落格。

Activity的外掛化

首先需要說明一點的是,啟動一個完全沒有在AndroidManifest註冊的Activity是不可能的。因為在啟動的過程中,存在一個校驗的過程,而這個校驗則是由PMS來完成的,這個我們無法干預。因此,Activity的外掛化方案大多使用佔坑的思想。不同的是如何在檢驗之前替換,在生成物件的時候還原。就目前來看,有兩種比較好方案:

  • Hook Instrumentation方案
  • 干預startActivity等方法,干預ClassLoader findClass的方案

這裡說一下Hook Instrumentation方法。根據上面提到的想法,我們需要在先繞過檢查,那麼,我們如何繞過檢查呢?通過分析Activity的啟動流程會發現,在Instrumentation#execStartActivity中,會有個checkStartActivityResult的方法去檢查錯誤,因此,我們可以複寫這個方法,讓啟動引數能通過系統的檢查。那麼,我們如何做呢?首先,我們需要檢查要啟動的Intent能不能匹配到,匹配不到的話,將ClassName修改為我們預先在AndroidManifest中配置的佔坑Activity,並且吧當前的這個ClassName放到當前intent的extra中,以便後續做恢復,看下程式碼。

    public ActivityResult execStartActivity(            Context who, IBinder contextThread, IBinder token, Activity target,            Intent intent, int requestCode, Bundle options) { 
List<
ResolveInfo>
infos = mPackageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL);
if (infos == null || infos.size() == 0) {
//沒查到,要啟動的這個沒註冊 intent.putExtra(TARGET_ACTIVITY, intent.getComponent().getClassName());
intent.setClassName(who, "com.guolei.plugindemo.StubActivity");

} Class instrumentationClz = Instrumentation.class;
try {
Method execMethod = instrumentationClz.getDeclaredMethod("execStartActivity", Context.class, IBinder.class, IBinder.class, Activity.class, Intent.class, int.class, Bundle.class);
return (ActivityResult) execMethod.invoke(mOriginInstrumentation, who, contextThread, token, target, intent, requestCode, options);

} catch (Exception e) {
e.printStackTrace();

} return null;

}複製程式碼

我們繞過檢測了,現在需要解決的問題是還原,我們知道,系統啟動Activity的最後會呼叫到ActivityThread裡面,在這裡,會通過Instrumentation#newActivity方法去反射構造一個Activity的物件,因此,我們只需要在這裡還原即可。程式碼如下:

    @Override    public Activity newActivity(ClassLoader cl, String className, Intent intent) throws InstantiationException,            IllegalAccessException, ClassNotFoundException { 
if (!TextUtils.isEmpty(intent.getStringExtra(TARGET_ACTIVITY))) {
return super.newActivity(cl, intent.getStringExtra(TARGET_ACTIVITY), intent);

} return super.newActivity(cl, className, intent);

}複製程式碼

一切準備就緒,我們最後的問題是,如何替換掉系統的Instrumentation。要替換掉也簡單,替換掉ActivityThread中的mInstrumentation欄位即可。

    private void hookInstrumentation() { 
Context context = getBaseContext();
try {
Class contextImplClz = Class.forName("android.app.ContextImpl");
Field mMainThread = contextImplClz.getDeclaredField("mMainThread");
mMainThread.setAccessible(true);
Object activityThread = mMainThread.get(context);
Class activityThreadClz = Class.forName("android.app.ActivityThread");
Field mInstrumentationField = activityThreadClz.getDeclaredField("mInstrumentation");
mInstrumentationField.setAccessible(true);
mInstrumentationField.set(activityThread, new HookInstrumentation((Instrumentation) mInstrumentationField.get(activityThread), context.getPackageManager()));

} catch (Exception e) {
e.printStackTrace();
Log.e("plugin", "hookInstrumentation: error");

}
}複製程式碼

這樣,我們就能啟動一個沒有註冊在AndroidManifest檔案中的Activity了,但是這裡要注意一下,由於我們這裡使用的ClassLoader是宿主的ClassLoader,這樣的話,我們需要將外掛的dex檔案新增到我們宿主中。這一點很重要。有一些多ClassLoader架構的實現,這裡的程式碼需要變下。

Service的外掛化

啟動一個未註冊的Service,並不會崩潰退出,只不過有點警告。並且,service啟動直接由ContextImpl交給AMS處理了,我們看下程式碼。

    private ComponentName startServiceCommon(Intent service, UserHandle user) { 
try {
validateServiceIntent(service);
service.prepareToLeaveProcess(this);
ComponentName cn = ActivityManagerNative.getDefault().startService( mMainThread.getApplicationThread(), service, service.resolveTypeIfNeeded( getContentResolver()), getOpPackageName(), user.getIdentifier());
if (cn != null) {
if (cn.getPackageName().equals("!")) {
throw new SecurityException( "Not allowed to start service " + service + " without permission " + cn.getClassName());

} else if (cn.getPackageName().equals("!!")) {
throw new SecurityException( "Unable to start service " + service + ": " + cn.getClassName());

}
} return cn;

} catch (RemoteException e) {
throw e.rethrowFromSystemServer();

}
}複製程式碼

並且建立物件的過程不由Instrumentation來建立了,而直接在ActivityThread#handleCreateService反射生成。那麼,Activity的思路我們就不能用了,怎麼辦呢?既然我們無法做替換還原,那麼,我們可以考慮代理,我們啟動一個真實註冊了的Service,我們啟動這個Service,並讓這個Service,就按照系統服務Service的處理,原模原樣的處理我們外掛的Service。

說做就做,我們以startService為例。我們首先要做的是,hook掉AMS,因為AMS啟動service的時候,假如要啟動外掛的Service,我們需要怎麼做呢?把外掛service替換成真是的代理Service,這樣,代理Service就啟動起來了,我們在代理Service中,構建外掛的Service,並呼叫attach、onCreate等方法。

Hook AMS程式碼如下:

    private void hookAMS() { 
try {
Class activityManagerNative = Class.forName("android.app.ActivityManagerNative");
Field gDefaultField = activityManagerNative.getDeclaredField("gDefault");
gDefaultField.setAccessible(true);
Object origin = gDefaultField.get(null);
Class singleton = Class.forName("android.util.Singleton");
Field mInstanceField = singleton.getDeclaredField("mInstance");
mInstanceField.setAccessible(true);
Object originAMN = mInstanceField.get(origin);
Object proxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), new Class[]{Class.forName("android.app.IActivityManager")
}, new ActivityManagerProxy(getPackageManager(),originAMN));
mInstanceField.set(origin, proxy);
Log.e(TAG, "hookAMS: success" );

} catch (Exception e) {
Log.e(TAG, "hookAMS: " + e.getMessage());

}
}複製程式碼

我們在看一下ActivityManagerProxy這個代理。

    @Override    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { 
if (method.getName().equals("startService")) {
Intent intent = (Intent) args[1];
List<
ResolveInfo>
infos = mPackageManager.queryIntentServices(intent, PackageManager.MATCH_ALL);
if (infos == null || infos.size() == 0) {
intent.putExtra(TARGET_SERVICE, intent.getComponent().getClassName());
intent.setClassName("com.guolei.plugindemo", "com.guolei.plugindemo.StubService");

}
} return method.invoke(mOrigin, args);

}複製程式碼

程式碼很清晰、也很簡單,不需要在做多餘的了,那麼,我們看下代理Service是如何啟動並且呼叫我們的外掛Service的。

    @Override    public int onStartCommand(Intent intent, int flags, int startId) { 
Log.e(TAG, "onStartCommand: stub service ");
if (intent != null &
&
!TextUtils.isEmpty(intent.getStringExtra(TARGET_SERVICE))) {
//啟動真正的service String serviceName = intent.getStringExtra(TARGET_SERVICE);
try {
Class activityThreadClz = Class.forName("android.app.ActivityThread");
Method getActivityThreadMethod = activityThreadClz.getDeclaredMethod("getApplicationThread");
getActivityThreadMethod.setAccessible(true);
//獲取ActivityThread Class contextImplClz = Class.forName("android.app.ContextImpl");
Field mMainThread = contextImplClz.getDeclaredField("mMainThread");
mMainThread.setAccessible(true);
Object activityThread = mMainThread.get(getBaseContext());
Object applicationThread = getActivityThreadMethod.invoke(activityThread);
//獲取token值 Class iInterfaceClz = Class.forName("android.os.IInterface");
Method asBinderMethod = iInterfaceClz.getDeclaredMethod("asBinder");
asBinderMethod.setAccessible(true);
Object token = asBinderMethod.invoke(applicationThread);
//Service的attach方法 Class serviceClz = Class.forName("android.app.Service");
Method attachMethod = serviceClz.getDeclaredMethod("attach", Context.class, activityThreadClz, String.class, IBinder.class, Application.class, Object.class);
attachMethod.setAccessible(true);
Class activityManagerNative = Class.forName("android.app.ActivityManagerNative");
Field gDefaultField = activityManagerNative.getDeclaredField("gDefault");
gDefaultField.setAccessible(true);
Object origin = gDefaultField.get(null);
Class singleton = Class.forName("android.util.Singleton");
Field mInstanceField = singleton.getDeclaredField("mInstance");
mInstanceField.setAccessible(true);
Object originAMN = mInstanceField.get(origin);
Service targetService = (Service) Class.forName(serviceName).newInstance();
attachMethod.invoke(targetService, this, activityThread, intent.getComponent().getClassName(), token, getApplication(), originAMN);
//service的oncreate方法 Method onCreateMethod = serviceClz.getDeclaredMethod("onCreate");
onCreateMethod.setAccessible(true);
onCreateMethod.invoke(targetService);
targetService.onStartCommand(intent, flags, startId);

} catch (Exception e) {
e.printStackTrace();
Log.e(TAG, "onStartCommand: " + e.getMessage());

}
} return super.onStartCommand(intent, flags, startId);

}複製程式碼

程式碼較長,邏輯如下:

  • 檢測到需要啟動外掛Service
  • 構建外掛Service attach方法需要的引數
  • 構造一個外掛Service
  • 呼叫外掛Service的attach方法
  • 呼叫外掛Service的onCreate方法

這樣,一個外掛Service就啟動起來了。

BroadcastReceiver的外掛化

BroadcastReceiver分為兩種,靜態註冊,和動態註冊。靜態註冊的是PMS在安裝或者系統啟動的時候掃描APK,解析配置檔案,並儲存在PMS端的,這個我們無法干預,並且,我們的外掛由於未安裝,靜態註冊的是無法通過系統正常行為裝載的。而動態註冊的,由於沒有檢測這一步,因此,也不需要我們干預。我們現在需要解決的問題就是,怎麼能裝載外掛中靜態註冊的。

我們可以通過解析配置檔案,自己呼叫動態註冊的方法去註冊這個。

程式碼這裡就不貼了,和下面ContentProvider的一起貼。

ContentProvider的外掛化

和其他三個元件不一樣的是,ContentProvider是在程式啟動入口,也就是ActivityThread中進行安裝的。那麼我們可以按照這個思路,自己去進行安裝的操作。

程式碼如下。

          Field providersField = packageClz.getDeclaredField("providers");
providersField.setAccessible(true);
ArrayList providers = (ArrayList) providersField.get(packageObject);
Class providerClz = Class.forName("android.content.pm.PackageParser$Provider");
Field providerInfoField = providerClz.getDeclaredField("info");
providersField.setAccessible(true);
List<
ProviderInfo>
providerInfos = new ArrayList<
>
();
for (int i = 0;
i <
providers.size();
i++) {
ProviderInfo providerInfo = (ProviderInfo) providerInfoField.get(providers.get(i));
providerInfo.applicationInfo = getApplicationInfo();
providerInfos.add(providerInfo);

} Class contextImplClz = Class.forName("android.app.ContextImpl");
Field mMainThread = contextImplClz.getDeclaredField("mMainThread");
mMainThread.setAccessible(true);
Object activityThread = mMainThread.get(this.getBaseContext());
Class activityThreadClz = Class.forName("android.app.ActivityThread");
Method installContentProvidersMethod = activityThreadClz.getDeclaredMethod("installContentProviders", Context.class, List.class);
installContentProvidersMethod.setAccessible(true);
installContentProvidersMethod.invoke(activityThread, this, providerInfos);
複製程式碼

貼一下整體的程式碼,這裡的程式碼,包括Multidex方法加dex,BroadcastReceiver的外掛化以及ContentProvider的外掛化。

    private void loadClassByHostClassLoader() { 
File apkFile = new File("/sdcard/plugin_1.apk");
ClassLoader baseClassLoader = this.getClassLoader();
try {
Field pathListField = baseClassLoader.getClass().getSuperclass().getDeclaredField("pathList");
pathListField.setAccessible(true);
Object pathList = pathListField.get(baseClassLoader);
Class clz = Class.forName("dalvik.system.DexPathList");
Field dexElementsField = clz.getDeclaredField("dexElements");
dexElementsField.setAccessible(true);
Object[] dexElements = (Object[]) dexElementsField.get(pathList);
Class elementClz = dexElements.getClass().getComponentType();
Object[] newDexElements = (Object[]) Array.newInstance(elementClz, dexElements.length + 1);
Constructor<
?>
constructor = elementClz.getConstructor(File.class, boolean.class, File.class, DexFile.class);
File file = new File(getFilesDir(), "test.dex");
if (file.exists()) {
file.delete();

} file.createNewFile();
Object pluginElement = constructor.newInstance(apkFile, false, apkFile, DexFile.loadDex(apkFile.getCanonicalPath(), file.getAbsolutePath(), 0));
Object[] toAddElementArray = new Object[]{pluginElement
};
System.arraycopy(dexElements, 0, newDexElements, 0, dexElements.length);
// 外掛的那個element複製進去 System.arraycopy(toAddElementArray, 0, newDexElements, dexElements.length, toAddElementArray.length);
dexElementsField.set(pathList, newDexElements);
AssetManager assetManager = getResources().getAssets();
Method method = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
method.invoke(assetManager, apkFile.getPath());
// PackageInfo packageInfo = getPackageManager().getPackageArchiveInfo(apkFile.getAbsolutePath(), PackageManager.GET_RECEIVERS);
// if (packageInfo != null) {// for (ActivityInfo info : packageInfo.receivers) {// Log.e(TAG, "loadClassByHostClassLoader: " + info.name );
////
}//
} Class packageParseClz = Class.forName("android.content.pm.PackageParser");
Object packageParser = packageParseClz.newInstance();
Method parseMethod = packageParseClz.getDeclaredMethod("parsePackage", File.class, int.class);
parseMethod.setAccessible(true);
Object packageObject = parseMethod.invoke(packageParser, apkFile, 1 <
<
2);
Class packageClz = Class.forName("android.content.pm.PackageParser$Package");
Field receiversField = packageClz.getDeclaredField("receivers");
receiversField.setAccessible(true);
ArrayList receives = (ArrayList) receiversField.get(packageObject);
Class componentClz = Class.forName("android.content.pm.PackageParser$Component");
Field intents = componentClz.getDeclaredField("intents");
intents.setAccessible(true);
Field classNameField = componentClz.getDeclaredField("className");
classNameField.setAccessible(true);
for (int i = 0;
i <
receives.size();
i++) {
ArrayList<
IntentFilter>
intentFilters = (ArrayList<
IntentFilter>
) intents.get(receives.get(i));
String className = (String) classNameField.get(receives.get(i));
registerReceiver((BroadcastReceiver) getClassLoader().loadClass(className).newInstance(), intentFilters.get(0));

} // 安裝ContentProvider Field providersField = packageClz.getDeclaredField("providers");
providersField.setAccessible(true);
ArrayList providers = (ArrayList) providersField.get(packageObject);
Class providerClz = Class.forName("android.content.pm.PackageParser$Provider");
Field providerInfoField = providerClz.getDeclaredField("info");
providersField.setAccessible(true);
List<
ProviderInfo>
providerInfos = new ArrayList<
>
();
for (int i = 0;
i <
providers.size();
i++) {
ProviderInfo providerInfo = (ProviderInfo) providerInfoField.get(providers.get(i));
providerInfo.applicationInfo = getApplicationInfo();
providerInfos.add(providerInfo);

} Class contextImplClz = Class.forName("android.app.ContextImpl");
Field mMainThread = contextImplClz.getDeclaredField("mMainThread");
mMainThread.setAccessible(true);
Object activityThread = mMainThread.get(this.getBaseContext());
Class activityThreadClz = Class.forName("android.app.ActivityThread");
Method installContentProvidersMethod = activityThreadClz.getDeclaredMethod("installContentProviders", Context.class, List.class);
installContentProvidersMethod.setAccessible(true);
installContentProvidersMethod.invoke(activityThread, this, providerInfos);

} catch (Exception e) {
e.printStackTrace();
Log.e(TAG, "loadClassByHostClassLoader: " + e.getMessage());

}
}複製程式碼

到這裡,四大元件的外掛化方案介紹了一點點,雖然每種元件只介紹了一種方法。上面的內容忽略了大部分原始碼細節。這部分內容需要大家自己去補。

資源的外掛化方案

資源的外掛化方案,目前有兩種

  • 合併資源方案
  • 各個外掛構造自己的資源方案

今天,我們介紹第一種方案,合併資源方案,合併資源方案,我們只需要往現有的AssetManager中呼叫addAsset新增一個資源即可,當然,存在比較多適配問題,我們暫時忽略。合併資源方案最大的問題就是資源衝突。要解決資源衝突,有兩種辦法。

  • 修改AAPT,能自由修改PP段
  • 干預編譯過程,修改ASRC和R檔案

為了簡單演示,我直接只用VirtualApk的編譯外掛去做。實際上VirtualApk的編譯外掛來自以Small的編譯外掛。只要對檔案格式熟悉,這個還是很好寫的。

            AssetManager assetManager = getResources().getAssets();
Method method = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
method.invoke(assetManager, apkFile.getPath());
複製程式碼

我們只需要上面簡單的程式碼,就能完成資源的外掛化。當然,這裡忽略了版本差異。

SO的外掛化方案

so的外掛化方案,我這裡介紹修改dexpathlist的方案。我們要做的是什麼呢?只需要往nativeLibraryPathElements中新增SO的Element,並且往nativeLibraryDirectories新增so路徑就可以了。程式碼如下。

            Method findLibMethod = elementClz.getDeclaredMethod("findNativeLibrary",String.class);
findLibMethod.setAccessible(true);
// Object soElement = constructor.newInstance(new File("/sdcard/"), true, apkFile, DexFile.loadDex(apkFile.getCanonicalPath(),// file.getAbsolutePath(), 0));
// findLibMethod.invoke(pluginElement,System.mapLibraryName("native-lib"));
ZipFile zipFile = new ZipFile(apkFile);
ZipEntry zipEntry = zipFile.getEntry("lib/armeabi/libnative-lib.so");
InputStream inputStream = zipFile.getInputStream(zipEntry);
File outSoFile = new File(getFilesDir(), "libnative-lib.so");
if (outSoFile.exists()) {
outSoFile.delete();

} FileOutputStream outputStream = new FileOutputStream(outSoFile);
byte[] cache = new byte[2048];
int count = 0;
while ((count = inputStream.read(cache)) != -1) {
outputStream.write(cache, 0, count);

} outputStream.flush();
outputStream.close();
inputStream.close();
// 構造Element Object soElement = constructor.newInstance(getFilesDir(), true, null, null);
// findLibMethod.invoke(soElement,System.mapLibraryName("native-lib"));
// 將soElement填充到nativeLibraryPathElements中, Field soElementField = clz.getDeclaredField("nativeLibraryPathElements");
soElementField.setAccessible(true);
Object[] soElements = (Object[]) soElementField.get(pathList);
Object[] newSoElements = (Object[]) Array.newInstance(elementClz, soElements.length + 1);
Object[] toAddSoElementArray = new Object[]{soElement
};
System.arraycopy(soElements, 0, newSoElements, 0, soElements.length);
// 外掛的那個element複製進去 System.arraycopy(toAddSoElementArray, 0, newSoElements, soElements.length, toAddSoElementArray.length);
soElementField.set(pathList, newSoElements);
//將so的資料夾填充到nativeLibraryDirectories中 Field libDir = clz.getDeclaredField("nativeLibraryDirectories");
libDir.setAccessible(true);
List libDirs = (List) libDir.get(pathList);
libDirs.add(getFilesDir());
libDir.set(pathList,libDirs);
複製程式碼

總結

在前人的精心研究下,外掛化方案已經很成熟了。外掛化方案的難點主要在適配方面。其他倒還好。

PS:熱修復的相關知識,PPT已經寫好了,下篇應該會淺析一下熱修復。

來源:https://juejin.im/post/5ac5d015f265da239a600a72

相關文章