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