Roman Kryvolapov Engineering Blog

DexClassLoader, или зареждане на част от приложението от мрежата

Веднъж написах малка програма, която изтегля от сървъра предварително компилиран код във формат .dex и го стартира. Исках да разбера може ли част от приложението да живее не в самия APK, а като код на сървъра, който се зарежда по време на работа.

Идеята ми дойде след статия в „Хакер“ за модули за Android троянци (част 2). Троянците не ме интересуваха: интересно ми беше как е устроена самата механика и какво ще каже Google Play за това.

Как работи

Кодът на Android приложението след билда се намира във файлове .dex – това е байткод за виртуалната машина ART. Обикновено всички .dex са опаковани в APK и се зареждат от системния зареждач на класове. Но в Android има и публични зареждачи, на които може да се подаде собствен файл:

  • DexClassLoader – зарежда класове от .dex, .jar или .apk файл, който се намира на диска;
  • PathClassLoader – същото, системата го използва за самото приложение;
  • InMemoryDexClassLoader (от API 26) – зарежда .dex директно от ByteBuffer, без файл.

Зареденият клас по нищо не се различава от „собствен“: можете да го създадете, да извикате методите му, да му подадете контекст. Изключение са компонентите на приложението: Activity или Service от такъв код не са декларирани в манифеста, затова системата няма да ги стартира.

Код

Плъгинът най-удобно се свързва с приложението чрез общ интерфейс: интерфейсът е в приложението, а класът в .dex го реализира. Тогава след зареждането класът се привежда към интерфейса и се извиква без рефлексия.

Java
File dexFile = new File(getCodeCacheDir(), "plugin.dex");
if (dexFile.exists()) {
dexFile.delete();
}
try (InputStream input = new URL(baseUrl + "plugin.dex").openStream();
FileOutputStream output = new FileOutputStream(dexFile)) {
// Android 14+: файлът трябва да стане read-only, преди в него да е записано нещо
dexFile.setReadOnly();
byte[] buffer = new byte[8192];
int count;
while ((count = input.read(buffer)) != -1) {
output.write(buffer, 0, count);
}
}
DexClassLoader loader = new DexClassLoader(
dexFile.getAbsolutePath(),
null, // optimizedDirectory: от API 26 се игнорира
null, // път до нативните библиотеки, ако са нужни
getClassLoader()); // родител: през него плъгинът вижда класовете на приложението
Class<?> pluginClass = loader.loadClass("com.example.plugin.PluginImpl");
Plugin plugin = (Plugin) pluginClass.getDeclaredConstructor().newInstance();
plugin.run(getApplicationContext());

Ако няма общ интерфейс, остава рефлексията – така беше в първия ми вариант:

Java
for (Method method : pluginClass.getDeclaredMethods()) {
method.setAccessible(true);
Log.d(TAG, "method: " + method.getName());
}
for (Field field : pluginClass.getDeclaredFields()) {
field.setAccessible(true);
Log.d(TAG, "field: " + field.getName());
}

Как се прави .dex

Плъгинът се компилира с обикновения javac или kotlinc срещу същия интерфейс Plugin, а получените .class се превръщат в .dex с помощта на инструмента d8 от Android SDK (папката build-tools). На d8 са нужни вече съществуваща папка за резултата, android.jar от SDK и в classpath компилираният интерфейс Plugin, който остава в приложението и в плъгина не попада:

Shell
mkdir -p out
d8 --lib "$ANDROID_HOME/platforms/android-36/android.jar" --classpath build/classes \
--output out/ build/classes/com/example/plugin/PluginImpl.class

В out/ ще се появи classes.dex – именно него трябва да качите на сървъра.

В релизния билд интерфейсът трябва да оцелее и при R8: плъгинът е компилиран срещу първоначалните му имена и те се запазват от правилото -keep interface com.example.app.Plugin { *; }.

Ограничения

Android 14. Ако приложението е насочено към API 34 и по-висок, всеки динамично зареждан файл трябва да е достъпен само за четене, иначе системата ще хвърли изключение при зареждането. Файлът трябва да се маркира като read-only веднага след отварянето му, преди записването на съдържанието, както в кода по-горе. Вече съществуващ файл е по-добре да се изтрие и да се изтегли наново. Подробности – в промените в поведението на Android 14.

Google Play. В първата версия на тази бележка написах, че след модерацията на устройството може да се зарежда каквото и да е. Това не е вярно. Правилото на Google Play Device and Network Abuse изрично забранява на приложенията и вградените в тях SDK да изтеглят изпълним код – .dex, .jar, .so – от какъвто и да е източник освен самия Google Play. Модерацията може да пропусне такова приложение, но това е нарушение на правилата и основание за премахване. Изключение е кодът, който се изпълнява във виртуална машина или интерпретатор и няма пряк достъп до Android API, например JavaScript в WebView. Законният начин да се доставят части от приложението при поискване е Play Feature Delivery.

Сигурност. Зареденият код получава всички права на приложението. Затова – само HTTPS и проверка на подписа на файла преди зареждането, с публичен ключ, зашит в APK; иначе подмяната на файла на сървъра или в мрежата води до изпълнение на чужд код във вашето приложение. Хешът, изтеглен от същия сървър, не доказва нищо: който е подменил файла, ще подмени и хеша. Google като цяло съветва да се избягва динамичното зареждане на код, ако няма сериозна причина за него.

Къде все пак е приложимо: корпоративни приложения, които не се разпространяват през Google Play, системи за плъгини във вътрешни инструменти, експерименти и изследване на това как е устроено зареждането на класове в ART.