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 его реализует. Тогда после загрузки класс приводится к интерфейсу и вызывается без рефлексии.

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());

Если общего интерфейса нет, остаётся рефлексия — так было в моём первом варианте:

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, который остаётся в приложении и в плагин не попадает:

Terminal window
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.