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.